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. -[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Made by WizardLoop](https://img.shields.io/badge/Made%20by-WizardLoop-blue)](https://github.com/WizardLoop) -[![Telegram Contact](https://img.shields.io/badge/contact-%40WizardLoop-blue?logo=telegram)](https://t.me/WizardLoop) +[English](README.md) | [ืขื‘ืจื™ืช](readme-he.md) ---- +[![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue)](LICENSE) +[![PHP 8.3โ€“8.5](https://img.shields.io/badge/PHP-8.3%E2%80%938.5-777BB4)](composer.json) +[![MadelineProto 8.7.0](https://img.shields.io/badge/MadelineProto-8.7.0-2CA5E0)](composer.lock) +[![Development 3.0.0-dev.9](https://img.shields.io/badge/status-3.0.0--dev.9-orange)](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 messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; +updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message reference_messages:flags.0?Vector qts:int = Update; +updateAiComposeTones#8c0f91fb = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; +topPeerCategoryBotsGuestChat#6c24f3dd = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#966e2dbf id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true subscribers_only:flags.11?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int countries_iso2:flags.12?Vector hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true can_view_stats:flags.7?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true send_reactions:flags.27?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; +webPageAttributeAiComposeTone#7781fe18 emoji_id:long = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#3fc18057 flags:# restore:flags.0?true phone_number:string phone_code_hash:string premium_days:int currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long 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 from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +stats.pollStats#2999beed votes_graph:StatsGraph = stats.PollStats; + +inputAiComposeToneDefault#1fe9a9bf tone:string = InputAiComposeTone; +inputAiComposeToneID#773c080 id:long access_hash:long = InputAiComposeTone; +inputAiComposeToneSlug#1fa01357 slug:string = InputAiComposeTone; + +aiComposeTone#cff63ea9 flags:# creator:flags.0?true id:long access_hash:long slug:string title:string emoji_id:flags.1?long prompt:flags.4?string installs_count:flags.2?int author_id:flags.3?long example_english:flags.5?AiComposeToneExample = AiComposeTone; +aiComposeToneDefault#9bad6414 tone:string emoji_id:long title:string = AiComposeTone; + +aicompose.tonesNotModified#c1f46103 = aicompose.Tones; +aicompose.tones#6c9d0efe hash:long tones:Vector users:Vector = aicompose.Tones; + +aiComposeToneExample#f1d628ec from:TextWithEntities to:TextWithEntities = AiComposeToneExample; + +bots.accessSettings#dd1fbf93 flags:# restricted:flags.0?true add_users:flags.1?Vector = bots.AccessSettings; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true bots_guestchat:flags.17?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; +messages.composeMessageWithAI#daecc589 flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string tone:flags.2?InputAiComposeTone = messages.ComposedMessageWithAI; +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.setBotGuestChatResult#b8f106e3 query_id:long result:InputBotInlineResult = InputBotInlineMessageID; +messages.deleteParticipantReactions#a0b80cf8 peer:InputPeer participant:InputPeer = Bool; +messages.deleteParticipantReaction#e3b7f82c peer:InputPeer msg_id:int participant:InputPeer = Updates; +messages.getPersonalChannelHistory#55fb0996 user_id:InputUser limit:int max_id:int min_id:int hash:long = messages.Messages; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; +bots.checkUsername#87f2219b username:string = Bool; +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; +bots.getAccessSettings#213853a3 bot:InputUser = bots.AccessSettings; +bots.editAccessSettings#31813cd8 flags:# restricted:flags.0?true bot:InputUser add_users:flags.1?Vector = Bool; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; +stats.getPollStats#c27dfa68 flags:# dark:flags.0?true peer:InputPeer msg_id:int = stats.PollStats; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +aicompose.createTone#4aa83913 flags:# display_author:flags.0?true emoji_id:long title:string prompt:string = AiComposeTone; +aicompose.updateTone#903bcf59 flags:# tone:InputAiComposeTone display_author:flags.0?Bool emoji_id:flags.1?long title:flags.2?string prompt:flags.3?string = AiComposeTone; +aicompose.saveTone#1782cbb1 tone:InputAiComposeTone unsave:Bool = Bool; +aicompose.deleteTone#dd39316a tone:InputAiComposeTone = Bool; +aicompose.getTone#b2e8ba03 tone:InputAiComposeTone = aicompose.Tones; +aicompose.getTones#abd59201 hash:long = aicompose.Tones; +aicompose.getToneExample#d1b4ab14 tone:InputAiComposeTone num:int = AiComposeToneExample; + +// LAYER 225 diff --git a/patches/madelineproto-formatted-date/formatted-date-entity.patch b/patches/madelineproto-formatted-date/formatted-date-entity.patch new file mode 100644 index 0000000..6189512 --- /dev/null +++ b/patches/madelineproto-formatted-date/formatted-date-entity.patch @@ -0,0 +1,126 @@ +--- a/src/EventHandler/Message/Entities/MessageEntity.php ++++ b/src/EventHandler/Message/Entities/MessageEntity.php +@@ -57,6 +57,8 @@ + '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']), ++ 'date_time' => FormattedDate::fromBotAPI($entity), ++ 'messageEntityFormattedDate' => FormattedDate::fromMTProto($entity), + default => throw new AssertionError("Unknown entity type: ".($entity['_'] ?? $entity['type'])) + }; + } +--- /dev/null ++++ b/src/EventHandler/Message/Entities/FormattedDate.php +@@ -0,0 +1,112 @@ ++ 2147483647) { ++ throw new \InvalidArgumentException('Formatted-date fields require nonnegative native int32 values.'); ++ } ++ } ++ if (($relative && ($shortTime || $longTime || $shortDate || $longDate || $dayOfWeek)) ++ || ($shortTime && $longTime) || ($shortDate && $longDate)) { ++ throw new \InvalidArgumentException('Contradictory native formatted-date flags.'); ++ } ++ parent::__construct($offset, $length); ++ } ++ ++ public static function fromMTProto(array $entity): self ++ { ++ if (($entity['_'] ?? null) !== 'messageEntityFormattedDate') { ++ throw new \InvalidArgumentException('Expected the native formatted-date constructor.'); ++ } ++ return new self( ++ self::integer($entity, 'offset'), ++ self::integer($entity, 'length'), ++ self::integer($entity, 'date'), ++ self::flag($entity, 'relative'), ++ self::flag($entity, 'short_time'), ++ self::flag($entity, 'long_time'), ++ self::flag($entity, 'short_date'), ++ self::flag($entity, 'long_date'), ++ self::flag($entity, 'day_of_week') ++ ); ++ } ++ ++ public static function fromBotAPI(array $entity): self ++ { ++ $format = array_key_exists('date_time_format', $entity) ? $entity['date_time_format'] : ''; ++ if (($entity['type'] ?? null) !== 'date_time' || !is_string($format) ++ || preg_match('/^(?:r|w?[dD]?[tT]?)$/D', $format) !== 1) { ++ throw new \InvalidArgumentException('Expected an official date_time entity and valid date_time_format.'); ++ } ++ return new self( ++ self::integer($entity, 'offset'), ++ self::integer($entity, 'length'), ++ self::integer($entity, 'unix_time'), ++ $format === 'r', ++ str_contains($format, 't'), ++ str_contains($format, 'T'), ++ str_contains($format, 'd'), ++ str_contains($format, 'D'), ++ str_contains($format, 'w') ++ ); ++ } ++ ++ #[\Override] ++ public function toMTProto(): array ++ { ++ return ['_' => 'messageEntityFormattedDate', 'offset' => $this->offset, 'length' => $this->length, ++ 'date' => $this->date, 'relative' => $this->relative, 'short_time' => $this->shortTime, ++ 'long_time' => $this->longTime, 'short_date' => $this->shortDate, ++ 'long_date' => $this->longDate, 'day_of_week' => $this->dayOfWeek]; ++ } ++ ++ #[\Override] ++ public function toBotAPI(): array ++ { ++ $format = $this->relative ? 'r' : ($this->dayOfWeek ? 'w' : '') ++ . ($this->shortDate ? 'd' : ($this->longDate ? 'D' : '')) ++ . ($this->shortTime ? 't' : ($this->longTime ? 'T' : '')); ++ return ['type' => 'date_time', 'offset' => $this->offset, 'length' => $this->length, ++ 'unix_time' => $this->date, 'date_time_format' => $format]; ++ } ++ ++ private static function integer(array $entity, string $field): int ++ { ++ if (!array_key_exists($field, $entity) || !is_int($entity[$field])) { ++ throw new \InvalidArgumentException('Formatted-date field must be a native integer: ' . $field); ++ } ++ return $entity[$field]; ++ } ++ ++ private static function flag(array $entity, string $field): bool ++ { ++ $value = array_key_exists($field, $entity) ? $entity[$field] : false; ++ if (!is_bool($value)) { ++ throw new \InvalidArgumentException('Formatted-date flag must be a native boolean: ' . $field); ++ } ++ return $value; ++ } ++} diff --git a/patches/madelineproto-formatted-date/manifest.json b/patches/madelineproto-formatted-date/manifest.json new file mode 100644 index 0000000..9121a7d --- /dev/null +++ b/patches/madelineproto-formatted-date/manifest.json @@ -0,0 +1,59 @@ +{ + "schema_version": 1, + "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" + } + ] + } + ], + "provenance": { + "prepared_at": "2026-10-14", + "implementation": "Independently implemented from the locked SDK entity contract and official Telegram layer225/BotAPI specifications.", + "reference_exports": [ + { + "path": "local-reference/newsearch-v2/docker/patch-madeline-formatted-date.php", + "sha256": "c3091f61a401d8022b8ef1d228cf8c5f31a1094982de69a0be108e21885cb967" + }, + { + "path": "local-reference/newsearch-v2/docker/madeline-formatted-date/FormattedDate.php", + "sha256": "a9e3146e49a88bec469e67e53f5e27cffb1b5127cde547590f2e385ef02dd264" + } + ], + "reference_limitations": "Byte-identical psy new/release-r21-work export; no recoverable Git history. Conflicting MIT/proprietary metadata without standalone grant. Historical code is not redistributed. Paths are logical local-reference identifiers, not public download URLs.", + "upstream_license_file": "upstream-LICENSE", + "upstream_license_sha256": "57c8ff33c9c0cfc3ef00e650a1cc910d7ee479a8bc509f6c9209a7c2a11399d6" + } +} diff --git a/patches/madelineproto-formatted-date/reviewed/FormattedDate.php b/patches/madelineproto-formatted-date/reviewed/FormattedDate.php new file mode 100644 index 0000000..a916a33 --- /dev/null +++ b/patches/madelineproto-formatted-date/reviewed/FormattedDate.php @@ -0,0 +1,112 @@ + 2147483647) { + throw new \InvalidArgumentException('Formatted-date fields require nonnegative native int32 values.'); + } + } + if (($relative && ($shortTime || $longTime || $shortDate || $longDate || $dayOfWeek)) + || ($shortTime && $longTime) || ($shortDate && $longDate)) { + throw new \InvalidArgumentException('Contradictory native formatted-date flags.'); + } + parent::__construct($offset, $length); + } + + public static function fromMTProto(array $entity): self + { + if (($entity['_'] ?? null) !== 'messageEntityFormattedDate') { + throw new \InvalidArgumentException('Expected the native formatted-date constructor.'); + } + return new self( + self::integer($entity, 'offset'), + self::integer($entity, 'length'), + self::integer($entity, 'date'), + self::flag($entity, 'relative'), + self::flag($entity, 'short_time'), + self::flag($entity, 'long_time'), + self::flag($entity, 'short_date'), + self::flag($entity, 'long_date'), + self::flag($entity, 'day_of_week') + ); + } + + public static function fromBotAPI(array $entity): self + { + $format = array_key_exists('date_time_format', $entity) ? $entity['date_time_format'] : ''; + if (($entity['type'] ?? null) !== 'date_time' || !is_string($format) + || preg_match('/^(?:r|w?[dD]?[tT]?)$/D', $format) !== 1) { + throw new \InvalidArgumentException('Expected an official date_time entity and valid date_time_format.'); + } + return new self( + self::integer($entity, 'offset'), + self::integer($entity, 'length'), + self::integer($entity, 'unix_time'), + $format === 'r', + str_contains($format, 't'), + str_contains($format, 'T'), + str_contains($format, 'd'), + str_contains($format, 'D'), + str_contains($format, 'w') + ); + } + + #[\Override] + public function toMTProto(): array + { + return ['_' => 'messageEntityFormattedDate', 'offset' => $this->offset, 'length' => $this->length, + 'date' => $this->date, 'relative' => $this->relative, 'short_time' => $this->shortTime, + 'long_time' => $this->longTime, 'short_date' => $this->shortDate, + 'long_date' => $this->longDate, 'day_of_week' => $this->dayOfWeek]; + } + + #[\Override] + public function toBotAPI(): array + { + $format = $this->relative ? 'r' : ($this->dayOfWeek ? 'w' : '') + . ($this->shortDate ? 'd' : ($this->longDate ? 'D' : '')) + . ($this->shortTime ? 't' : ($this->longTime ? 'T' : '')); + return ['type' => 'date_time', 'offset' => $this->offset, 'length' => $this->length, + 'unix_time' => $this->date, 'date_time_format' => $format]; + } + + private static function integer(array $entity, string $field): int + { + if (!array_key_exists($field, $entity) || !is_int($entity[$field])) { + throw new \InvalidArgumentException('Formatted-date field must be a native integer: ' . $field); + } + return $entity[$field]; + } + + private static function flag(array $entity, string $field): bool + { + $value = array_key_exists($field, $entity) ? $entity[$field] : false; + if (!is_bool($value)) { + throw new \InvalidArgumentException('Formatted-date flag must be a native boolean: ' . $field); + } + return $value; + } +} diff --git a/patches/madelineproto-formatted-date/reviewed/MessageEntity.php b/patches/madelineproto-formatted-date/reviewed/MessageEntity.php new file mode 100644 index 0000000..df0e159 --- /dev/null +++ b/patches/madelineproto-formatted-date/reviewed/MessageEntity.php @@ -0,0 +1,86 @@ + $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']), + 'date_time' => FormattedDate::fromBotAPI($entity), + 'messageEntityFormattedDate' => FormattedDate::fromMTProto($entity), + 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/upstream-LICENSE b/patches/madelineproto-formatted-date/upstream-LICENSE new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/patches/madelineproto-formatted-date/upstream-LICENSE @@ -0,0 +1,661 @@ + 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/patches/madelineproto-fresh-peer/README.md b/patches/madelineproto-fresh-peer/README.md new file mode 100644 index 0000000..9e19399 --- /dev/null +++ b/patches/madelineproto-fresh-peer/README.md @@ -0,0 +1,31 @@ +# MadelineProto 8.7.0 fresh full-peer response compatibility patch + +Reviewed: 2026-10-16. This is a new local WizardLoop Shabbat compatibility change, not a recovered historical patch or an upstream release. Package: `danog/madelineproto` 8.7.0, commit `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`. + +## Source and reason + +The immutable [upstream PeerHandler.php](https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/MTProtoTools/PeerHandler.php) was compared byte-for-byte with the installed locked source. Its SHA256 is `3e1af3624f2256f53264fe4256475d0a5f353af8761cc9716133b7f0412538d2`. Baseline and reviewed output are retained in this directory; the manifest binds the unified diff and both hashes. + +Upstream `getFullInfo` refreshes the full information but combines it with an earlier Chat object. Re-reading through `getInfo` is insufficient: the pinned minimal-peer branch can return its older local record after refreshing the peer cache. The bot must establish that the default permissions being saved came from the request it just completed. + +Direct `channels.getFullChannel` and `messages.getFullChat` namespace calls are blacklisted by the pinned SDK. This patch keeps the public `getFullInfo(mixed $id)` signature and uses its existing internal calls. It does not bypass that API boundary or change dependency versions. + +## Change + +When the full-info cache lifetime is zero, bypass cached full information even if its timestamp is in the future. Keep the immediate full-peer RPC response in a namespaced `_shabbat_fresh_full_peer_v1` return field for this call only. Do not persist that field in the peer cache. Other helper fields and normal positive-cache-lifetime behavior remain intact. + +The application reader validates the returned native constructor, matching signed peer ID, matching ChatFull object and complete non-minimal Chat. Cached records cannot replace the raw Chat, including when fresh complete data legitimately omits optional default restrictions. Malformed, forbidden, migrated, ambiguous and mismatched responses fail before a permission write. Individual restriction bits, including `manage_topics`, are not silently discarded. + +This fixes the reproduced reading defects. It does not prove which historical server event produced the user's broadly permissive dev.6 snapshot. Existing unverified snapshots require explicit administrator resolution; see [the dev.8 investigation](../../docs/DEV8_PERMISSIONS.md). + +## Installation and source offer + +The normal Composer lifecycle and Docker build run `scripts/apply-patches.php` then `scripts/verify-patches.php`; startup verifies only. For `--no-scripts`, run those two scripts explicitly after installation and before starting the bot. Original known bytes apply and verify; approved patched bytes succeed unchanged; unknown version, hash, partial patch or artifact tampering fails the build. No patch is downloaded from a changing URL. Never modify an active container/library tree. + +The exact original source, reviewed source, unified diff and manifest accompany the corresponding-source archive. The original upstream copyright/AGPL notices are retained. Local changes are identified here and in the patch comment; the modified source remains under its upstream AGPL grant. See [third-party notices](../../THIRD_PARTY_NOTICES.md). Vendor remains excluded from Git tracking. + +## Tests and limitations + +`FreshPeerReaderTest` executes the pinned SDK trait with controlled replies, including stale complete/minimal peer records, both basic-group and supergroup RPC paths, disabled/future cache timestamps, missing optional defaults, malformed/missing/minimal/forbidden/duplicate/mismatched replies, cancellation and timeout. `FreshPeerPatchTest` verifies clean apply, repeat, unknown source/version, partial edits and tampered patch evidence. Actual SDK declarations, TL schema and signed-ID decoding are checked separately. + +The tests do not authenticate or call Telegram. Real Telegram readback, client behavior and load acceptance must be checked with the isolated bot. Full frozen results and exact output hashes are recorded in the versioned validation report. diff --git a/patches/madelineproto-fresh-peer/baseline/PeerHandler.php b/patches/madelineproto-fresh-peer/baseline/PeerHandler.php new file mode 100644 index 0000000..5ead123 --- /dev/null +++ b/patches/madelineproto-fresh-peer/baseline/PeerHandler.php @@ -0,0 +1,946 @@ +. + * + * @author Daniil Gentili + * @copyright 2016-2025 Daniil Gentili + * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 + * @link https://docs.madelineproto.xyz MadelineProto documentation + */ + +namespace danog\MadelineProto\MTProtoTools; + +use AssertionError; +use danog\DialogId\DialogId; +use danog\MadelineProto\API; +use danog\MadelineProto\EventHandler\Message\Entities\InputMentionName; +use danog\MadelineProto\EventHandler\Message\Entities\MentionName; +use danog\MadelineProto\Exception; +use danog\MadelineProto\Logger; +use danog\MadelineProto\PeerNotInDbException; +use danog\MadelineProto\RPCError\ChannelPrivateError; +use danog\MadelineProto\RPCError\ChatAdminRequiredError; +use danog\MadelineProto\RPCError\ChatForbiddenError; +use danog\MadelineProto\RPCErrorException; +use danog\MadelineProto\Settings; +use danog\MadelineProto\Tools; +use Throwable; +use Webmozart\Assert\Assert; + +use const SORT_NUMERIC; +use function Amp\async; +use function Amp\Future\await; + +/** + * Manages peers. + * + * @property Settings $settings Settings + * @property PeerDatabase $peerDatabase + * + * @internal + */ +trait PeerHandler +{ + /** + * Set support info. + * + * @internal + * + * @param array $support Support info + * @internal + */ + public function addSupport(array $support): void + { + $this->supportUser = $support['user']['id']; + } + + /** + * Check if the specified peer is a forum. + * + */ + public function isForum(mixed $peer): bool + { + return $this->getInfo($peer, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)['forum'] ?? false; + } + + /** + * Check if the specified peer is a bot. + * + */ + public function isBot(mixed $peer): bool + { + return $this->getType($peer) === API::PEER_TYPE_BOT; + } + + /** + * Check if peer is present in internal peer database. + * + * @param mixed $id Peer + */ + public function peerIsset(mixed $id): bool + { + try { + return $this->peerDatabase->isset($this->getIdInternal($id)); + } catch (ChannelPrivateError|ChatForbiddenError) { + return true; + } catch (RPCErrorException|Exception) { + return false; + } + } + /** + * Check if all peer entities are in db. + * + * @param array $entities Entity list + * @internal + */ + public function entitiesPeerIsset(array $entities): bool + { + try { + foreach ($entities as $entity) { + if ($entity instanceof MentionName) { + if (!($this->peerIsset($entity->userId))) { + return false; + } + } elseif ($entity instanceof InputMentionName) { + if (!($this->peerIsset($entity->userId))) { + return false; + } + } elseif (\is_array($entity) && ($entity['_'] === 'messageEntityMentionName' || $entity['_'] === 'inputMessageEntityMentionName')) { + if (!($this->peerIsset($entity['user_id']))) { + return false; + } + } + } + } catch (Exception $e) { + return false; + } + return true; + } + /** + * Check if fwd peer is set. + * + * @param array $fwd Forward info + * @internal + */ + public function fwdPeerIsset(array $fwd) + { + try { + if (isset($fwd['user_id']) && !($this->peerIsset($fwd['user_id']))) { + return false; + } + if (isset($fwd['channel_id']) && !($this->peerIsset($fwd['channel_id']))) { + return false; + } + } catch (Exception $e) { + return false; + } + return true; + } + + /** + * Get the bot API ID of a peer. + * + * @param mixed $id Peer + */ + public function getId(mixed $id): int + { + if (\is_int($id)) { + return $id; + } + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_ID); + } + + /** + * Get bot API ID from peer object. + * + * @internal + * + * @param mixed $id Peer + */ + public function getIdInternal(mixed $id): ?int + { + if (\is_array($id)) { + switch ($id['_']) { + case 'updateDialogPinned': + case 'updateDialogUnreadMark': + case 'updateNotifySettings': + case 'updateDraftMessage': + case 'updateReadHistoryInbox': + case 'updateReadHistoryOutbox': + case 'updateChatDefaultBannedRights': + case 'updateDeleteScheduledMessages': + case 'updateSentStoryReaction': + case 'updateBotCommands': + case 'updateBotChatInviteRequester': + case 'updatePendingJoinRequests': + case 'updateStory': + case 'updatePinnedMessages': + case 'dialog': + case 'dialogPeer': + case 'notifyPeer': + case 'help.proxyDataPromo': + case 'folderPeer': + case 'inputDialogPeer': + case 'inputNotifyPeer': + case 'inputFolderPeer': + return $this->getIdInternal($id['peer']); + case 'inputUserSelf': + case 'inputPeerSelf': + return $this->authorization['user']['id']; + case 'user': + case 'userFull': + return $id['id']; + case 'messageActionChatJoinedByLink': + return $id['inviter_id']; + case 'chat': + case 'chatForbidden': + case 'chatFull': + return $id['id']; + case 'inputPeerChat': + case 'peerChat': + return $id['chat_id']; + case 'channelForbidden': + case 'channel': + case 'channelFull': + return $id['id']; + case 'updatePeerBlocked': + case 'message': + case 'messageService': + if (!isset($id['from_id']) // No other option + // It's a channel/chat, 100% what we need + || $id['peer_id'] < 0 + // It is a user, and it's not ourselves + || $id['peer_id'] !== $this->authorization['user']['id'] + ) { + return $id['peer_id']; + } + return $id['from_id']; + case 'peerChannel': + case 'requestedPeerChannel': + case 'inputChannel': + case 'inputPeerChannel': + case 'inputChannelFromMessage': + case 'inputPeerChannelFromMessage': + case 'updateChannelReadMessagesContents': + case 'updateChannelAvailableMessages': + case 'updateChannel': + case 'updateChannelWebPage': + case 'updateChannelMessageViews': + case 'updateReadChannelInbox': + case 'updateReadChannelOutbox': + case 'updateDeleteChannelMessages': + case 'updateChannelPinnedMessage': + case 'updateChannelTooLong': + case 'updateChannelParticipant': + case 'updatePinnedChannelMessages': + case 'updateChannelMessageForwards': + case 'updateChannelUserTyping': + return $id['channel_id']; + case 'updateChatParticipants': + $id = $id['participants']; + // no break + case 'updateChatUserTyping': + case 'updateChatParticipantAdd': + case 'updateChatParticipantDelete': + case 'updateChatParticipantAdmin': + case 'updateChatAdmins': + case 'updateChatPinnedMessage': + return $id['chat_id']; + case 'contact': + case 'updateUserTyping': + case 'updateUserStatus': + case 'updateUserName': + case 'updateUserPhoto': + case 'updateUserPhone': + case 'updateUserBlocked': + case 'updateContactRegistered': + case 'updateContactLink': + case 'updateBotInlineQuery': + case 'updateInlineBotCallbackQuery': + case 'updateBotInlineSend': + case 'updateBotCallbackQuery': + case 'updateBotPrecheckoutQuery': + case 'updateBotShippingQuery': + case 'updateUserPinnedMessage': + case 'updateUser': + case 'updateUserEmojiStatus': + case 'updateBotStopped': + case 'inputUserFromMessage': + case 'inputPeerUserFromMessage': + case 'inputPeerUser': + case 'inputUser': + case 'peerUser': + case 'requestedPeerUser': + case 'messageEntityMentionName': + case 'messageActionChatDeleteUser': + return $id['user_id']; + case 'updatePhoneCall': + return $id['phone_call']->getOtherID(); + case 'updateNewMessage': + case 'updateNewChannelMessage': + case 'updateEditMessage': + case 'updateEditChannelMessage': + return $this->getIdInternal($id['message']); + default: + throw new Exception('Invalid constructor given '.$id['_']); + } + } + if (is_numeric($id)) { + if (\is_string($id)) { + $id = (int) $id; + } + return $id; + } + return null; + } + /** + * Get InputPeer object. + * + * @internal + * @param mixed $id Peer + */ + public function getInputPeer(mixed $id) + { + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_PEER); + } + /** + * Get InputUser/InputChannel object. + * + * @internal + * @param mixed $id Peer + */ + public function getInputConstructor(mixed $id) + { + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR); + } + + public array $caching_full_info = []; + + /** + * Get type of peer. + * + * @param mixed $id Peer + * + * @return \danog\MadelineProto\API::PEER_TYPE_* + */ + public function getType(mixed $id): string + { + return $this->getInfo($id, API::INFO_TYPE_TYPE); + } + + /** + * Get info about peer, returns an Info object. + * + * If passed a secret chat ID, returns information about the user, not about the secret chat. + * Use getSecretChat to return information about the secret chat. + * + * @param mixed $id Peer + * @param \danog\MadelineProto\API::INFO_TYPE_* $type Whether to generate an Input*, an InputPeer or the full set of constructors + * @see https://docs.madelineproto.xyz/Info.html + * @return ($type is \danog\MadelineProto\API::INFO_TYPE_ALL ? array{ + * User?: array, + * Chat?: array, + * bot_api_id: int, + * user_id?: int, + * chat_id?: int, + * channel_id?: int, + * type: string + * } : ($type is API::INFO_TYPE_TYPE ? string : ($type is \danog\MadelineProto\API::INFO_TYPE_ID ? int : array{_: string, user_id?: int, access_hash?: int, min?: bool, chat_id?: int, channel_id?: int}|array{_: string, user_id?: int, access_hash?: int, min?: bool}|array{_: string, channel_id: int, access_hash: int, min: bool}))) + */ + public function getInfo(mixed $id, int $type = \danog\MadelineProto\API::INFO_TYPE_ALL): array|int|string + { + if (\is_array($id)) { + switch ($id['_']) { + case 'updateEncryption': + $id = $this->getSecretChat($id['chat']['id'])->otherID; + break; + case 'updateNewEncryptedMessage': + $id = $id['message']; + // no break + case 'inputEncryptedChat': + case 'updateEncryptedChatTyping': + case 'updateEncryptedMessagesRead': + case 'encryptedMessage': + case 'encryptedMessageService': + $id = $this->getSecretChat($id['chat_id'])->otherID; + break; + } + } + $try_id = $this->getIdInternal($id); + if ($try_id !== null) { + $id = $try_id; + } + if (is_numeric($id)) { + $id = (int) $id; + Assert::true($id !== 0, "An invalid ID was specified!"); + if (DialogId::isSecretChat($id)) { + $id = $this->getSecretChat($id)->otherID; + } + if (!$this->peerDatabase->isset($id)) { + try { + $this->logger->logger("Try fetching {$id} with access hash 0"); + if (DialogId::isSupergroupOrChannelOrMonoforum($id)) { + $this->peerDatabase->addChatBlocking($id); + } elseif ($id < 0) { + $this->methodCallAsyncRead('messages.getChats', ['id' => [-$id]]); + } else { + $this->peerDatabase->addUserBlocking($id); + } + } catch (Exception $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } catch (RPCErrorException $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } + } + $chat = $this->peerDatabase->get($id); + if (!$chat) { + if ($this->cacheFullDialogs()) { + return $this->getInfo($id, $type); + } + throw new PeerNotInDbException(); + } + if (($chat['min'] ?? false) + && $this->minDatabase->hasPeer($id) + && !isset($this->caching_full_info[$id]) + ) { + $this->caching_full_info[$id] = true; + $this->logger->logger("Only have min peer for {$id} in database, trying to fetch full info"); + try { + if ($id < 0) { + $this->methodCallAsyncRead('channels.getChannels', ['id' => [$this->genAll($chat, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)]]); + } else { + $this->methodCallAsyncRead('users.getUsers', ['id' => [$this->genAll($chat, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)]]); + } + } catch (Exception $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } catch (RPCErrorException $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } finally { + unset($this->caching_full_info[$id]); + } + } + try { + return $this->genAll($chat, $type); + } catch (PeerNotInDbException $e) { + $this->peerDatabase->clear($id); + throw $e; + } + } + if ($r = Tools::parseLink($id)) { + [$invite, $content] = $r; + if ($invite) { + $invite = $this->methodCallAsyncRead('messages.checkChatInvite', ['hash' => $content]); + if (isset($invite['chat'])) { + return $this->getInfo($invite['chat'], $type); + } + throw new Exception('You have not joined this chat'); + } else { + $id = $content; + } + } + if (\is_string($id)) { + $id = strtolower(str_replace('@', '', $id)); + if ($id === 'me') { + return $this->getInfo($this->authorization['user']['id'], $type); + } + if ($id === 'support') { + if (!$this->supportUser) { + $this->methodCallAsyncRead('help.getSupport', []); + } + return $this->getInfo($this->supportUser, $type); + } + if ($bot_api_id = $this->peerDatabase->getIdFromUsername($id)) { + return $this->getInfo($bot_api_id, $type); + } + if ($bot_api_id = $this->peerDatabase->resolveUsername($id)) { + return $this->getInfo($bot_api_id, $type); + } + } else { + return $this->getInfo($id, $type); + } + if ($this->cacheFullDialogs()) { + return $this->getInfo($id, $type); + } + throw new PeerNotInDbException(); + } + /** + * @param array $constructor + * @param \danog\MadelineProto\API::INFO_TYPE_* $type + * @return ($type is \danog\MadelineProto\API::INFO_TYPE_ALL ? (array{ + * User?: array, + * Chat?: array, + * bot_api_id: int, + * user_id?: int, + * chat_id?: int, + * channel_id?: int, + * type: string + * }&array) : array|int) + */ + private function genAll($constructor, int $type): array|int|string + { + if ($type === \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR) { + if ($constructor['_'] === 'user') { + return ($constructor['self'] ?? false) ? ['_' => 'inputUserSelf'] : ['_' => 'inputUser', 'user_id' => $constructor['id'], 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'channel') { + return ['_' => 'inputChannel', 'channel_id' => DialogId::toMTProtoId($constructor['id']), 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_PEER) { + if ($constructor['_'] === 'user') { + return ($constructor['self'] ?? false) ? ['_' => 'inputPeerSelf'] : ['_' => 'inputPeerUser', 'user_id' => $constructor['id'], 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'channel') { + return ['_' => 'inputPeerChannel', 'channel_id' => DialogId::toMTProtoId($constructor['id']), 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'chat' || $constructor['_'] === 'chatForbidden') { + return ['_' => 'inputPeerChat', 'chat_id' => -$constructor['id']]; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_ID) { + return $constructor['id']; + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_TYPE) { + if ($constructor['_'] === 'user') { + return $constructor['bot'] ?? false ? 'bot' : 'user'; + } + if ($constructor['_'] === 'channel') { + return $constructor['megagroup'] ?? false ? 'supergroup' : 'channel'; + } + if ($constructor['_'] === 'chat' || $constructor['_'] === 'chatForbidden') { + return 'chat'; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_USERNAMES) { + return PeerDatabase::getUsernames($constructor); + } + $res = [$this->TL->getConstructors()->findByPredicate($constructor['_'])['type'] => $constructor]; + switch ($constructor['_']) { + case 'user': + if (!isset($constructor['access_hash'])) { + $this->cacheFullDialogs(); + throw new PeerNotInDbException(); + } + $res['user_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = $constructor['bot'] ?? false ? 'bot' : 'user'; + break; + case 'chat': + case 'chatForbidden': + $res['chat_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = 'chat'; + break; + case 'channel': + if (!isset($constructor['access_hash'])) { + $this->cacheFullDialogs(); + throw new PeerNotInDbException(); + } + $res['channel_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = $constructor['megagroup'] ?? false ? 'supergroup' : 'channel'; + break; + case 'channelForbidden': + throw new PeerNotInDbException(); + default: + throw new Exception('Invalid constructor given '.$constructor['_']); + } + return $res; + } + /** + * Refresh full peer cache for a certain peer. + * + * @param mixed $id The peer to refresh + */ + public function refreshFullPeerCache(mixed $id): void + { + $this->peerDatabase->refreshFullPeerCache($id); + } + /** + * Refresh peer cache for a certain peer. + * + */ + public function refreshPeerCache(mixed ...$ids): void + { + $users = []; + $chats = []; + $supergroups = []; + foreach ($ids as $id) { + if (\is_string($id)) { + if ($r = Tools::parseLink($id)) { + [$invite, $content] = $r; + if ($invite) { + $invite = $this->methodCallAsyncRead('messages.checkChatInvite', ['hash' => $content]); + if (isset($invite['chat'])) { + continue; + } + throw new Exception('You have not joined this chat'); + } else { + $id = $content; + } + } + if (\is_string($id)) { + $id = strtolower(str_replace('@', '', $id)); + if ($id === 'me') { + $id = $this->authorization['user']['id']; + } elseif ($id === 'support') { + $this->methodCallAsyncRead('help.getSupport', []); + continue; + } else { + $id = $this->peerDatabase->resolveUsername($id); + if ($id === null) { + throw new PeerNotInDbException; + } + } + } + } + $id = $this->getIdInternal($id); + Assert::notNull($id); + switch (DialogId::getType($id)) { + case DialogId::CHANNEL_OR_SUPERGROUP: + $supergroups []= $id; + break; + case DialogId::MONOFORUM: + $supergroups []= $id; + break; + case DialogId::CHAT: + $chats []= $id; + break; + case DialogId::USER: + $users []= $id; + break; + default: + throw new AssertionError("Invalid ID $id!"); + } + } + if ($supergroups) { + $this->methodCallAsyncRead('channels.getChannels', ['id' => $supergroups]); + } + if ($chats) { + $this->methodCallAsyncRead('messages.getChats', ['id' => $chats]); + } + if ($users) { + $this->methodCallAsyncRead('users.getUsers', ['id' => $users]); + } + } + /** + * When was full info for this chat last cached. + * + * @param mixed $id Chat ID + */ + public function fullChatLastUpdated(mixed $id): int + { + return $this->peerDatabase->getFull($this->getIdInternal($id))['inserted'] ?? 0; + } + /** + * Get full info about peer, returns an FullInfo object. + * + * @param mixed $id Peer + * @see https://docs.madelineproto.xyz/FullInfo.html + */ + public function getFullInfo(mixed $id): array + { + $partial = $this->getInfo($id); + if (DialogId::isSecretChat($partial['bot_api_id'])) { + return $partial; + } + $full = $this->peerDatabase->getFull($partial['bot_api_id']); + if (time() - ($full['inserted'] ?? 0) < $this->getSettings()->getPeer()->getFullInfoCacheTime()) { + return array_merge($partial, $full); + } + switch ($partial['type']) { + case 'user': + case 'bot': + $this->methodCallAsyncRead('users.getFullUser', ['id' => $partial['bot_api_id']]); + break; + case 'chat': + $this->methodCallAsyncRead('messages.getFullChat', $partial); + break; + case 'channel': + case 'supergroup': + $this->methodCallAsyncRead('channels.getFullChannel', ['channel' => $partial['bot_api_id']]); + break; + } + return array_merge($partial, $this->peerDatabase->getFull($partial['bot_api_id'])); + } + /** + * Get full info about peer (including full list of channel members), returns a Chat object. + * + * @param mixed $id Peer + * @see https://docs.madelineproto.xyz/Chat.html + */ + public function getPwrChat(mixed $id, bool $fullfetch = true): array + { + try { + $full = $this->getSettings()->getDb()->getEnableFullPeerDb() ? $this->getFullInfo($id) : $this->getInfo($id); + } catch (Throwable) { + $full = $this->getInfo($id); + } + $res = ['id' => $full['bot_api_id'], 'type' => $full['type']]; + switch ($full['type']) { + case 'user': + case 'bot': + foreach (['first_name', 'last_name', 'username', 'usernames', 'verified', 'restricted', 'restriction_reason', 'status', 'bot_inline_placeholder', 'phone', 'lang_code', 'bot_nochats'] as $key) { + if (isset($full['User'][$key])) { + $res[$key] = $full['User'][$key]; + } + } + foreach (['about', 'bot_info', 'phone_calls_available', 'phone_calls_private', 'common_chats_count', 'can_pin_message', 'pinned_msg_id', 'notify_settings'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($full['full']['profile_photo'])) { + $res['photo'] = $full['full']['profile_photo']; + } + if (isset($full['full']['fallback_photo'])) { + $res['fallback_photo'] = $full['full']['fallback_photo']; + } + if (isset($full['full']['personal_photo'])) { + $res['personal_photo'] = $full['full']['personal_photo']; + } + break; + case 'chat': + foreach (['title', 'participants_count', 'admin', 'admins_enabled'] as $key) { + if (isset($full['Chat'][$key])) { + $res[$key] = $full['Chat'][$key]; + } + } + foreach (['bot_info', 'pinned_msg_id', 'notify_settings'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($res['admins_enabled'])) { + $res['all_members_are_administrators'] = !$res['admins_enabled']; + } + if (isset($full['full']['chat_photo'])) { + $res['photo'] = $full['full']['chat_photo']; + } + if (isset($full['full']['exported_invite']['link'])) { + $res['invite'] = $full['full']['exported_invite']['link']; + } + if (isset($full['full']['participants']['participants'])) { + $res['participants'] = $full['full']['participants']['participants']; + } + break; + case 'channel': + case 'supergroup': + foreach (['title', 'democracy', 'restricted', 'restriction_reason', 'username', 'usernames', 'signatures'] as $key) { + if (isset($full['Chat'][$key])) { + $res[$key] = $full['Chat'][$key]; + } + } + foreach (['read_inbox_max_id', 'read_outbox_max_id', 'hidden_prehistory', 'bot_info', 'notify_settings', 'can_set_stickers', 'stickerset', 'can_view_participants', 'can_set_username', 'participants_count', 'admins_count', 'kicked_count', 'banned_count', 'migrated_from_chat_id', 'migrated_from_max_id', 'pinned_msg_id', 'about', 'hidden_prehistory', 'available_min_id', 'can_view_stats', 'online_count'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($full['full']['chat_photo'])) { + $res['photo'] = $full['full']['chat_photo']; + } + if (isset($full['full']['exported_invite']['link'])) { + $res['invite'] = $full['full']['exported_invite']['link']; + } + break; + } + if (isset($res['participants']) && $fullfetch) { + foreach ($res['participants'] as $key => $participant) { + $newres = []; + $newres['user'] = ($this->getPwrChat($participant['user_id'] ?? $participant['peer'], false)); + if (isset($participant['inviter_id'])) { + $newres['inviter'] = ($this->getPwrChat($participant['inviter_id'], false)); + } + if (isset($participant['kicked_by'])) { + $newres['kicked_by'] = ($this->getPwrChat($participant['kicked_by'], false)); + } + if (isset($participant['date'])) { + $newres['date'] = $participant['date']; + } + switch ($participant['_']) { + case 'chatParticipant': + $newres['role'] = 'user'; + break; + case 'chatParticipantAdmin': + $newres['role'] = 'admin'; + break; + case 'chatParticipantCreator': + $newres['role'] = 'creator'; + break; + } + $res['participants'][$key] = $newres; + } + } + if (!isset($res['participants']) && $fullfetch && \in_array($res['type'], ['supergroup', 'channel'], true)) { + $total_count = ($res['participants_count'] ?? 0) + ($res['admins_count'] ?? 0) + ($res['kicked_count'] ?? 0) + ($res['banned_count'] ?? 0); + $res['participants'] = []; + $filters = ['channelParticipantsAdmins', 'channelParticipantsBots']; + $promises = []; + foreach ($filters as $filter) { + $promises []= async(function () use ($filter, $total_count, &$res): void { + $this->fetchParticipants($res['id'], $filter, '', $total_count, $res); + }); + } + await($promises); + + $q = ''; + $filters = ['channelParticipantsSearch', 'channelParticipantsKicked', 'channelParticipantsBanned']; + $promises = []; + foreach ($filters as $filter) { + $promises []= async(function () use ($filter, $q, $total_count, &$res): void { + $this->recurseAlphabetSearchParticipants($res['id'], $filter, $q, $total_count, $res, 0); + }); + } + await($promises); + + $this->logger->logger('Fetched '.\count($res['participants'])." out of {$total_count}"); + $res['participants'] = array_values($res['participants']); + } + if (!$fullfetch) { + unset($res['participants']); + } + return $res; + } + private function recurseAlphabetSearchParticipants(int $channel, string $filter, string $q, int $total_count, array &$res, int $depth): array + { + if (!($this->fetchParticipants($channel, $filter, $q, $total_count, $res))) { + return []; + } + ++$depth; + $promises = []; + for ($x = 'a'; $x !== 'aa' && $total_count > \count($res['participants']); $x++) { + $promises []= async(function () use ($channel, $filter, $q, $x, $total_count, &$res, $depth) { + return $this->recurseAlphabetSearchParticipants($channel, $filter, $q.$x, $total_count, $res, $depth); + }); + } + + if ($depth > 3) { + return $promises; + } + + $yielded = array_merge(...await($promises)); + while ($yielded) { + $newYielded = []; + + foreach (array_chunk($yielded, 10) as $promises) { + $newYielded = array_merge($newYielded, ...(await($promises))); + } + + $yielded = $newYielded; + } + + return []; + } + private function fetchParticipants(int $channel, string $filter, string $q, int $total_count, array &$res): bool + { + $offset = 0; + $limit = 200; + $has_more = false; + $cached = false; + $last_count = -1; + do { + try { + $gres = $this->methodCallAsyncRead('channels.getParticipants', ['channel' => $channel, 'filter' => ['_' => $filter, 'q' => $q], 'offset' => $offset, 'limit' => $limit, 'hash' => $hash = $this->getParticipantsHash($channel, $filter, $q, $offset, $limit), 'floodWaitLimit' => 86400]); + } catch (ChatAdminRequiredError) { + $this->logger->logger('CHAT_ADMIN_REQUIRED'); + return $has_more; + } + if ($cached = ($gres['_'] === 'channels.channelParticipantsNotModified')) { + $gres = $this->fetchParticipantsCache($channel, $filter, $q, $offset, $limit); + } else { + $this->storeParticipantsCache($gres, $channel, $filter, $q, $offset, $limit); + } + \assert($gres !== null); + if ($last_count !== -1 && $last_count !== $gres['count']) { + $has_more = true; + } else { + $last_count = $gres['count']; + } + $promises = []; + foreach ($gres['participants'] as $participant) { + $promises []= async(function () use (&$res, $participant): void { + $newres = $participant; + unset($newres['user_id'], $newres['peer']); + $newres['user'] = ($this->getPwrChat($participant['user_id'] ?? $participant['peer'], false)); + if (isset($participant['inviter_id'])) { + unset($newres['inviter_id']); + $newres['inviter'] = ($this->getPwrChat($participant['inviter_id'], false)); + } + if (isset($participant['kicked_by'])) { + unset($newres['kicked_by']); + $newres['kicked_by'] = ($this->getPwrChat($participant['kicked_by'], false)); + } + if (isset($participant['promoted_by'])) { + unset($newres['promoted_by']); + $newres['promoted_by'] = ($this->getPwrChat($participant['promoted_by'], false)); + } + switch ($participant['_']) { + case 'channelParticipantSelf': + $newres['role'] = 'user'; + break; + case 'channelParticipant': + $newres['role'] = 'user'; + break; + case 'channelParticipantCreator': + $newres['role'] = 'creator'; + break; + case 'channelParticipantAdmin': + $newres['role'] = 'admin'; + break; + case 'channelParticipantBanned': + $newres['role'] = 'banned'; + break; + } + $res['participants'][$participant['user_id'] ?? $this->getIdInternal($participant['peer'])] = $newres; + }); + } + await($promises); + $h = $hash !== null ? 'present' : 'absent'; + $this->logger->logger('Fetched '.\count($gres['participants'])." channel participants with filter {$filter}, query {$q}, offset {$offset}, limit {$limit}, hash {$h}: ".($cached ? 'cached' : 'not cached').', '.($offset + \count($gres['participants'])).' participants out of '.$gres['count'].', in total fetched '.\count($res['participants']).' out of '.$total_count); + $offset += \count($gres['participants']); + } while (\count($gres['participants'])); + if ($offset === $limit) { + return true; + } + return $has_more; + } + /** + * Key for participatns cache. + */ + private static function participantsKey(int $channelId, string $filter, string $q, int $offset, int $limit): string + { + return "$channelId'$filter'$q'$offset'$limit"; + } + private function fetchParticipantsCache(int $channel, string $filter, string $q, int $offset, int $limit): ?array + { + return $this->channelParticipants[$this->participantsKey($channel, $filter, $q, $offset, $limit)]; + } + private function storeParticipantsCache(array $gres, int $channel, string $filter, string $q, int $offset, int $limit): void + { + unset($gres['users']); + $ids = []; + foreach ($gres['participants'] as $participant) { + if (isset($participant['user_id'])) { + $ids[] = $participant['user_id']; + } + } + sort($ids, SORT_NUMERIC); + /** @psalm-suppress MixedArgumentTypeCoercion Typechecks are done inside */ + $gres['hash'] = Tools::genVectorHash($ids); + $this->channelParticipants[$this->participantsKey($channel, $filter, $q, $offset, $limit)] = $gres; + } + private function getParticipantsHash(int $channel, string $filter, string $q, int $offset, int $limit): ?string + { + return $this->fetchParticipantsCache($channel, $filter, $q, $offset, $limit)['hash'] ?? null; + } +} diff --git a/patches/madelineproto-fresh-peer/fresh-full-peer-response.patch b/patches/madelineproto-fresh-peer/fresh-full-peer-response.patch new file mode 100644 index 0000000..d04ab91 --- /dev/null +++ b/patches/madelineproto-fresh-peer/fresh-full-peer-response.patch @@ -0,0 +1,61 @@ +--- a/src/MTProtoTools/PeerHandler.php ++++ b/src/MTProtoTools/PeerHandler.php +@@ -651,25 +651,33 @@ +- public function getFullInfo(mixed $id): array +- { +- $partial = $this->getInfo($id); +- if (DialogId::isSecretChat($partial['bot_api_id'])) { +- return $partial; +- } +- $full = $this->peerDatabase->getFull($partial['bot_api_id']); +- if (time() - ($full['inserted'] ?? 0) < $this->getSettings()->getPeer()->getFullInfoCacheTime()) { +- return array_merge($partial, $full); +- } +- switch ($partial['type']) { +- case 'user': +- case 'bot': +- $this->methodCallAsyncRead('users.getFullUser', ['id' => $partial['bot_api_id']]); +- break; +- case 'chat': +- $this->methodCallAsyncRead('messages.getFullChat', $partial); +- break; +- case 'channel': +- case 'supergroup': +- $this->methodCallAsyncRead('channels.getFullChannel', ['channel' => $partial['bot_api_id']]); +- break; +- } +- return array_merge($partial, $this->peerDatabase->getFull($partial['bot_api_id'])); +- } ++ public function getFullInfo(mixed $id): array ++ { ++ $partial = $this->getInfo($id); ++ if (DialogId::isSecretChat($partial['bot_api_id'])) { ++ return $partial; ++ } ++ $full = $this->peerDatabase->getFull($partial['bot_api_id']); ++ $cacheTime = $this->getSettings()->getPeer()->getFullInfoCacheTime(); ++ if ($cacheTime > 0 && time() - ($full['inserted'] ?? 0) < $cacheTime) { ++ return array_merge($partial, $full); ++ } ++ $freshReply = null; ++ switch ($partial['type']) { ++ case 'user': ++ case 'bot': ++ $this->methodCallAsyncRead('users.getFullUser', ['id' => $partial['bot_api_id']]); ++ break; ++ case 'chat': ++ $freshReply = $this->methodCallAsyncRead('messages.getFullChat', $partial); ++ break; ++ case 'channel': ++ case 'supergroup': ++ $freshReply = $this->methodCallAsyncRead('channels.getFullChannel', ['channel' => $partial['bot_api_id']]); ++ break; ++ } ++ $result = array_merge($partial, $this->peerDatabase->getFull($partial['bot_api_id'])); ++ // Shabbat local compatibility patch: never persist this per-call RPC evidence. ++ // Keep the public helper signature and ordinary cache semantics unchanged. ++ if ($cacheTime === 0 && $freshReply !== null) { ++ $result['_shabbat_fresh_full_peer_v1'] = $freshReply; ++ } ++ return $result; ++ } diff --git a/patches/madelineproto-fresh-peer/manifest.json b/patches/madelineproto-fresh-peer/manifest.json new file mode 100644 index 0000000..1d74345 --- /dev/null +++ b/patches/madelineproto-fresh-peer/manifest.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "reviewed_at": "2026-10-16", + "package": "danog/madelineproto", + "target": "src/MTProtoTools/PeerHandler.php", + "provenance": { + "kind": "new-local-authoritative-full-peer-response", + "source_url": "https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/MTProtoTools/PeerHandler.php", + "source_verified_against_locked_installed_file": true, + "source_verified_against_pinned_remote_bytes": true, + "source_verified_sha256": "3e1af3624f2256f53264fe4256475d0a5f353af8761cc9716133b7f0412538d2" + }, + "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" + } + ], + "official_versions": [] +} diff --git a/patches/madelineproto-fresh-peer/reviewed/PeerHandler.php b/patches/madelineproto-fresh-peer/reviewed/PeerHandler.php new file mode 100644 index 0000000..254eddb --- /dev/null +++ b/patches/madelineproto-fresh-peer/reviewed/PeerHandler.php @@ -0,0 +1,954 @@ +. + * + * @author Daniil Gentili + * @copyright 2016-2025 Daniil Gentili + * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 + * @link https://docs.madelineproto.xyz MadelineProto documentation + */ + +namespace danog\MadelineProto\MTProtoTools; + +use AssertionError; +use danog\DialogId\DialogId; +use danog\MadelineProto\API; +use danog\MadelineProto\EventHandler\Message\Entities\InputMentionName; +use danog\MadelineProto\EventHandler\Message\Entities\MentionName; +use danog\MadelineProto\Exception; +use danog\MadelineProto\Logger; +use danog\MadelineProto\PeerNotInDbException; +use danog\MadelineProto\RPCError\ChannelPrivateError; +use danog\MadelineProto\RPCError\ChatAdminRequiredError; +use danog\MadelineProto\RPCError\ChatForbiddenError; +use danog\MadelineProto\RPCErrorException; +use danog\MadelineProto\Settings; +use danog\MadelineProto\Tools; +use Throwable; +use Webmozart\Assert\Assert; + +use const SORT_NUMERIC; +use function Amp\async; +use function Amp\Future\await; + +/** + * Manages peers. + * + * @property Settings $settings Settings + * @property PeerDatabase $peerDatabase + * + * @internal + */ +trait PeerHandler +{ + /** + * Set support info. + * + * @internal + * + * @param array $support Support info + * @internal + */ + public function addSupport(array $support): void + { + $this->supportUser = $support['user']['id']; + } + + /** + * Check if the specified peer is a forum. + * + */ + public function isForum(mixed $peer): bool + { + return $this->getInfo($peer, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)['forum'] ?? false; + } + + /** + * Check if the specified peer is a bot. + * + */ + public function isBot(mixed $peer): bool + { + return $this->getType($peer) === API::PEER_TYPE_BOT; + } + + /** + * Check if peer is present in internal peer database. + * + * @param mixed $id Peer + */ + public function peerIsset(mixed $id): bool + { + try { + return $this->peerDatabase->isset($this->getIdInternal($id)); + } catch (ChannelPrivateError|ChatForbiddenError) { + return true; + } catch (RPCErrorException|Exception) { + return false; + } + } + /** + * Check if all peer entities are in db. + * + * @param array $entities Entity list + * @internal + */ + public function entitiesPeerIsset(array $entities): bool + { + try { + foreach ($entities as $entity) { + if ($entity instanceof MentionName) { + if (!($this->peerIsset($entity->userId))) { + return false; + } + } elseif ($entity instanceof InputMentionName) { + if (!($this->peerIsset($entity->userId))) { + return false; + } + } elseif (\is_array($entity) && ($entity['_'] === 'messageEntityMentionName' || $entity['_'] === 'inputMessageEntityMentionName')) { + if (!($this->peerIsset($entity['user_id']))) { + return false; + } + } + } + } catch (Exception $e) { + return false; + } + return true; + } + /** + * Check if fwd peer is set. + * + * @param array $fwd Forward info + * @internal + */ + public function fwdPeerIsset(array $fwd) + { + try { + if (isset($fwd['user_id']) && !($this->peerIsset($fwd['user_id']))) { + return false; + } + if (isset($fwd['channel_id']) && !($this->peerIsset($fwd['channel_id']))) { + return false; + } + } catch (Exception $e) { + return false; + } + return true; + } + + /** + * Get the bot API ID of a peer. + * + * @param mixed $id Peer + */ + public function getId(mixed $id): int + { + if (\is_int($id)) { + return $id; + } + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_ID); + } + + /** + * Get bot API ID from peer object. + * + * @internal + * + * @param mixed $id Peer + */ + public function getIdInternal(mixed $id): ?int + { + if (\is_array($id)) { + switch ($id['_']) { + case 'updateDialogPinned': + case 'updateDialogUnreadMark': + case 'updateNotifySettings': + case 'updateDraftMessage': + case 'updateReadHistoryInbox': + case 'updateReadHistoryOutbox': + case 'updateChatDefaultBannedRights': + case 'updateDeleteScheduledMessages': + case 'updateSentStoryReaction': + case 'updateBotCommands': + case 'updateBotChatInviteRequester': + case 'updatePendingJoinRequests': + case 'updateStory': + case 'updatePinnedMessages': + case 'dialog': + case 'dialogPeer': + case 'notifyPeer': + case 'help.proxyDataPromo': + case 'folderPeer': + case 'inputDialogPeer': + case 'inputNotifyPeer': + case 'inputFolderPeer': + return $this->getIdInternal($id['peer']); + case 'inputUserSelf': + case 'inputPeerSelf': + return $this->authorization['user']['id']; + case 'user': + case 'userFull': + return $id['id']; + case 'messageActionChatJoinedByLink': + return $id['inviter_id']; + case 'chat': + case 'chatForbidden': + case 'chatFull': + return $id['id']; + case 'inputPeerChat': + case 'peerChat': + return $id['chat_id']; + case 'channelForbidden': + case 'channel': + case 'channelFull': + return $id['id']; + case 'updatePeerBlocked': + case 'message': + case 'messageService': + if (!isset($id['from_id']) // No other option + // It's a channel/chat, 100% what we need + || $id['peer_id'] < 0 + // It is a user, and it's not ourselves + || $id['peer_id'] !== $this->authorization['user']['id'] + ) { + return $id['peer_id']; + } + return $id['from_id']; + case 'peerChannel': + case 'requestedPeerChannel': + case 'inputChannel': + case 'inputPeerChannel': + case 'inputChannelFromMessage': + case 'inputPeerChannelFromMessage': + case 'updateChannelReadMessagesContents': + case 'updateChannelAvailableMessages': + case 'updateChannel': + case 'updateChannelWebPage': + case 'updateChannelMessageViews': + case 'updateReadChannelInbox': + case 'updateReadChannelOutbox': + case 'updateDeleteChannelMessages': + case 'updateChannelPinnedMessage': + case 'updateChannelTooLong': + case 'updateChannelParticipant': + case 'updatePinnedChannelMessages': + case 'updateChannelMessageForwards': + case 'updateChannelUserTyping': + return $id['channel_id']; + case 'updateChatParticipants': + $id = $id['participants']; + // no break + case 'updateChatUserTyping': + case 'updateChatParticipantAdd': + case 'updateChatParticipantDelete': + case 'updateChatParticipantAdmin': + case 'updateChatAdmins': + case 'updateChatPinnedMessage': + return $id['chat_id']; + case 'contact': + case 'updateUserTyping': + case 'updateUserStatus': + case 'updateUserName': + case 'updateUserPhoto': + case 'updateUserPhone': + case 'updateUserBlocked': + case 'updateContactRegistered': + case 'updateContactLink': + case 'updateBotInlineQuery': + case 'updateInlineBotCallbackQuery': + case 'updateBotInlineSend': + case 'updateBotCallbackQuery': + case 'updateBotPrecheckoutQuery': + case 'updateBotShippingQuery': + case 'updateUserPinnedMessage': + case 'updateUser': + case 'updateUserEmojiStatus': + case 'updateBotStopped': + case 'inputUserFromMessage': + case 'inputPeerUserFromMessage': + case 'inputPeerUser': + case 'inputUser': + case 'peerUser': + case 'requestedPeerUser': + case 'messageEntityMentionName': + case 'messageActionChatDeleteUser': + return $id['user_id']; + case 'updatePhoneCall': + return $id['phone_call']->getOtherID(); + case 'updateNewMessage': + case 'updateNewChannelMessage': + case 'updateEditMessage': + case 'updateEditChannelMessage': + return $this->getIdInternal($id['message']); + default: + throw new Exception('Invalid constructor given '.$id['_']); + } + } + if (is_numeric($id)) { + if (\is_string($id)) { + $id = (int) $id; + } + return $id; + } + return null; + } + /** + * Get InputPeer object. + * + * @internal + * @param mixed $id Peer + */ + public function getInputPeer(mixed $id) + { + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_PEER); + } + /** + * Get InputUser/InputChannel object. + * + * @internal + * @param mixed $id Peer + */ + public function getInputConstructor(mixed $id) + { + return $this->getInfo($id, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR); + } + + public array $caching_full_info = []; + + /** + * Get type of peer. + * + * @param mixed $id Peer + * + * @return \danog\MadelineProto\API::PEER_TYPE_* + */ + public function getType(mixed $id): string + { + return $this->getInfo($id, API::INFO_TYPE_TYPE); + } + + /** + * Get info about peer, returns an Info object. + * + * If passed a secret chat ID, returns information about the user, not about the secret chat. + * Use getSecretChat to return information about the secret chat. + * + * @param mixed $id Peer + * @param \danog\MadelineProto\API::INFO_TYPE_* $type Whether to generate an Input*, an InputPeer or the full set of constructors + * @see https://docs.madelineproto.xyz/Info.html + * @return ($type is \danog\MadelineProto\API::INFO_TYPE_ALL ? array{ + * User?: array, + * Chat?: array, + * bot_api_id: int, + * user_id?: int, + * chat_id?: int, + * channel_id?: int, + * type: string + * } : ($type is API::INFO_TYPE_TYPE ? string : ($type is \danog\MadelineProto\API::INFO_TYPE_ID ? int : array{_: string, user_id?: int, access_hash?: int, min?: bool, chat_id?: int, channel_id?: int}|array{_: string, user_id?: int, access_hash?: int, min?: bool}|array{_: string, channel_id: int, access_hash: int, min: bool}))) + */ + public function getInfo(mixed $id, int $type = \danog\MadelineProto\API::INFO_TYPE_ALL): array|int|string + { + if (\is_array($id)) { + switch ($id['_']) { + case 'updateEncryption': + $id = $this->getSecretChat($id['chat']['id'])->otherID; + break; + case 'updateNewEncryptedMessage': + $id = $id['message']; + // no break + case 'inputEncryptedChat': + case 'updateEncryptedChatTyping': + case 'updateEncryptedMessagesRead': + case 'encryptedMessage': + case 'encryptedMessageService': + $id = $this->getSecretChat($id['chat_id'])->otherID; + break; + } + } + $try_id = $this->getIdInternal($id); + if ($try_id !== null) { + $id = $try_id; + } + if (is_numeric($id)) { + $id = (int) $id; + Assert::true($id !== 0, "An invalid ID was specified!"); + if (DialogId::isSecretChat($id)) { + $id = $this->getSecretChat($id)->otherID; + } + if (!$this->peerDatabase->isset($id)) { + try { + $this->logger->logger("Try fetching {$id} with access hash 0"); + if (DialogId::isSupergroupOrChannelOrMonoforum($id)) { + $this->peerDatabase->addChatBlocking($id); + } elseif ($id < 0) { + $this->methodCallAsyncRead('messages.getChats', ['id' => [-$id]]); + } else { + $this->peerDatabase->addUserBlocking($id); + } + } catch (Exception $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } catch (RPCErrorException $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } + } + $chat = $this->peerDatabase->get($id); + if (!$chat) { + if ($this->cacheFullDialogs()) { + return $this->getInfo($id, $type); + } + throw new PeerNotInDbException(); + } + if (($chat['min'] ?? false) + && $this->minDatabase->hasPeer($id) + && !isset($this->caching_full_info[$id]) + ) { + $this->caching_full_info[$id] = true; + $this->logger->logger("Only have min peer for {$id} in database, trying to fetch full info"); + try { + if ($id < 0) { + $this->methodCallAsyncRead('channels.getChannels', ['id' => [$this->genAll($chat, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)]]); + } else { + $this->methodCallAsyncRead('users.getUsers', ['id' => [$this->genAll($chat, \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR)]]); + } + } catch (Exception $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } catch (RPCErrorException $e) { + $this->logger->logger($e->getMessage(), Logger::WARNING); + } finally { + unset($this->caching_full_info[$id]); + } + } + try { + return $this->genAll($chat, $type); + } catch (PeerNotInDbException $e) { + $this->peerDatabase->clear($id); + throw $e; + } + } + if ($r = Tools::parseLink($id)) { + [$invite, $content] = $r; + if ($invite) { + $invite = $this->methodCallAsyncRead('messages.checkChatInvite', ['hash' => $content]); + if (isset($invite['chat'])) { + return $this->getInfo($invite['chat'], $type); + } + throw new Exception('You have not joined this chat'); + } else { + $id = $content; + } + } + if (\is_string($id)) { + $id = strtolower(str_replace('@', '', $id)); + if ($id === 'me') { + return $this->getInfo($this->authorization['user']['id'], $type); + } + if ($id === 'support') { + if (!$this->supportUser) { + $this->methodCallAsyncRead('help.getSupport', []); + } + return $this->getInfo($this->supportUser, $type); + } + if ($bot_api_id = $this->peerDatabase->getIdFromUsername($id)) { + return $this->getInfo($bot_api_id, $type); + } + if ($bot_api_id = $this->peerDatabase->resolveUsername($id)) { + return $this->getInfo($bot_api_id, $type); + } + } else { + return $this->getInfo($id, $type); + } + if ($this->cacheFullDialogs()) { + return $this->getInfo($id, $type); + } + throw new PeerNotInDbException(); + } + /** + * @param array $constructor + * @param \danog\MadelineProto\API::INFO_TYPE_* $type + * @return ($type is \danog\MadelineProto\API::INFO_TYPE_ALL ? (array{ + * User?: array, + * Chat?: array, + * bot_api_id: int, + * user_id?: int, + * chat_id?: int, + * channel_id?: int, + * type: string + * }&array) : array|int) + */ + private function genAll($constructor, int $type): array|int|string + { + if ($type === \danog\MadelineProto\API::INFO_TYPE_CONSTRUCTOR) { + if ($constructor['_'] === 'user') { + return ($constructor['self'] ?? false) ? ['_' => 'inputUserSelf'] : ['_' => 'inputUser', 'user_id' => $constructor['id'], 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'channel') { + return ['_' => 'inputChannel', 'channel_id' => DialogId::toMTProtoId($constructor['id']), 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_PEER) { + if ($constructor['_'] === 'user') { + return ($constructor['self'] ?? false) ? ['_' => 'inputPeerSelf'] : ['_' => 'inputPeerUser', 'user_id' => $constructor['id'], 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'channel') { + return ['_' => 'inputPeerChannel', 'channel_id' => DialogId::toMTProtoId($constructor['id']), 'access_hash' => $constructor['access_hash'], 'min' => $constructor['min'] ?? false]; + } + if ($constructor['_'] === 'chat' || $constructor['_'] === 'chatForbidden') { + return ['_' => 'inputPeerChat', 'chat_id' => -$constructor['id']]; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_ID) { + return $constructor['id']; + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_TYPE) { + if ($constructor['_'] === 'user') { + return $constructor['bot'] ?? false ? 'bot' : 'user'; + } + if ($constructor['_'] === 'channel') { + return $constructor['megagroup'] ?? false ? 'supergroup' : 'channel'; + } + if ($constructor['_'] === 'chat' || $constructor['_'] === 'chatForbidden') { + return 'chat'; + } + } + if ($type === \danog\MadelineProto\API::INFO_TYPE_USERNAMES) { + return PeerDatabase::getUsernames($constructor); + } + $res = [$this->TL->getConstructors()->findByPredicate($constructor['_'])['type'] => $constructor]; + switch ($constructor['_']) { + case 'user': + if (!isset($constructor['access_hash'])) { + $this->cacheFullDialogs(); + throw new PeerNotInDbException(); + } + $res['user_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = $constructor['bot'] ?? false ? 'bot' : 'user'; + break; + case 'chat': + case 'chatForbidden': + $res['chat_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = 'chat'; + break; + case 'channel': + if (!isset($constructor['access_hash'])) { + $this->cacheFullDialogs(); + throw new PeerNotInDbException(); + } + $res['channel_id'] = $constructor['id']; + $res['bot_api_id'] = $constructor['id']; + $res['type'] = $constructor['megagroup'] ?? false ? 'supergroup' : 'channel'; + break; + case 'channelForbidden': + throw new PeerNotInDbException(); + default: + throw new Exception('Invalid constructor given '.$constructor['_']); + } + return $res; + } + /** + * Refresh full peer cache for a certain peer. + * + * @param mixed $id The peer to refresh + */ + public function refreshFullPeerCache(mixed $id): void + { + $this->peerDatabase->refreshFullPeerCache($id); + } + /** + * Refresh peer cache for a certain peer. + * + */ + public function refreshPeerCache(mixed ...$ids): void + { + $users = []; + $chats = []; + $supergroups = []; + foreach ($ids as $id) { + if (\is_string($id)) { + if ($r = Tools::parseLink($id)) { + [$invite, $content] = $r; + if ($invite) { + $invite = $this->methodCallAsyncRead('messages.checkChatInvite', ['hash' => $content]); + if (isset($invite['chat'])) { + continue; + } + throw new Exception('You have not joined this chat'); + } else { + $id = $content; + } + } + if (\is_string($id)) { + $id = strtolower(str_replace('@', '', $id)); + if ($id === 'me') { + $id = $this->authorization['user']['id']; + } elseif ($id === 'support') { + $this->methodCallAsyncRead('help.getSupport', []); + continue; + } else { + $id = $this->peerDatabase->resolveUsername($id); + if ($id === null) { + throw new PeerNotInDbException; + } + } + } + } + $id = $this->getIdInternal($id); + Assert::notNull($id); + switch (DialogId::getType($id)) { + case DialogId::CHANNEL_OR_SUPERGROUP: + $supergroups []= $id; + break; + case DialogId::MONOFORUM: + $supergroups []= $id; + break; + case DialogId::CHAT: + $chats []= $id; + break; + case DialogId::USER: + $users []= $id; + break; + default: + throw new AssertionError("Invalid ID $id!"); + } + } + if ($supergroups) { + $this->methodCallAsyncRead('channels.getChannels', ['id' => $supergroups]); + } + if ($chats) { + $this->methodCallAsyncRead('messages.getChats', ['id' => $chats]); + } + if ($users) { + $this->methodCallAsyncRead('users.getUsers', ['id' => $users]); + } + } + /** + * When was full info for this chat last cached. + * + * @param mixed $id Chat ID + */ + public function fullChatLastUpdated(mixed $id): int + { + return $this->peerDatabase->getFull($this->getIdInternal($id))['inserted'] ?? 0; + } + /** + * Get full info about peer, returns an FullInfo object. + * + * @param mixed $id Peer + * @see https://docs.madelineproto.xyz/FullInfo.html + */ + public function getFullInfo(mixed $id): array + { + $partial = $this->getInfo($id); + if (DialogId::isSecretChat($partial['bot_api_id'])) { + return $partial; + } + $full = $this->peerDatabase->getFull($partial['bot_api_id']); + $cacheTime = $this->getSettings()->getPeer()->getFullInfoCacheTime(); + if ($cacheTime > 0 && time() - ($full['inserted'] ?? 0) < $cacheTime) { + return array_merge($partial, $full); + } + $freshReply = null; + switch ($partial['type']) { + case 'user': + case 'bot': + $this->methodCallAsyncRead('users.getFullUser', ['id' => $partial['bot_api_id']]); + break; + case 'chat': + $freshReply = $this->methodCallAsyncRead('messages.getFullChat', $partial); + break; + case 'channel': + case 'supergroup': + $freshReply = $this->methodCallAsyncRead('channels.getFullChannel', ['channel' => $partial['bot_api_id']]); + break; + } + $result = array_merge($partial, $this->peerDatabase->getFull($partial['bot_api_id'])); + // Shabbat local compatibility patch: never persist this per-call RPC evidence. + // Keep the public helper signature and ordinary cache semantics unchanged. + if ($cacheTime === 0 && $freshReply !== null) { + $result['_shabbat_fresh_full_peer_v1'] = $freshReply; + } + return $result; + } + /** + * Get full info about peer (including full list of channel members), returns a Chat object. + * + * @param mixed $id Peer + * @see https://docs.madelineproto.xyz/Chat.html + */ + public function getPwrChat(mixed $id, bool $fullfetch = true): array + { + try { + $full = $this->getSettings()->getDb()->getEnableFullPeerDb() ? $this->getFullInfo($id) : $this->getInfo($id); + } catch (Throwable) { + $full = $this->getInfo($id); + } + $res = ['id' => $full['bot_api_id'], 'type' => $full['type']]; + switch ($full['type']) { + case 'user': + case 'bot': + foreach (['first_name', 'last_name', 'username', 'usernames', 'verified', 'restricted', 'restriction_reason', 'status', 'bot_inline_placeholder', 'phone', 'lang_code', 'bot_nochats'] as $key) { + if (isset($full['User'][$key])) { + $res[$key] = $full['User'][$key]; + } + } + foreach (['about', 'bot_info', 'phone_calls_available', 'phone_calls_private', 'common_chats_count', 'can_pin_message', 'pinned_msg_id', 'notify_settings'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($full['full']['profile_photo'])) { + $res['photo'] = $full['full']['profile_photo']; + } + if (isset($full['full']['fallback_photo'])) { + $res['fallback_photo'] = $full['full']['fallback_photo']; + } + if (isset($full['full']['personal_photo'])) { + $res['personal_photo'] = $full['full']['personal_photo']; + } + break; + case 'chat': + foreach (['title', 'participants_count', 'admin', 'admins_enabled'] as $key) { + if (isset($full['Chat'][$key])) { + $res[$key] = $full['Chat'][$key]; + } + } + foreach (['bot_info', 'pinned_msg_id', 'notify_settings'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($res['admins_enabled'])) { + $res['all_members_are_administrators'] = !$res['admins_enabled']; + } + if (isset($full['full']['chat_photo'])) { + $res['photo'] = $full['full']['chat_photo']; + } + if (isset($full['full']['exported_invite']['link'])) { + $res['invite'] = $full['full']['exported_invite']['link']; + } + if (isset($full['full']['participants']['participants'])) { + $res['participants'] = $full['full']['participants']['participants']; + } + break; + case 'channel': + case 'supergroup': + foreach (['title', 'democracy', 'restricted', 'restriction_reason', 'username', 'usernames', 'signatures'] as $key) { + if (isset($full['Chat'][$key])) { + $res[$key] = $full['Chat'][$key]; + } + } + foreach (['read_inbox_max_id', 'read_outbox_max_id', 'hidden_prehistory', 'bot_info', 'notify_settings', 'can_set_stickers', 'stickerset', 'can_view_participants', 'can_set_username', 'participants_count', 'admins_count', 'kicked_count', 'banned_count', 'migrated_from_chat_id', 'migrated_from_max_id', 'pinned_msg_id', 'about', 'hidden_prehistory', 'available_min_id', 'can_view_stats', 'online_count'] as $key) { + if (isset($full['full'][$key])) { + $res[$key] = $full['full'][$key]; + } + } + if (isset($full['full']['chat_photo'])) { + $res['photo'] = $full['full']['chat_photo']; + } + if (isset($full['full']['exported_invite']['link'])) { + $res['invite'] = $full['full']['exported_invite']['link']; + } + break; + } + if (isset($res['participants']) && $fullfetch) { + foreach ($res['participants'] as $key => $participant) { + $newres = []; + $newres['user'] = ($this->getPwrChat($participant['user_id'] ?? $participant['peer'], false)); + if (isset($participant['inviter_id'])) { + $newres['inviter'] = ($this->getPwrChat($participant['inviter_id'], false)); + } + if (isset($participant['kicked_by'])) { + $newres['kicked_by'] = ($this->getPwrChat($participant['kicked_by'], false)); + } + if (isset($participant['date'])) { + $newres['date'] = $participant['date']; + } + switch ($participant['_']) { + case 'chatParticipant': + $newres['role'] = 'user'; + break; + case 'chatParticipantAdmin': + $newres['role'] = 'admin'; + break; + case 'chatParticipantCreator': + $newres['role'] = 'creator'; + break; + } + $res['participants'][$key] = $newres; + } + } + if (!isset($res['participants']) && $fullfetch && \in_array($res['type'], ['supergroup', 'channel'], true)) { + $total_count = ($res['participants_count'] ?? 0) + ($res['admins_count'] ?? 0) + ($res['kicked_count'] ?? 0) + ($res['banned_count'] ?? 0); + $res['participants'] = []; + $filters = ['channelParticipantsAdmins', 'channelParticipantsBots']; + $promises = []; + foreach ($filters as $filter) { + $promises []= async(function () use ($filter, $total_count, &$res): void { + $this->fetchParticipants($res['id'], $filter, '', $total_count, $res); + }); + } + await($promises); + + $q = ''; + $filters = ['channelParticipantsSearch', 'channelParticipantsKicked', 'channelParticipantsBanned']; + $promises = []; + foreach ($filters as $filter) { + $promises []= async(function () use ($filter, $q, $total_count, &$res): void { + $this->recurseAlphabetSearchParticipants($res['id'], $filter, $q, $total_count, $res, 0); + }); + } + await($promises); + + $this->logger->logger('Fetched '.\count($res['participants'])." out of {$total_count}"); + $res['participants'] = array_values($res['participants']); + } + if (!$fullfetch) { + unset($res['participants']); + } + return $res; + } + private function recurseAlphabetSearchParticipants(int $channel, string $filter, string $q, int $total_count, array &$res, int $depth): array + { + if (!($this->fetchParticipants($channel, $filter, $q, $total_count, $res))) { + return []; + } + ++$depth; + $promises = []; + for ($x = 'a'; $x !== 'aa' && $total_count > \count($res['participants']); $x++) { + $promises []= async(function () use ($channel, $filter, $q, $x, $total_count, &$res, $depth) { + return $this->recurseAlphabetSearchParticipants($channel, $filter, $q.$x, $total_count, $res, $depth); + }); + } + + if ($depth > 3) { + return $promises; + } + + $yielded = array_merge(...await($promises)); + while ($yielded) { + $newYielded = []; + + foreach (array_chunk($yielded, 10) as $promises) { + $newYielded = array_merge($newYielded, ...(await($promises))); + } + + $yielded = $newYielded; + } + + return []; + } + private function fetchParticipants(int $channel, string $filter, string $q, int $total_count, array &$res): bool + { + $offset = 0; + $limit = 200; + $has_more = false; + $cached = false; + $last_count = -1; + do { + try { + $gres = $this->methodCallAsyncRead('channels.getParticipants', ['channel' => $channel, 'filter' => ['_' => $filter, 'q' => $q], 'offset' => $offset, 'limit' => $limit, 'hash' => $hash = $this->getParticipantsHash($channel, $filter, $q, $offset, $limit), 'floodWaitLimit' => 86400]); + } catch (ChatAdminRequiredError) { + $this->logger->logger('CHAT_ADMIN_REQUIRED'); + return $has_more; + } + if ($cached = ($gres['_'] === 'channels.channelParticipantsNotModified')) { + $gres = $this->fetchParticipantsCache($channel, $filter, $q, $offset, $limit); + } else { + $this->storeParticipantsCache($gres, $channel, $filter, $q, $offset, $limit); + } + \assert($gres !== null); + if ($last_count !== -1 && $last_count !== $gres['count']) { + $has_more = true; + } else { + $last_count = $gres['count']; + } + $promises = []; + foreach ($gres['participants'] as $participant) { + $promises []= async(function () use (&$res, $participant): void { + $newres = $participant; + unset($newres['user_id'], $newres['peer']); + $newres['user'] = ($this->getPwrChat($participant['user_id'] ?? $participant['peer'], false)); + if (isset($participant['inviter_id'])) { + unset($newres['inviter_id']); + $newres['inviter'] = ($this->getPwrChat($participant['inviter_id'], false)); + } + if (isset($participant['kicked_by'])) { + unset($newres['kicked_by']); + $newres['kicked_by'] = ($this->getPwrChat($participant['kicked_by'], false)); + } + if (isset($participant['promoted_by'])) { + unset($newres['promoted_by']); + $newres['promoted_by'] = ($this->getPwrChat($participant['promoted_by'], false)); + } + switch ($participant['_']) { + case 'channelParticipantSelf': + $newres['role'] = 'user'; + break; + case 'channelParticipant': + $newres['role'] = 'user'; + break; + case 'channelParticipantCreator': + $newres['role'] = 'creator'; + break; + case 'channelParticipantAdmin': + $newres['role'] = 'admin'; + break; + case 'channelParticipantBanned': + $newres['role'] = 'banned'; + break; + } + $res['participants'][$participant['user_id'] ?? $this->getIdInternal($participant['peer'])] = $newres; + }); + } + await($promises); + $h = $hash !== null ? 'present' : 'absent'; + $this->logger->logger('Fetched '.\count($gres['participants'])." channel participants with filter {$filter}, query {$q}, offset {$offset}, limit {$limit}, hash {$h}: ".($cached ? 'cached' : 'not cached').', '.($offset + \count($gres['participants'])).' participants out of '.$gres['count'].', in total fetched '.\count($res['participants']).' out of '.$total_count); + $offset += \count($gres['participants']); + } while (\count($gres['participants'])); + if ($offset === $limit) { + return true; + } + return $has_more; + } + /** + * Key for participatns cache. + */ + private static function participantsKey(int $channelId, string $filter, string $q, int $offset, int $limit): string + { + return "$channelId'$filter'$q'$offset'$limit"; + } + private function fetchParticipantsCache(int $channel, string $filter, string $q, int $offset, int $limit): ?array + { + return $this->channelParticipants[$this->participantsKey($channel, $filter, $q, $offset, $limit)]; + } + private function storeParticipantsCache(array $gres, int $channel, string $filter, string $q, int $offset, int $limit): void + { + unset($gres['users']); + $ids = []; + foreach ($gres['participants'] as $participant) { + if (isset($participant['user_id'])) { + $ids[] = $participant['user_id']; + } + } + sort($ids, SORT_NUMERIC); + /** @psalm-suppress MixedArgumentTypeCoercion Typechecks are done inside */ + $gres['hash'] = Tools::genVectorHash($ids); + $this->channelParticipants[$this->participantsKey($channel, $filter, $q, $offset, $limit)] = $gres; + } + private function getParticipantsHash(int $channel, string $filter, string $q, int $offset, int $limit): ?string + { + return $this->fetchParticipantsCache($channel, $filter, $q, $offset, $limit)['hash'] ?? null; + } +} diff --git a/patches/madelineproto-geopoint/README.md b/patches/madelineproto-geopoint/README.md new file mode 100644 index 0000000..b09d4ea --- /dev/null +++ b/patches/madelineproto-geopoint/README.md @@ -0,0 +1,15 @@ +# MadelineProto GeoPoint double-coordinate correction + +This is a **new local patch**, reviewed on 2026-10-13. It is separate from the historical UpdateLoop cancellation patch and is not presented as that original artifact. + +The locked package is `danog/madelineproto` 8.7.0 at commit `7b99dd0f2d964a492c377912ffce9f5ce61aa9ed`. The pristine `src/EventHandler/Media/GeoPoint.php` bytes were downloaded from that immutable [upstream commit](https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/EventHandler/Media/GeoPoint.php) and matched the installed source exactly (SHA256 `9ed8054caade6abc96420b0f5b120bec029ee2bc7b7b6aacca56fb7ddc61a27b`). + +Telegram's native `geoPoint` constructor declares `long` and `lat` as doubles. The locked SDK GeoPoint wrapper instead declared both readonly properties as integers. Assigning a fractional coordinate such as latitude 31.778 and longitude 35.235 throws a TypeError; InlineQuery constructs this wrapper before either typed or raw event dispatch. + +The unified diff changes only those two property types and their matching PHPDoc annotations from `int` to `float`. The constructor, access hash, accuracy radius, JSON serialization and all other source bytes remain unchanged. Integer zero coordinates remain valid because PHP permits integer-to-float assignment. No coordinates are rounded or guessed. The application separately validates finite geographical bounds. + +The target upstream file contains no standalone copyright header; its remaining source is retained byte for byte. MadelineProto's package copyright notices and AGPL-3.0-or-later license remain in the corresponding source/dependency notices. `baseline/GeoPoint.php`, `reviewed/GeoPoint.php`, the immutable source reference, unified diff and manifest are included in the source offer for the bot. No entire vendor directory is tracked. + +Installation uses the shared exact-source patch manager through `php scripts/apply-patches.php` and `php scripts/verify-patches.php`, including Composer and Docker before bot startup. For `composer install --no-scripts`, run those two commands explicitly while writers are stopped. The installer verifies version/commit and baseline/diff/output hashes, applies only pristine approved bytes, succeeds without mutation when already corrected, and stops on unknown, partial or incompatible source. It never fetches a patch during build. The shared writer lock prevents mutation of an active installation. + +Tests reproduce the pristine fractional-coordinate TypeError using the bundled baseline alone, exercise the actual installed corrected constructor with positive, negative, fractional and zero coordinates, and verify precision through JSON serialization/roundtrip. Installer tests cover clean/repeated/verify-only operation, mismatched version/commit, unknown/partial source, artifact tampering and an active writer. These are isolated local tests; no Telegram connection, live inline request or running container was used. No later official version is admitted without a separate source and behavior review. diff --git a/patches/madelineproto-geopoint/baseline/GeoPoint.php b/patches/madelineproto-geopoint/baseline/GeoPoint.php new file mode 100644 index 0000000..c2f7482 --- /dev/null +++ b/patches/madelineproto-geopoint/baseline/GeoPoint.php @@ -0,0 +1,37 @@ +long = $rawGeoPoint['long']; + $this->lat = $rawGeoPoint['lat']; + $this->accessHash = $rawGeoPoint['access_hash']; + $this->accuracyRadius = $rawGeoPoint['accuracy_radius'] ?? null; + } + + /** @internal */ + #[\Override] + public function jsonSerialize(): mixed + { + $v = get_object_vars($this); + $v['_'] = static::class; + return $v; + } +} diff --git a/patches/madelineproto-geopoint/geopoint-double-coordinates.patch b/patches/madelineproto-geopoint/geopoint-double-coordinates.patch new file mode 100644 index 0000000..ef13dd0 --- /dev/null +++ b/patches/madelineproto-geopoint/geopoint-double-coordinates.patch @@ -0,0 +1,12 @@ +--- a/src/EventHandler/Media/GeoPoint.php ++++ b/src/EventHandler/Media/GeoPoint.php +@@ -9,2 +9,2 @@ +- /** @var int Longitude */ +- public readonly int $long; ++ /** @var float Longitude */ ++ public readonly float $long; +@@ -12,2 +12,2 @@ +- /** @var int Latitude */ +- public readonly int $lat; ++ /** @var float Latitude */ ++ public readonly float $lat; diff --git a/patches/madelineproto-geopoint/manifest.json b/patches/madelineproto-geopoint/manifest.json new file mode 100644 index 0000000..a8b9600 --- /dev/null +++ b/patches/madelineproto-geopoint/manifest.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "reviewed_at": "2026-10-13", + "package": "danog/madelineproto", + "target": "src/EventHandler/Media/GeoPoint.php", + "provenance": { + "kind": "new-local-tl-double-type-correction", + "note": "New reviewed correction for this upgrade, distinct from the historical UpdateLoop cancellation patch. The locked GeoPoint constructor assigns TL double coordinates to integer properties before inline dispatch.", + "source_url": "https://github.com/danog/MadelineProto/blob/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed/src/EventHandler/Media/GeoPoint.php", + "source_verified_against_locked_installed_file": true, + "source_verified_sha256": "9ed8054caade6abc96420b0f5b120bec029ee2bc7b7b6aacca56fb7ddc61a27b" + }, + "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" + } + ], + "official_versions": [] +} diff --git a/patches/madelineproto-geopoint/reviewed/GeoPoint.php b/patches/madelineproto-geopoint/reviewed/GeoPoint.php new file mode 100644 index 0000000..c3c5adc --- /dev/null +++ b/patches/madelineproto-geopoint/reviewed/GeoPoint.php @@ -0,0 +1,37 @@ +long = $rawGeoPoint['long']; + $this->lat = $rawGeoPoint['lat']; + $this->accessHash = $rawGeoPoint['access_hash']; + $this->accuracyRadius = $rawGeoPoint['accuracy_radius'] ?? null; + } + + /** @internal */ + #[\Override] + public function jsonSerialize(): mixed + { + $v = get_object_vars($this); + $v['_'] = static::class; + return $v; + } +} diff --git a/patches/madelineproto/README.md b/patches/madelineproto/README.md new file mode 100644 index 0000000..fa7927c --- /dev/null +++ b/patches/madelineproto/README.md @@ -0,0 +1,53 @@ +# UpdateLoop cancellation patch + +Reviewed 2026-10-13. Locked MadelineProto **8.7.0** at +[7b99dd0f2d964a492c377912ffce9f5ce61aa9ed](https://github.com/danog/MadelineProto/tree/7b99dd0f2d964a492c377912ffce9f5ce61aa9ed). + +Original local implementation: local-reference/newsearch-v2/docker/patch-madeline-update-loop.php, +also identical in local-reference/psy new/release-r21-work/docker/patch-madeline-update-loop.php. +Both SHA256: 1ec2691498e878d28ca8e9cde94b00c803d9ccba789096ffc8471156dfb429e9. +The original script is preserved in original-reference.php. It adds the Amp +CancelledException import and two union catches guarded by getPrevious() instanceof +TimeoutException. It rethrows other cancellation. Historical source was a script; +original-reference.patch is a reproduced unified diff, not a historical diff. + +The applied update-loop-timeout-cancellation.patch is a **new reviewed derivative**. +Both difference branches retain those guards, check isRunning()/authorization, +and return the existing 10-second interval on a transient timeout. This lets the +parent loop honor stop() and prevents immediate retry/report loops. A stopped +loop returns STOP; unrelated cancellation while running is rethrown. + +The reviewed locked version is 8.7.0; the recorded GitHub release is a prerelease. MTProtoTools/CallHandler delegates to +MTProtoSession/CallHandler, which creates TimeoutCancellation and awaits the send +future using it. Amp emits CancelledException with TimeoutException as previous. +No conversion exists there; upstream UpdateLoop still has two TimeoutException-only +catches. danog/loop 1.1.1 stop() marks running=false, completing after loop() returns. +No official fixed release is currently approved in the manifest. + +The manifest pins exact version, commit, baseline/output/patch SHA256. Installation +applies exact unified-diff context, then verifies output SHA256 and syntax. +Unknown files, partial patches and unknown commits fail. No dynamic patch downloads, +no broad source regex replacements, no silent skipping. Already patched succeeds +without a write. A future approved mode=official entry checks its exact sha256 and +applies no patch; no such version is listed today. + +Composer invokes apply then verify. For --no-scripts: + + composer install --no-scripts + php scripts/apply-patches.php + php scripts/verify-patches.php + php bin/self-check.php + +Never patch an active process/container. Runner only verifies. Build a new image. +Tests exercise first/repeated application, unknown version/file, partial patch, +direct timeout, timeout-derived cancellation, deliberate cancellation, shutdown, +and recovery in both actual loop branches using a fake Telegram transport. +They do not contact Telegram. See [operational details](../../docs/PATCHES.md). + +All baseline copyright/license notices are unmodified. Upstream metadata says +AGPL-3.0-only; source headers permit version 3 or later. Preserve and report this +discrepancy. Baseline, original script, diffs and manifest ship in source archives. + +Regenerate against the fixed baseline with php patches/madelineproto/generate-artifacts.php. +Regeneration does not approve an upgrade; review source and rerun behavioral tests. +Historical local-reference paths are logical project/file identifiers, not public download URLs or installation requirements. diff --git a/patches/madelineproto/baseline/UpdateLoop.php b/patches/madelineproto/baseline/UpdateLoop.php new file mode 100644 index 0000000..bc54fca --- /dev/null +++ b/patches/madelineproto/baseline/UpdateLoop.php @@ -0,0 +1,263 @@ +. + * + * @author Daniil Gentili + * @copyright 2016-2025 Daniil Gentili + * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 + * @link https://docs.madelineproto.xyz MadelineProto documentation + */ + +namespace danog\MadelineProto\Loop\Update; + +use Amp\TimeoutException; +use danog\Loop\Loop; +use danog\MadelineProto\API; +use danog\MadelineProto\Exception; +use danog\MadelineProto\Logger; +use danog\MadelineProto\Loop\InternalLoop; +use danog\MadelineProto\MTProto; +use danog\MadelineProto\MTProto\LoginState; +use danog\MadelineProto\MTProto\SpecialMethodType; +use danog\MadelineProto\PeerNotInDbException; +use danog\MadelineProto\PTSException; +use danog\MadelineProto\Reactive\SimpleSubscriber; +use danog\MadelineProto\RPCError\ChannelInvalidError; +use danog\MadelineProto\RPCError\ChannelPrivateError; +use danog\MadelineProto\RPCError\ChatForbiddenError; +use danog\MadelineProto\RPCError\TimeoutError; +use danog\MadelineProto\RPCError\UserBannedInChannelError; + +use Revolt\EventLoop; +use function Amp\delay; + +/** + * Update loop. + * + * @internal + * + * @author Daniil Gentili + * + * @implements SimpleSubscriber + */ +final class UpdateLoop extends Loop implements SimpleSubscriber +{ + use InternalLoop { + __construct as private init; + } + /** + * Main loop ID. + */ + public const GENERIC = 0; + + private const DEFAULT_TIMEOUT = 10.0; + + /** + * Feed loop. + */ + private ?FeedLoop $feeder = null; + private ?int $authorizedDc = null; + /** + * Constructor. + */ + public function __construct(MTProto $API, private int $channelId) + { + $this->init($API); + $API->loginState->subscribe($this); + } + public function __sleep(): array + { + return ['channelId', 'API', 'feeder', 'authorizedDc']; + } + #[\Override] + public function onSimpleStateChange($state): void + { + if ($state->state !== API::LOGGED_IN) { + $this->authorizedDc = null; + return; + } + if (null !== $this->authorizedDc = $state->authorizedDc) { + if ($this->isRunning()) { + $this->resume(true); + } else { + $this->start(); + } + } + } + + /** + * Main loop. + */ + #[\Override] + public function loop(): ?float + { + if ($this->authorizedDc === null) { + return self::PAUSE; + } + $this->API->feeders[$this->channelId] ??= new FeedLoop($this->API, $this->channelId); + $this->API->updaters[$this->channelId] ??= new UpdateLoop($this->API, $this->channelId); + + $this->feeder = $this->API->feeders[$this->channelId]; + $state = $this->channelId === self::GENERIC ? $this->API->loadUpdateState() : $this->API->loadChannelState($this->channelId); + + $result = []; + while (true) { + if ($this->channelId) { + $this->API->logger('Resumed and fetching '.$this->channelId.' difference...', Logger::ULTRA_VERBOSE); + if ($state->pts() <= 1) { + $limit = 10; + } elseif ($this->API->authorization['user']['bot']) { + $limit = 100000; + } else { + $limit = 100; + } + $request_pts = $state->pts(); + try { + $difference = $this->API->methodCallAsyncRead('updates.getChannelDifference', ['channel' => $this->channelId, 'filter' => ['_' => 'channelMessagesFilterEmpty'], 'pts' => $request_pts, 'limit' => $limit, 'force' => true, 'floodWaitLimit' => 86400]); + } catch (ChannelPrivateError|ChatForbiddenError|ChannelInvalidError|UserBannedInChannelError) { + $this->feeder->stop(); + unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); + $this->API->getChannelStates()->remove($this->channelId); + $this->API->logger("Channel private, exiting {$this}"); + return self::STOP; + } catch (TimeoutError $e) { + delay(1.0); + continue; + } catch (PeerNotInDbException) { + $this->feeder->stop(); + $this->API->getChannelStates()->remove($this->channelId); + unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); + $this->API->logger("Channel private, exiting {$this}"); + return self::STOP; + } catch (PTSException $e) { + $this->feeder->stop(); + $this->API->getChannelStates()->remove($this->channelId); + unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); + $this->API->logger("Got PTS exception, exiting update loop for $this: $e", Logger::FATAL_ERROR); + return self::STOP; + } catch (TimeoutException) { + EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); + continue; + } + $timeout = min(self::DEFAULT_TIMEOUT, $difference['timeout'] ?? self::DEFAULT_TIMEOUT); + $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); + switch ($difference['_']) { + case 'updates.channelDifferenceEmpty': + $state->update($difference); + unset($difference); + break 2; + case 'updates.channelDifference': + if ($request_pts >= $difference['pts'] && $request_pts > 1) { + $this->API->logger("The PTS ({$difference['pts']}) I got with getDifference is smaller than the PTS I requested ".$state->pts().', using '.($state->pts() + 1), Logger::VERBOSE); + $difference['pts'] = $request_pts + 1; + } + $result += ($this->feeder->feed($difference['other_updates'])); + $state->update($difference); + if ($difference['new_messages']) { + $result[$this->channelId] = true; + } + $this->feeder->saveMessages($difference['new_messages']); + if (!$difference['final']) { + unset($difference); + break; + } + unset($difference); + break 2; + case 'updates.channelDifferenceTooLong': + if (isset($difference['dialog']['pts'])) { + $difference['pts'] = $difference['dialog']['pts']; + } + $state->update($difference); + if ($difference['messages']) { + $result[$this->channelId] = true; + } + $this->feeder->saveMessages($difference['messages']); + unset($difference); + break; + default: + throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); + } + } else { + $this->API->logger('Resumed and fetching normal difference...', Logger::ULTRA_VERBOSE); + do { + try { + $difference = $this->API->methodCallAsyncRead('updates.getDifference', ['pts' => $state->pts(), 'date' => $state->date(), 'qts' => $state->qts(), 'specialMethodType' => SpecialMethodType::USER_RELATED], $this->authorizedDc); + break; + } catch (TimeoutError) { + delay(1.0); + } catch (TimeoutException) { + EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); + } + } while (true); + $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); + $timeout = self::DEFAULT_TIMEOUT; + switch ($difference['_']) { + case 'updates.differenceEmpty': + $state->update($difference); + unset($difference); + break 2; + case 'updates.difference': + $state->qts($difference['state']['qts']); + foreach ($difference['new_encrypted_messages'] as &$encrypted) { + $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; + } + $result += ($this->feeder->feed($difference['other_updates'])); + $result += ($this->feeder->feed($difference['new_encrypted_messages'])); + $state->update($difference['state']); + if ($difference['new_messages']) { + $result[$this->channelId] = true; + } + $this->feeder->saveMessages($difference['new_messages']); + unset($difference); + break 2; + case 'updates.differenceSlice': + $state->qts($difference['intermediate_state']['qts']); + foreach ($difference['new_encrypted_messages'] as &$encrypted) { + $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; + } + $result += ($this->feeder->feed($difference['other_updates'])); + $result += ($this->feeder->feed($difference['new_encrypted_messages'])); + $state->update($difference['intermediate_state']); + if ($difference['new_messages']) { + $result[$this->channelId] = true; + } + $this->feeder->saveMessages($difference['new_messages']); + unset($difference); + break; + case 'updates.differenceTooLong': + $state->update($difference); + unset($difference); + break; + default: + throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); + } + } + } + $this->API->logger("Finished parsing updates in {$this}, now resuming feeders", Logger::ULTRA_VERBOSE); + foreach ($result as $channelId => $_) { + $this->API->feeders[$channelId]?->resume(); + } + $this->API->logger("Finished parsing updates in {$this}, pausing for $timeout seconds", Logger::ULTRA_VERBOSE); + + return $timeout; + } + /** + * Get loop name. + */ + public function __toString(): string + { + return $this->channelId ? + "getUpdate loop channel {$this->channelId}" : + 'getUpdate loop generic'; + } +} diff --git a/patches/madelineproto/generate-artifacts.php b/patches/madelineproto/generate-artifacts.php new file mode 100644 index 0000000..45100ea --- /dev/null +++ b/patches/madelineproto/generate-artifacts.php @@ -0,0 +1,58 @@ +getPrevious() instanceof TimeoutException)) {'; +foreach ([20, 24] as $indent) { + $space = str_repeat(' ', $indent); + $patched = str_replace("\n" . $space . $guard, "\n" . $space . 'if (!$this->isRunning() || $this->authorizedDc === null) {' . "\n" . $space . ' return self::STOP;' . "\n" . $space . '}' . "\n" . $space . $guard, $patched, $count); + if ($count !== 1) { + throw new RuntimeException('Unexpected reference patch layout.'); + } +} +$report = 'EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!");'; +$patched = str_replace(' ' . $report . "\n" . ' continue;', ' ' . $report . "\n" . ' return self::DEFAULT_TIMEOUT;', $patched, $count); +if ($count !== 1) { + throw new RuntimeException('Unexpected channel recovery layout.'); +} +$patched = str_replace(' ' . $report . "\n", ' ' . $report . "\n" . ' return self::DEFAULT_TIMEOUT;' . "\n", $patched, $count); +if ($count !== 1) { + throw new RuntimeException('Unexpected generic recovery layout.'); +} +$diff = static function (string $before, string $after): string { + $old = explode("\n", rtrim($before, "\n")); + $new = explode("\n", rtrim($after, "\n")); + return "--- a/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php\n+++ b/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php\n" + . '@@ -1,' . count($old) . ' +1,' . count($new) . " @@\n" + . implode("\n", array_map(static fn (string $line): string => '-' . $line, $old)) . "\n" + . implode("\n", array_map(static fn (string $line): string => '+' . $line, $new)) . "\n"; +}; +file_put_contents(__DIR__ . '/original-reference.patch', $diff($source, $reference)); +$patch = $diff($source, $patched); +file_put_contents(__DIR__ . '/update-loop-timeout-cancellation.patch', $patch); +$manifest = ['schema_version' => 1, 'reviewed_at' => '2026-10-13', 'package' => 'danog/madelineproto', 'target' => 'src/Loop/Update/UpdateLoop.php', + 'original_reference' => ['path' => 'local-reference/newsearch-v2/docker/patch-madeline-update-loop.php', 'also_found' => 'local-reference/psy new/release-r21-work/docker/patch-madeline-update-loop.php', 'sha256' => hash_file('sha256', __DIR__ . '/original-reference.php'), 'note' => 'Historical artifact was a guarded PHP patch script; reference unified diff is reproduced from that script. Paths are logical local-reference identifiers, not public download URLs.'], + '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' => hash('sha256', $source), 'patched_sha256' => hash('sha256', $patched), 'patch' => 'update-loop-timeout-cancellation.patch', 'patch_sha256' => hash('sha256', $patch)]], + 'official_versions' => [], 'notes' => 'No released official solution was verified. New versions require source, call-chain and behavioral review before admission.']; +file_put_contents(__DIR__ . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); +echo "Reviewed patch artifacts regenerated.\n"; \ No newline at end of file diff --git a/patches/madelineproto/manifest.json b/patches/madelineproto/manifest.json new file mode 100644 index 0000000..4a67cdd --- /dev/null +++ b/patches/madelineproto/manifest.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "reviewed_at": "2026-10-13", + "package": "danog/madelineproto", + "target": "src/Loop/Update/UpdateLoop.php", + "original_reference": { + "path": "local-reference/newsearch-v2/docker/patch-madeline-update-loop.php", + "also_found": "local-reference/psy new/release-r21-work/docker/patch-madeline-update-loop.php", + "sha256": "1ec2691498e878d28ca8e9cde94b00c803d9ccba789096ffc8471156dfb429e9", + "note": "Historical artifact was a guarded PHP patch script; reference unified diff is reproduced from that script. Paths are logical local-reference identifiers, not public download URLs." + }, + "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" + } + ], + "official_versions": [], + "notes": "No released official solution was verified. New versions require source, call-chain and behavioral review before admission." +} diff --git a/patches/madelineproto/original-reference.patch b/patches/madelineproto/original-reference.patch new file mode 100644 index 0000000..03fa67b --- /dev/null +++ b/patches/madelineproto/original-reference.patch @@ -0,0 +1,536 @@ +--- a/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php ++++ b/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php +@@ -1,263 +1,270 @@ +-. +- * +- * @author Daniil Gentili +- * @copyright 2016-2025 Daniil Gentili +- * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 +- * @link https://docs.madelineproto.xyz MadelineProto documentation +- */ +- +-namespace danog\MadelineProto\Loop\Update; +- +-use Amp\TimeoutException; +-use danog\Loop\Loop; +-use danog\MadelineProto\API; +-use danog\MadelineProto\Exception; +-use danog\MadelineProto\Logger; +-use danog\MadelineProto\Loop\InternalLoop; +-use danog\MadelineProto\MTProto; +-use danog\MadelineProto\MTProto\LoginState; +-use danog\MadelineProto\MTProto\SpecialMethodType; +-use danog\MadelineProto\PeerNotInDbException; +-use danog\MadelineProto\PTSException; +-use danog\MadelineProto\Reactive\SimpleSubscriber; +-use danog\MadelineProto\RPCError\ChannelInvalidError; +-use danog\MadelineProto\RPCError\ChannelPrivateError; +-use danog\MadelineProto\RPCError\ChatForbiddenError; +-use danog\MadelineProto\RPCError\TimeoutError; +-use danog\MadelineProto\RPCError\UserBannedInChannelError; +- +-use Revolt\EventLoop; +-use function Amp\delay; +- +-/** +- * Update loop. +- * +- * @internal +- * +- * @author Daniil Gentili +- * +- * @implements SimpleSubscriber +- */ +-final class UpdateLoop extends Loop implements SimpleSubscriber +-{ +- use InternalLoop { +- __construct as private init; +- } +- /** +- * Main loop ID. +- */ +- public const GENERIC = 0; +- +- private const DEFAULT_TIMEOUT = 10.0; +- +- /** +- * Feed loop. +- */ +- private ?FeedLoop $feeder = null; +- private ?int $authorizedDc = null; +- /** +- * Constructor. +- */ +- public function __construct(MTProto $API, private int $channelId) +- { +- $this->init($API); +- $API->loginState->subscribe($this); +- } +- public function __sleep(): array +- { +- return ['channelId', 'API', 'feeder', 'authorizedDc']; +- } +- #[\Override] +- public function onSimpleStateChange($state): void +- { +- if ($state->state !== API::LOGGED_IN) { +- $this->authorizedDc = null; +- return; +- } +- if (null !== $this->authorizedDc = $state->authorizedDc) { +- if ($this->isRunning()) { +- $this->resume(true); +- } else { +- $this->start(); +- } +- } +- } +- +- /** +- * Main loop. +- */ +- #[\Override] +- public function loop(): ?float +- { +- if ($this->authorizedDc === null) { +- return self::PAUSE; +- } +- $this->API->feeders[$this->channelId] ??= new FeedLoop($this->API, $this->channelId); +- $this->API->updaters[$this->channelId] ??= new UpdateLoop($this->API, $this->channelId); +- +- $this->feeder = $this->API->feeders[$this->channelId]; +- $state = $this->channelId === self::GENERIC ? $this->API->loadUpdateState() : $this->API->loadChannelState($this->channelId); +- +- $result = []; +- while (true) { +- if ($this->channelId) { +- $this->API->logger('Resumed and fetching '.$this->channelId.' difference...', Logger::ULTRA_VERBOSE); +- if ($state->pts() <= 1) { +- $limit = 10; +- } elseif ($this->API->authorization['user']['bot']) { +- $limit = 100000; +- } else { +- $limit = 100; +- } +- $request_pts = $state->pts(); +- try { +- $difference = $this->API->methodCallAsyncRead('updates.getChannelDifference', ['channel' => $this->channelId, 'filter' => ['_' => 'channelMessagesFilterEmpty'], 'pts' => $request_pts, 'limit' => $limit, 'force' => true, 'floodWaitLimit' => 86400]); +- } catch (ChannelPrivateError|ChatForbiddenError|ChannelInvalidError|UserBannedInChannelError) { +- $this->feeder->stop(); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->getChannelStates()->remove($this->channelId); +- $this->API->logger("Channel private, exiting {$this}"); +- return self::STOP; +- } catch (TimeoutError $e) { +- delay(1.0); +- continue; +- } catch (PeerNotInDbException) { +- $this->feeder->stop(); +- $this->API->getChannelStates()->remove($this->channelId); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->logger("Channel private, exiting {$this}"); +- return self::STOP; +- } catch (PTSException $e) { +- $this->feeder->stop(); +- $this->API->getChannelStates()->remove($this->channelId); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->logger("Got PTS exception, exiting update loop for $this: $e", Logger::FATAL_ERROR); +- return self::STOP; +- } catch (TimeoutException) { +- EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); +- continue; +- } +- $timeout = min(self::DEFAULT_TIMEOUT, $difference['timeout'] ?? self::DEFAULT_TIMEOUT); +- $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); +- switch ($difference['_']) { +- case 'updates.channelDifferenceEmpty': +- $state->update($difference); +- unset($difference); +- break 2; +- case 'updates.channelDifference': +- if ($request_pts >= $difference['pts'] && $request_pts > 1) { +- $this->API->logger("The PTS ({$difference['pts']}) I got with getDifference is smaller than the PTS I requested ".$state->pts().', using '.($state->pts() + 1), Logger::VERBOSE); +- $difference['pts'] = $request_pts + 1; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $state->update($difference); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- if (!$difference['final']) { +- unset($difference); +- break; +- } +- unset($difference); +- break 2; +- case 'updates.channelDifferenceTooLong': +- if (isset($difference['dialog']['pts'])) { +- $difference['pts'] = $difference['dialog']['pts']; +- } +- $state->update($difference); +- if ($difference['messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['messages']); +- unset($difference); +- break; +- default: +- throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); +- } +- } else { +- $this->API->logger('Resumed and fetching normal difference...', Logger::ULTRA_VERBOSE); +- do { +- try { +- $difference = $this->API->methodCallAsyncRead('updates.getDifference', ['pts' => $state->pts(), 'date' => $state->date(), 'qts' => $state->qts(), 'specialMethodType' => SpecialMethodType::USER_RELATED], $this->authorizedDc); +- break; +- } catch (TimeoutError) { +- delay(1.0); +- } catch (TimeoutException) { +- EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); +- } +- } while (true); +- $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); +- $timeout = self::DEFAULT_TIMEOUT; +- switch ($difference['_']) { +- case 'updates.differenceEmpty': +- $state->update($difference); +- unset($difference); +- break 2; +- case 'updates.difference': +- $state->qts($difference['state']['qts']); +- foreach ($difference['new_encrypted_messages'] as &$encrypted) { +- $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $result += ($this->feeder->feed($difference['new_encrypted_messages'])); +- $state->update($difference['state']); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- unset($difference); +- break 2; +- case 'updates.differenceSlice': +- $state->qts($difference['intermediate_state']['qts']); +- foreach ($difference['new_encrypted_messages'] as &$encrypted) { +- $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $result += ($this->feeder->feed($difference['new_encrypted_messages'])); +- $state->update($difference['intermediate_state']); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- unset($difference); +- break; +- case 'updates.differenceTooLong': +- $state->update($difference); +- unset($difference); +- break; +- default: +- throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); +- } +- } +- } +- $this->API->logger("Finished parsing updates in {$this}, now resuming feeders", Logger::ULTRA_VERBOSE); +- foreach ($result as $channelId => $_) { +- $this->API->feeders[$channelId]?->resume(); +- } +- $this->API->logger("Finished parsing updates in {$this}, pausing for $timeout seconds", Logger::ULTRA_VERBOSE); +- +- return $timeout; +- } +- /** +- * Get loop name. +- */ +- public function __toString(): string +- { +- return $this->channelId ? +- "getUpdate loop channel {$this->channelId}" : +- 'getUpdate loop generic'; +- } +-} ++. ++ * ++ * @author Daniil Gentili ++ * @copyright 2016-2025 Daniil Gentili ++ * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 ++ * @link https://docs.madelineproto.xyz MadelineProto documentation ++ */ ++ ++namespace danog\MadelineProto\Loop\Update; ++ ++use Amp\CancelledException; ++use Amp\TimeoutException; ++use danog\Loop\Loop; ++use danog\MadelineProto\API; ++use danog\MadelineProto\Exception; ++use danog\MadelineProto\Logger; ++use danog\MadelineProto\Loop\InternalLoop; ++use danog\MadelineProto\MTProto; ++use danog\MadelineProto\MTProto\LoginState; ++use danog\MadelineProto\MTProto\SpecialMethodType; ++use danog\MadelineProto\PeerNotInDbException; ++use danog\MadelineProto\PTSException; ++use danog\MadelineProto\Reactive\SimpleSubscriber; ++use danog\MadelineProto\RPCError\ChannelInvalidError; ++use danog\MadelineProto\RPCError\ChannelPrivateError; ++use danog\MadelineProto\RPCError\ChatForbiddenError; ++use danog\MadelineProto\RPCError\TimeoutError; ++use danog\MadelineProto\RPCError\UserBannedInChannelError; ++ ++use Revolt\EventLoop; ++use function Amp\delay; ++ ++/** ++ * Update loop. ++ * ++ * @internal ++ * ++ * @author Daniil Gentili ++ * ++ * @implements SimpleSubscriber ++ */ ++final class UpdateLoop extends Loop implements SimpleSubscriber ++{ ++ use InternalLoop { ++ __construct as private init; ++ } ++ /** ++ * Main loop ID. ++ */ ++ public const GENERIC = 0; ++ ++ private const DEFAULT_TIMEOUT = 10.0; ++ ++ /** ++ * Feed loop. ++ */ ++ private ?FeedLoop $feeder = null; ++ private ?int $authorizedDc = null; ++ /** ++ * Constructor. ++ */ ++ public function __construct(MTProto $API, private int $channelId) ++ { ++ $this->init($API); ++ $API->loginState->subscribe($this); ++ } ++ public function __sleep(): array ++ { ++ return ['channelId', 'API', 'feeder', 'authorizedDc']; ++ } ++ #[\Override] ++ public function onSimpleStateChange($state): void ++ { ++ if ($state->state !== API::LOGGED_IN) { ++ $this->authorizedDc = null; ++ return; ++ } ++ if (null !== $this->authorizedDc = $state->authorizedDc) { ++ if ($this->isRunning()) { ++ $this->resume(true); ++ } else { ++ $this->start(); ++ } ++ } ++ } ++ ++ /** ++ * Main loop. ++ */ ++ #[\Override] ++ public function loop(): ?float ++ { ++ if ($this->authorizedDc === null) { ++ return self::PAUSE; ++ } ++ $this->API->feeders[$this->channelId] ??= new FeedLoop($this->API, $this->channelId); ++ $this->API->updaters[$this->channelId] ??= new UpdateLoop($this->API, $this->channelId); ++ ++ $this->feeder = $this->API->feeders[$this->channelId]; ++ $state = $this->channelId === self::GENERIC ? $this->API->loadUpdateState() : $this->API->loadChannelState($this->channelId); ++ ++ $result = []; ++ while (true) { ++ if ($this->channelId) { ++ $this->API->logger('Resumed and fetching '.$this->channelId.' difference...', Logger::ULTRA_VERBOSE); ++ if ($state->pts() <= 1) { ++ $limit = 10; ++ } elseif ($this->API->authorization['user']['bot']) { ++ $limit = 100000; ++ } else { ++ $limit = 100; ++ } ++ $request_pts = $state->pts(); ++ try { ++ $difference = $this->API->methodCallAsyncRead('updates.getChannelDifference', ['channel' => $this->channelId, 'filter' => ['_' => 'channelMessagesFilterEmpty'], 'pts' => $request_pts, 'limit' => $limit, 'force' => true, 'floodWaitLimit' => 86400]); ++ } catch (ChannelPrivateError|ChatForbiddenError|ChannelInvalidError|UserBannedInChannelError) { ++ $this->feeder->stop(); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->getChannelStates()->remove($this->channelId); ++ $this->API->logger("Channel private, exiting {$this}"); ++ return self::STOP; ++ } catch (TimeoutError $e) { ++ delay(1.0); ++ continue; ++ } catch (PeerNotInDbException) { ++ $this->feeder->stop(); ++ $this->API->getChannelStates()->remove($this->channelId); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->logger("Channel private, exiting {$this}"); ++ return self::STOP; ++ } catch (PTSException $e) { ++ $this->feeder->stop(); ++ $this->API->getChannelStates()->remove($this->channelId); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->logger("Got PTS exception, exiting update loop for $this: $e", Logger::FATAL_ERROR); ++ return self::STOP; ++ } catch (TimeoutException|CancelledException $e) { ++ if ($e instanceof CancelledException && !($e->getPrevious() instanceof TimeoutException)) { ++ throw $e; ++ } ++ EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); ++ continue; ++ } ++ $timeout = min(self::DEFAULT_TIMEOUT, $difference['timeout'] ?? self::DEFAULT_TIMEOUT); ++ $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); ++ switch ($difference['_']) { ++ case 'updates.channelDifferenceEmpty': ++ $state->update($difference); ++ unset($difference); ++ break 2; ++ case 'updates.channelDifference': ++ if ($request_pts >= $difference['pts'] && $request_pts > 1) { ++ $this->API->logger("The PTS ({$difference['pts']}) I got with getDifference is smaller than the PTS I requested ".$state->pts().', using '.($state->pts() + 1), Logger::VERBOSE); ++ $difference['pts'] = $request_pts + 1; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $state->update($difference); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ if (!$difference['final']) { ++ unset($difference); ++ break; ++ } ++ unset($difference); ++ break 2; ++ case 'updates.channelDifferenceTooLong': ++ if (isset($difference['dialog']['pts'])) { ++ $difference['pts'] = $difference['dialog']['pts']; ++ } ++ $state->update($difference); ++ if ($difference['messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['messages']); ++ unset($difference); ++ break; ++ default: ++ throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); ++ } ++ } else { ++ $this->API->logger('Resumed and fetching normal difference...', Logger::ULTRA_VERBOSE); ++ do { ++ try { ++ $difference = $this->API->methodCallAsyncRead('updates.getDifference', ['pts' => $state->pts(), 'date' => $state->date(), 'qts' => $state->qts(), 'specialMethodType' => SpecialMethodType::USER_RELATED], $this->authorizedDc); ++ break; ++ } catch (TimeoutError) { ++ delay(1.0); ++ } catch (TimeoutException|CancelledException $e) { ++ if ($e instanceof CancelledException && !($e->getPrevious() instanceof TimeoutException)) { ++ throw $e; ++ } ++ EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); ++ } ++ } while (true); ++ $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); ++ $timeout = self::DEFAULT_TIMEOUT; ++ switch ($difference['_']) { ++ case 'updates.differenceEmpty': ++ $state->update($difference); ++ unset($difference); ++ break 2; ++ case 'updates.difference': ++ $state->qts($difference['state']['qts']); ++ foreach ($difference['new_encrypted_messages'] as &$encrypted) { ++ $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $result += ($this->feeder->feed($difference['new_encrypted_messages'])); ++ $state->update($difference['state']); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ unset($difference); ++ break 2; ++ case 'updates.differenceSlice': ++ $state->qts($difference['intermediate_state']['qts']); ++ foreach ($difference['new_encrypted_messages'] as &$encrypted) { ++ $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $result += ($this->feeder->feed($difference['new_encrypted_messages'])); ++ $state->update($difference['intermediate_state']); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ unset($difference); ++ break; ++ case 'updates.differenceTooLong': ++ $state->update($difference); ++ unset($difference); ++ break; ++ default: ++ throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); ++ } ++ } ++ } ++ $this->API->logger("Finished parsing updates in {$this}, now resuming feeders", Logger::ULTRA_VERBOSE); ++ foreach ($result as $channelId => $_) { ++ $this->API->feeders[$channelId]?->resume(); ++ } ++ $this->API->logger("Finished parsing updates in {$this}, pausing for $timeout seconds", Logger::ULTRA_VERBOSE); ++ ++ return $timeout; ++ } ++ /** ++ * Get loop name. ++ */ ++ public function __toString(): string ++ { ++ return $this->channelId ? ++ "getUpdate loop channel {$this->channelId}" : ++ 'getUpdate loop generic'; ++ } ++} diff --git a/patches/madelineproto/original-reference.php b/patches/madelineproto/original-reference.php new file mode 100644 index 0000000..e484f93 --- /dev/null +++ b/patches/madelineproto/original-reference.php @@ -0,0 +1,148 @@ +getPrevious() instanceof TimeoutException)) {'; + $report = 'EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!");'; + + $originalChannel = implode($newline, [ + ' } catch (TimeoutException) {', + ' ' . $report, + ' continue;', + ' }', + ]); + $patchedChannel = implode($newline, [ + ' } catch (TimeoutException|CancelledException $e) {', + ' ' . $guard, + ' throw $e;', + ' }', + ' ' . $report, + ' continue;', + ' }', + ]); + $originalGeneric = implode($newline, [ + ' } catch (TimeoutException) {', + ' ' . $report, + ' }', + ]); + $patchedGeneric = implode($newline, [ + ' } catch (TimeoutException|CancelledException $e) {', + ' ' . $guard, + ' throw $e;', + ' }', + ' ' . $report, + ' }', + ]); + + $commonLayoutIsExpected = substr_count($source, $timeoutImport) === 1 + && substr_count($source, "methodCallAsyncRead('updates.getChannelDifference'") === 1 + && substr_count($source, "methodCallAsyncRead('updates.getDifference'") === 1 + && substr_count($source, $report) === 2; + if (!$commonLayoutIsExpected) { + throw new RuntimeException('Unsupported MadelineProto UpdateLoop layout; expected imports, difference calls, or timeout reports changed.'); + } + + $isFullyPatched = substr_count($source, $cancelledImport) === 1 + && substr_count($source, $plainCatch) === 0 + && substr_count($source, $unionCatch) === 2 + && substr_count($source, $guard) === 2 + && substr_count($source, $patchedChannel) === 1 + && substr_count($source, $patchedGeneric) === 1; + if ($isFullyPatched) { + return 'already-patched'; + } + + $isExactOriginal = substr_count($source, $cancelledImport) === 0 + && substr_count($source, $plainCatch) === 2 + && substr_count($source, $unionCatch) === 0 + && substr_count($source, $guard) === 0 + && substr_count($source, $originalChannel) === 1 + && substr_count($source, $originalGeneric) === 1; + if (!$isExactOriginal) { + throw new RuntimeException('MadelineProto UpdateLoop is partially patched or has an unsupported upstream layout; refusing an ambiguous patch.'); + } + + $patched = str_replace( + $timeoutImport, + $cancelledImport . $newline . $timeoutImport, + $source, + $importReplacements, + ); + $patched = str_replace($originalChannel, $patchedChannel, $patched, $channelReplacements); + $patched = str_replace($originalGeneric, $patchedGeneric, $patched, $genericReplacements); + if ($importReplacements !== 1 || $channelReplacements !== 1 || $genericReplacements !== 1) { + throw new RuntimeException('MadelineProto UpdateLoop compatibility patch did not replace every guarded anchor exactly once.'); + } + + if (substr_count($patched, $cancelledImport) !== 1 + || substr_count($patched, $plainCatch) !== 0 + || substr_count($patched, $unionCatch) !== 2 + || substr_count($patched, $guard) !== 2 + || substr_count($patched, $patchedChannel) !== 1 + || substr_count($patched, $patchedGeneric) !== 1 + ) { + throw new RuntimeException('MadelineProto UpdateLoop compatibility patch failed its postconditions.'); + } + + try { + token_get_all($patched, TOKEN_PARSE); + } catch (ParseError $error) { + throw new RuntimeException('Patched MadelineProto UpdateLoop is not valid PHP: ' . $error->getMessage(), 0, $error); + } + + newSearchWriteUpdateLoopCompatibilityFile($updateLoopFile, $patched); + return 'patched'; +} + +function newSearchWriteUpdateLoopCompatibilityFile(string $path, string $contents): void +{ + $temporary = $path . '.newsearch-' . bin2hex(random_bytes(6)) . '.tmp'; + try { + $bytes = file_put_contents($temporary, $contents, LOCK_EX); + if ($bytes !== strlen($contents)) { + throw new RuntimeException('Unable to write complete UpdateLoop compatibility file: ' . $path); + } + if (!rename($temporary, $path)) { + throw new RuntimeException('Unable to atomically install UpdateLoop compatibility file: ' . $path); + } + } finally { + if (is_file($temporary)) { + @unlink($temporary); + } + } +} + +if (isset($_SERVER['SCRIPT_FILENAME']) && realpath((string) $_SERVER['SCRIPT_FILENAME']) === __FILE__) { + try { + $vendorRoot = $argv[1] ?? dirname(__DIR__) . '/vendor'; + $result = newSearchPatchMadelineUpdateLoop($vendorRoot); + fwrite(STDOUT, "MadelineProto UpdateLoop timeout compatibility: {$result}\n"); + } catch (Throwable $error) { + fwrite(STDERR, 'ERROR: MadelineProto UpdateLoop timeout compatibility patch failed: ' . $error->getMessage() . "\n"); + exit(1); + } +} diff --git a/patches/madelineproto/update-loop-timeout-cancellation.patch b/patches/madelineproto/update-loop-timeout-cancellation.patch new file mode 100644 index 0000000..5a7928d --- /dev/null +++ b/patches/madelineproto/update-loop-timeout-cancellation.patch @@ -0,0 +1,543 @@ +--- a/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php ++++ b/vendor/danog/madelineproto/src/Loop/Update/UpdateLoop.php +@@ -1,263 +1,277 @@ +-. +- * +- * @author Daniil Gentili +- * @copyright 2016-2025 Daniil Gentili +- * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 +- * @link https://docs.madelineproto.xyz MadelineProto documentation +- */ +- +-namespace danog\MadelineProto\Loop\Update; +- +-use Amp\TimeoutException; +-use danog\Loop\Loop; +-use danog\MadelineProto\API; +-use danog\MadelineProto\Exception; +-use danog\MadelineProto\Logger; +-use danog\MadelineProto\Loop\InternalLoop; +-use danog\MadelineProto\MTProto; +-use danog\MadelineProto\MTProto\LoginState; +-use danog\MadelineProto\MTProto\SpecialMethodType; +-use danog\MadelineProto\PeerNotInDbException; +-use danog\MadelineProto\PTSException; +-use danog\MadelineProto\Reactive\SimpleSubscriber; +-use danog\MadelineProto\RPCError\ChannelInvalidError; +-use danog\MadelineProto\RPCError\ChannelPrivateError; +-use danog\MadelineProto\RPCError\ChatForbiddenError; +-use danog\MadelineProto\RPCError\TimeoutError; +-use danog\MadelineProto\RPCError\UserBannedInChannelError; +- +-use Revolt\EventLoop; +-use function Amp\delay; +- +-/** +- * Update loop. +- * +- * @internal +- * +- * @author Daniil Gentili +- * +- * @implements SimpleSubscriber +- */ +-final class UpdateLoop extends Loop implements SimpleSubscriber +-{ +- use InternalLoop { +- __construct as private init; +- } +- /** +- * Main loop ID. +- */ +- public const GENERIC = 0; +- +- private const DEFAULT_TIMEOUT = 10.0; +- +- /** +- * Feed loop. +- */ +- private ?FeedLoop $feeder = null; +- private ?int $authorizedDc = null; +- /** +- * Constructor. +- */ +- public function __construct(MTProto $API, private int $channelId) +- { +- $this->init($API); +- $API->loginState->subscribe($this); +- } +- public function __sleep(): array +- { +- return ['channelId', 'API', 'feeder', 'authorizedDc']; +- } +- #[\Override] +- public function onSimpleStateChange($state): void +- { +- if ($state->state !== API::LOGGED_IN) { +- $this->authorizedDc = null; +- return; +- } +- if (null !== $this->authorizedDc = $state->authorizedDc) { +- if ($this->isRunning()) { +- $this->resume(true); +- } else { +- $this->start(); +- } +- } +- } +- +- /** +- * Main loop. +- */ +- #[\Override] +- public function loop(): ?float +- { +- if ($this->authorizedDc === null) { +- return self::PAUSE; +- } +- $this->API->feeders[$this->channelId] ??= new FeedLoop($this->API, $this->channelId); +- $this->API->updaters[$this->channelId] ??= new UpdateLoop($this->API, $this->channelId); +- +- $this->feeder = $this->API->feeders[$this->channelId]; +- $state = $this->channelId === self::GENERIC ? $this->API->loadUpdateState() : $this->API->loadChannelState($this->channelId); +- +- $result = []; +- while (true) { +- if ($this->channelId) { +- $this->API->logger('Resumed and fetching '.$this->channelId.' difference...', Logger::ULTRA_VERBOSE); +- if ($state->pts() <= 1) { +- $limit = 10; +- } elseif ($this->API->authorization['user']['bot']) { +- $limit = 100000; +- } else { +- $limit = 100; +- } +- $request_pts = $state->pts(); +- try { +- $difference = $this->API->methodCallAsyncRead('updates.getChannelDifference', ['channel' => $this->channelId, 'filter' => ['_' => 'channelMessagesFilterEmpty'], 'pts' => $request_pts, 'limit' => $limit, 'force' => true, 'floodWaitLimit' => 86400]); +- } catch (ChannelPrivateError|ChatForbiddenError|ChannelInvalidError|UserBannedInChannelError) { +- $this->feeder->stop(); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->getChannelStates()->remove($this->channelId); +- $this->API->logger("Channel private, exiting {$this}"); +- return self::STOP; +- } catch (TimeoutError $e) { +- delay(1.0); +- continue; +- } catch (PeerNotInDbException) { +- $this->feeder->stop(); +- $this->API->getChannelStates()->remove($this->channelId); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->logger("Channel private, exiting {$this}"); +- return self::STOP; +- } catch (PTSException $e) { +- $this->feeder->stop(); +- $this->API->getChannelStates()->remove($this->channelId); +- unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); +- $this->API->logger("Got PTS exception, exiting update loop for $this: $e", Logger::FATAL_ERROR); +- return self::STOP; +- } catch (TimeoutException) { +- EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); +- continue; +- } +- $timeout = min(self::DEFAULT_TIMEOUT, $difference['timeout'] ?? self::DEFAULT_TIMEOUT); +- $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); +- switch ($difference['_']) { +- case 'updates.channelDifferenceEmpty': +- $state->update($difference); +- unset($difference); +- break 2; +- case 'updates.channelDifference': +- if ($request_pts >= $difference['pts'] && $request_pts > 1) { +- $this->API->logger("The PTS ({$difference['pts']}) I got with getDifference is smaller than the PTS I requested ".$state->pts().', using '.($state->pts() + 1), Logger::VERBOSE); +- $difference['pts'] = $request_pts + 1; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $state->update($difference); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- if (!$difference['final']) { +- unset($difference); +- break; +- } +- unset($difference); +- break 2; +- case 'updates.channelDifferenceTooLong': +- if (isset($difference['dialog']['pts'])) { +- $difference['pts'] = $difference['dialog']['pts']; +- } +- $state->update($difference); +- if ($difference['messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['messages']); +- unset($difference); +- break; +- default: +- throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); +- } +- } else { +- $this->API->logger('Resumed and fetching normal difference...', Logger::ULTRA_VERBOSE); +- do { +- try { +- $difference = $this->API->methodCallAsyncRead('updates.getDifference', ['pts' => $state->pts(), 'date' => $state->date(), 'qts' => $state->qts(), 'specialMethodType' => SpecialMethodType::USER_RELATED], $this->authorizedDc); +- break; +- } catch (TimeoutError) { +- delay(1.0); +- } catch (TimeoutException) { +- EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); +- } +- } while (true); +- $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); +- $timeout = self::DEFAULT_TIMEOUT; +- switch ($difference['_']) { +- case 'updates.differenceEmpty': +- $state->update($difference); +- unset($difference); +- break 2; +- case 'updates.difference': +- $state->qts($difference['state']['qts']); +- foreach ($difference['new_encrypted_messages'] as &$encrypted) { +- $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $result += ($this->feeder->feed($difference['new_encrypted_messages'])); +- $state->update($difference['state']); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- unset($difference); +- break 2; +- case 'updates.differenceSlice': +- $state->qts($difference['intermediate_state']['qts']); +- foreach ($difference['new_encrypted_messages'] as &$encrypted) { +- $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; +- } +- $result += ($this->feeder->feed($difference['other_updates'])); +- $result += ($this->feeder->feed($difference['new_encrypted_messages'])); +- $state->update($difference['intermediate_state']); +- if ($difference['new_messages']) { +- $result[$this->channelId] = true; +- } +- $this->feeder->saveMessages($difference['new_messages']); +- unset($difference); +- break; +- case 'updates.differenceTooLong': +- $state->update($difference); +- unset($difference); +- break; +- default: +- throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); +- } +- } +- } +- $this->API->logger("Finished parsing updates in {$this}, now resuming feeders", Logger::ULTRA_VERBOSE); +- foreach ($result as $channelId => $_) { +- $this->API->feeders[$channelId]?->resume(); +- } +- $this->API->logger("Finished parsing updates in {$this}, pausing for $timeout seconds", Logger::ULTRA_VERBOSE); +- +- return $timeout; +- } +- /** +- * Get loop name. +- */ +- public function __toString(): string +- { +- return $this->channelId ? +- "getUpdate loop channel {$this->channelId}" : +- 'getUpdate loop generic'; +- } +-} ++. ++ * ++ * @author Daniil Gentili ++ * @copyright 2016-2025 Daniil Gentili ++ * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 ++ * @link https://docs.madelineproto.xyz MadelineProto documentation ++ */ ++ ++namespace danog\MadelineProto\Loop\Update; ++ ++use Amp\CancelledException; ++use Amp\TimeoutException; ++use danog\Loop\Loop; ++use danog\MadelineProto\API; ++use danog\MadelineProto\Exception; ++use danog\MadelineProto\Logger; ++use danog\MadelineProto\Loop\InternalLoop; ++use danog\MadelineProto\MTProto; ++use danog\MadelineProto\MTProto\LoginState; ++use danog\MadelineProto\MTProto\SpecialMethodType; ++use danog\MadelineProto\PeerNotInDbException; ++use danog\MadelineProto\PTSException; ++use danog\MadelineProto\Reactive\SimpleSubscriber; ++use danog\MadelineProto\RPCError\ChannelInvalidError; ++use danog\MadelineProto\RPCError\ChannelPrivateError; ++use danog\MadelineProto\RPCError\ChatForbiddenError; ++use danog\MadelineProto\RPCError\TimeoutError; ++use danog\MadelineProto\RPCError\UserBannedInChannelError; ++ ++use Revolt\EventLoop; ++use function Amp\delay; ++ ++/** ++ * Update loop. ++ * ++ * @internal ++ * ++ * @author Daniil Gentili ++ * ++ * @implements SimpleSubscriber ++ */ ++final class UpdateLoop extends Loop implements SimpleSubscriber ++{ ++ use InternalLoop { ++ __construct as private init; ++ } ++ /** ++ * Main loop ID. ++ */ ++ public const GENERIC = 0; ++ ++ private const DEFAULT_TIMEOUT = 10.0; ++ ++ /** ++ * Feed loop. ++ */ ++ private ?FeedLoop $feeder = null; ++ private ?int $authorizedDc = null; ++ /** ++ * Constructor. ++ */ ++ public function __construct(MTProto $API, private int $channelId) ++ { ++ $this->init($API); ++ $API->loginState->subscribe($this); ++ } ++ public function __sleep(): array ++ { ++ return ['channelId', 'API', 'feeder', 'authorizedDc']; ++ } ++ #[\Override] ++ public function onSimpleStateChange($state): void ++ { ++ if ($state->state !== API::LOGGED_IN) { ++ $this->authorizedDc = null; ++ return; ++ } ++ if (null !== $this->authorizedDc = $state->authorizedDc) { ++ if ($this->isRunning()) { ++ $this->resume(true); ++ } else { ++ $this->start(); ++ } ++ } ++ } ++ ++ /** ++ * Main loop. ++ */ ++ #[\Override] ++ public function loop(): ?float ++ { ++ if ($this->authorizedDc === null) { ++ return self::PAUSE; ++ } ++ $this->API->feeders[$this->channelId] ??= new FeedLoop($this->API, $this->channelId); ++ $this->API->updaters[$this->channelId] ??= new UpdateLoop($this->API, $this->channelId); ++ ++ $this->feeder = $this->API->feeders[$this->channelId]; ++ $state = $this->channelId === self::GENERIC ? $this->API->loadUpdateState() : $this->API->loadChannelState($this->channelId); ++ ++ $result = []; ++ while (true) { ++ if ($this->channelId) { ++ $this->API->logger('Resumed and fetching '.$this->channelId.' difference...', Logger::ULTRA_VERBOSE); ++ if ($state->pts() <= 1) { ++ $limit = 10; ++ } elseif ($this->API->authorization['user']['bot']) { ++ $limit = 100000; ++ } else { ++ $limit = 100; ++ } ++ $request_pts = $state->pts(); ++ try { ++ $difference = $this->API->methodCallAsyncRead('updates.getChannelDifference', ['channel' => $this->channelId, 'filter' => ['_' => 'channelMessagesFilterEmpty'], 'pts' => $request_pts, 'limit' => $limit, 'force' => true, 'floodWaitLimit' => 86400]); ++ } catch (ChannelPrivateError|ChatForbiddenError|ChannelInvalidError|UserBannedInChannelError) { ++ $this->feeder->stop(); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->getChannelStates()->remove($this->channelId); ++ $this->API->logger("Channel private, exiting {$this}"); ++ return self::STOP; ++ } catch (TimeoutError $e) { ++ delay(1.0); ++ continue; ++ } catch (PeerNotInDbException) { ++ $this->feeder->stop(); ++ $this->API->getChannelStates()->remove($this->channelId); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->logger("Channel private, exiting {$this}"); ++ return self::STOP; ++ } catch (PTSException $e) { ++ $this->feeder->stop(); ++ $this->API->getChannelStates()->remove($this->channelId); ++ unset($this->API->updaters[$this->channelId], $this->API->feeders[$this->channelId]); ++ $this->API->logger("Got PTS exception, exiting update loop for $this: $e", Logger::FATAL_ERROR); ++ return self::STOP; ++ } catch (TimeoutException|CancelledException $e) { ++ if (!$this->isRunning() || $this->authorizedDc === null) { ++ return self::STOP; ++ } ++ if ($e instanceof CancelledException && !($e->getPrevious() instanceof TimeoutException)) { ++ throw $e; ++ } ++ EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); ++ return self::DEFAULT_TIMEOUT; ++ } ++ $timeout = min(self::DEFAULT_TIMEOUT, $difference['timeout'] ?? self::DEFAULT_TIMEOUT); ++ $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); ++ switch ($difference['_']) { ++ case 'updates.channelDifferenceEmpty': ++ $state->update($difference); ++ unset($difference); ++ break 2; ++ case 'updates.channelDifference': ++ if ($request_pts >= $difference['pts'] && $request_pts > 1) { ++ $this->API->logger("The PTS ({$difference['pts']}) I got with getDifference is smaller than the PTS I requested ".$state->pts().', using '.($state->pts() + 1), Logger::VERBOSE); ++ $difference['pts'] = $request_pts + 1; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $state->update($difference); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ if (!$difference['final']) { ++ unset($difference); ++ break; ++ } ++ unset($difference); ++ break 2; ++ case 'updates.channelDifferenceTooLong': ++ if (isset($difference['dialog']['pts'])) { ++ $difference['pts'] = $difference['dialog']['pts']; ++ } ++ $state->update($difference); ++ if ($difference['messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['messages']); ++ unset($difference); ++ break; ++ default: ++ throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); ++ } ++ } else { ++ $this->API->logger('Resumed and fetching normal difference...', Logger::ULTRA_VERBOSE); ++ do { ++ try { ++ $difference = $this->API->methodCallAsyncRead('updates.getDifference', ['pts' => $state->pts(), 'date' => $state->date(), 'qts' => $state->qts(), 'specialMethodType' => SpecialMethodType::USER_RELATED], $this->authorizedDc); ++ break; ++ } catch (TimeoutError) { ++ delay(1.0); ++ } catch (TimeoutException|CancelledException $e) { ++ if (!$this->isRunning() || $this->authorizedDc === null) { ++ return self::STOP; ++ } ++ if ($e instanceof CancelledException && !($e->getPrevious() instanceof TimeoutException)) { ++ throw $e; ++ } ++ EventLoop::queue($this->API->report(...), "Network issues detected, please check logs!"); ++ return self::DEFAULT_TIMEOUT; ++ } ++ } while (true); ++ $this->API->logger('Got '.$difference['_'], Logger::ULTRA_VERBOSE); ++ $timeout = self::DEFAULT_TIMEOUT; ++ switch ($difference['_']) { ++ case 'updates.differenceEmpty': ++ $state->update($difference); ++ unset($difference); ++ break 2; ++ case 'updates.difference': ++ $state->qts($difference['state']['qts']); ++ foreach ($difference['new_encrypted_messages'] as &$encrypted) { ++ $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $result += ($this->feeder->feed($difference['new_encrypted_messages'])); ++ $state->update($difference['state']); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ unset($difference); ++ break 2; ++ case 'updates.differenceSlice': ++ $state->qts($difference['intermediate_state']['qts']); ++ foreach ($difference['new_encrypted_messages'] as &$encrypted) { ++ $encrypted = ['_' => 'updateNewEncryptedMessage', 'message' => $encrypted]; ++ } ++ $result += ($this->feeder->feed($difference['other_updates'])); ++ $result += ($this->feeder->feed($difference['new_encrypted_messages'])); ++ $state->update($difference['intermediate_state']); ++ if ($difference['new_messages']) { ++ $result[$this->channelId] = true; ++ } ++ $this->feeder->saveMessages($difference['new_messages']); ++ unset($difference); ++ break; ++ case 'updates.differenceTooLong': ++ $state->update($difference); ++ unset($difference); ++ break; ++ default: ++ throw new Exception('Unrecognized update difference received: '.var_export($difference, true)); ++ } ++ } ++ } ++ $this->API->logger("Finished parsing updates in {$this}, now resuming feeders", Logger::ULTRA_VERBOSE); ++ foreach ($result as $channelId => $_) { ++ $this->API->feeders[$channelId]?->resume(); ++ } ++ $this->API->logger("Finished parsing updates in {$this}, pausing for $timeout seconds", Logger::ULTRA_VERBOSE); ++ ++ return $timeout; ++ } ++ /** ++ * Get loop name. ++ */ ++ public function __toString(): string ++ { ++ return $this->channelId ? ++ "getUpdate loop channel {$this->channelId}" : ++ 'getUpdate loop generic'; ++ } ++} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..b4c69be --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,13 @@ + + + PSR-12 for new services, tools and tests. Legacy bot.php and immutable upstream patch baselines are syntax checked separately. + + src + bin + scripts + tests + + + + tests/Fixtures/* + \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..cf396b9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +parameters: + level: 5 + paths: + - src + - tests/PatchIntegrationAnalysis.php + tmpDir: .phpstan.cache + treatPhpDocTypesAsCertain: false + scanFiles: + - scripts/apply-patches.php \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0b00d1e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,5 @@ + + + tests + src + \ No newline at end of file diff --git a/readme-he.md b/readme-he.md new file mode 100644 index 0000000..bf10478 --- /dev/null +++ b/readme-he.md @@ -0,0 +1,538 @@ +
+ +# ๐Ÿ•ฏ๏ธ ื‘ื•ื˜ ื”ืฉื‘ืช ืฉืœ WizardLoop + +ื‘ื•ื˜ Telegram ื”ืžื‘ื•ืกืก ืขืœ PHP, โ€MadelineProto ื•ึพSQLite ืœื ื™ื”ื•ืœ ื–ืžื ื™ ืฉืงื˜ ื‘ืงื‘ื•ืฆื•ืช ื‘ืฉื‘ืชื•ืช ื•ื‘ื—ื’ื™ื, ืœื”ืฆื’ืช ื”ืœื•ื— ื”ื™ื”ื•ื“ื™ ื•ืœืชื–ื›ื•ืจื•ืช ื‘ื”ืคืขืœื” ืžืคื•ืจืฉืช. + +[English](README.md) | [ืขื‘ืจื™ืช](readme-he.md) + +[![ืจื™ืฉื™ื•ืŸ AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue)](LICENSE) +[![PHP 8.3โ€“8.5](https://img.shields.io/badge/PHP-8.3%E2%80%938.5-777BB4)](composer.json) +[![MadelineProto 8.7.0](https://img.shields.io/badge/MadelineProto-8.7.0-2CA5E0)](composer.lock) +[![ื’ืจืกืช ืคื™ืชื•ื— 3.0.0-dev.9](https://img.shields.io/badge/status-3.0.0--dev.9-orange)](VERSION) + +**ื”ื’ืจืกื” ื”ื ื•ื›ื—ื™ืช: 3.0.0-dev.9, ื’ืจืกื” ืžืงื“ื™ืžื”.** ื”ืฉืชืžืฉื• ื‘[ืงื•ื‘ืฆื™ ื”ืฉื—ืจื•ืจ ื”ืžืชืื™ืžื™ื](https://github.com/WizardLoop/shabbat/releases/tag/v3.0.0-dev.9) ืื• ื‘ืขื•ืชืง ืžื”ืชื’ ื•ืฉืžืจื• ืืช ื ืชื•ื ื™ ื”ื–ื™ื”ื•ื™ ืฉืœื•. ื‘ื“ื™ืงื•ืช ืื•ื˜ื•ืžื˜ื™ื•ืช, ื‘ื“ื™ืงื•ืช Docker ื•ื‘ื“ื™ืงื•ืช Telegram ื—ื™ื•ืช ื”ืŸ ื‘ื“ื™ืงื•ืช ื ืคืจื“ื•ืช. ื‘ืžืขื‘ืจ ืžื‘ื•ื˜ ืงื™ื™ื ืคืขืœื• ืœืคื™ [ืžื“ืจื™ืš ื”ืฉื“ืจื•ื’ ื•ื”ื—ื–ืจื” ืœื’ืจืกื” ืงื•ื“ืžืช](docs/PRODUCTION_UPGRADE.md) ืœืคื ื™ ื”ื—ืœืคืช ืงื‘ืฆื™ื ืื• ื”ืคืขืœืช ืžื•ืคืข ื ื•ืกืฃ. ืจืื• [ืžืฆื‘ ื”ืคืจืกื•ื](docs/PUBLICATION_STATUS.md), [ืฉื™ื ื•ื™ื™ื](CHANGELOG.md) ื•[ื‘ื˜ื™ื—ื•ืช ืžืกื™ืจื”](docs/DEV9_DELIVERY_SAFETY.md). ื”ืชื’ื™ื ืžืชืืจื™ื ืืช ืชืฆื•ืจืช ื”ืžืงื•ืจ ื•ืื™ื ื ืื™ืฉื•ืจ ืœืชืงื™ื ื•ืช ื‘ืกื‘ื™ื‘ืช production. + +## ืชื•ื›ืŸ ืขื ื™ื™ื ื™ื + +[ื™ื›ื•ืœื•ืช](#features) ยท [ื“ืจื™ืฉื•ืช](#requirements) ยท [ื”ืชืงื ืช Docker](#docker-installation) ยท [ื”ืชืงื ื” ื™ื“ื ื™ืช](#manual-installation) ยท [ืชืฆื•ืจื”](#configuration) ยท [ื”ื›ื ืช Telegram](#telegram-setup) ยท [ื”ื•ืกืคืช ืงื‘ื•ืฆื”](#group-setup) ยท [ืžื™ืงื•ื ื•ืฉืคื•ืช](#location-and-languages) ยท [ืžื•ื“ื•ืœื™ ื”ืœื•ื—](#calendar-modules) ยท [ืคืงื•ื“ื•ืช ื”ื‘ื•ื˜](#bot-commands) ยท [ืชืคืขื•ืœ](#operations) ยท [ืฉื“ืจื•ื’ ื•ื™ื™ื‘ื•ื](#upgrade-and-legacy-import) ยท [ื’ื™ื‘ื•ื™ ื•ื—ื–ืจื” ืœื’ืจืกื” ืงื•ื“ืžืช](#backup-and-rollback) ยท [ืคืชืจื•ืŸ ืชืงืœื•ืช](#troubleshooting) ยท [ืžื‘ื ื” ื•ืคื™ืชื•ื—](#project-and-development) ยท [ืจื™ืฉื•ื™ ื•ืงืจื“ื™ื˜ื™ื](#license-and-credits) + + +## ื™ื›ื•ืœื•ืช + +| ืชื—ื•ื | ื”ื”ืชื ื”ื’ื•ืช ื”ืžืžื•ืžืฉืช | +|---|---| +| ืฉื‘ืช ื•ื—ื’ื™ื | ืžื™ืงื•ื ืœื›ืœ ืงื‘ื•ืฆื”, ืœื•ื— ื™ืฉืจืืœ/ื—ื•ืดืœ, ืฉื™ื˜ื•ืช ื ืจื•ืช ื•ื”ื‘ื“ืœื”, ื”ื™ืกื˜ื™ื ืชืคืขื•ืœื™ื™ื ื•ืกื’ื™ืจื” ืจืฆื•ืคื” ื‘ื™ืžื™ื ืงื“ื•ืฉื™ื ืกืžื•ื›ื™ื. | +| ืžืฆื‘ื™ื | ื›ื‘ื•ื™, ื”ื•ื“ืขื•ืช ื‘ืœื‘ื“, ื ืขื™ืœื” ื‘ืœื‘ื“ ืื• ื ืขื™ืœื” ื•ื”ื•ื“ืขื•ืช; ืฉื‘ืช ื•ื—ื’ื™ื ื ื‘ื—ืจื™ื ื‘ื ืคืจื“. | +| ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช | ืฆื™ืœื•ื ืžืงื•ืจื™ ืฉืœ 22 ื“ื’ืœื™ื, ืงืจื™ืื” ื˜ืจื™ื™ื” ืžืื•ืžืชืช, ืžืฆื‘ ืฉื—ื–ื•ืจ ืฉืžื•ืจ ื•ืคืื ืœ ืœื˜ื™ืคื•ืœ ืžื•ืจืฉื” ื‘ื”ืชื ื’ืฉื•ื™ื•ืช. ืžืกื›ืช ื”ื ืขื™ืœื” ื”ืจื’ื™ืœื” ื›ื•ืœืœืช 21 ื”ื’ื‘ืœื•ืช ื—ื‘ืจื™ื ืœื‘ื—ื™ืจื”; ืกืžื›ื•ื™ื•ืช ืžื ื”ืœื™ื ื ืคืจื“ื•ืช. | +| ืฉืœื™ื˜ื” ื‘ืคืจื˜ื™ | ืงื‘ื•ืฆื•ืช ืžื•ื›ืจื•ืช, ื”ื’ื“ืจื•ืช ืžื•ื“ืจื›ื•ืช, ื”ืขื“ืคื•ืช ืื™ืฉื™ื•ืช ื ืคืจื“ื•ืช, ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื•ืื™ืฉื•ืจ. | +| ืœื•ื— ื•ืชื–ื›ื•ืจื•ืช | ื–ืžื ื™ ื”ื™ื•ื, ื“ืฃ ื™ื•ืžื™, ืชืคื™ืœื™ืŸ ื•ืขื•ืžืจ ืขื•ื ืชื™ ื‘ื”ืคืขืœื” ืžืคื•ืจืฉืช ื•ื‘ื”ื’ื“ืจื•ืช ื ืคืจื“ื•ืช ืœื›ืœ ืงื‘ื•ืฆื”. | +| ืขื™ืฆื•ื‘ ื”ื•ื“ืขื•ืช | ื˜ืงืกื˜, ื™ืฉื•ื™ื•ืช Telegram, ืžื“ื™ื” ืœืฉื™ืžื•ืฉ ื—ื•ื–ืจ ื•ื›ืคืชื•ืจื™ ื‘ืจื™ืจืช ืžื—ื“ืœ/ืžื•ืชืืžื™ื/ืœืœื ื›ืคืชื•ืจื™ื; ืชื‘ื ื™ื•ืช ื ืคืจื“ื•ืช ืœืกื’ื™ืจื”, ืคืชื™ื—ื”, ื”ื•ื“ืขืช ื–ืžื ื™ื ื•ืžื•ื“ื•ืœื™ื. ื ื™ืงื•ื™ ื”ื•ื“ืขื•ืช ื›ื‘ื•ื™ ื›ื‘ืจื™ืจืช ืžื—ื“ืœ. | +| ื ื™ื”ื•ืœ | ืžื ื”ืœื™ื ืžืœืื™ื/ืžื•ื’ื‘ืœื™ื/ืœืฆืคื™ื™ื” ื‘ืœื‘ื“, ืกืคืจื™ื™ืช ืžื ื•ื™ื™ื, ืฉื™ื“ื•ืจื™ื, ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช, ืื‘ื—ื•ืŸ, ืงื™ืฉื•ืจื™ ืžืขืจื›ืช ื•ืžืขืงื‘. | +| ืื—ืกื•ืŸ ื•ืชืคืขื•ืœ | ืžื™ื’ืจืฆื™ื•ืช ื•ื’ื™ื‘ื•ื™ื™ SQLite, ืžืฉื™ืžื•ืช ืžืกื™ืจื” ืฉืžื•ืจื•ืช, ืคืขื™ืžื•ืช ืžืชื–ืžืŸ ื ืคืจื“ื•ืช, ื›ื•ืชื‘ ื™ื™ืฉื•ื ื™ื—ื™ื“ ื•ื›ืœื™ ืชื—ื–ื•ืงื” ืžืคื•ืจืฉื™ื. | +| ืชืจื•ืžื•ืช ื•ืžืงื•ืจ | ืชืจื•ืžื•ืช Telegram Stars ืœื‘ื—ื™ืจื”, ืœืจื‘ื•ืช ืกื›ื•ื ืฉืœื ืžื•ืชืื 1โ€“10000; ื’ืจืกื”, ืจื™ืฉื™ื•ืŸ ื•ืงื™ืฉื•ืจ ืžืคืขื™ืœ ืœืžืงื•ืจ ื”ืžื“ื•ื™ืง. | + +ื‘ึพdev.9 ื ื•ืกืคื• ื‘ื“ื™ืงืช ื–ื›ืื•ืช ื‘ื–ืžืŸ ื”ืžืกื™ืจื”, ื”ื’ื‘ืœืช ืชื–ื›ื•ืจื•ืช ืฉื”ืฆื˜ื‘ืจื•, ืื›ื™ืคืช ืžืจื•ื•ื—ื™ื ื•ืฉืขื•ืช ืฉืงื˜ ื‘ืคื•ืขืœ ื•ื”ืคืจื“ื” ื‘ื™ืŸ ืขื‘ื•ื“ืช ื”ืจืฉืื•ืช, ื”ื•ื“ืขื•ืช ื•ืฉื™ื“ื•ืจื™ื. MadelineProto ื ืฉืืจ ื‘ึพ8.7.0 ื•ื ืฉืžืจ ืงื•ื‘ืฅ ื”ืชืœื•ื™ื•ืช ื”ื ืขื•ืœ. ืขืจืš ืฉืžืฉ ื—ืกืจ ืžื“ื•ื•ื— ื›ื—ืกืจ, ืœืœื ื”ืžืฆืืช ื—ืœื•ืคื” ืœืื–ื•ืจื™ ืงื•ื˜ื‘. ื”ื–ืžื ื™ื ื•ื”ื ื•ืกื—ื™ื ื ื•ืขื“ื• ืœืžื™ื“ืข, ื•ื”ืžื ื”ืœ ื‘ื•ื—ืจ ืืช ืฉื™ื˜ื•ืช ื”ื—ื™ืฉื•ื‘. + + +## ื“ืจื™ืฉื•ืช + +| ืจื›ื™ื‘ | ื“ืจื™ืฉื” | +|---|---| +| ืคืจื™ืกื” ืžื•ืžืœืฆืช | Linux, โ€Docker Engine ื•ึพCompose v2, ื’ื™ืฉื” ื™ื•ืฆืืช ืœึพHTTPS ื•ืœึพTelegram ื•ืื—ืกื•ืŸ ืžืชืžืฉืš. | +| ื”ืจืฆื” ื™ื“ื ื™ืช | PHP โ€8.3/8.4/8.5 ื‘ึพ64 ื‘ื™ื˜ ื•ึพComposer 2. ื‘ึพDocker ื ืขื•ืœื™ื PHP 8.4.19 ื•ึพComposer 2.10.3. | +| ื”ืจื—ื‘ื•ืช | PDO/PDO SQLite, โ€mbstring, โ€XML, โ€DOM, โ€fileinfo ื•ึพzlib; โ€ZIP ืœืืจื™ื–ื”. ื‘ื“ืงื• ืืช ื›ืœ ื“ืจื™ืฉื•ืช ื”ึพlock ื‘ืืžืฆืขื•ืช `composer check-platform-reqs`. ื‘ึพDocker ื ื‘ื ื•ืช ื’ื pcntl ื•ึพsockets. | +| SQLite | ืžื™ื ื™ืžื•ื ื™ื™ืฉื•ื 3.35. ื‘ื“ื™ืงืช ืฉื—ืจื•ืจ ืžืงื‘ืœืช 3.51.3 ื•ืžืขืœื” ืื• 3.44.6 / 3.50.7 ืฉื ื‘ื“ืงื•. ื—ืฉื•ื‘ ืœื‘ื“ื•ืง ืืช ื”ืกืคืจื™ื™ื” ืฉึพPHP ื˜ื•ืขืŸ. Docker ื‘ื•ื ื” 3.51.3 ืขื ืื™ืžื•ืช SHA-256. | +| Telegram | ืคืจื˜ื™ ื™ื™ืฉื•ื API ID/hash, ื˜ื•ืงืŸ BotFather ื•ืžื–ื”ื™ ืžืฉืชืžืฉ ืื• ืฉืžื•ืช ืžืฉืชืžืฉ ื ื™ืชื ื™ื ืœืคืขื ื•ื— ืขื‘ื•ืจ ื”ืžืคืขื™ืœื™ื. | +| GeoNames | ื—ืฉื‘ื•ืŸ ืฉืœื›ื ืขื webservices ืคืขื™ืœื™ื ืœื—ื™ืคื•ืฉ ืขื™ืจ ื•ืื–ื•ืจ ื–ืžืŸ ืื•ื˜ื•ืžื˜ื™. ืขืจื™ื ืžื•ื›ืจื•ืช ื•ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ืžืคื•ืจืฉื•ืช ื–ืžื™ื ื•ืช ื’ื ื‘ืœืขื“ื™ื•. | +| ืžืขืจื›ืช ืงื‘ืฆื™ื | ื ืชื•ื ื™ ื™ื™ืฉื•ื ืžืชืžืฉื›ื™ื ื•ืกืฉืŸ MadelineProto ื ืคืจื“. ื ื“ืจืฉืช ื’ื ื›ืชื™ื‘ื” ื‘ืชื™ืงื™ื™ืช ื”ืขื‘ื•ื“ื” ืœืœื•ื’ ืชื”ืœื™ืš ื”ืกืคืจื™ื™ื” ื•ืœืงื•ื‘ืฆื™ PrimeModule. | + +ื”ื‘ื“ื™ืงื•ืช ื”ืื•ื˜ื•ืžื˜ื™ื•ืช ืžืฉืชืžืฉื•ืช ื‘ืชื—ืœื™ืคื™ Telegram ื•ื‘ื ืชื•ื ื™ ื‘ื“ื™ืงื” ืฉืœ ืกืคืงื™ื, ืœืœื ืคืจื˜ื™ ื”ืชื—ื‘ืจื•ืช. ื‘ื“ื™ืงื•ืช ื—ื™ื•ืช ื‘ืกื‘ื™ื‘ื” ื ืคืจื“ืช ืฆืจื™ื›ื•ืช ืœื›ืกื•ืช ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช, ื”ืคืจืขื” ื•ื”ืคืขืœื” ืžื—ื“ืฉ, ื ื•ืฉืื™ื, ืชืฆื•ื’ืช ืœืงื•ื—ื•ืช, ืื™ื ืœื™ื™ืŸ/GPS, ื›ื•ื›ื‘ื™ื ื•ืชืคืจื™ื˜ื™ ืคืงื•ื“ื•ืช. + + +## ื”ืชืงื ืช Docker + +ื“ื•ื’ืžืื•ืช ื”ืžืขื˜ืคืช ืžื™ื•ืขื“ื•ืช ืœืžืกื•ืฃ Linux/POSIX. ืคืงื•ื“ื•ืช Docker ืžืจื™ืฆื™ื **ื‘ืžื—ืฉื‘ ื”ืžืืจื—, ื‘ืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜ ืฉื—ื•ืœืฅ**, ืขื ื”ืจืฉืื” ืœืฉื™ืจื•ืช Docker. ื”ืชื”ืœื™ืš ื‘ืชืžื•ื ื” ืจืฅ ื›ึพ`shabbat`, ืขื UID/GID โ€`10001`. ืื™ืŸ ืฉื™ืจื•ืช Composer ื ืคืจื“. + +1. ืงื‘ืœื• ืืช ื”ืžืงื•ืจ ืฉื ื‘ื“ืง. ืขื‘ื•ืจ ืืจื›ื™ื•ืŸ ื”ื”ื›ื ื” ืœืคืจืกื•ื: + +
+ +~~~sh +unzip shabbat-3.0.0-dev.9.zip -d shabbat +cd shabbat +~~~ + +
+ +ื”ืืจื›ื™ื•ืŸ ืžื›ื™ืœ ืžืงื•ืจ ื•ึพ`composer.lock`, ืœืœื `vendor/` ืžื•ืชืงืŸ. Docker ืžืชืงื™ืŸ ืืช ืชืœื•ื™ื•ืช ื”ื”ืจืฆื” ื”ื ืขื•ืœื•ืช. **ืฉืžืจื• ืืช `build-info.json` ืฉื‘ืืจื›ื™ื•ืŸ.** ื‘ืืจื›ื™ื•ืŸ ื”ืคืจืกื•ื ื”ื•ื ืžืชืขื“ commit ื‘ืกื™ืก ื”ื™ืกื˜ื•ืจื™ ืžืื•ืžืช, `local_changes=true` ื•ื˜ื‘ื™ืขืช ืžืงื•ืจ ืžื“ื•ื™ืงืช ืฉืœ ื”ื”ื›ื ื”; ื”ื‘ืกื™ืก ืื™ื ื• commit ื—ื“ืฉ ืฉืœ ื”ืฉื™ื ื•ื™ื™ื. ืื ืžืฉืชืžืฉื™ื ื‘ืขื•ืชืง Git ืฉื ื‘ื“ืง, ืžืกืจื• ืœืคื ื™ ื”ื‘ื ื™ื™ื” ืืช ื”ึพcommit ื”ืืžื™ืชื™ ืฉืœื•: + +
+ +~~~sh +export BUILD_COMMIT="$(git rev-parse HEAD)" +~~~ + +
+ +commit ื”ื‘ืกื™ืก ืœื‘ื“ื• ืื™ื ื• ืžื–ื”ื” ืฉื™ื ื•ื™ื™ื ืฉื˜ืจื ื ืฉืžืจื• ื‘ึพGit; ื’ื ื˜ื‘ื™ืขืช ื”ืžืงื•ืจ ืžื–ื”ื” ืืช ื”ื—ื‘ื™ืœื”. ืื™ืŸ ืœื”ื—ืœื™ืคื• ื‘ึพcommit ืœื ืงืฉื•ืจ. + +2. ืฆืจื• ืคืขื ืื—ืช ืชืฆื•ืจื” ืคืจื˜ื™ืช ื‘ื”ืชืงื ื” ื”ื—ื“ืฉื”: + +
+ +~~~sh +cp .env.example .env +chmod 600 .env +~~~ + +
+ +ืขืจื›ื• ืืช `.env`: ื”ื—ืœื™ืคื• ืืช ื“ื•ื’ืžืื•ืช Telegram, ื”ื’ื“ื™ืจื• `ADMIN`, ื—ืฉื‘ื•ืŸ GeoNames ืœืคื™ ื”ืฆื•ืจืš ื•ึพ`SOURCE_URL` ืœืžืงื•ืจ ื”ืชื•ืื ืฉืชืขืžื™ื“ื• ืœืจืฉื•ืช ื”ืžืฉืชืžืฉื™ื. ืฉืžืจื• ืขืœ ืชื—ื‘ื™ืจ ืคืฉื•ื˜ ืฉืœ `KEY=value`, ื”ืžืชืื™ื ืœึพPHP raw INI ื•ืœึพCompose. ื”ืงื•ื‘ืฅ ื”ืžื•ื’ื“ืจ ืื™ื ื• ืžื™ื•ืขื“ ืœื”ืขืœืื” ืœืžืื’ืจ. + +3. ื‘ื ื• ื•ื‘ื“ืงื• ืืช ื”ืชืžื•ื ื”. ื”ืฉืœื‘ื™ื ืžืชืงื™ื ื™ื ื•ืžืชืงื ื™ื ืชืœื•ื™ื•ืช ื•ื›ื•ืชื‘ื™ื ื ืชื•ื ื™ ื–ื™ื”ื•ื™ ื‘ืชื•ืš ื”ืชืžื•ื ื”, ื‘ืœื™ ืœื”ืคืขื™ืœ ืืช ื”ื‘ื•ื˜: + +
+ +~~~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. ืืชื—ืœื• **ืžืกื“ ื—ื“ืฉ ื•ืจื™ืง**, ื›ืฉื”ืฉื™ืจื•ืช ืขืฆื•ืจ. ืœืžืกื“ ืงื™ื™ื ื ื“ืจืฉ ื ื•ื”ืœ ืฉื“ืจื•ื’ ื•ื’ื™ื‘ื•ื™. + +
+ +~~~sh +docker compose run --rm --no-deps shabbat php bin/migrate.php --writers-stopped --db /app/data/shabbat.sqlite +~~~ + +
+ +5. ื”ืคืขื™ืœื• ื›ืฉืžื•ื›ื ื™ื ืœื—ื™ื‘ื•ืจ ืืžื™ืชื™ ืœึพTelegram ื•ืœืคืขื™ืœื•ืช ื‘ืงื‘ื•ืฆื•ืช ื”ืจืฉื•ืžื•ืช: + +
+ +~~~sh +docker compose up -d shabbat +docker compose ps +docker compose logs --tail=100 shabbat +docker compose exec shabbat php bin/health.php +~~~ + +
+ +ืฉื ื”ืฉื™ืจื•ืช: `shabbat`. ื›ืจื›ื™ื: `bot-data` ื‘ึพ`/app/data`, ื•ึพ`madeline-session` ื‘ึพ`/app/runtime`; ื”ืฉืžื•ืช ื‘ืคื•ืขืœ ื›ื•ืœืœื™ื ืืช ืฉื ืคืจื•ื™ืงื˜ Compose. ืฉืžืจื• ืืช ื–ื”ื•ืช ื”ืคืจื•ื™ืงื˜ ื•ื”ื›ืจื›ื™ื ื‘ืฉื“ืจื•ื’. Compose ื›ื•ืคื” `DATA_DIR=/app/data` ื•ึพ`SESSION_PATH=/app/runtime/bot.shabbat`. ื‘ื“ื™ืงืช ื”ื‘ืจื™ืื•ืช ื”ืจืืฉื•ื ื™ืช ืžืžืชื™ื ื” ืœืฉื ื™ ื”ืžืชื–ืžื ื™ื; ืชืงื•ืคืช ื”ื”ืชื—ืœื” ื”ืžื•ื’ื“ืจืช ื”ื™ื 90 ืฉื ื™ื•ืช. + +[ื”ืชืงื ื” ืžืคื•ืจื˜ืช](docs/INSTALLATION.md) ยท [ืกื‘ื™ื‘ืช ื‘ื“ื™ืงื” ื ืคืจื“ืช](docs/SERVER_TEST_DEPLOYMENT.md) + + +## ื”ืชืงื ื” ื™ื“ื ื™ืช + +ื”ืฉืชืžืฉื• ื‘ืžืงื•ืจ ื ืคืจื“ ื•ื‘ื—ืฉื‘ื•ืŸ ืฉื™ืจื•ืช ืฉืื™ื ื• root ื•ืฉื™ื›ื•ืœ ืœื›ืชื•ื‘ ืœืชื™ืงื™ื•ืช ื”ืขื‘ื•ื“ื”, ื”ื ืชื•ื ื™ื ื•ื”ืกืฉืŸ. ื”ืจื™ืฆื• **ืžืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜ ื”ื–ื”**, ืœืื—ืจ ื”ืขืชืงืช ื•ื”ื’ื“ืจืช `.env` ื›ืžืชื•ืืจ: + +
+ +~~~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 +~~~ + +
+ +ื”ืคืงื•ื“ื” ื”ืื—ืจื•ื ื” ืžืคืขื™ืœื” ืืช ื”ื‘ื•ื˜ ื‘ื—ื–ื™ืช ื•ืžืชื—ื‘ืจืช ืœึพTelegram. ืขืฆืจื• ื‘ืืžืฆืขื•ืช Ctrl+C ื•ื”ืžืชื™ื ื• ืœืกื™ื•ื; ื”ืคืขืœื” ื—ื•ื–ืจืช ื ืขืฉื™ืช ื‘ึพ`php bot.php`. ื ื™ื”ื•ืœ ืชื”ืœื™ืš ื™ื“ื ื™ ื‘ืื—ืจื™ื•ืช ื”ืžืคืขื™ืœ; ืื™ืŸ ื‘ืคืจื•ื™ืงื˜ ื™ื—ื™ื“ืช systemd ืžื•ื›ื ื”. Windows ืžืชืื™ื ืœืจื‘ื•ืช ืžื”ื‘ื“ื™ืงื•ืช ื”ืœืึพืžืงื•ื•ื ื•ืช, ืืš ื‘ื“ื™ืงืช POSIX ืฉืœ ืžืฉืชืžืฉ ืฉืื™ื ื• root ื•ื‘ื“ื™ืงืช ืคืจื™ืกืช Linux ื“ื•ืจืฉื•ืช ืžืืจื— ืžืชืื™ื. + +Composer ืžื—ื™ืœ ื•ืžืืžืช ืืช ื”ืชื™ืงื•ื ื™ื ื”ืžืงื•ืžื™ื™ื ื‘ื”ืชืงื ื” ืจื’ื™ืœื”. ืื ื”ืกืงืจื™ืคื˜ื™ื ื‘ื•ื˜ืœื•, ื”ืจื™ืฆื• **ื‘ื”ืชืงื ื” ื”ืขืฆื•ืจื”**: + +
+ +~~~sh +composer install --no-dev --no-scripts --no-interaction --prefer-dist +php scripts/apply-patches.php +php scripts/verify-patches.php +~~~ + +
+ +ืชื•ื›ืŸ ืชืœื•ืช ืœื ืžื•ื›ืจ ืื• ืชื™ืงื•ืŸ ื—ืœืงื™ ื’ื•ืจืžื™ื ืœื›ื™ืฉืœื•ืŸ ืื™ืžื•ืช. ื”ื—ืœื” ื—ื•ื–ืจืช ืื™ื ื” ืžืฉื ื” ืžืงื•ืจ ืฉื›ื‘ืจ ืชื•ืงืŸ ื›ืจืื•ื™. ืื™ืŸ ืœืชืงืŸ ืกืคืจื™ื™ื” ืžื•ืชืงื ืช ื‘ื–ืžืŸ ืฉื”ื‘ื•ื˜ ืžืฉืชืžืฉ ื‘ื”. + + +## ืชืฆื•ืจื” + +ื”ื‘ื•ื˜ ืงื•ืจื ืืช `.env` ืฉื‘ืฉื•ืจืฉ ื›ึพraw INI. ืขืจื›ื™ ืกื‘ื™ื‘ื” ืœื ืจื™ืงื™ื ืงื•ื“ืžื™ื ืœืงื•ื‘ืฅ ื‘ื—ื™ืคื•ืฉ ืคืจื˜ื™ ื”ืชื—ื‘ืจื•ืช ื•ื ืชื™ื‘ื™ื; ื”ื™ืžื ืขื• ืžื”ื’ื“ืจื•ืช ื›ืคื•ืœื•ืช ื•ืจื™ืงื•ืช. ื’ื ื›ืœื™ ื”ืชื—ื–ื•ืงื” ืงื•ืจืื™ื ืืช `.env` ืœืคืชืจื•ืŸ ื ืชื™ื‘ื™ื; `--db` ืžืคื•ืจืฉ ื‘ื•ื—ืจ ืืช ืžืกื“ ื”ื™ืขื“. ื ืชื™ื‘ื™ื ื™ื—ืกื™ื™ื ืžืชื—ื™ืœื™ื ื‘ืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜. + +| ืžืฉืชื ื” | ื‘ืจื™ืจืช ืžื—ื“ืœ / ื“ืจื™ืฉื” | ืชืคืงื™ื“ | +|---|---|---| +| `API_ID` | ืžืกืคืจ ืฉืœื ื—ื™ื•ื‘ื™, ื—ื•ื‘ื” | ืžื–ื”ื” ื™ื™ืฉื•ื Telegram. | +| `API_HASH` | 32 ืชื•ื•ื™ื ื”ืงืกื“ืฆื™ืžืœื™ื™ื, ื—ื•ื‘ื” | ืกื•ื“ ื™ื™ืฉื•ื Telegram. | +| `BOT_TOKEN` | ื—ื•ื‘ื” | ื˜ื•ืงืŸ BotFather ืฉืœ ื”ื”ืชืงื ื”. | +| `ADMIN` | ื™ืฉ ืœื”ื’ื“ื™ืจ ืœื’ื™ืฉืช ืžืคืขื™ืœื™ื | ืžื–ื”ื™ื/ืฉืžื•ืช ื ื™ืชื ื™ื ืœืคืขื ื•ื—, ืžื•ืคืจื“ื™ื ื‘ืคืกื™ืง; ืžื–ื”ื™ื ืžืกืคืจื™ื™ื ืžื•ื ืขื™ื ืขืžื™ืžื•ืช. ื”ื‘ืขืœื™ื ื”ืžื•ื’ื“ืจื™ื ืฉื•ืžืจื™ื ื’ื™ืฉืช ื”ืชืื•ืฉืฉื•ืช ื•ื”ืคืขืœื” ืžื—ื“ืฉ. | +| `DATA_DIR` | `data` ื‘ืคืจื•ื™ืงื˜ | ื ืชื•ื ื™ ื”ื™ื™ืฉื•ื ื•ึพBroadcastManager; ื‘ึพCompose ืžื•ื—ืœืฃ ื‘ึพ`/app/data`. | +| `APP_DB_PATH` | `DATA_DIR/shabbat.sqlite` | ืžืกื“ ื”ื™ื™ืฉื•ื; ื‘ื“ื•ื’ืžื” ืžื•ื’ื“ืจ ื‘ืžืคื•ืจืฉ `data/shabbat.sqlite`. | +| `SESSION_PATH` | `bot.shabbat` ื‘ืคืจื•ื™ืงื˜ | ืกืฉืŸ MadelineProto; ื‘ื“ื•ื’ืžื” `data/session/bot.shabbat`, ื•ื‘ึพCompose โ€`/app/runtime/bot.shabbat`. | +| `HEARTBEAT_PATH` | `DATA_DIR/scheduler-heartbeat.json` | ืงื•ื‘ืฅ ืคืขื™ืžื•ืช ืžืชื–ืžื ื™ ื”ื”ืจืฉืื•ืช ื•ื”ื”ื•ื“ืขื•ืช. | +| `LEGACY_DATA_PATH` | ืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜ | ืฉื•ืจืฉ ืžืงื•ืจ ื™ืฉืŸ ืœืงืจื™ืื” ื‘ืœื‘ื“ ืขื `data/DBgroups.txt`; ื‘ืขืช ืขืœื™ื™ื” ื ื•ืกืคื™ื ืจืง ืจื™ืฉื•ืžื™ ืงื‘ื•ืฆื•ืช ื—ืกืจื™ื. | +| `GEONAMES_USERNAME` | ืจื™ืง | ื—ืฉื‘ื•ืŸ webservices ืฉืœื›ื; demo ืื™ื ื• ื ืชืžืš. | +| `GEONAMES_ENDPOINT` | `https://secure.geonames.org` | ื ืงื•ื“ืช HTTPS ืžืื•ืžืชืช, ืœืœื ืžืขื‘ืจ ืœึพHTTP. | +| `CUSTOM_EMOJI_ENABLED` | `1` | `0` ื‘ื•ื—ืจ ืื™ืžื•ื’ืณื™ื ื‘ืกื™ืกื™ื™ื; ื’ื ื“ื—ื™ื™ืช ื–ื›ืื•ืช ื•ื“ืื™ืช ืžืคืขื™ืœื” ื—ืœื•ืคื” ืžื‘ื•ืงืจืช. | +| `SOURCE_URL` | ืจื™ืง; ื™ืฉ ืœื”ื’ื“ื™ืจ ืœืคื ื™ ืคืจื™ืกื” | URL ื‘ึพHTTPS ืœืžืงื•ืจ ื”ืžื“ื•ื™ืง. ื—ื•ืกืจ ื‘ื”ื’ื“ืจื” ืžื“ื•ื•ื—, ืืš ืื™ื ื• ื—ื•ืกื ืขืœื™ื™ื”. | +| `BUILD_COMMIT` | ื ืชื•ื ื™ ื”ืืจื›ื™ื•ืŸ ืื• commit ื‘ืŸ 40 ืชื•ื•ื™ hex | ื–ื”ื•ืช ื‘ื ื™ื™ื”/ืจื™ืฆื” ื•ืคืจืžื˜ืจ ื‘ื ื™ื™ืช Compose. ืฉื•ืžืจื™ื ื’ื ื˜ื‘ื™ืขื•ืช ืžืงื•ืจ ืœืฉื™ื ื•ื™ื™ื ืžืงื•ืžื™ื™ื. | + +[ืชืฆื•ืจื” ืžืคื•ืจื˜ืช](docs/CONFIGURATION.md) ยท [ืคืจื˜ื™ื•ืช](docs/PRIVACY.md) + + +## ื”ื›ื ืช Telegram + +1. ืงื‘ืœื• ืคืจื˜ื™ API ื“ืจืš [ื ื™ื”ื•ืœ ื™ื™ืฉื•ืžื™ Telegram](https://my.telegram.org/apps). +2. ืฆืจื• ืื• ื‘ื—ืจื• ืืช ื”ื‘ื•ื˜ ื‘ึพ[BotFather](https://t.me/BotFather), ืงื‘ืœื• ื˜ื•ืงืŸ ื•ืืคืฉืจื• ื”ื•ืกืคื” ืœืงื‘ื•ืฆื•ืช. +3. ืคืชื—ื• ืืช ื”ื‘ื•ื˜ ื‘ืคืจื˜ื™ ื‘ืืžืฆืขื•ืช `/start`. ื”ื•ืกื™ืคื• ืื•ืชื• ืœืงื‘ื•ืฆื” ืขื ื”ืจืฉืื•ืช ืœื”ื’ื‘ืœืช ื—ื‘ืจื™ื ื•ืœืฉื™ื ื•ื™ ื”ืจืฉืื•ืช ื‘ืจื™ืจืช ืžื—ื“ืœ. ืžื—ื™ืงื” ื•ื ืขื™ืฆื” ืื•ืคืฆื™ื•ื ืœื™ื•ืช ื“ื•ืจืฉื•ืช ืืช ื”ืจืฉืื•ืช Telegram ื”ืžืชืื™ืžื•ืช. +4. ื”ืคืขื™ืœื• `/setinline` ื‘ึพBotFather ืœื—ื™ืคื•ืฉ ื•ืฉื™ืชื•ืฃ ื‘ืื™ื ืœื™ื™ืŸ. ื˜ืงืกื˜ ืžื•ืฆืข: `shabbat ยท zmanim ยท daf ยท omer ยท loc city`. ื—ื™ืคื•ืฉ ื‘ืฉื ืขื™ืจ ืื™ื ื• ื“ื•ืจืฉ `/setinlinegeo` ืื• `/setinlinefeedback`; ืฉื™ืชื•ืฃ GPS ืžืคื•ืจืฉ. +5. ืขื ื”ืจืฉืืช ืฉื™ื ื•ื™ ืชืคืจื™ื˜ื™ื, ืฉืœื—ื• ื‘ืคืจื˜ื™ `/setcommands all all preview` ืœืฆืคื™ื™ื” ื‘ื›ืžื•ืช ื”ืชืคืจื™ื˜ื™ื; `/setcommands all all` ืžืขื“ื›ืŸ ืื•ืชื ื‘ึพTelegram. ืžื ื”ืœ ืงื‘ื•ืฆื” ืื™ื ื• ืžืงื‘ืœ ืžื›ืš ื”ืจืฉืื” ื–ื•. + +ื ื™ืงื•ื™ ืื•ืคืฆื™ื•ื ืœื™ ืžื˜ืคืœ ืจืง ื‘ืขื“ื›ื•ื ื™ื ืฉึพTelegram ืžื•ืกืจ ืœื‘ื•ื˜. ืœืงื‘ืœืช ื”ื•ื“ืขื•ืช ื›ืœืœื™ื•ืช ืžื‘ื•ื˜ื™ื ืื—ืจื™ื, ื”ื‘ื•ื˜ ื”ืžืงื‘ืœ ืฆืจื™ืš **Bot-to-Bot Communication Mode** ืคืขื™ืœ ื“ืจืš BotFather, ื”ืจืฉืืช ืžื ื”ืœ ื‘ืงื‘ื•ืฆื” ื•ึพ**Group Privacy Mode** ื›ื‘ื•ื™, ื›ืžืคื•ืจื˜ ื‘[ืชื™ืขื•ื“ Telegram ืœืชืงืฉื•ืจืช ื‘ื™ืŸ ื‘ื•ื˜ื™ื](https://core.telegram.org/api/bots/bot-to-bot). ื”ืžื—ื™ืงื” ืขื“ื™ื™ืŸ ื“ื•ืจืฉืช ื”ืจืฉืื” ืžืชืื™ืžื”; ืื™ืŸ ื‘ื›ืš ื”ื‘ื˜ื—ื” ืœืงื‘ืœืช ื›ืœ ื”ื•ื“ืขื”. + + +## ื”ื•ืกืคืช ืงื‘ื•ืฆื” + +1. ืฉืœื—ื• `/add` **ื‘ืงื‘ื•ืฆื”, ื›ืžื ื”ืœื™ ื”ืงื‘ื•ืฆื”**. ืงื‘ื•ืฆื” ื—ื“ืฉื” ืฉืžืชื•ื•ืกืคืช ื›ืš ืžืชื—ื™ืœื” **ื›ื‘ื•ื™ื”**, ื•ื”ื”ื’ื“ืจื•ืช ื ืคืชื—ื•ืช ื‘ืคืจื˜ื™. +2. ืืฉืจื• ืืช ืฉื ื”ืงื‘ื•ืฆื”, ื”ืžื™ืงื•ื, ืื–ื•ืจ IANA ื•ืœื•ื— ื™ืฉืจืืœ/ื—ื•ืดืœ. ื™ืฉ ืœื‘ื“ื•ืง ืืช ืขืจื›ื™ ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ืฉืœ ื™ืจื•ืฉืœื™ื; ื”ื ืื™ื ื ืžื™ืงื•ื ืฉื ืงื‘ืข ืื•ื˜ื•ืžื˜ื™ืช. +3. ื‘ื—ืจื• ืžืฆื‘ ื•ืžื“ื™ื ื™ื•ืช ืฉื‘ืช/ื—ื’, ื•ืื—ืจ ื›ืš ืฉื™ื˜ื•ืช ื ืจื•ืช/ื”ื‘ื“ืœื” ื•ื”ื™ืกื˜ื™ื ืชืคืขื•ืœื™ื™ื. +4. ื”ืคืขื™ืœื• ื‘ืžืคื•ืจืฉ ืืช ืžื•ื“ื•ืœื™ ื”ื™ื•ื/ื“ืฃ/ืชืคื™ืœื™ืŸ/ืขื•ืžืจ ื”ืจืฆื•ื™ื™ื. ืฆืคื• ื‘ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™; ื‘ื“ื™ืงื” ื‘ืงื‘ื•ืฆื” ื“ื•ืจืฉืช ืื™ืฉื•ืจ ื ืคืจื“ ื•ืžื•ื’ื‘ืœืช ื‘ืงืฆื‘. +5. ื—ื–ืจื• ื“ืจืš `/groups` ื‘ืคืจื˜ื™ ืื• `/settings` ื‘ืงื‘ื•ืฆื”. ื”ื’ื“ืจื•ืช ืื™ืฉื™ื•ืช ื•ืงื‘ื•ืฆืชื™ื•ืช ื ืคืจื“ื•ืช. + +**ื”ืกืชื™ื™ื’ื•ืช ื‘ืžื™ืžื•ืฉ ื”ืจื™ืฉื•ื ื”ื ื•ื›ื—ื™:** ื’ื `/settings` ื™ื›ื•ืœ ืœืจืฉื•ื ืงื‘ื•ืฆื” ืœื ืžื•ื›ืจืช ืื—ืจื™ ืื™ืžื•ืช ื”ืžื ื”ืœ, ื•ื ืชื™ื‘ ื–ื” ื™ื•ืจืฉ ืžื”ืžืกื“ ืžืฆื‘ **ื ืขื™ืœื”**. ื”ืฉืชืžืฉื• ื‘ึพ`/add` ืœืžืกืœื•ืœ ื”ืžืชื—ื™ืœ ื‘ืžืฆื‘ ื›ื‘ื•ื™ ื•ื‘ื“ืงื• ืžื™ื“ ืžื™ืงื•ื ื•ืžืฆื‘. `/settings` ืื™ื ื• ืžืคืขื™ืœ ืžื—ื“ืฉ ืงื‘ื•ืฆื” ืฉื”ื•ืกืจื”; `/add` ื™ื›ื•ืœ. ื–ื• ื”ื”ืชื ื”ื’ื•ืช ื”ืงื™ื™ืžืช, ื•ื”ื™ื ืœื ืฉื•ื ืชื” ื‘ืกืชืจ ื‘ืžืกื’ืจืช ื”ื›ื ืช ื”ืคืจืกื•ื. + +ื”ืคืื ืœื™ื ืžืฆื™ื’ื™ื ืขืจื›ื™ื ืงื™ื™ืžื™ื, ืขื‘ื•ื“ื” ืงืจื•ื‘ื” ื•ืžืฆื‘ ื”ืชืื•ืฉืฉื•ืช. ืฉื™ื ื•ื™ ื“ื•ืจืฉ ื”ืจืฉืื” ืขื“ื›ื ื™ืช ื•ืื™ืฉื•ืจ ื”ืงืฉื•ืจ ืœืžืฉืชืžืฉ, ืœืงื‘ื•ืฆื” ื•ืœื’ืจืกืช ื”ื”ื’ื“ืจื•ืช. ื ื•ืฉื ืคื•ืจื•ื ื—ืกืจ ืื• ืกื’ื•ืจ ื™ื•ืฆืจ ืฉื’ื™ืื”, ื•ืœื ื”ืคื ื™ื” ืœื ื•ืฉื ืื—ืจ. ื”ืฉื—ื–ื•ืจ ืžืฉืชืžืฉ ื‘ื”ืจืฉืื•ืช ื”ืžืงื•ืจื™ื•ืช; ืฆื™ืœื•ื ื—ืกืจ, ืœื ืžืื•ืžืช ืื• ืžืชื ื’ืฉ ื“ื•ืจืฉ ืคืชืจื•ืŸ ืžื•ืจืฉื”. ื ื™ืงื•ื™ ื”ื•ื“ืขื•ืช ื—ื‘ืจื™ื ื•ื‘ื•ื˜ื™ื ืžืชื—ื™ืœ ื›ื‘ื•ื™. ื—ื•ื‘ ืฉื—ื–ื•ืจ ืœื ืคืชื•ืจ ื—ื•ืกื ื”ืกืจืช ืงื‘ื•ืฆื”. + +[ื”ื’ื“ืจื•ืช](docs/GROUP_SETTINGS.md) ยท [ืžืžืฉืง ืžื•ื“ืจืš](docs/USER_EXPERIENCE.md) ยท [ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช](docs/PERMISSION_RECOVERY.md) + + +## ืžื™ืงื•ื ื•ืฉืคื•ืช + +ืงื™ื™ืžื™ื ืฉื™ืฉื” ืงื˜ืœื•ื’ื™ ืฉืคื”: ืขื‘ืจื™ืช `he`, ืื ื’ืœื™ืช `en`, ืจื•ืกื™ืช `ru`, ืฆืจืคืชื™ืช `fr`, ืกืคืจื“ื™ืช `es` ื•ืคื•ืจื˜ื•ื’ื–ื™ืช ื‘ืจื–ื™ืœืื™ืช `pt-br`. ื ื‘ื“ืงื™ื ืžืคืชื—ื•ืช ื•ืžืฆื™ื™ื ื™ ืขืจื›ื™ื; ืื™ืŸ ื‘ื›ืš ื”ื‘ื˜ื—ื” ืœืชืจื’ื•ื ื›ืœ ืฉื’ื™ืืช ืฆื“ ืฉืœื™ืฉื™ ืื• ืžืžืฉืง ื™ืฉืŸ. ื ื•ืกื— ื”ืชืคื™ืœื” ื”ืขื‘ืจื™ ื ืฉืืจ ืขื‘ืจื™, ืœืฆื“ ื˜ืงืกื˜ ื”ืกื‘ืจ ื›ืืฉืจ ื”ื•ื ืžืกื•ืคืง. ืชืคืจื™ื˜ื™ Telegram ืžืฉืชืžืฉื™ื ื‘ึพ`pt`; ืฉืคืช ื”ืชืคืจื™ื˜ ืœืคื™ ื”ืœืงื•ื— ื•ืฉืคืช ื”ื‘ื•ื˜ ื”ืฉืžื•ืจื” ื”ืŸ ื”ื’ื“ืจื•ืช ื ืคืจื“ื•ืช. + +ื”ืฉืชืžืฉื• ื‘ึพ`/preferences` ืื• `/language` ื‘ืคืจื˜ื™; `/language` ื‘ืงื‘ื•ืฆื” ืžื—ื™ื™ื‘ ื ื™ื”ื•ืœ ืงื‘ื•ืฆื”. ืœื—ื™ืคื•ืฉ ืขื™ืจ, ืคืชื—ื• [ื—ืฉื‘ื•ืŸ GeoNames](https://www.geonames.org/login), ื”ืคืขื™ืœื• free webservices ื•ื”ื’ื“ื™ืจื• `GEONAMES_USERNAME`. + +ื‘ืคืื ืœ ื”ืžืชืื™ื: **ืžื™ืงื•ื ื•ืื–ื•ืจ ื–ืžืŸ โ†’ ื—ื™ืคื•ืฉ ืขื™ืจ ื‘ืื™ื ืœื™ื™ืŸ โ†’ ืขื™ืจ/ืžื“ื™ื ื”/ืื–ื•ืจ โ†’ ื‘ื“ื™ืงืช ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ื•ืื–ื•ืจ ื–ืžืŸ โ†’ ืื™ืฉื•ืจ ื”ื™ืขื“ ืฉืฉืžื• ืžื•ืฆื’**. ืงื™ื™ืžื™ื ื’ื ื—ื™ืคื•ืฉ ืคืจื˜ื™, ืขืจื™ื ืžื•ื›ืจื•ืช, ืงื•ืื•ืจื“ื™ื ื˜ื•ืช, ื—ื™ืคื•ืฉ ืื–ื•ืจ IANA ื•ืชืฉื•ื‘ืช GPS ืœืคื™ ื‘ืงืฉื”. ื”ื‘ื—ื™ืจื” ื–ืžื ื™ืช, ืฉื™ื™ื›ืช ืœืžืฉืชืžืฉ ืื—ื“ ื•ื—ื“ึพืคืขืžื™ืช. ืฉื™ื ื•ื™ ืื–ื•ืจ ื–ืžืŸ ืื™ื ื• ืžื–ื™ื– ืงื•ืื•ืจื“ื™ื ื˜ื•ืช. + +ื—ื™ืคื•ืฉ ืื™ื ืœื™ื™ืŸ ืฆื™ื‘ื•ืจื™ `loc city` ืื• ืฉื ืขื™ืจ ื‘ืœื‘ื“ ืžืืคืฉืจ ื—ื™ืคื•ืฉ ื•ืฉื™ืชื•ืฃ ืžื™ืงื•ื. ื—ื™ืคื•ืฉ ืื–ื•ืจ ื–ืžืŸ ืฆื™ื‘ื•ืจื™ ืืคืฉืจื™ ื‘ืืžืฆืขื•ืช `tz Jerusalem` ืื• `Asia/Jerusalem`, ืœืœื ืฉื™ื ื•ื™ ื”ื’ื“ืจื•ืช. ืฉืื™ืœืชื•ืช ื”ื”ื’ื“ืจื” `location CITY` ื•ึพ`timezone QUERY` ื“ื•ืจืฉื•ืช ื‘ืงืฉืช ืžื™ืงื•ื ืื• ืื–ื•ืจ ื–ืžืŸ ืคืขื™ืœื” ื‘ืคืจื˜ื™ ื•ืžื•ื‘ื™ืœื•ืช ืœืื™ืฉื•ืจ. ื”ืžืฆื‘ื™ื ื”ื ื•ืกืคื™ื: `shabat`, `shabbat`, `ืฉื‘ืช`, `zmanim`, `daf`, `omer`. ื‘ื“ืงื• ืืช ื”ืžื™ืงื•ื ื”ืžื•ืฆื’: ืฉื™ืชื•ืฃ ืื™ืฉื™ ื•ืชืฆื•ื’ื” ืžื•ืจืฉื™ืช ื‘ื”ืงืฉืจ ืงื‘ื•ืฆื” ืขืฉื•ื™ื™ื ืœื”ืฉืชืžืฉ ื‘ื”ื’ื“ืจื•ืช ืฉื•ื ื•ืช. ืชืงืœื•ืช ื—ืฉื‘ื•ืŸ ื•ืžื›ืกื” ื ืฉืืจื•ืช ืฉื’ื™ืื•ืช ื’ืœื•ื™ื•ืช. + +[ืื™ื ืœื™ื™ืŸ ื•ึพGeoNames](docs/INLINE_GEONAMES.md) + + +## ืžื•ื“ื•ืœื™ ื”ืœื•ื— + +| ืžื•ื“ื•ืœ | ื”ื’ื“ืจื•ืช ื•ืžื’ื‘ืœื•ืช ืžืžื•ืžืฉื•ืช | +|---|---| +| ืฉื‘ืช ื•ื—ื’ื™ื | ืžื ื”ื’ ื ืจื•ืช ืฉืœ ืขื™ืจ ืื• ื‘ื“ื™ื•ืง 10/15/18/20/22/30/40 ื“ืงื•ืช ืื• ืขืจืš ืžื•ืชืื. ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ื”ืขื™ืจื•ื ื™ืช ืฉืœ ื™ืจื•ืฉืœื™ื ื”ื™ื 40; ื‘ื—ื™ืจืช 18 ื ืฉืืจืช 18. ื”ื‘ื“ืœื”: โ€Ž8.5ยฐโ€Ž ืื• 42/50/72 ื“ืงื•ืช ืงื‘ื•ืขื•ืช ืื• ืขืจืš ืžื•ืชืื. 72 ืงื‘ื•ืขื•ืช ืื™ื ืŸ ื›ืœ ืฉื™ื˜ื•ืช ืจื‘ื ื• ืชื. | +| ืชื–ืžื•ืŸ ืงื‘ื•ืฆืชื™ | ื”ื™ืกื˜ื™ ืกื’ื™ืจื” ืœืคื ื™ ื ืจื•ืช ื•ืคืชื™ื—ื” ืœืื—ืจ ื”ื‘ื“ืœื” ื ืคืจื“ื™ื. ืจืฆืฃ ื™ืžื™ื ืงื“ื•ืฉื™ื ื ืฉืืจ ืกื’ื•ืจ. ื™ืฉืจืืœ/ื—ื•ืดืœ ื ื‘ื—ืจ ื‘ืžืคื•ืจืฉ. ื”ื•ื“ืขืช ื”ื–ืžื ื™ื ื›ื•ืœืœืช ืชื–ืžื•ืŸ ื•ืขื™ืฆื•ื‘ ืขืฆืžืื™ื™ื, ืชืืจื™ื›ื™ื ืขื‘ืจื™ื™ื ื•ืžื™ื“ืข ืขืœ ื”ืคืจืฉื”. | +| ื–ืžื ื™ ื”ื™ื•ื | ืชืืจื™ืš ืื–ืจื—ื™ ื•ืื–ื•ืจ IANA; ืขืœื•ืช, ืžืฉื™ื›ื™ืจ, ื”ื ืฅ, ืฉืžืข ื•ืชืคื™ืœื” ืœื’ืจืดื/ืžื’ืดื, ื—ืฆื•ืช, ืžื ื—ื”, ืคืœื’, ืฉืงื™ืขื”, ืฆืืช ื•ื—ืฆื•ืช ืœื™ืœื” ื›ืฉื–ืžื™ื ื™ื. ื ืชื•ืŸ ื—ืกืจ ื ืฉืืจ ืœื ื–ืžื™ืŸ. | +| ื“ืฃ ื™ื•ืžื™ | ื—ื™ืฉื•ื‘ ืœืคื™ ืชืืจื™ืš ืื–ืจื—ื™ ื•ืงื™ืฉื•ืจ ืœื™ืžื•ื“ ืžื”ืกืคืง; ื“ื™ืœื•ื’ ืขืœ ืฉืœื™ื—ื” ืื™ื ื• ืขื•ืฆืจ ืืช ื”ืžื—ื–ื•ืจ. | +| ืชืคื™ืœื™ืŸ | ืฉืขื•ืช ืงื‘ื•ืขื•ืช, ืžืจื•ื•ื—ื™ื ื•ื”ื™ืกื˜ื™ื ืžืฉืงื™ืขื”, ืžื ื™ืขืช ื›ืคื™ืœื•ื™ื•ืช, ืžื›ืกื•ืช, ืžืจื•ื•ื— ืžื™ื ื™ืžืœื™, ืฉืงื˜ ื•ื”ืฉื”ื™ื”. ืชื—ื™ืœืช ื”ื–ืžืŸ ืžืฉืชืžืฉืช ื‘ึพ`misheyakir` ืฉืœ โ€Ž11.5ยฐโ€Ž; ืฉื“ื” ื”ืกืคืง ื”ืžื—ืžื™ืจ ืื™ื ื• ืžื•ืฆืข ืœื‘ื—ื™ืจื”. ืื™ืŸ ืฉืœื™ื—ื” ื‘ืฉื‘ืช/ื™ื•ื ื˜ื•ื‘; ืžื“ื™ื ื™ื•ืช ืžืคื•ืจืฉืช ืœื—ื•ืœ ื”ืžื•ืขื“ ื•ืœืชืฉืขื” ื‘ืื‘ ื‘ืคื•ืขืœ. ืฉืขืช ื”ืกื™ื•ื ื ื‘ื“ืงืช ืฉื•ื‘ ื‘ืžืกื™ืจื”. ืื™ืฉื•ืจ ื”ื ื—ื” ืื•ืคืฆื™ื•ื ืœื™ ืกื•ืคืจ ืžืฉืชืžืฉื™ื ื™ื™ื—ื•ื“ื™ื™ื ื•ื ื™ืชืŸ ืœื‘ื™ื˜ื•ืœ ื‘ืœื—ื™ืฆื” ื—ื•ื–ืจืช. | +| ืขื•ืžืจ | 49 ื™ืžื™ ื”ืขื•ื ื” ืžืชืงื“ืžื™ื ื’ื ื‘ื™ืžื™ื ืœืœื ืฉืœื™ื—ื” ื•ื‘ื™ืŸ ืฉื ื™ื. ืœื™ืœื” ืžืžืชื™ืŸ ืœืฆืืช ืฉื ื‘ื—ืจ ื•ืœืกื•ืฃ ื”ืฉืงื˜ ื”ืงื‘ื•ืฆืชื™; ื”ืฉืœืžืช ื™ื•ื ืื•ืคืฆื™ื•ื ืœื™ืช ืœืœื ื‘ืจื›ื”. ืžืžื•ืžืฉื™ื `ashkenaz` ื•ืจืฆืฃ `sephard` ื”ืžืชื•ืขื“, ื•ืœื ื›ืœ ืžื ื”ื’ื™ ื”ืงื”ื™ืœื•ืช. ื”ื ื•ืกื— ื”ืžืœื ื›ื•ืœืœ ื”ื›ื ื” ื•ืชื•ืกืคื•ืช ืžืกื•ืจืชื™ื•ืช ืœืคื™ ื”ื‘ื—ื™ืจื”. | + +ื”ื”ื ื—ื™ื” ืœื‘ืจื›ื” ืžืชื™ื™ื—ืกืช ืœื–ื›ืื•ืช ืื™ืฉื™ืช; ื”ื™ืกื˜ื•ืจื™ื™ืช ื”ื•ื“ืขื•ืช ืื™ื ื” ื”ื™ืกื˜ื•ืจื™ื™ืช ื”ืกืคื™ืจื” ืฉืœ ืื“ื. ืจืื• [ื”ื’ื“ืจื•ืช ืฉื™ื˜ื•ืช](docs/ZMANIM_METHODS.md), [ืชืฆื•ื’ืช ื”ืœื•ื—](docs/CALENDAR_PRESENTATION.md) ื•[ืžืงื•ืจื•ืช ื”ื ื•ืกื—ื™ื](THIRD_PARTY_NOTICES.md). + + +## ืคืงื•ื“ื•ืช ื”ื‘ื•ื˜ + +ืืช ื”ืคืงื•ื“ื•ืช ืฉื•ืœื—ื™ื **ื‘ึพTelegram**. ื”ืชืคืจื™ื˜ ื”ื•ื ืงื™ืฆื•ืจ ื“ืจืš ื•ืœื ืžืคืจื˜ ื”ืจืฉืื•ืช ืžืœื. `[...]` ืžืฆื™ื™ืŸ ืคืจืžื˜ืจ ืื•ืคืฆื™ื•ื ืœื™. + +### ืžืฉืชืžืฉื™ื + +| ืคืงื•ื“ื” | ืžืงื•ื ื•ื”ืจืฉืื” | ื”ืฉืคืขื” | +|---|---|---| +| `/start`, `/help` | ื‘ืคืจื˜ื™; ื›ืœ ืžืฉืชืžืฉ | ืชืคืจื™ื˜ ื”ื‘ื™ืช ื•ืขื–ืจื”. | +| `/preferences` | ื‘ืคืจื˜ื™; ื”ืขื“ืคื•ืช ืื™ืฉื™ื•ืช | ืžื™ืงื•ื, ืœื•ื— ื•ืฉื™ื˜ื•ืช ืื™ืฉื™ื™ื. | +| `/language`, `/lang` | ื‘ืคืจื˜ื™; ื”ืขื“ืคื•ืช ืื™ืฉื™ื•ืช | ืฉืคื” ืื™ืฉื™ืช; ืฉื™ืžื•ืฉ ื‘ืงื‘ื•ืฆื” ื“ื•ืจืฉ ื ื™ื”ื•ืœ ืงื‘ื•ืฆื”. | +| `/groups [search]` | ื‘ืคืจื˜ื™; ืื™ืžื•ืช ื ื™ื”ื•ืœ ืขื“ื›ื ื™ | ืจืฉื™ืžื” ื•ื—ื™ืคื•ืฉ ื‘ืงื‘ื•ืฆื•ืช ืžื•ื›ืจื•ืช ืฉื ื™ืชืŸ ืœื ื”ืœ. | +| `/shabat [YYYY-MM-DD]`, `/shabbat [YYYY-MM-DD]` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื” ืจืฉื•ืžื”; ื›ืœ ืžืฉืชืžืฉ | ื–ืžื ื™ ืฉื‘ืช/ื—ื’ ืœืคื™ ื”ื”ืงืฉืจ. | +| `/zmanim [YYYY-MM-DD]` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื” ืจืฉื•ืžื”; ื›ืœ ืžืฉืชืžืฉ | ื–ืžื ื™ ื”ื™ื•ื. | +| `/daf [YYYY-MM-DD]` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื” ืจืฉื•ืžื”; ื›ืœ ืžืฉืชืžืฉ | ื“ืฃ ื™ื•ืžื™. | +| `/omer [YYYY-MM-DD]` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื” ืจืฉื•ืžื”; ื›ืœ ืžืฉืชืžืฉ | ืกืคื™ืจื” ืื• ืžื™ื“ืข ืขืœ ื”ืขื•ื ื” ื‘ืชืืจื™ืš ืฉื ื‘ื—ืจ. | +| `/stats` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื”; ื›ืœ ืžืฉืชืžืฉ | ื ืชื•ื ื™ื ืžืฆืจืคื™ื™ื ืฆื™ื‘ื•ืจื™ื™ื, ืœืœื ื–ื”ื•ื™ื•ืช ืžื ื•ื™ื™ื. | +| `/donate` | ื‘ืคืจื˜ื™; ื›ืœ ืžืฉืชืžืฉ | ืืคืฉืจื•ื™ื•ืช ืชืžื™ื›ื” ื‘ื›ื•ื›ื‘ื™ื; ืชืฉืœื•ื ืžื—ื™ื™ื‘ ืื™ืฉื•ืจ ื‘ึพTelegram. | +| `/source`, `/license`, `/version` | ื‘ืคืจื˜ื™ ืื• ื‘ืงื‘ื•ืฆื”; ื›ืœ ืžืฉืชืžืฉ | ื”ืฆืขืช ืžืงื•ืจ, ืจื™ืฉื•ื™ ื•ื–ื™ื”ื•ื™ ื’ืจืกื”. | + +### ืžื ื”ืœื™ ืงื‘ื•ืฆื•ืช + +| ืคืงื•ื“ื” | ืžืงื•ื ื•ื”ืจืฉืื” | ื”ืฉืคืขื” | +|---|---|---| +| `/add` | ื‘ืงื‘ื•ืฆื”; ืžื ื”ืœ ื ื•ื›ื—ื™ | ืจื™ืฉื•ื/ื”ืคืขืœื” ืžื—ื“ืฉ ื•ืคืชื™ื—ืช ื”ื”ื’ื“ืจื•ืช. | +| `/remove` | ื‘ืงื‘ื•ืฆื”; ืžื ื”ืœ ื ื•ื›ื—ื™ | ื”ืฉื‘ืชืช ื”ืจื™ืฉื•ื; ื—ื•ื‘ ืฉื—ื–ื•ืจ ื—ื•ืกื ื”ืกืจื”. | +| `/settings` | ื‘ืงื‘ื•ืฆื”; ืžื ื”ืœ ื ื•ื›ื—ื™ | ื”ื’ื“ืจื•ืช ืงื‘ื•ืฆื” ื‘ืคืจื˜ื™; ืจืื• ื”ืกืชื™ื™ื’ื•ืช ืจื™ืฉื•ื ืœืขื™ืœ. | +| `/settings [GROUP_ID]` | ื‘ืคืจื˜ื™; ืžื ื”ืœ ื ื•ื›ื—ื™ ื‘ืงื‘ื•ืฆืช ื”ื™ืขื“ | ืคืชื™ื—ืช ื™ืขื“, ืื• ืจืฉื™ืžืช ืงื‘ื•ืฆื•ืช ืœืœื ืžื–ื”ื”. | +| `/language`, `/lang` | ื‘ืงื‘ื•ืฆื”; ืžื ื”ืœ ื ื•ื›ื—ื™ | ื‘ื—ื™ืจืช ืฉืคืช ืงื‘ื•ืฆื” ื‘ืคืจื˜ื™. | + +ื˜ืคืกื™ื ืžืชืงื“ืžื™ื **ื‘ืคืจื˜ื™** ืžื—ื™ื™ื‘ื™ื ืืช ื”ื˜ื•ืงืŸ ื”ืžื“ื•ื™ืง ืฉื”ืคืื ืœ ื”ื ืคื™ืง, ืืช ื‘ืขืœื™ื• ื•ื”ื”ืงืฉืจ/ื’ืจืกื” ื”ืชืงืคื™ื, ื•ื›ืŸ ื”ืจืฉืืช ืงื‘ื•ืฆื” ืขื“ื›ื ื™ืช ืœื™ืขื“ ืงื‘ื•ืฆืชื™. ื”ืขื“ื™ืคื• ืืช ื”ื›ืคืชื•ืจื™ื ื”ืžื•ื“ืจื›ื™ื. + +| ื˜ื•ืคืก | ื”ืฉืคืขื” | +|---|---| +| `/city TOKEN QUERY` | ื—ื™ืคื•ืฉ ื”ืžื™ืงื•ื ืฉื‘ื‘ื—ื™ืจื”. | +| `/coordinates TOKEN LATITUDE LONGITUDE IANA_ZONE [NAME]` | ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืงื•ืื•ืจื“ื™ื ื˜ื•ืช ื•ืื–ื•ืจ ื–ืžืŸ. | +| `/location TOKEN IANA_ZONE` ืื• `/location TOKEN auto` | ื‘ืงืฉืช ืชืฉื•ื‘ืช GPS; โ€auto ื“ื•ืจืฉ GeoNames. | +| `/timezone TOKEN QUERY` | ื—ื™ืคื•ืฉ ืื–ื•ืจื™ IANA. | +| `/configure TOKEN FORM` | ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืฉืœ ื˜ื•ืคืก ื”ื”ื’ื“ืจื•ืช ื”ื ืชืžืš ื‘ืคืื ืœ. | +| `/viewdate TOKEN YYYY-MM-DD` | ืชืืจื™ืš ืœืชืฆื•ื’ืช ื”ืœื•ื—. | +| `/template TOKEN` + ืฉื•ืจื” ื—ื“ืฉื” + ืชื•ื›ืŸ | ืกืงื™ืจืช ืชื‘ื ื™ืช ืงื‘ื•ืฆื” ืžื•ืฆืขืช. | +| `/buttons TOKEN` + ืฉื•ืจื” ื—ื“ืฉื” + ืชื•ื›ืŸ ื›ืคืชื•ืจื™ื | ืกืงื™ืจืช ื›ืคืชื•ืจื™ื ื‘ืคื•ืจืžื˜ JSON ืื• ื‘ืคื•ืจืžื˜ ื”ื™ืฉืŸ. | + +### ืžื ื”ืœื™ ื”ื‘ื•ื˜ + +ื›ืœ ื”ืคืงื•ื“ื•ืช ืœื”ืœืŸ **ื‘ืคืจื˜ื™**. ืชืคืงื™ื“ื™ ื ื™ื”ื•ืœ ื”ื‘ื•ื˜ ื ืคืจื“ื™ื ืžื ื™ื”ื•ืœ ืงื‘ื•ืฆื”; ืฆืคื™ื™ื” ื‘ืœื‘ื“ ืื™ื ื” ืžืชื™ืจื” ืฉื™ื ื•ื™. + +| ืคืงื•ื“ื” | ื”ืจืฉืื” ื ื“ืจืฉืช | ื”ืฉืคืขื” | +|---|---|---| +| `/admin` | ืžื ื”ืœ ื‘ื•ื˜ | ืคืื ืœ ื”ื ื™ื”ื•ืœ. | +| `/adminstats` | ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช | ื ืชื•ื ื™ื ืžืฆืจืคื™ื™ื ื ื™ื”ื•ืœื™ื™ื. | +| `/testzmanim [GROUP_ID]` | ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช | ืื‘ื—ื•ืŸ ืžื“ื•ืคื“ืฃ, ืกื™ื ื•ืŸ ื•ื—ื™ืคื•ืฉ ื•ื™ื™ืฆื•ื JSON ืคืจื˜ื™ ืœืคื™ ื‘ื—ื™ืจื”. | +| `/subscribers` | ืžื ื•ื™ื™ื | ืกืคืจื™ื™ืช ื ืžืขื ื™ื ืžื•ื›ืจื™ื ื•ืคืจื•ืคื™ืœื™ื. | +| `/broadcast` | ืฉื™ื“ื•ืจื™ื | ื˜ื™ื•ื˜ื•ืช ื•ืžืฉื™ืžื•ืช; ืฉืœื™ื—ื”, ืขืจื™ื›ื” ื•ืžื—ื™ืงื” ื“ื•ืจืฉื•ืช ื”ืจืฉืืช ืฉื™ื ื•ื™ ื•ืื™ืฉื•ืจ ืžืชืื™ื. | +| `/managers` | ื ื™ื”ื•ืœ ืžื ื”ืœื™ื | ืชืคืงื™ื“ื™ื ืžื•ืืฆืœื™ื ื‘ืžืกื’ืจืช ืกืžื›ื•ืช ื”ืžื‘ืฆืข. | +| `/systemlinks` | ืงื™ืฉื•ืจื™ ืžืขืจื›ืช | ืงื™ืฉื•ืจื™ ื ื™ื•ื•ื˜, ืฉืื™ื ื ืžืขื ื™ืงื™ื ื”ืจืฉืื•ืช. | +| `/trackinglinks` | ืงื™ืฉื•ืจื™ ืžืขืงื‘ | ืกืคื™ืจืช ื”ืคืขืœื•ืช ื•ื ื™ื”ื•ืœ; ืฉื™ื ื•ื™ ื“ื•ืจืฉ ื”ืจืฉืืช ืฉื™ื ื•ื™. | +| `/setcommands [LANGUAGE] [SCOPE] [preview]` | ื”ืจืฉืืช ืฉื™ื ื•ื™ ืชืคืจื™ื˜ื™ื, ื’ื ืœืชืฆื•ื’ื” | ืฆืคื™ื™ื” ืื• ืขื“ื›ื•ืŸ ืชืคืจื™ื˜ื™ Telegram. | +| `/closegroups`, `/opengroups` | ื”ืจืฉืืช ืฉื™ื ื•ื™ ื‘ืคืขื•ืœื•ืช ืงื‘ื•ืฆื•ืช | ื”ื—ืœืช ื—ืจื™ื’ื” ื™ื“ื ื™ืช ืœืฉืขื” ืžื™ื“ ืขืœ ืงื‘ื•ืฆื•ืช ืคืขื™ืœื•ืช ืจืฉื•ืžื•ืช; ืฉืžื™ืจืช ื ืขื™ืœื•ืช ืงื•ื“ืžื•ืช ื•ื“ื™ื•ื•ื— ื”ืชื ื’ืฉื•ื™ื•ืช. ืืœื” ืคืขื•ืœื•ืช ื”ืจืฉืื” ืืžื™ืชื™ื•ืช. | +| `/restart` | ื‘ืขืœื™ื ืžื•ื’ื“ืจ ื‘ึพ`ADMIN` ื‘ืœื‘ื“ | ื”ืคืขืœื” ืžื—ื“ืฉ ืžืกื•ื“ืจืช ืฉืœ ื”ื‘ื•ื˜. | + +ืฉืคื•ืช ืœืชืคืจื™ื˜ื™ื: `all|he|en|ru|fr|es|pt-br`; ืชื—ื•ืžื™ื: `all|private|operators|groups|group_admins`; ื‘ืจื™ืจืช ืžื—ื“ืœ: `all all`. ื“ื•ื’ืžื”: `/setcommands ru group_admins preview`. ื‘ืชืคืจื™ื˜ื™ ื”ืงื‘ื•ืฆื•ืช ืžื•ืคื™ืข ื›ืจื’ืข `/settings`; ืืคืฉืจ ืœื”ืงืœื™ื“ ืคืงื•ื“ื•ืช ื ืชืžื›ื•ืช ื ื•ืกืคื•ืช. + +[ื ื™ื”ื•ืœ ื”ื‘ื•ื˜](docs/ADMIN_PANEL.md) ยท [ืื‘ื—ื•ืŸ](docs/DIAGNOSTICS.md) + + +## ืชืคืขื•ืœ + +ืคืงื•ื“ื•ืช Docker ืจืฆื•ืช **ื‘ืžืืจื—, ื‘ืื•ืชื• ืฉื•ืจืฉ ื•ื‘ืื•ืชื” ื–ื”ื•ืช ืคืจื•ื™ืงื˜**, ืขื ื’ื™ืฉื” ืœืฉื™ืจื•ืช Docker. ืคืงื•ื“ื•ืช PHP ืจืฆื•ืช **ื‘ืชืžื•ื ื” ื‘ึพ/app**, ืื• ื‘ืฉื•ืจืฉ ื”ืชืงื ื” ื™ื“ื ื™ืช ื›ืžืฉืชืžืฉ ื”ืฉื™ืจื•ืช. ืคืœื˜ ืชืคืขื•ืœื™ ืขืœื•ืœ ืœื›ืœื•ืœ ืžื–ื”ื™ ืงื‘ื•ืฆื•ืช ื•ืžืฆื‘; ืฉืžืจื• ืื•ืชื• ื‘ืคืจื˜ื™ื•ืช. + +| ืคืขื•ืœื” | ืคืงื•ื“ื” | ื”ืฉืคืขื” ืื• ืชื ืื™ | +|---|---|---| +| ื‘ื ื™ื™ื” | `docker compose build` | ื‘ื ื™ื™ื” ืžื”ืžืงื•ืจ ื•ืžื”ึพlock. | +| ื”ืคืขืœื” | `docker compose up -d shabbat` | ื”ืคืขืœื”/ื™ืฆื™ืจื” ืžื—ื“ืฉ ื•ื—ื™ื‘ื•ืจ ืœึพTelegram. | +| ืขืฆื™ืจื” | `docker compose stop shabbat` | ืขืฆื™ืจื” ืžืกื•ื“ืจืช ืขื 60 ืฉื ื™ื•ืช ื—ืกื“; ื”ื›ืจื›ื™ื ื ืฉืžืจื™ื. | +| ื”ืคืขืœื” ืžื—ื“ืฉ | `docker compose restart shabbat` | ื”ืคืขืœืช ืื•ืชื• ืฉื™ืจื•ืช ืžื—ื“ืฉ; ืœืœื ื‘ื ื™ื™ืช ืชืžื•ื ื”. | +| ืžืฆื‘ | `docker compose ps` | ืžืฆื‘ ื”ืฉื™ืจื•ืช ื•ื”ืžื›ื•ืœื”. | +| ืœื•ื’ื™ื | `docker compose logs --tail=100 shabbat` | ืฆืคื™ื™ื” ื‘ืœื•ื’ื™ื; `--follow` ืœืžืขืงื‘, Ctrl+C ืžืกื™ื™ื ืืช ื”ืฆื•ืคื”. | +| ื‘ืจื™ืื•ืช | `docker compose exec shabbat php bin/health.php` | ืกื›ืžื” ื•ื—ื™ื‘ื•ืจ, ืฉืชื™ ืคืขื™ืžื•ืช ื•ืžื“ื“ื™ ืชื•ืจื™ื/ื ืขื™ืœื•ืช. | +| ื‘ืจื™ืื•ืช ืžืขืžื™ืงื” | `docker compose exec shabbat php bin/health.php --deep` | ื’ื ืฉืœืžื•ืช ื•ืžืคืชื—ื•ืช ื–ืจื™ื; ืคืขื•ืœื” ื™ืงืจื” ื™ื•ืชืจ. | +| ื‘ื“ื™ืงืช ืฉื—ืจื•ืจ | `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` | ื‘ื“ื™ืงืช ืงื‘ืฆื™ื ื–ืžื ื™ื™ื; ืขืฉื•ื™ื” ืœื”ื•ืกื™ืฃ ืฉื•ืจื” ืœืœื•ื’ ืชื”ืœื™ืš ืงื™ื™ื. | + +ืฉืชื™ ืคืขื™ืžื•ืช ื”ืžืชื–ืžืŸ ื—ื™ื™ื‘ื•ืช ืœื”ื™ื•ืช ื‘ื ื•ืช 180 ืฉื ื™ื•ืช ืœื›ืœ ื”ื™ื•ืชืจ. ืžืกื“ ืžืฉื•ื—ื–ืจ ืื• ืžื ื•ืชืง ืขืฉื•ื™ ืœืขื‘ื•ืจ ื‘ื“ื™ืงื•ืช ืžื‘ื ื” ื•ื‘ื›ืœ ื–ืืช ืœื”ื—ื–ื™ืจ `healthy=false` ื•ืงื•ื“ ื™ืฆื™ืื” 1 ืœืœื ืžืชื–ืžื ื™ื. ืื™ืŸ ืœื™ืฆื•ืจ ืคืขื™ืžื•ืช ืžืœืื›ื•ืชื™ื•ืช. + +ื›ืœื™ ื”ืชื—ื–ื•ืงื” ื”ื™ืฉื™ืจื™ื ืžื•ืคืขืœื™ื **ืžืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜ ื›ืžืฉืชืžืฉ ื”ืฉื™ืจื•ืช**. ื‘ึพDocker ืžืงื“ื™ืžื™ื ืœืคืงื•ื“ืช PHP โ€`docker compose run --rm --no-deps shabbat` ื•ืžืฉืชืžืฉื™ื ื‘ื ืชื™ื‘ื™ ื”ืžื›ื•ืœื”. `--writers-stopped` ื”ื•ื ื”ืฆื”ืจืช ื”ืžืคืขื™ืœ ื•ืœื ืคืงื•ื“ืช ืขืฆื™ืจื”. + +| ืคืขื•ืœื” | ืคืงื•ื“ื” | ื”ืฉืคืขื” ืื• ื“ืจื™ืฉื” | +|---|---|---| +| ืžื™ื’ืจืฆื™ื” | `php bin/migrate.php --writers-stopped --db data/shabbat.sqlite --backup-dir backups` | ื›ืœ ื”ื›ื•ืชื‘ื™ื ืขืฆื•ืจื™ื ื•ืชื™ืงื™ื™ืช ื’ื™ื‘ื•ื™ ืงื™ื™ืžืช; ื’ื™ื‘ื•ื™ ืžืกื“ ืงื™ื™ื ื•ื”ื—ืœืช ืžื™ื’ืจืฆื™ื•ืช. ืžื•ื•ืชืจื™ื ืขืœ ื”ื’ื™ื‘ื•ื™ ืจืง ืœืžืกื“ ื—ื“ืฉ. | +| ื’ื™ื‘ื•ื™ | `php bin/backup.php --db data/shabbat.sqlite --directory backups --keep 7` | ื”ืขืชืง SQLite ืขืงื‘ื™ ื•ืžื ื™ืคืกื˜, ืฉืžื™ืจืช ืฉื‘ืขื” ื’ื™ื‘ื•ื™ื™ื ื‘ืฉื ื”ื›ืœื™. ืืคืฉืจ ื‘ืชื—ื–ื•ืงื” ื‘ื–ืžืŸ ืคืขื™ืœื•ืช. | +| ืฉื—ื–ื•ืจ | `php bin/restore.php --backup backups/CHOSEN.sqlite --db data/shabbat.sqlite --writers-stopped` | ื’ื™ื‘ื•ื™ ื ื‘ื—ืจ ื•ืžืื•ืžืช, ื›ื•ืชื‘ื™ื ืขืฆื•ืจื™ื; ื”ื™ืขื“ ื”ืงื•ื“ื ื ืฉืžืจ. | +| ื‘ื“ื™ืงืช ื™ื™ื‘ื•ื | `php bin/import-legacy.php --db data/shabbat.sqlite --legacy /path/to/legacy-copy --dry-run` | ื‘ื“ื™ืงื” ื•ื“ื•ื—, ืœืœื ื›ืชื™ื‘ืช ืจืฉื•ืžื•ืช ืžื™ื•ื‘ืื•ืช ืื• ืคืขื•ืœื•ืช Telegram. | +| ื”ื—ืœืช ื™ื™ื‘ื•ื | `php bin/import-legacy.php --db data/shabbat.sqlite --legacy /path/to/legacy-copy --apply --writers-stopped --backup-dir backups` | ืžืกื“ ืฉืขื‘ืจ ืžื™ื’ืจืฆื™ื•ืช, ืชื™ืงื™ื™ืช ื’ื™ื‘ื•ื™ ื•ื›ื•ืชื‘ื™ื ืขืฆื•ืจื™ื; ื™ื™ื‘ื•ื ืชื•ืกืคืชื™. | +| ืื‘ื—ื•ืŸ ืœื•ื— | `php bin/check-calendar.php --date=2026-10-15` | HTTPS ืฆื™ื‘ื•ืจื™ ื‘ืœื‘ื“; ืœืœื DB, โ€.env ืื• ืกืฉืŸ. `--help` ืžืฆื™ื’ ืงื•ืื•ืจื“ื™ื ื˜ื•ืช/ืื–ื•ืจ/ืœื•ื—/ืฉืคื”. | +| ืื™ืžื•ืช ืชื™ืงื•ื ื™ื | `php scripts/verify-patches.php` | ื‘ื“ื™ืงืช ื”ืชื•ื›ืŸ ื”ืžื•ืชืงืŸ ื”ืžืื•ืฉืจ. | +| ื”ื—ืœืช ืชื™ืงื•ื ื™ื | `composer patches` | ื”ื—ืœื” ื•ืื™ืžื•ืช; ื‘ื”ืชืงื ื” ืขืฆื•ืจื” ื‘ืœื‘ื“. | +| ื–ื™ื”ื•ื™ ื‘ื ื™ื™ื” | `php bin/build-metadata.php --commit=YOUR_40_HEX_COMMIT` | ืžื—ืœื™ืคื™ื ืืช ืžืฆื™ื™ืŸ ื”ืžืงื•ื ื‘ืžืงื•ืจ ืžืื•ืžืช; ื›ืชื™ื‘ืช ื–ื™ื”ื•ื™ ื•ื“ื—ื™ื™ืช ื˜ื‘ื™ืขื” ืœื ืชื•ืืžืช. | + + +## ืฉื“ืจื•ื’ ื•ื™ื™ื‘ื•ื ืžื”ื’ืจืกื” ื”ื™ืฉื ื” + +ืฉืžืจื• ืชืฆื•ืจื” ืคืจื˜ื™ืช, ื ืชื•ื ื™ื ื•ืกืฉืŸ ื•ืืช ื–ื”ื•ืช ืคืจื•ื™ืงื˜ Compose. ื”ืฉืชืžืฉื• ื‘ืžืงื•ืจ ื•ึพlock ืฉื ื‘ื“ืงื•; ืขืฆืจื• ื›ืœ ื›ื•ืชื‘ ืœืคื ื™ ืฉื™ื ื•ื™ ืกื›ืžื” ืื• ืชืœื•ื™ื•ืช ืžื•ืชืงื ื•ืช. ืฉื“ืจื•ื’ ืจื’ื™ืœ ื ืขืฉื” ืขื `composer install`, ื•ืœื ืขื `composer update`. + +ืœืžืกื“ Compose ืงื™ื™ื, ืžืชื•ืš ื”ืคืจื•ื™ืงื˜ ื”ืžื•ื›ืŸ ื”ืžื—ื•ื‘ืจ ืœึพ**ืื•ืชื ื›ืจื›ื™ื**: + +
+ +~~~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 +~~~ + +
+ +ื”ืฉืœื™ืžื• ื’ื™ื‘ื•ื™ ืกืฉืŸ ื•ึพBroadcastManager ื›ืฉื”ื ืขืฆื•ืจื™ื ื•ืชืจื’ื•ืœ ืฉื—ื–ื•ืจ, ื›ืžืชื•ืืจ ื‘ื”ืžืฉืš, **ืœืคื ื™ ื”ืžืฉืš ื”ืขื‘ื•ื“ื”**. ืœืื—ืจ ืžื›ืŸ ื”ืจื™ืฆื• ืืช ื”ืžื™ื’ืจืฆื™ื” ื•ื‘ื“ื™ืงืช ื”ืฉื—ืจื•ืจ: + +
+ +~~~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` ืžืคืขื™ืœ ืœืื—ืจ ืžื›ืŸ ืืช ื”ืชืžื•ื ื” ืฉื ื‘ื“ืงื”; ื‘ื“ืงื• ืœื•ื’ื™ื ื•ื‘ืจื™ืื•ืช. ื”ืกื“ืจ ื‘ื”ืชืงื ื” ื™ื“ื ื™ืช: ืขืฆื™ืจื”, ื’ื™ื‘ื•ื™, ื”ืชืงื ื” ืžื”ึพlock, ืžื™ื’ืจืฆื™ื”, ื‘ื“ื™ืงื” ื•ื”ืคืขืœื”. + +ืžืขื‘ืจ ืžึพdev.8 ืœึพdev.9 ืžื•ืกื™ืฃ `016_delivery_safety.sql`. ืžึพdev.6 ื ื“ืจืฉื•ืช ื’ื 013โ€“015; ื’ืจืกืื•ืช ืžื•ืงื“ืžื•ืช ืžื—ื™ืœื•ืช ืื•ื˜ื•ืžื˜ื™ืช ืืช ื›ืœ ื”ื—ืกืจื•ืช. ืื™ืŸ ืœืขืจื•ืš SQL ืฉื›ื‘ืจ ื”ื•ื—ืœ. ื ื™ืกื™ื•ื ื•ืช ืžืกื™ืจื” ืงื•ื“ืžื™ื ืฉืœื ื”ื•ื›ืจืขื• ืžื˜ื•ืคืœื™ื ื‘ื–ื”ื™ืจื•ืช; ืฆื™ืœื•ืžื™ ื”ืจืฉืื•ืช ื™ืฉื ื™ื ืฉืื™ื ื ืžืื•ืžืชื™ื ืขื“ื™ื™ืŸ ื“ื•ืจืฉื™ื ื‘ื“ื™ืงืช ืžื ื”ืœ. + +ื‘ืžืขื‘ืจ ืžื”ื‘ื•ื˜ ื”ื™ืฉืŸ ืžื‘ื•ืกืก ื”ืงื‘ืฆื™ื: ืฉืžืจื• ืขื•ืชืง ืžื•ื’ืŸ, ืขืฆืจื• ืืช ื”ื›ื•ืชื‘ื™ื ื”ื™ืฉื ื™ื ืœื™ื™ื‘ื•ื ื”ืกื•ืคื™ ื•ืœืžืขื‘ืจ, ืืชื—ืœื• ืžืกื“ ื—ื“ืฉ, ื‘ืฆืขื• dry-run, ื”ืฉื•ื• ืกืคื™ืจื•ืช/ืชื‘ื ื™ื•ืช/ื”ืจืฉืื•ืช/ืื–ื”ืจื•ืช ื•ืื– ื”ื—ื™ืœื• ืขื ื›ื•ืชื‘ื™ื ืขืฆื•ืจื™ื ื•ืชื™ืงื™ื™ืช ื’ื™ื‘ื•ื™. ื”ืคืงื•ื“ื•ืช ื”ืžื“ื•ื™ืงื•ืช ืžื•ืคื™ืขื•ืช ืœืขื™ืœ. ื‘ึพDocker ื—ื‘ืจื• ืจืง ืืช ื”ืขื•ืชืง ื”ืžื•ื’ืŸ ืœืงืจื™ืื” ื‘ืœื‘ื“: + +
+ +~~~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 +~~~ + +
+ +ื™ื™ื‘ื•ื ืžืคื•ืจืฉ ืžืฉืžืจ ืœืคื™ ื”ื ืชื•ื ื™ื ืงื‘ื•ืฆื•ืช, ืชื‘ื ื™ื•ืช/ื™ืฉื•ื™ื•ืช/ืžื“ื™ื”/ื›ืคืชื•ืจื™ื, ื˜ื™ื•ื˜ื•ืช, ื ืžืขื ื™ื ืžื•ื›ืจื™ื ื•ืขื“ื•ื™ื•ืช ื”ืจืฉืื•ืช, ืœืœื ืงืจื™ืื•ืช Telegram. ื”ืžื•ื“ื•ืœื™ื ื”ื—ื“ืฉื™ื ื ืฉืืจื™ื ื›ื‘ื•ื™ื™ื; ืฆื™ืœื•ื ื—ืกืจ ืื• ืคื’ื•ื ื ืฉืืจ ืœื˜ื™ืคื•ืœ. ื”ื’ืฉืจ ืฉืœ `DBgroups.txt` ื‘ืขืช ืขืœื™ื™ื” ืžื•ืกื™ืฃ ืจื™ืฉื•ืžื™ื ื‘ืœื‘ื“ ื•ืื™ื ื• ืžื—ืœื™ืฃ ื™ื™ื‘ื•ื ืžืœื. ืื™ืŸ ืœื”ื—ืœื™ืฃ ื ืชื•ื ื™ ื™ื™ืฆื•ืจ ื‘ืžืกื“ ืื• ื‘ืกืฉืŸ ืฉืœ ื‘ื•ื˜ ื‘ื“ื™ืงื”. + +[ืžืขื‘ืจ](docs/MIGRATION.md) ยท [ืกื›ืžื”](docs/DATABASE.md) ยท [ืฆื™ืœื•ืžื™ื ื™ืฉื ื™ื](docs/DEV8_PERMISSIONS.md) + + +## ื’ื™ื‘ื•ื™ ื•ื—ื–ืจื” ืœื’ืจืกื” ืงื•ื“ืžืช + +ื’ื™ื‘ื•ื™ SQLite ืžืฉืชืžืฉ ื‘ึพ`VACUUM INTO`, ืžืืžืช ืฉืœืžื•ืช, ืžืคืชื—ื•ืช ื–ืจื™ื ื•ื”ื™ืกื˜ื•ืจื™ื™ืช ืกื›ืžื” ื•ื›ื•ืชื‘ ื ืชื•ื ื™ SHA-256. ื”ืขืชืงืช ื”ืงื•ื‘ืฅ ื”ืจืืฉื™ ื”ื—ื™ ื‘ืœื‘ื“ ืขืœื•ืœื” ืœื”ืฉืžื™ื˜ ื ืชื•ื ื™ื ืฉื›ื‘ืจ ื ื›ืชื‘ื• ืœึพWAL. ืฉืžื™ืจืช ื”ื’ื™ื‘ื•ื™ื™ื ืžื•ื—ืงืช ืจืง ืฉืžื•ืช ืžื“ื•ื™ืงื™ื ืฉืœ ื”ื›ืœื™ ื‘ืชื™ืงื™ื™ื” ืฉื ื‘ื—ืจื”; ื‘ื—ืจื• `--keep` ื‘ืžื›ื•ื•ืŸ. + +SQLite **ืื™ื ื•** ื›ื•ืœืœ ืืช ื”ืกืฉืŸ ื•ืืช ื›ืœ ืงื•ื‘ืฆื™ BroadcastManager. ืขืฆืจื• ืืช ื”ื›ื•ืชื‘ื™ื ื•ืฉืžืจื• ื‘ื ืคืจื“ ื ืชื™ื‘ื™ื ืืœื”, ืชืฆื•ืจื” ืคืจื˜ื™ืช ื•ืงื•ื“/lock ืชื•ืืžื™ื ืœืฆื“ ื’ื™ื‘ื•ื™ ื”ืžืกื“. ื“ื•ื’ืžืช Docker **ืœืื—ืจ ืขืฆื™ืจืช ื”ืฉื™ืจื•ืช**, ืžืฉื•ืจืฉ ื”ืคืจื•ื™ืงื˜ ื‘ืžืืจื—. ื”ืฉืชืžืฉื• ื‘ืชื™ืงื™ื™ืช ืขื•ืชืง ื—ื“ืฉื” ื•ืจื™ืงื” ืœื›ืœ ื’ื™ื‘ื•ื™ (ื”ื—ืœื™ืคื• ืืช `../shabbat-private-backup` ื‘ืื•ืคืŸ ืขืงื‘ื™): + +
+ +~~~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 +~~~ + +
+ +ื›ืš ืžื•ืขืชืงื•ืช ืชื™ืงื™ื•ืช ื”ื ืชื•ื ื™ื ื”ืžืชืžืฉื›ื™ื ื›ืฉื”ืชื”ืœื™ืš ืขืฆื•ืจ ืœืชื™ืงื™ื™ื” ืžืงื‘ื™ืœื” ืžื—ื•ืฅ ืœื”ืงืฉืจ ื”ื‘ื ื™ื™ื” ืฉืœ Docker. ืฉืžืจื• ืื•ืชืŸ ื‘ืคืจื˜ื™ื•ืช ื•ืžื—ื•ืฅ ืœื—ื•ืžืจ ื”ืคืจืกื•ื. ื‘ื”ืชืงื ื” ื™ื“ื ื™ืช ื”ืขืชื™ืงื• ืืช ื ืชื™ื‘ื™ ื”ื ืชื•ื ื™ื ื•ื”ืกืฉืŸ ื”ืืžื™ืชื™ื™ื ื›ืฉื”ื›ื•ืชื‘ื™ื ืขืฆื•ืจื™ื. ืืžืชื• ื’ื™ื‘ื•ื™ื™ื ืœืคื ื™ ื”ื—ืœืคื”. + +ืฆืจื• ืชื™ืงื™ื™ืช ืชืจื’ื•ืœ ื ืคืจื“ืช ื•ืฉื—ื–ืจื• ืืœื™ื” ื’ื™ื‘ื•ื™ SQLite ื ื‘ื—ืจ ื•ืžืื•ืžืช: + +
+ +~~~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 +~~~ + +
+ +ื”ื—ืœื™ืคื• `CHOSEN.sqlite` ื‘ืฉื ื”ืืžื™ืชื™. ื‘ื“ืงื• ืฉื“ื•ืช ืžื‘ื ื”, ืกื›ืžื” ื•ื“ื’ื™ืžืช ืจืฉื•ืžื•ืช; ื‘ื“ื™ืงืช ื”ื‘ืจื™ืื•ืช ื”ื›ื•ืœืœืช ืขืฉื•ื™ื” ืœืฆืืช ื‘ึพ1 ืœืœื ืคืขื™ืžื•ืช ื—ื™ื•ืช. + +ื—ื–ืจื” ืœื’ืจืกื” ืงื•ื“ืžืช: ืขืฆืจื• ื›ื•ืชื‘ื™ื, ืฉืžืจื• ื’ื™ื‘ื•ื™ ื”ืชืื•ืฉืฉื•ืช ืขื“ื›ื ื™, ืคืชืจื• ืื• ืชืขื“ื• ื—ื•ื‘ื•ืช ื”ืจืฉืื” ื‘ึพTelegram, ืฉื—ื–ืจื• ืžืกื“/ืงื•ื“/lock/ืกืฉืŸ/ืžืฆื‘ BroadcastManager ืชื•ืืžื™ื, ื”ืคืขื™ืœื• ืขื•ืชืง ื™ื—ื™ื“ ื•ื‘ื“ืงื• ื”ืชืืžื” ืœื”ืจืฉืื•ืช ืžืจื•ื—ืงื•ืช ื˜ืจื™ื•ืช. ืฉื—ื–ื•ืจ ืžืกื“ ืื™ื ื• ืžื‘ื˜ืœ ืฉื™ื ื•ื™ ืฉื›ื‘ืจ ื”ืชืงื‘ืœ ื‘ึพTelegram. ื›ืœื™ ื”ืฉื—ื–ื•ืจ ืžืืžืช ืงืœื˜ ื•ืžื ื™ืคืกื˜, ืžืงื‘ืœ ื ืขื™ืœืช ื›ื•ืชื‘ ื•ืฉื•ืžืจ `*.before-restore-*`. ื›ื•ืชื‘ ืขืกื•ืง ืื• ืกื›ืžื” ืœื ืชื•ืืžืช ื“ื•ืจืฉื™ื ื‘ื“ื™ืงื”, ื•ืœื ืžื—ื™ืงืช ื ืขื™ืœื•ืช ืื• ื›ืจื›ื™ื. + +[ื’ื™ื‘ื•ื™ ื•ืฉื—ื–ื•ืจ ืžืคื•ืจื˜ื™ื](docs/BACKUP_RESTORE.md) + + +## ืคืชืจื•ืŸ ืชืงืœื•ืช + +| ืชืกืžื™ืŸ | ืคืขื•ืœื” | +|---|---| +| commit ื—ืกืจ ืื• ืžืงื•ืจ ืฉืฉื•ื ื” ื‘ื–ืžืŸ ื”ื‘ื ื™ื™ื” | ืฉืžืจื• ื ืชื•ื ื™ ืืจื›ื™ื•ืŸ ืžืื•ืžืชื™ื ืื• ืžืกืจื• ื–ื”ื•ืช ืืžื™ืชื™ืช ืฉืœ ืขื•ืชืง ืฉื ื‘ื“ืง. ืœืื—ืจ ืฉื™ื ื•ื™ ืžืงื•ืจ ืžืคื™ืงื™ื ืžื—ื“ืฉ ื ืชื•ื ื™ ืืจื›ื™ื•ืŸ. | +| ื‘ื“ื™ืงืช ืฉื—ืจื•ืจ SQLite ื ื›ืฉืœืช | ื”ืฉืชืžืฉื• ื‘ืชืžื•ื ื” ื”ื ืขื•ืœื” ืื• ื‘ืกื‘ื™ื‘ื” ืžืชื•ืงื ืช ื•ืžืชืงื‘ืœืช; ืื–ื”ืจืช ืคื™ืชื•ื— ืื™ื ื” ืื™ืฉื•ืจ ื™ื™ืฆื•ืจ. | +| ืกื›ืžื” ื—ืกืจื” ืื• ื™ืฉื ื” | ืขืฆืจื• ื›ื•ืชื‘ื™ื, ื’ื‘ื• ื•ื”ืจื™ืฆื• ืžื™ื’ืจืฆื™ื” ืœื ืชื™ื‘ ืžืคื•ืจืฉ. ื”ืขืœื™ื™ื” ืื™ื ื” ืžื‘ืฆืขืช ืžื™ื’ืจืฆื™ื”. | +| ื›ื™ืฉืœื•ืŸ ืชื™ืงื•ืŸ | ื‘ื“ืงื• lock ื•ืžื ื™ืคืกื˜ื™ื ื•ื”ืชืงื™ื ื• ื‘ืขื•ืชืง ื ืงื™ ื•ืขืฆื•ืจ; ืื™ืŸ ืœื”ื—ืœื™ืฉ ืื™ืžื•ืช. | +| ืงื‘ื•ืฆื” ื—ื“ืฉื” ืœื ืฉื•ืœื—ืช | ื‘ื“ืงื• ืžืฆื‘, ืžื™ืงื•ื, ื”ืคืขืœืช ืžื•ื“ื•ืœ ื•ืชื–ืžื•ืŸ; `/add` ืžืชื—ื™ืœ ื›ื‘ื•ื™. | +| ื”ื’ื“ืจื•ืช ื—ืกื•ืžื•ืช ืื• ืคื’ื• | ืคืชื—ื• ืฉื™ื—ื” ืคืจื˜ื™ืช ื•ืคืื ืœ `/settings` ื—ื“ืฉ ื›ืžื ื”ืœื™ ื”ืงื‘ื•ืฆื” ื”ื ื•ื›ื—ื™ื™ื. | +| ื—ื™ืคื•ืฉ ืขื™ืจ ื ื›ืฉืœ | ื‘ื“ืงื• ื”ืคืขืœืช GeoNames, ื—ืฉื‘ื•ืŸ, ืžื›ืกื” ื•ึพHTTPS; ื”ืฉืชืžืฉื• ื‘ืขื™ืจ ืžื•ื›ืจืช ืื• ื‘ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ืœืคื™ ื”ืฆื•ืจืš. | +| ืžืฆื‘ ืœื ื‘ืจื™ื | ื‘ื“ืงื• ื’ื™ืœ ืฉืชื™ ื”ืคืขื™ืžื•ืช, ืกื›ืžื”, ืชื•ืจ ื•ืœื•ื’ื™ื; ืžืชื–ืžืŸ ืขืฆื•ืจ ืื™ื ื• ืžืขื“ื›ืŸ ืคืขื™ืžื”. | +| ืงื‘ื•ืฆื” ืœื ื ืคืชื—ืช | ื”ืฉืชืžืฉื• ื‘ื”ืชืื•ืฉืฉื•ืช ื•ื‘ืื‘ื—ื•ืŸ ืœืฆื™ืœื•ืžื™ื ื—ืกืจื™ื/ืœื ืžืื•ืžืชื™ื/ืžืชื ื’ืฉื™ื ื•ืฉืžืจื• ื”ื’ื‘ืœื•ืช ืžืงื•ืจื™ื•ืช. | +| ืชื–ื›ื•ืจืช ื—ืกืจื” ืื• ืžืื•ื—ืจืช | ื‘ื“ืงื• ืื–ื•ืจ ื–ืžืŸ/ืชืืจื™ืš, ืฉืงื˜, ืžื“ื™ื ื™ื•ืช ื—ื’ ื•ืกื™ื•ื, ืžื•ื“ื•ืœ ื•ื ื•ืฉื. ื”ื–ื›ืื•ืช ื ื‘ื“ืงืช ื‘ืžืกื™ืจื”. | +| ื“ื—ื™ื™ืช ืื™ืžื•ื’ืณื™ ืžื•ืชืื | ื”ื’ื“ื™ืจื• `CUSTOM_EMOJI_ENABLED=0`; ื’ื ื“ื—ื™ื™ืช ื–ื›ืื•ืช ื•ื“ืื™ืช ืžืคืขื™ืœื” ื—ืœื•ืคื”. | +| ืฉื’ื™ืืช ื”ืจืฉืื•ืช ืงื‘ืฆื™ื | ื‘ื“ืงื• UID/GID โ€10001 ื•ื ืชื™ื‘ื™ ืขื‘ื•ื“ื”/ื ืชื•ื ื™ื/ืกืฉืŸ, ื•ื”ืจื™ืฆื• ื‘ื“ื™ืงืช ื›ืชื™ื‘ื”. ื”ื™ืžื ืขื• ืžื”ืจืฉืืช ื›ืชื™ื‘ื” ืœื›ื•ืœื. | + +[ืชืงืœื•ืช ื ื•ืกืคื•ืช](docs/TROUBLESHOOTING.md) ยท [ืคืจื˜ื™ื•ืช](docs/PRIVACY.md) ยท [ื“ื™ื•ื•ื— ืื‘ื˜ื—ื”](.github/SECURITY.md) + + +## ืžื‘ื ื” ื”ืคืจื•ื™ืงื˜ ื•ืคื™ืชื•ื— + +| ื ืชื™ื‘ | ืชื•ื›ืŸ | +|---|---| +| `bot.php`, `src/` | ื›ื ื™ืกื” ืœื‘ื•ื˜ ื•ืฉื™ืจื•ืชื™ ืœื•ื—, ืžืžืฉืง, ืชื–ืžื•ืŸ, ืื—ืกื•ืŸ, Telegram ื•ืจื™ืฆื”. | +| `bin/`, `migrations/` | ืชื—ื–ื•ืงื” ื•ืฉื™ื ื•ื™ื™ ืกื›ืžื” ืžืžื•ืกืคืจื™ื ืฉืื™ื ื ืžืฉืชื ื™ื ืื—ืจื™ ื”ื—ืœื”. | +| `resources/` | ืฉืฉ ืฉืคื•ืช, ืžืงื•ืจื•ืช ืขื•ืžืจ ืžื™ื•ื—ืกื™ื ื•ืชืฆื•ืจืช PHP. | +| `patches/` | ืžืงื•ืจื•ืช ื‘ืกื™ืก ื•ืชื•ื›ืŸ ืžืชื•ืงืŸ ืฉื ื‘ื“ืงื•, diffs, ืžืงื•ืจ ื•ื—ืชื™ืžื•ืช SHA-256. | +| `tests/` | ื‘ื“ื™ืงื•ืช ื•ื ืชื•ื ื™ JSON/TXT/PHP ื—ื™ื•ื ื™ื™ื. | +| `scripts/` | ืชื™ืงื•ื ื™ื, ืื™ื›ื•ืช, ืชืœื•ื™ื•ืช ื•ืืจื™ื–ื” ืžืงื•ืžื™ืช. | +| `docs/`, `LICENSES/` | ืžื“ืจื™ื›ื™ื, ืžืงื•ืจ ื•ืจื™ืฉื™ื•ื ื•ืช. | +| `.github/` | ืชื”ืœื™ื›ื™ CI ื•ืฉื—ืจื•ืจ ื•ืชื‘ื ื™ื•ืช ืชืจื•ืžื” ื•ืื‘ื˜ื—ื”. | + +ื”ืจื™ืฆื• **ื‘ืฉื•ืจืฉ ืขื•ืชืง ืคื™ืชื•ื— ื ืคืจื“, ื›ื—ืฉื‘ื•ืŸ ื”ืžืคืชื—**, ืœืœื ื ืชื•ื ื™ ื™ื™ืฆื•ืจ ืื• ืกื•ื“ื•ืช. ื”ื”ืชืงื ื” ื›ื•ืœืœืช ื›ืœื™ ื‘ื“ื™ืงื”; ื”ื‘ื“ื™ืงื•ืช ืžืฉืชืžืฉื•ืช ื‘ืžืฆื‘ ื–ืžื ื™ ืžื‘ื•ื“ื“. + +
+ +~~~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` ืžื“ืœื’ืช ืขืœ ืขืฆืช ื˜ื•ื•ื— ื’ืจืกืื•ืช ื›ืœืœื™ืช ื‘ืฉืœ ื ืขื™ืœืช MadelineProto ื”ืžื“ื•ื™ืงืช; ื‘ื“ื™ืงื•ืช ืกื›ืžื” ื•ึพlock ืžื—ืžื™ืจื•ืช ื ืฉืืจื•ืช. `lint` ื‘ื•ื“ืง ืชื—ื‘ื™ืจ ื•ืื™ื›ื•ืช, `style` ืื•ื›ืฃ ืฉื’ื™ืื•ืช PHPCS ื•ึพ`analyse` ืžืคืขื™ืœ PHPStan. โ€`composer style-review` ืžืฆื™ื’ ื“ื•ื— ืกื’ื ื•ืŸ ืจื—ื‘ ื™ื•ืชืจ; `composer style-fix` **ืžืฉื›ืชื‘ ืงื‘ืฆื™ื**, ื•ืœื›ืŸ ืžื™ื•ืขื“ ืœืขื•ืชืง ืคื™ืชื•ื— ืžื‘ื•ืงืจ ื‘ืœื‘ื“. + +ื‘ืื•ืชื• ืฉื•ืจืฉ ืคื™ืชื•ื—: + +
+ +~~~sh +composer show --locked --format=json +composer licenses --format=json +composer audit --locked --format=json +composer dependencies +~~~ + +
+ +ื”ืคืงื•ื“ื•ืช ืžื“ื•ื•ื—ื•ืช ืขืœ ื—ื‘ื™ืœื•ืช ื ืขื•ืœื•ืช, ืจื™ืฉื™ื•ื ื•ืช ืžื•ืชืงื ื™ื ื•ื”ืชืจืื•ืช ืขื“ื›ื ื™ื•ืช; audit ืคื•ื ื” ืœืฉื™ืจื•ืชื™ ื”ืชืจืื•ืช. `dependencies` ื›ื•ืชื‘ ืขื“ื•ื™ื•ืช ื‘ึพ`docs/`; ื‘ื“ืงื• ื•ืกื ื ื• ืื•ืชืŸ ืœืคื ื™ ื”ื›ืœืœื”. ืื™ืŸ ืœื”ื—ืœื™ืฃ lock ื›ื“ื™ ืœื”ืฉืชื™ืง ื‘ื“ื™ืงื”. + +ื”ืคืงืช ืชื™ืงื•ื ื™ื ืžื—ื“ืฉ ืœืžืคืชื—ื™ื ื‘ืœื‘ื“, ืžืฉื•ืจืฉ ืขื•ืชืง ื”ืคื™ืชื•ื—: + +
+ +~~~sh +php patches/madelineproto/generate-artifacts.php +php patches/broadcastmanager/generate-artifacts.php +~~~ + +
+ +ื”ืคืงื•ื“ื•ืช ืžืฉื›ืชื‘ื•ืช ืชื•ืฆืจื™ ืชื™ืงื•ืŸ ื•ืžื ื™ืคืกื˜ื™ื ืžื”ืžืงื•ืจื•ืช ื”ืžืฆื•ืจืคื™ื ืฉื ื‘ื“ืงื•. ื”ืŸ ืžื™ื•ืขื“ื•ืช ืœืกืงื™ืจืช ืชื™ืงื•ืŸ ื™ื–ื•ืžื”, ื•ืœื ืœื”ืชืงื ื” ืจื’ื™ืœื”. ื‘ื“ืงื• ื—ืชื™ืžื•ืช ื•ึพdiffs ืฉืฉื•ื ื• ื•ื”ืจื™ืฆื• ืœืื—ืจ ืžื›ืŸ ืืช ื›ืœ ื‘ื“ื™ืงื•ืช ื”ืชื™ืงื•ื ื™ื. ืกืงืจื™ืคื˜ื™ ื”ื”ื—ืœื” ื•ื”ืื™ืžื•ืช ื‘ึพPHP ืžืงื‘ืœื™ื ื’ื ืคืจืžื˜ืจ ืžื™ืงื•ื ืจืืฉื•ืŸ ืื•ืคืฆื™ื•ื ืœื™ ืœืชื™ืงื™ื™ืช vendor; ื”ืฉืื™ืจื• ืื•ืชื• ืจื™ืง ืœืฉื™ืžื•ืฉ ืจื’ื™ืœ ื‘ึพ`vendor/`. + +ืœืืจื™ื–ื” ืžืงื•ืžื™ืช ืขื ืชืœื•ื™ื•ืช ื”ืคื™ืชื•ื— ื”ื ื“ืจืฉื•ืช ื•ื–ื™ื”ื•ื™ ืžืื•ืžืช: + +
+ +~~~sh +php scripts/package.php +~~~ + +
+ +ื”ื›ืœื™ ื›ื•ืชื‘ ZIP ืœื™ื™ืฉื•ื, ืœืžืงื•ืจ ืชื•ืื ื•ืœืฉืจืช ื‘ื“ื™ืงื”, ืขื ื—ืชื™ืžื•ืช ื•ืžื ื™ืคืกื˜ื™ื ื‘ึพ`dist/`, ืœืœื ืคืจืกื•ื. ื”ืฉื—ืจื•ืจ ื”ืžืงื“ื™ื ืžืฉืชืžืฉ ื‘ึพ`shabbat-3.0.0-dev.9.zip` ืœืœื ืชืœื•ื™ื•ืช ืžื•ืชืงื ื•ืช ื•ื‘ื—ื‘ื™ืœืช ืžืงื•ืจ ืชื•ืื ืœืชืœื•ื™ื•ืช ื”ืจื™ืฆื” ืขื ืžืœืื™ ื ืคืจื“; ืื™ืŸ ืœื”ื—ืœื™ืคื” ื‘ื”ืคืฆื•ืช ื›ืœื™ ื”ืคื™ืชื•ื— ื”ืžื•ืชืงื ื™ื ืฉืžืคื™ืง ื›ืœื™ ื”ืืจื™ื–ื” ืœืžืคืชื—ื™ื. ื‘ื“ืงื• ื‘ื›ืœ ืžื ื™ืคืกื˜ ืžื” ื›ืœื•ืœ ื•ืžื” ื”ื•ื—ืจื’; ืžืงื•ืจ ื”ื™ื™ืฉื•ื ื•ึพlock ืื™ื ื ืœื‘ื“ื ื—ื‘ื™ืœืช ืžืงื•ืจ ืชืœื•ื™ื•ืช ืžืœืื”. + +[ืืจื›ื™ื˜ืงื˜ื•ืจื”](docs/ARCHITECTURE.md) ยท [ืชืœื•ื™ื•ืช](docs/DEPENDENCIES.md) ยท [ืชื™ืงื•ื ื™ื](docs/PATCHES.md) ยท [ื”ื›ื ืช ืฉื—ืจื•ืจ](docs/RELEASE.md) ยท [ืชืจื•ืžื”](.github/CONTRIBUTING.md) ยท [ืงื•ื“ ื”ืชื ื”ื’ื•ืช](.github/CODE_OF_CONDUCT.md) + + +## ืจื™ืฉื•ื™ ื•ืงืจื“ื™ื˜ื™ื + +ื”ื™ื™ืฉื•ื ื”ืžืฉื•ืœื‘ ื”ื•ื **AGPL-3.0-only**: [ื”ืจื™ืฉื™ื•ืŸ](LICENSE), [ืžื“ื™ื ื™ื•ืช ืจื™ืฉื•ื™](docs/LICENSING.md). ื”ื•ื“ืขื•ืช MIT ื•ื”ืจืฉืื•ืช ื”ื™ืกื˜ื•ืจื™ื•ืช ื ืฉืžืจื•ืช. ืจื™ืฉื™ื•ื ื•ืช ื”ืชืœื•ื™ื•ืช, ื”ื ืชื•ื ื™ื ื•ื”ืชืขืชื™ืงื™ื ื ืคืจื“ื™ื; ื”ื›ื•ืชืจื•ืช ื•ื”ื”ื•ื“ืขื•ืช ืฉืœื”ื ื ืฉืžืจื•ืช. + +ืงืจื“ื™ื˜ ืœึพ[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/) ื•ืœืžืงื•ืจื•ืช ื”ืชืคื™ืœื” ื”ืžื™ื•ื—ืกื™ื ืžื•ื•ื™ืงื™ื˜ืงืกื˜. ืจื›ื™ื‘ื™ื, ืจื™ืฉื™ื•ื ื•ืช, ื’ืจืกืื•ืช ืžืงื•ืจ ืงื‘ื•ืขื•ืช, ืฉื™ื ื•ื™ื™ื ื•ื™ื™ื—ื•ืก ืžืชื•ืขื“ื™ื ื‘ึพ[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), ื‘ืžื ื™ืคืกื˜ื™ ืชืœื•ื™ื•ืช ื•ื‘ื ืชื•ื ื™ ื”ืžืงื•ืจ ืฉืœ ื”ืžืฉืื‘ื™ื. + +ืžืคืขื™ืœื™ื ื•ึพforks ืฆืจื™ื›ื™ื ืœื”ืขืžื™ื“ **ืžืงื•ืจ ืชื•ืื ื‘ื“ื™ื•ืง ืœื’ืจืกื” ืฉื ืคืจืกื”**, ื›ื•ืœืœ ืฉื™ื ื•ื™ื™ื ืžืงื•ืžื™ื™ื ื‘ื™ื™ืฉื•ื ื•ื‘ืชืœื•ื™ื•ืช ื•ื—ื•ืžืจ ื‘ื ื™ื™ื” ื ื—ื•ืฅ, ื•ืœื”ื’ื“ื™ืจ `SOURCE_URL`. ืงื™ืฉื•ืจ ืœึพ`main` ืฆื™ื‘ื•ืจื™ ืฉื•ื ื” ืื™ื ื• ืžืกืคื™ืง. ื—ื•ืžืจ ื”ืคืจืกื•ื ืžื—ืจื™ื’ ืชืฆื•ืจื” ืคืจื˜ื™ืช, DB/WAL/SHM ื—ื™ื™ื, ืกืฉื ื™ื, ื’ื™ื‘ื•ื™ื™ื, ืœื•ื’ื™ื ื•ึพcaches, ื•ืฉื•ืžืจ ืžืงื•ืจ ื ื—ื•ืฅ, ื‘ื“ื™ืงื•ืช ื•ื ืชื•ื ื™ื”ืŸ, ืžืงื•ืจื•ืช ื‘ืกื™ืก ืœืชื™ืงื•ื ื™ื ื•ืจื™ืฉื™ื•ื ื•ืช. + +
diff --git a/resources/locales/en.json b/resources/locales/en.json new file mode 100644 index 0000000..5622054 --- /dev/null +++ b/resources/locales/en.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ Shabbat and holidays", + "zmanim": "๐Ÿ•’ Daily times", + "daf": "๐Ÿ“– Daf Yomi", + "omer": "๐ŸŒพ Counting the Omer", + "tefillin": "๐Ÿ”” Tefillin reminders", + "add": "โž• Add me to a group", + "groups": "๐Ÿ‘ฅ My groups", + "preferences": "โš™๏ธ My preferences", + "donate": "โญ Support the bot", + "source": "๐Ÿ’ป Source and license", + "back": "โ†ฉ๏ธ Back", + "cancel": "Cancel", + "confirm": "โœ… Confirm save", + "language": "๐ŸŒ Language", + "location": "๐Ÿ“ Location and timezone", + "candles": "๐Ÿ•ฏ Candle lighting", + "havdalah": "๐ŸŒ™ Havdalah", + "messages": "โœ‰๏ธ Messages and design", + "schedule": "๐Ÿ—“ Upcoming actions", + "status": "๐Ÿ›  Status and issues", + "preview": "๐Ÿ‘€ Private preview", + "reset": "Reset this module", + "edit": "โœ๏ธ Edit settings", + "on": "Enabled", + "off": "Disabled", + "today": "Today", + "tomorrow": "Tomorrow", + "other_date": "Another date", + "share": "Share", + "short": "Short", + "full": "Full", + "next": "Next page", + "previous": "Previous page", + "no_groups": "No known groups that you currently administer were found. In a known group use /settings.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nMode: {{mode}}\nNext action: {{next}}\n\nChoose a topic to configure this group. Review and confirm each change before saving.", + "personal_panel": "โš™๏ธ My preferences\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nChoose your language, location and calculation methods. These preferences determine your personal times.", + "denied": "This action is unavailable: authorization or the settings context expired. Reopen the group panel.", + "saved": "โœ… Saved for {{title}}. Future actions were recalculated; permission restoration is retained.", + "language_choose": "Choose the language of this scope. The private panel keeps your personal language.", + "candle_choose": "Select actual minutes before sunset, or the city's documented custom. No hidden extra minute is applied.", + "city_custom": "City custom", + "havdalah_choose": "Choose degrees or fixed minutes after sunset. 72 minutes means fixed 72 minutes; the method and value are stored separately.", + "degrees": "8.5 degrees", + "minutes": "{{value}} fixed minutes", + "location_choose": "Search a city, select a result, review the timezone, then confirm for the named scope. Searching and sharing never save settings.", + "inline_search": "Search city in inline", + "city_search": "Search in private", + "coordinates": "Coordinates / shared location", + "timezone": "Manual timezone", + "calendar_israel": "Israel calendar", + "calendar_diaspora": "Diaspora calendar", + "copy_personal": "Copy my preferences (preview)", + "geonames_missing": "GeoNames search is disabled. The operator must configure GEONAMES_USERNAME and enable web services. Use a common city or coordinates below.", + "city_prompt": "Send /city {{token}} a city name in a provider-supported language. This token belongs only to this settings flow.", + "coordinates_prompt": "Send /coordinates {{token}} latitude longitude IANA-zone name. Example: /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem Jerusalem. To share GPS, send /location {{token}} IANA-zone, then reply to the bot prompt with a Telegram location.\nUse auto instead of the timezone when GeoNames is configured; the proposed IANA zone is always shown before saving.", + "timezone_prompt": "Send /timezone {{token}} search text to search IANA timezone identifiers. Selection requires confirmation and leaves coordinates unchanged.", + "location_preview": "Location preview for {{title}}\nCity: {{location}}\nCountry: {{country}}\nRegion: {{region}}\nLatitude: {{latitude}}\nLongitude: {{longitude}}\nGeoNames ID: {{geoname}}\nIANA timezone: {{timezone}}\nCurrent UTC offset: {{offset}} (display only)\n{{warning}}", + "timezone_warning": "A manually selected timezone may differ from the geographical timezone. Coordinates have not changed.", + "no_results": "No cities found for this query.", + "search_error": "Location search unavailable: {{error}}", + "select_location": "Review this place; settings are saved only after your private confirmation.", + "select_private": "Continue in the bot's private chat", + "public_location": "Public place information. Sharing this result never changes personal or group settings.", + "provider_error": "Verified times are unavailable: {{error}}. No time was guessed.", + "unavailable": "Unavailable for this location/date", + "shabbat_view": "Shabbat and holidays\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nCandle lighting: {{candles}}\nGroup closes: {{close}}\nHavdalah: {{havdalah}}\nGroup opens: {{open}}\nActive event: {{current}}\nNext event: {{next}}\nCalendar: {{calendar}}\nCandles: {{candle_minutes}} minutes before sunset. Havdalah: {{value}} {{method}}.\nSource: Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ Counting the Omer\n\nCounting the Omer is outside its season for this date.", + "help_text": "Commands\n/start - main menu\n/shabat, /shabbat - Shabbat times\n/zmanim [YYYY-MM-DD] - daily times\n/daf [YYYY-MM-DD] - Daf Yomi\n/omer - counting the Omer\n/groups - groups you administer\n/settings - private panel of a known group (group admins)\n/preferences - personal settings\n/language, /lang - language selection\n/source, /license, /version - running build information\n/donate - Telegram Stars support\n\nInline: shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer and loc city. Group setting actions require current administrator permission. Broadcast and operator controls remain system-admin only.", + "mode": "Operation mode", + "mode_off": "Off", + "mode_messages": "Messages only", + "mode_lock": "Lock only", + "mode_lock_messages": "Lock and messages", + "shabbat_enabled": "Shabbat activity", + "holidays_enabled": "Holiday activity", + "module_panel": "{{module}} for {{title}}\n{{settings}}\nNew notification modules are disabled until explicitly enabled.", + "edit_prompt": "Edit {{module}} for {{title}}. Send /configure {{token}} followed by field=value pairs separated by semicolons. Supported fields and current values:\n{{fields}}\nLists use commas. Review and confirm the proposed settings before they are saved.", + "changes_preview": "Settings preview for {{title}}\n{{changes}}", + "date_prompt": "Send /viewdate {{token}} YYYY-MM-DD. The date is explicit and uses this scope's timezone.", + "schedule_view": "Upcoming actions for {{title}}\n{{content}}\nPermission restoration takes priority over routine messages.", + "status_view": "Status for {{title}}\nUTC clock: {{utc}}\nLocal clock: {{local}}\nCache coverage: {{coverage}}\nLast successful provider check: {{fetched}}\nCurrent event: {{current}}\nNext event: {{next}}\nIssue: {{error}}", + "reset_preview": "Reset only {{module}} for {{title}}? Location, language and other modules remain unchanged.", + "suspend": "Pause tefillin for today", + "not_private": "Open the private group panel to change settings.", + "date_invalid": "Use a real date formatted YYYY-MM-DD.", + "hours_invalid": "Use local HH:mm times and supported choices.", + "location_label": "Location", + "alotHaShachar": "Dawn", + "misheyakir": "Earliest tallit and tefillin", + "sunrise": "Sunrise", + "sofZmanShma": "Latest Shema (GRA)", + "sofZmanShmaMGA": "Latest Shema (MGA)", + "sofZmanTfilla": "Latest prayer (GRA)", + "sofZmanTfillaMGA": "Latest prayer (MGA)", + "chatzot": "Midday", + "minchaGedola": "Earliest mincha", + "minchaKetana": "Mincha ketana", + "plagHaMincha": "Plag hamincha", + "sunset": "Sunset", + "tzeit85deg": "Nightfall (8.5ยฐ)", + "tzeit42min": "Nightfall (42 fixed minutes)", + "tzeit50min": "Nightfall (50 fixed minutes)", + "tzeit72min": "Nightfall (72 fixed minutes)", + "chatzotNight": "Midnight", + "settings_search": "Filter groups: /groups search text", + "field_language": "Language", + "field_candle_minutes": "Candle minutes (city = city custom)", + "field_havdalah_method": "Havdalah method", + "field_havdalah_value": "Havdalah value", + "field_close_before_candles_minutes": "Close before candles (minutes)", + "field_open_after_havdalah_minutes": "Open after havdalah (minutes)", + "field_target_topic_id": "Forum topic ID (off disables)", + "field_enabled": "Enabled", + "field_time": "Local time", + "field_days": "Weekdays: 0 Sunday through 6 Saturday", + "field_format": "Format", + "field_template": "Template text", + "field_fixed_times": "Fixed times", + "field_interval_minutes": "Interval minutes (off disables, minimum 5)", + "field_window_start": "Local window start", + "field_window_end": "Local window end", + "field_sunset_offsets": "Minutes before sunset", + "field_max_per_day": "Daily maximum (1โ€“24)", + "field_min_spacing_minutes": "Minimum spacing minutes (5 or more)", + "field_quiet_start": "Quiet period start (off disables)", + "field_quiet_end": "Quiet period end (off disables)", + "field_chol_hamoed": "Chol hamoed reminders", + "field_tisha_bav_policy": "Tisha B'Av", + "field_sunset_safety_minutes": "Safety minutes before sunset", + "field_mode": "Timing", + "field_after_nightfall_minutes": "Minutes after nightfall", + "field_nightfall_method": "Nightfall method", + "field_nightfall_value": "Nightfall value", + "field_nusach": "Nusach", + "field_day_completion": "Day completion", + "field_early_policy": "Too early", + "field_after_open_minutes": "Quiet minutes after group opens", + "field_times_alert_enabled": "Times announcement", + "field_times_alert_time": "Announcement local time", + "field_times_alert_mode": "Announcement timing", + "field_times_alert_before_minutes": "Announcement minutes before close", + "field_close_message_enabled": "Closure message", + "field_open_message_enabled": "Opening message", + "method_degrees": "degrees", + "method_minutes": "fixed minutes after sunset", + "window_end": "Window ends", + "no_reminders": "No eligible reminders for this day and settings.", + "preview_only": "Private preview only; no group message is sent.", + "template_panel": "{{title}} - Messages and design", + "template_menu": "{{title}} - {{kind}}\nPrivate preview never sends to the group.", + "template_kind_close": "Closure message", + "template_kind_open": "Opening message", + "template_kind_zmanim": "Daily-times message", + "template_kind_daf": "Daf Yomi message", + "template_kind_tefillin": "Tefillin message", + "template_kind_omer": "Omer message", + "template_edit": "โœ๏ธ Edit text / media", + "template_buttons": "โŒจ๏ธ Edit buttons", + "template_preview": "๐Ÿ‘€ Private preview", + "template_reset": "Reset this message", + "template_test": "Send TEST to group (confirmation)", + "template_edit_prompt": "Send text/media with this caption prefix, then a new line and the content:\n/template {{token}}\nText entities, media and existing buttons are preserved.", + "template_buttons_prompt": "Send /buttons {{token}}, then a new line and a JSON array of button rows. Each button has text and one action: HTTPS URL, callback_data, or inline switch. Optional styles: primary, success, danger.\nThe original text format also works: Label - https://example.org - style:bg_success - icon:123. Use && for buttons on one row, or a new line. Other actions: alert:text, popup:text, copy:text. Callback data is limited to 64 bytes.", + "template_cleared": "The message was reset. Reopen the panel.", + "template_confirm_test": "โœ… Confirm TEST", + "template_test_confirm": "Explicit confirmation: a TEST message will be sent to {{title}}. This action is separate from a private preview.", + "template_test_wait": "Wait five minutes before another test in this group.", + "template_cancelled": "The test was cancelled; no group message was sent.", + "template_saved": "The message was saved. Reopen the panel for a private preview.", + "template_remove_text": "๐Ÿ—‘ Remove text only", + "template_remove_media": "๐Ÿ—‘ Remove media only", + "template_remove_buttons": "๐Ÿ—‘ Remove buttons only", + "gps_prompt": "Reply to this exact message with a Telegram location for {{title}}. The preview will use timezone {{timezone}}. A location without this reply never selects a settings flow.", + "gps_reply_required": "Start GPS sharing with /location and the flow token, then reply to the bot prompt with your location. No group is selected automatically.", + "location_attribution": "Place data: GeoNames (CC-BY-4.0).", + "timezone_auto": "Detect timezone from location with GeoNames", + "auto_timezone_missing": "Automatic timezone detection is disabled because GeoNames is not configured. Use an explicit IANA identifier such as Asia/Jerusalem instead of auto.", + "command_start": "๐Ÿ  Open the main menu", + "command_preferences": "โš™๏ธ Your personal preferences", + "command_lang": "๐ŸŒ Select your language", + "command_groups": "๐Ÿ‘ฅ Groups you administer", + "command_help": "โ„น๏ธ Commands and help", + "command_shabbat": "๐Ÿ•ฏ Shabbat and festival times", + "command_zmanim": "๐Ÿ•’ Daily halachic times", + "command_daf": "๐Ÿ“– Today's Daf Yomi", + "command_omer": "๐ŸŒพ Count the Omer", + "command_source": "๐Ÿ’ป Matching source and license", + "command_license": "๐Ÿ“œ Project license", + "command_version": "๐Ÿท Running version and commit", + "command_donate": "โญ Support with Telegram Stars", + "command_stats": "๐Ÿ“Š Bot service statistics", + "command_add": "โž• Register this group", + "command_remove": "โž– Remove this group safely", + "command_settings": "โš™๏ธ Group settings in private", + "command_admin": "๐Ÿ›  System administrator panel", + "command_testzmanim": "๐Ÿ”Ž Timing and scheduler diagnostics", + "command_closegroups": "๐Ÿ”’ Authorized manual group closure", + "command_opengroups": "๐Ÿ”“ Authorized permission restoration", + "command_setcommands": "๐Ÿ“‹ Publish scoped command menus", + "command_restart": "๐Ÿ”„ Gracefully restart the bot", + "donate_title": "Support Shabbat Bot", + "donate_description": "Voluntary support: {{amount}} Telegram Stars. Thank you!", + "donate_prompt": "Choose a one-time support amount in Telegram Stars.", + "donate_invoice_error": "The donation invoice could not be created. Please try /donate again.", + "donate_partial_error": "Some support amounts are currently unavailable. You can use an invoice that was created successfully.", + "donate_thanks": "Thank you for your donation of {{amount}} Telegram Stars!", + "donate_received_admin": "Confirmed donation from user {{user_id}}: {{amount}} Telegram Stars.", + "donate_unverified_payment": "This payment could not be verified. It has been flagged for operator review; no success is claimed.", + "commands_setup_help": "Command menus are published separately for private chats, group administrators and system operators, in each supported language. Telegram uses pt for Brazilian Portuguese.", + "commands_setup_result": "Command menu updates: {{total}} requests; {{failed}} failed.", + "commands_setup_preview": "Preview: {{total}} scoped language menus will be updated.", + "commands_forbidden": "Only a system operator may publish command menus.", + "operator_panel": "๐Ÿ›  System operator panel", + "operator_subscribers": "๐Ÿ‘ฅ Subscribers", + "operator_statistics": "๐Ÿ“Š Service statistics", + "operator_broadcast": "๐Ÿ“ฃ Broadcast", + "operator_preview": "๐Ÿ‘€ Private preview", + "operator_send": "Send broadcast", + "operator_confirm_send": "Confirm sending this broadcast to {{count}} recipients.", + "operator_recipients_private": "Private subscribers", + "operator_recipients_groups": "Groups", + "operator_recipients_all": "All subscribers", + "operator_pin": "Pin message", + "operator_unpin": "Unpin messages", + "operator_delete_last": "Delete last broadcast", + "operator_delete_all": "Delete all broadcasts", + "operator_progress": "Broadcast progress", + "operator_cancel": "Cancel", + "operator_buttons": "Edit buttons", + "operator_edit_text": "Edit text", + "operator_edit_media": "Edit media", + "operator_clear_draft": "Clear draft", + "operator_active_broadcast": "A broadcast is already running. Wait or cancel it before starting another.", + "operator_draft_saved": "The broadcast draft was saved.", + "operator_no_draft": "No broadcast draft is available.", + "operator_page": "Page {{page}} of {{total}}", + "operator_manual_result": "Action {{action}}: changes verified in {{changed}} groups, {{failed}} failed or unverified. Until: {{until}}.", + "operator_manual_lock": "Manual group closure", + "operator_manual_open": "Restore group permissions", + "group_new_wizard": "This new group starts with a visible default location. Review its location, timezone and calendar, then explicitly choose an operation mode.", + "permission_required": "Current administrator permission is required for this group.", + "group_removed": "The group was removed from the service after its permissions were handled safely.", + "omer_blessing_eligibility": "The blessing is for those who are entitled to recite it. The bot cannot determine an individual's eligibility from group message history.", + "omer_day_completion_label": "Day completion - without a blessing.", + "omer_translation_note": "Hebrew ritual text; the following count is an explanatory translation.", + "group_close_notice": "The Shabbat or festival quiet period has begun.", + "group_open_notice": "The Shabbat or festival quiet period has ended.", + "tefillin_reminder": "Have you put on tefillin today?\nFor the group location {{location}}, sunset is at {{sunset}}.", + "no_event": "No event is available in the verified data range.", + "stats_counts": "Known groups: {{known}}\nRegistered for service: {{registered}}\nLocking enabled: {{locking}}\nCurrently locked by the bot: {{locked}}\nAwaiting recovery: {{recovery}}", + "source_info": "๐Ÿ’ป Shabbat Guardian - source, version and license\n\nOfficial repository\nWizardLoop/shabbat on GitHub\n\nRunning build\nVersion: {{version}}\nCommit: {{commit}}\n\nLicense\n{{license}}\nThe bot's code is available under its license terms. Dependency licenses and copyright notices are included with the source offer.\n\nSource corresponding to this build\n{{url}}\nThe official repository is the project home; local modifications may appear only in this build's source.", + "version_info": "Running version: {{version}}\nCommit: {{commit}}", + "license_info": "Application license: {{license}}\nBuild source and license: {{url}}", + "source_unpublished": "The operator has not configured a corresponding-source URL for this build.", + "donate_checkout_error": "This invoice is unavailable or its details are invalid. Create a new donation invoice with /donate.", + "operator_recipients_channels": "Channels", + "operator_subscriber_stats": "Users: {{users}}\nGroups: {{groups}}\nChannels: {{channels}}\nBots: {{bots}}\nTotal: {{total}}", + "operator_message_prompt": "Send the message to draft. Formatting and media are preserved. Commands keep working while this editor is open.", + "operator_buttons_prompt": "Send buttons as JSON rows or as: Label - https://example.org - style:bg_success. Put && between buttons on one row; use a new line for another row. Supported actions: HTTPS URL, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "The draft is empty, too long, or contains invalid formatting or buttons. It was not saved.", + "operator_empty_recipients": "No registered recipients match this audience.", + "operator_complete": "Broadcast finished. Check its progress for delivery results.", + "operator_pause": "Pause", + "operator_resume": "Resume", + "operator_bc_starting": "{{operation}}: gathering recipientsโ€ฆ", + "operator_bc_counts": "Processed: {{processed}} / {{total}}\nSent: {{sent}}\nEdited: {{edited}}\nUnchanged: {{unchanged}}\nDeleted: {{deleted}}\nUnpinned: {{unpin}}\nFailed: {{failed}}\nFlood waits: {{flood}}\nPending: {{pending}}\nRate: {{tps}}/s", + "operator_bc_paused": "Paused", + "operator_bc_cancelled": "Cancelled", + "operator_bc_finished": "Finished", + "operator_bc_selfdestruct": "Scheduled deletion", + "operator_bc_legacy_summary": "This older report remains in the library's history. Check current broadcast results for the available counters.", + "operator_bc_edit": "Broadcast editing", + "moderation": "๐Ÿ”’ Group restrictions and cleanup", + "moderation_intro": "Choose restrictions applied while this group is closed. A selected item means the action is blocked. Existing restrictions are preserved; reopening restores the original snapshot.", + "moderation_permissions": "Choose blocked actions", + "moderation_visibility_warning": "Blocking message visibility can hide the group from members. It is excluded from the default selection.", + "moderation_default": "Restore default restrictions", + "moderation_none": "Select no additional restrictions", + "moderation_all": "Select all restrictions", + "moderation_cleanup_hint": "Cleanup applies only to received messages during an active Shabbat/festival closure. These options start off; each save requires confirmation. Bot-owned announcements are excluded.", + "moderation_bot_mode_hint": "Messages from other bots require this bot to be an administrator, Group Privacy Mode disabled, and Bot-to-Bot Communication Mode enabled in BotFather. Messages Telegram does not deliver cannot be deleted.", + "moderation_capabilities": "Bot permission check", + "moderation_delete_capability": "Verified permission to delete messages: {{state}}", + "field_lock_block_flags": "Additional actions blocked while closed", + "field_delete_holy_user_messages": "Delete members' messages during holy closure", + "field_delete_holy_bot_messages": "Delete other bots' messages during holy closure", + "field_delete_previous": "Delete the previous tefillin reminder after a new one is sent", + "announcement_schedule": "๐Ÿ•ฐ Shabbat/festival times announcement", + "announcement_schedule_intro": "Choose the announcement time in {{timezone}}. A preset sets local-time scheduling without enabling announcements. Use custom settings for another time or minutes before closure.", + "announcement_custom": "Custom time / before closure", + "template_kind_times_alert": "Shabbat/festival times announcement", + "permission_view_messages": "View messages", + "permission_send_messages": "Send messages", + "permission_send_media": "Send media", + "permission_send_stickers": "Send stickers", + "permission_send_gifs": "Send GIFs", + "permission_send_games": "Send games", + "permission_send_inline": "Send inline results", + "permission_embed_links": "Embed link previews", + "permission_send_polls": "Send polls", + "permission_change_info": "Change group information", + "permission_invite_users": "Invite users", + "permission_pin_messages": "Pin messages", + "permission_manage_topics": "Manage topics", + "permission_send_photos": "Send photos", + "permission_send_videos": "Send videos", + "permission_send_roundvideos": "Send video notes", + "permission_send_audios": "Send audio", + "permission_send_voices": "Send voice messages", + "permission_send_docs": "Send documents", + "permission_send_plain": "Send plain text", + "permission_edit_rank": "Edit member titles", + "permission_send_reactions": "Send reactions", + "bot_permissions": "๐Ÿ›ก Bot administrator rights", + "bot_permissions_intro": "Read-only Telegram permission diagnostics. Grant required rights through the group's administrator settings; the bot cannot grant rights to itself.", + "bot_role_creator": "Group owner", + "bot_role_administrator": "Group administrator", + "admin_right_change_info": "Change group information", + "admin_right_post_messages": "Post channel messages", + "admin_right_edit_messages": "Edit channel messages", + "admin_right_delete_messages": "Delete messages", + "admin_right_ban_users": "Restrict members", + "admin_right_invite_users": "Invite users", + "admin_right_pin_messages": "Pin messages", + "admin_right_add_admins": "Add administrators", + "admin_right_anonymous": "Remain anonymous", + "admin_right_manage_call": "Manage voice/video chats", + "admin_right_other": "Other administrator privileges", + "admin_right_manage_topics": "Manage topics", + "admin_right_post_stories": "Post stories", + "admin_right_edit_stories": "Edit stories", + "admin_right_delete_stories": "Delete stories", + "admin_right_manage_direct_messages": "Manage channel direct messages", + "admin_right_manage_ranks": "Manage member ranks", + "welcome": "โœก๏ธ Shabbat Guardian\nShabbat and holiday times, daily zmanim, Daf Yomi and the Omer, for your location.\n\nChoose what to view, or open your preferences to manage your language and location.\n\nGroup administrators can manage each group through โ€œMy groupsโ€.", + "help": "โ„น๏ธ Information and help", + "home": "๐Ÿ  Main menu", + "info_intro": "โ„น๏ธ Your guide to Shabbat Guardian\n\nYour times, in your language, for your location.\nChoose a topic for clear instructions and useful commands. Group administrators can also find automatic closing, reminders and message design here.", + "info_personal_title": "โš™๏ธ My preferences", + "info_personal_text": "/preferences - language, location, Israel or diaspora calendar, and candle-lighting and havdalah methods.\n/lang - choose from six supported languages.\nPrivate-chat times use your preferences. Personal changes do not change group settings.\n/start - return to the main menu.", + "info_calendar_title": "๐Ÿ•ฏ Times and Jewish calendar", + "info_calendar_text": "Your times\n/shabbat - upcoming Shabbat, continuous Yom Tov periods, Torah portion, Mevarchim and nearby observances in one message.\n/zmanim - daily times for your location; choose a date with buttons.\n/daf - Daf Yomi. /omer - seasonal Omer count.\n\nConsecutive holy days\nEvery candle lighting and the final exit are shown. Holiday start/end times are shown only for Yom Tov classified by Hebcal. Hanukkah, Chol Hamoed and other observances are not themselves Yom Tov.\n\nLocation and methods\nPersonal preferences control private views; every group has separate settings. Jerusalem defaults to 40 minutes before sunset for candles and can be customized. Times use the selected IANA timezone, including daylight-saving changes.\n\nMissing times are never guessed. Share sends a result to another chat.\n\nCalendar and times: Hebcal.com. The exit policy shown in the message is your selected setting, in degrees or minutes after sunset.", + "info_groups_title": "๐Ÿ‘ฅ Manage groups", + "info_groups_text": "Connect and open the panel\nAdd the bot as a group administrator, then send /settings there. Group administrators can also open it from My groups in private. Group command menus show only settings.\n\nWhat can you configure?\nLocation and language, Shabbat and holidays, daily times, Daf Yomi, tefillin, Omer, message design and permissions. Every group has independent settings.\n\nChoose a mode\nOff, messages only, locking only, or messages and locking. Under Shabbat and holidays choose days, timing methods and the times-announcement schedule.\n\nSaving requires current administrator permission. Review the summary before enabling; private previews do not send to the group.", + "info_notifications_title": "๐Ÿ”” Reminders and announcements", + "info_notifications_text": "Each reminder is independent\nChoose daily times, Daf Yomi, tefillin or Omer in the group panel, enable it explicitly and select local times. Buttons guide you without configuration keys or tokens.\n\nTefillin\nChoose weekdays, fixed times, intervals or minutes before sunset. Set quiet hours, a daily limit and deletion of the previous reminder. Shabbat, Yom Tov, Chol Hamoed and Tisha B'Av settings are respected.\n\nI put on tefillin\nThe reminder button counts unique users; another click cancels your choice. Administrators can rename or disable it.\n\nTimes announcement\nUnder Shabbat and holidays, choose publication time and message design. Previews stay private; a group test requires confirmation.", + "info_messages_title": "โœ‰๏ธ Design and permissions", + "info_messages_text": "Messages and design\nChoose closing, opening, Shabbat/holiday times, daily times, Daf Yomi, tefillin or Omer. Edit text, photo/media and buttons independently.\n\nNatural editing\nSend the message as you want it to appear. The bot preserves Telegram text and formatting, including bold, links and custom emoji. Review the preview and confirm Save.\n\nButtons\nAdd a link, choose a style, remove buttons or restore defaults. Times and Daf Yomi messages have a default share button. Changing only buttons preserves generated content.\n\nWhen custom emoji are unavailable, regular emoji are used. Group test messages require explicit confirmation.", + "info_privacy_title": "๐Ÿ›ก Privacy and safety", + "info_privacy_text": "Control over settings\nCity search and location sharing show a choice and preview; only confirmation saves changes. Personal preferences do not change groups. Current administrator rights are checked before management actions.\n\nStored data\nUser/group identifiers, preferences, rights and operation records needed for the service. The tefillin button records your choice for unique counting; another click cancels it.\n\nTesting and transparency\nPreviews remain private. Group tests require explicit confirmation. Do not send passwords or tokens.\n/source brings together the official repository, running version, license and corresponding build source.", + "info_operators_title": "๐Ÿ›  Bot administration", + "info_operators_text": "/admin - subscribers, statistics and broadcasts.\n/setcommands - command menus by language and chat scope; preview before publishing.\n/testzmanim - check time calculations and scheduling.\n/closegroups and /opengroups - manual actions for authorized groups.\n/restart - restart gracefully.\nThese controls are reserved for bot operators. Group administrator rights do not grant bot administration access.", + "callback_panel_unavailable": "This menu could not be updated. Open it again with /start or /settings.", + "provider_unavailable": "Verified time data is temporarily unavailable. Please try again in a few minutes.", + "operator_subscriber_stats_full": "๐Ÿ“Š Shabbat Bot subscribers\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค Users: {{users}}\n๐Ÿ‘ฅ Groups: {{chats}}\n๐Ÿ‘ฅ Supergroups: {{supergroups}}\n๐Ÿ“ข Channels: {{channels}}\n๐Ÿค– Bots: {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nTotal: {{total}}", + "operator_group_stats": "๐Ÿ”’ Group activity\nKnown groups: {{known}}\nRegistered for service: {{registered}}\nLocking enabled: {{locking}}\nCurrently locked by the bot: {{locked}}\nAwaiting recovery: {{recovery}}", + "times_section_schedule": "Start and end times", + "times_section_calendar": "Calendar details", + "times_stale": "โ„น๏ธ Showing saved data from the last successful check; the provider is currently unavailable.", + "times_day_name_0": "Sunday", + "times_day_name_1": "Monday", + "times_day_name_2": "Tuesday", + "times_day_name_3": "Wednesday", + "times_day_name_4": "Thursday", + "times_day_name_5": "Friday", + "times_day_name_6": "Saturday", + "times_heading": "๐Ÿ•ฏ Shabbat and festival times", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… Holy dates: {{dates}}", + "times_candles": "๐Ÿ•ฏ Candle lighting - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ End / Havdalah: {{time}}", + "times_close": "๐Ÿ”’ Group closes: {{time}}", + "times_open": "๐Ÿ”“ Group opens: {{time}}", + "times_sequence": "โ„น๏ธ Consecutive holy days: the group stays closed until the final opening time.", + "times_parasha": "๐Ÿ•ฏ Torah portion: {{title}}", + "times_mevarchim": "๐ŸŒ™ Shabbat Mevarchim: {{title}}", + "times_holiday": "โœก๏ธ Calendar event: {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "Candle lighting: {{minutes}} minutes before sunset\nEnd: {{method}} {{value}}", + "times_source": "Source: Hebcal ยท Updated: {{time}}", + "times_data_missing": "Some time data is currently unavailable.", + "location_menu_intro": "๐Ÿ“ Location for {{title}}\nCurrent location: {{location}}\nTimezone: {{timezone}}\nChoose how to find your location. You will review and confirm the details before saving.", + "location_input_city_title": "๐Ÿ”Ž Search city", + "location_input_city_prompt": "Enter a city name. You can search in English or other supported languages. Choose a result from the list.", + "location_input_timezone_title": "๐ŸŒ Timezone", + "location_input_timezone_prompt": "Enter a city name or timezone, such as Jerusalem or Asia/Jerusalem. Changing the timezone does not change the coordinates.", + "location_input_geoname_title": "๐Ÿ”ข GeoNames ID", + "location_input_geoname_prompt": "Enter the place's numeric GeoNames ID, such as 281184 for Jerusalem.", + "location_input_coordinates_title": "๐Ÿงญ Coordinates", + "location_input_coordinates_prompt": "Enter latitude and longitude, for example 31.76904, 35.21633. You can add a timezone, such as Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ Share location", + "location_input_gps_prompt": "Reply to this message with a Telegram location. You will review and confirm the location and timezone.", + "location_input_invalid": "The location could not be understood. Enter a city name, GeoNames ID, coordinates or timezone for the option you selected.", + "location_inline_hint": "You can also search in a message field: @{{bot}} followed by the city name.", + "location_input_cancelled": "Location selection was cancelled. Your settings have not changed.", + "daf_open_page": "๐Ÿ”— Read the page", + "timezone_inline_hint": "You can also search in a message field: @{{bot}} tz Jerusalem or @{{bot}} Asia/Jerusalem. To choose for the current settings, use the search button here.", + "donate_other": "Other", + "donate_custom_prompt": "โญ Choose your contribution\n\nSend a whole number from 1 to 10,000 Stars.\nYou will receive a payment link before confirming your contribution in Telegram.", + "donate_custom_invalid": "Enter a whole number from 1 to 10,000 Stars, without symbols or fractions.", + "donate_custom_ready": "โญ To contribute {{amount}} Stars, open the payment link and confirm in Telegram.", + "donate_custom_cancel": "โ†ฉ๏ธ Back to amounts", + "times_upcoming": "๐Ÿ•ฏ Upcoming Shabbat and holidays", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "Page {{page}} of {{pages}}", + "times_period_exit": "๐ŸŒ™ End of this period: {{time}}", + "times_no_events": "No further Shabbat or holiday events are available in the verified date range.", + "times_lighting_after": "Candle lighting on this evening is after the previous holy day ends; use a flame lit before the holiday began.", + "times_event_link": "{{title}} times", + "omer_nightfall_label": "Nightfall", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "Choose a setting to change. Each option shows its current value.", + "wizard_custom": "โœ๏ธ Custom value", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nCurrent: {{current}}\n\n{{hint}}", + "wizard_invalid": "That value does not fit this setting. Follow the example or return to the setting list.", + "wizard_done": "โœ… Review selection", + "wizard_more": "โš™๏ธ All settings", + "wizard_choose_option": "Choose an option. Review the change before saving.", + "wizard_template": "Use the message editor to customize text, formatting, media and buttons. The preview stays private.", + "wizard_hint_time": "Send a local time, for example 09:30.", + "wizard_hint_times": "Send one or more times separated by commas, for example 09:00, 13:30.", + "wizard_hint_integer": "Send a whole number. It will be checked against this setting's allowed range.", + "wizard_hint_float": "Send a number, for example 8.5 for degrees or 50 for fixed minutes.", + "wizard_hint_minutes_list": "Send one or more minute values separated by commas, for example 60, 30, 15.", + "wizard_hint_text": "Send the text you want to save.", + "wizard_hint_days": "Select weekdays, then choose โ€œReview selectionโ€.", + "template_edit_guided_prompt": "Send the new message here. You can attach media and format the text in Telegram. Review the preview before saving.", + "template_buttons_guided_prompt": "๐Ÿ”— Message buttons\n\nFor a simple link, choose โ€œAdd a link buttonโ€. We will ask for the label and address separately.\n\nYou can also send several buttons using this example:\nInformation - https://example.org - style:bg_primary\nSupport - https://example.org/support\n\nA new line starts another button row. Separate buttons on one row with &&. JSON is also supported. Review the preview before saving.", + "operator_audience_authorize": "โœ… Confirm broadcast audience", + "command_adminstats": "๐Ÿ“Š Administration statistics", + "command_subscribers": "๐Ÿ‘ฅ Subscribers and search", + "command_broadcast": "๐Ÿ“ฃ Broadcast a message", + "command_managers": "๐Ÿ‘ฅ Administrators and permissions", + "command_systemlinks": "โš™๏ธ System links", + "command_trackinglinks": "โ„น๏ธ Tracking links", + "times_upcoming_calendar": "๐Ÿ•ฏ Upcoming holidays and observances", + "times_sequence_calendar": "Continuous Shabbat and holiday period: each candle lighting is shown, with the final exit at the end.", + "template_default_buttons": "โ†ฉ๏ธ Restore default buttons", + "tefillin_ack": "I put on tefillin", + "field_ack_enabled": "Tefillin confirmation button", + "field_ack_label": "Tefillin confirmation label", + "field_tefillin_ack_enabled": "Tefillin confirmation button", + "field_tefillin_ack_label": "Tefillin confirmation label", + "template_add_link": "โž• Add a link button", + "template_link_text_prompt": "๐Ÿ”— New button\n\nSend a short button label, for example โ€œMore informationโ€.", + "template_link_url_prompt": "๐Ÿ”— Where should the button lead?\n\nSend a full address starting with https://, for example https://example.org.\nReview the button before saving.", + "template_save_review": "โœ… Save message", + "template_ack_preview": "This is a preview. Pressing here does not change the counter.", + "template_restore_text": "๐Ÿ“ Restore automatic text", + "template_remove_caption": "๐Ÿ—‘ Remove media caption", + "template_text_restored": "The automatic text was restored. Media and buttons were kept.", + "template_caption_removed": "The media caption was removed. Media and buttons were kept.", + "settings_mode_intro": "โš™๏ธ Operating mode for {{title}}\n\nOff - no automatic Shabbat or holiday messages or locking.\nMessages only - configured messages without changing group permissions.\nLock only - close and reopen at the configured times without Shabbat or holiday messages.\nMessages and lock - send messages and close/reopen the group.\n\nApplies to the Shabbat and Yom Tov days you enable.", + "settings_mode_button": "Mode: {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ Entry, exit and locking", + "settings_shabbat_messages": "โœ๏ธ Messages and design", + "settings_shabbat_timing_intro": "Choose the candle-lighting custom, end-time method and closing/opening offsets. All times use the group location. Consecutive Shabbat and Yom Tov days stay locked until the entire period ends.", + "settings_shabbat_messages_intro": "Choose closing/opening messages, quiet hours and the group topic. Edit each message's text, formatting, media and buttons separately.", + "settings_shabbat_summary": "โœก๏ธ Shabbat and holidays - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nMode: {{mode}}\nShabbat: {{shabbat}} ยท Yom Tov: {{holidays}}\n\n๐Ÿ•ฏ๏ธ Entry and exit\nCandle lighting: {{candles}} minutes before sunset\nEnd time: {{havdalah}}\nLock: {{close}} minutes before candle lighting\nReopen: {{open}} minutes after the end\n\nChoose a section below. Confirmed changes return you to the same section.", + "template_current_summary": "Current design\nText: {{text}}\nMedia: {{media}}\nButtons: {{buttons}}\n\nEdit content or buttons, review the preview, then save. A test message to the group requires separate confirmation.", + "template_text_default": "Automatic message", + "template_text_custom": "Custom text", + "template_buttons_inherit": "Bot defaults", + "template_buttons_custom": "Custom buttons", + "template_buttons_none": "No buttons", + "template_panel_hint": "Choose a message type. Each message supports formatted text, media and buttons. Changes apply only to this group.", + "times_period_enter": "๐Ÿ•ฏ Start / candle lighting: {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ Following Shabbat dates", + "times_yomtov_unavailable": "Verified start and end times for this Yom Tov are not yet available.", + "info_permissions_title": "๐Ÿ›ก Permissions, closing and opening", + "info_permissions_text": "Which groups are supported?\nTelegram allows bots to change default permissions in basic groups and supergroups. The bot must be an administrator with permission to restrict members. Default restrictions apply to ordinary members; administrators may still write.\n\nBefore enabling\nChoose the city, timezone and Israel or diaspora calendar. Under Shabbat and holidays, choose locking only or messages and locking. Review candle lighting, opening and closing offsets, and the bot's permissions.\n\nWhen the group opens\nThe bot saves permissions before closing and restores them afterwards. Consecutive Shabbat and Yom Tov days stay closed until the final configured exit. External permission changes or missing rights may require administrator review.\n\nDeleting messages\nCleanup while closed requires delete-message permission. Cleanup of other bots' messages is a separate option and also depends on which updates Telegram delivers.", + "template_buttons_simple_prompt": "Add a link button in a few steps: choose its label, then an address starting with https://. Review the preview before saving.\n\nFor other button actions or several rows at once, open Advanced options.", + "template_advanced_buttons": "โš™๏ธ Advanced options", + "operator_manual_details": "Details by group", + "operator_manual_pending": "Waiting: {{count}}", + "operator_manual_more": "And {{count}} more groups", + "permission_error_admin": "Give the bot administrator permission to restrict members.", + "permission_error_access": "The bot is not a member or cannot access this group.", + "permission_error_flags": "The selected restrictions are invalid. Reset them in Shabbat and holidays settings.", + "permission_error_snapshot": "The original permissions cannot be verified. Administrator review is required before opening.", + "permission_error_conflict": "Current permissions differ from the saved state. Automatic changes were stopped for administrator review.", + "permission_error_provider": "The calendar provider is unavailable.", + "permission_error_unknown": "The operation was not confirmed. Check the group diagnostic log.", + "permission_status_wait": "Waiting to retry after the Telegram limit", + "permission_status_busy": "Another operation for this group is in progress", + "permission_status_prelocked": "The group was locked outside the bot and will not be opened automatically", + "permission_status_idle": "There is no bot-owned lock to open", + "permission_status_unchanged": "The group is already in the requested state", + "restart_requested": "โ™ป๏ธ Starting a graceful restart. The bot will return after starting again.", + "permission_conflict_fields": "Differences: {{fields}}.", + "permission_until_date": "Permission expiry", + "permission_unknown": "Unrecognized permission", + "times_shabbat_title": "Shabbat", + "permission_recovery": "๐Ÿ›  Resolve permission issue", + "recovery_title": "๐Ÿ›  Resolve permission issue\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "The bot saves normal permissions before locking and restores them when opening. Review an unfinished attempt here and choose how to continue.", + "recovery_compare": "Comparison with the saved permissions snapshot", + "recovery_difference": "โ€ข {{permission}}: now {{current}} ยท saved {{saved}}", + "recovery_more": "{{count}} more differences. The action covers every permission, including those not displayed here.", + "recovery_allowed": "allowed", + "recovery_blocked": "blocked", + "recovery_no_differences": "No differences from the saved original permissions.", + "recovery_adopt": "โœ… Accept current permissions", + "recovery_restore": "โ†ฉ๏ธ Restore saved permissions", + "recovery_preview_adopt": "Accept current permissions\nThe group's current permissions will become its normal state. This action does not change group permissions. It closes the old lock attempt; the next lock saves the latest state.", + "recovery_preview_restore": "Restore saved permissions\nThe bot will request every group permission saved before the issue, undoing later changes. Restoration succeeds only after verification with Telegram.", + "recovery_confirm_adopt": "โœ… Confirm current permissions", + "recovery_confirm_restore": "โœ… Confirm restoration", + "recovery_preview_notice": "Check the group name and your choice before confirming. Confirmation expires in five minutes. Permissions and state are checked again; manual recovery is unavailable during an active lock, Shabbat or festival.", + "recovery_result_adopted": "โœ… Current permissions accepted as normal. The old attempt is closed.", + "recovery_result_restored": "โœ… Saved permissions restored and verified. The old attempt is closed.", + "recovery_block_active_lock": "๐Ÿ”’ A safe recovery window cannot currently be verified: a lock, Shabbat or festival may be active, or calendar data may be unavailable. You can inspect and refresh; manual changes require confirmation that no protected period is active.", + "recovery_block_no_problem": "โœ… No permission issue is awaiting recovery. The next lock will save the group's latest permissions.", + "recovery_block_missing_snapshot": "โš ๏ธ No verified original snapshot can be restored. If accepting current permissions is offered, first check that they are the normal permissions you want.", + "recovery_block_locked_current": "๐Ÿ”’ The group currently appears locked. This cannot be accepted as its normal state. Review restoration or contact the bot operator.", + "recovery_block_unknown": "โš ๏ธ Safe recovery cannot be verified right now. Refresh; if this continues, contact the bot operator.", + "recovery_result_stale": "โŒ› Confirmation expired or the state changed. Review the current state and create a new confirmation.", + "recovery_result_forbidden": "Current administrator permission for this group is required.", + "recovery_result_busy": "โณ Another group operation is running. Refresh shortly.", + "recovery_result_conflict": "โš ๏ธ Telegram returned an unexpected state. The original information is preserved; review the comparison before trying again.", + "recovery_result_error": "Recovery could not be completed or verified. Saved information was not deleted. Refresh before trying again.", + "recovery_result_retry_wait": "โณ Telegram requested a wait. Avoid repeated clicks; refresh later.", + "recovery_refresh": "๐Ÿ”„ Refresh status", + "recovery_cancelled": "Choice cancelled. No manual recovery was performed.", + "recovery_state_problem": "โš ๏ธ A lock attempt is unfinished. Review the differences and choose the intended state. Each option opens a separate confirmation before execution.", + "diagnostics_title": "๐Ÿงช Calendar and permissions diagnostics", + "diagnostics_read_only": "Read only. Snapshot valid for 15 minutes; refresh for current data.", + "diagnostics_context": "Context", + "diagnostics_build": "Running build", + "diagnostics_location": "Location and time zone", + "diagnostics_methods": "Candle minutes / exit method and value", + "diagnostics_lock_now": "Lock required now", + "diagnostics_quiet_now": "Shabbat/festival or active lock period", + "diagnostics_stale": "Stale provider data", + "diagnostics_evaluated_at": "Evaluation time", + "diagnostics_fetched_at": "Data fetched at", + "diagnostics_period": "Close and open window", + "diagnostics_provider_error": "Calendar provider error", + "diagnostics_queue": "Job queue", + "diagnostics_group": "Group", + "diagnostics_limited": "Groups shown / total. Another group: /testzmanim and its ID", + "diagnostics_lock_state": "Saved lock state", + "diagnostics_original": "Saved normal permissions: active restrictions", + "diagnostics_requested": "Requested permissions: active restrictions", + "diagnostics_current": "Current Telegram permissions: active restrictions", + "diagnostics_compare_original": "Current vs saved normal permissions", + "diagnostics_compare_requested": "Current vs requested permissions", + "diagnostics_all": "All", + "diagnostics_problems": "โš ๏ธ Problems and warnings", + "diagnostics_summary": "Summary", + "diagnostics_calendar": "Calendar", + "diagnostics_permissions": "Permissions", + "diagnostics_groups": "๐Ÿ‘ฅ Groups", + "diagnostics_busy": "Processing or recently completed. Refresh every 30 seconds; export once a minute.", + "diagnostics_unavailable": "Diagnostics unavailable, expired, or access denied. Retry /testzmanim privately in 30 seconds.", + "diagnostics_export_caption": "๐Ÿ“„ Calendar and permissions diagnostics. Read-only snapshot.", + "diagnostics_export_sent": "JSON file sent.", + "diagnostics_previous": "โ—€๏ธ Previous", + "diagnostics_next": "Next โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž Search", + "diagnostics_filters": "๐Ÿ”ฝ Filter", + "diagnostics_refresh": "๐Ÿ”„ Refresh", + "diagnostics_clear": "Clear search and filters", + "diagnostics_export": "๐Ÿ“„ Export JSON", + "diagnostics_back": "Back to administration", + "diagnostics_cancel": "Cancel", + "diagnostics_empty": "No results match this search and filter.", + "diagnostics_page_prompt": "Send a page number from 1 to {{pages}}. The existing panel will update.", + "diagnostics_search_prompt": "Send a word, date or group ID to search the saved snapshot (up to 80 characters).", + "diagnostics_invalid_page": "Invalid page number. Choose a number within the displayed range.", + "diagnostics_is_work_prohibited_now": "Work prohibited now", + "diagnostics_is_shabbat": "Shabbat now", + "diagnostics_is_yom_tov": "Yom Tov now", + "diagnostics_next_close_at": "Next closure", + "diagnostics_next_open_at": "Next opening", + "diagnostics_baseline": "Recovered normal permissions: active restrictions", + "diagnostics_compare_previous": "Current vs previous intermediate permissions", + "diagnostics_compare_baseline": "Current vs recovery baseline", + "diagnostics_concurrent_change": "Saved permissions changed during the check. Refresh before comparing.", + "diagnostics_operation": "Latest operation: action / stage / outcome", + "diagnostics_previous_rights": "Previous intermediate permissions: active restrictions", + "diagnostics_invalid_search": "Search is too long. Enter up to 80 characters.", + "recovery_block_unverified_snapshot": "โš ๏ธ The saved permissions come from an older version and were not verified by the new reader. They may differ from the former group settings, so restoring them is disabled.\nOutside Shabbat, Yom Tov or an active lock: set the intended group permissions in Telegram, choose Refresh here, review them, then confirm Use current permissions. The older snapshot remains available for diagnosis.", + "permission_error_unverified": "The snapshot from the older version needs administrator review. Open Resolve permission problem in group settings; do not restore the older state from it.", + "diagnostics_snapshot_verification": "Permission snapshot verification", + "diagnostics_snapshot_verified": "Saved after a verified read", + "diagnostics_snapshot_unverified": "Older-version snapshot - administrator review required", + "permission_error_readback": "โš ๏ธ The permission update was sent, but its result was not verified. Permissions may already have changed. Review them in Telegram and Resolve permission problem before retrying." +} diff --git a/resources/locales/es.json b/resources/locales/es.json new file mode 100644 index 0000000..ac1c03f --- /dev/null +++ b/resources/locales/es.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ Shabat y fiestas", + "zmanim": "๐Ÿ•’ Horarios diarios", + "daf": "๐Ÿ“– Daf Yomi", + "omer": "๐ŸŒพ Cuenta del ร“mer", + "tefillin": "๐Ÿ”” Recordatorios de tefilรญn", + "add": "โž• Aรฑadir el bot a un grupo", + "groups": "๐Ÿ‘ฅ Mis grupos", + "preferences": "โš™๏ธ Mis preferencias", + "donate": "โญ Apoyar al bot", + "source": "๐Ÿ’ป Cรณdigo fuente y licencia", + "back": "โ†ฉ๏ธ Volver", + "cancel": "Cancelar", + "confirm": "โœ… Confirmar el guardado", + "language": "๐ŸŒ Idioma", + "location": "๐Ÿ“ Ubicaciรณn y zona horaria", + "candles": "๐Ÿ•ฏ Encendido de velas", + "havdalah": "๐ŸŒ™ Havdalรก", + "messages": "โœ‰๏ธ Mensajes y diseรฑo", + "schedule": "๐Ÿ—“ Prรณximas acciones", + "status": "๐Ÿ›  Estado e incidencias", + "preview": "๐Ÿ‘€ Vista previa privada", + "reset": "Restablecer este mรณdulo", + "edit": "โœ๏ธ Editar la configuraciรณn", + "on": "Activado", + "off": "Desactivado", + "today": "Hoy", + "tomorrow": "Maรฑana", + "other_date": "Otra fecha", + "share": "Compartir", + "short": "Breve", + "full": "Completo", + "next": "Pรกgina siguiente", + "previous": "Pรกgina anterior", + "no_groups": "No se encontraron grupos registrados que administres actualmente. En un grupo ya registrado, usa /settings.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nModo: {{mode}}\nPrรณxima acciรณn: {{next}}\n\nElige una secciรณn para configurar este grupo. Revisa y confirma cada cambio antes de guardarlo.", + "personal_panel": "โš™๏ธ Mis preferencias\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nElige tu idioma, ubicaciรณn y mรฉtodos de cรกlculo. Estas preferencias determinan tus horarios personales.", + "denied": "Esta acciรณn ya no estรก disponible: caducรณ la autorizaciรณn o el contexto de la configuraciรณn. Vuelve a abrir el panel del grupo.", + "saved": "โœ… Guardado para {{title}}. Se recalcularon las acciones futuras; se conserva la restauraciรณn de permisos.", + "language_choose": "Elige el idioma de este รกmbito. El panel privado conserva tu idioma personal.", + "candle_choose": "Elige los minutos reales antes de la puesta del sol o la costumbre documentada de la ciudad. No se aรฑade ningรบn minuto adicional oculto.", + "city_custom": "Costumbre de la ciudad", + "havdalah_choose": "Elige grados o minutos fijos despuรฉs de la puesta del sol. 72 minutos significa 72 minutos fijos; el mรฉtodo y el valor se guardan por separado.", + "degrees": "8,5 grados", + "minutes": "{{value}} minutos fijos", + "location_choose": "Busca una ciudad, selecciona un resultado, revisa la zona horaria y confirma para el รกmbito indicado. Buscar o compartir nunca guarda la configuraciรณn.", + "inline_search": "Buscar una ciudad en modo inline", + "city_search": "Buscar en privado", + "coordinates": "Coordenadas / ubicaciรณn compartida", + "timezone": "Zona horaria manual", + "calendar_israel": "Calendario de Israel", + "calendar_diaspora": "Calendario de la diรกspora", + "copy_personal": "Copiar mis preferencias (vista previa)", + "geonames_missing": "La bรบsqueda GeoNames estรก desactivada. El operador debe configurar GEONAMES_USERNAME y activar los servicios web. Usa una ciudad habitual o las coordenadas indicadas abajo.", + "city_prompt": "Envรญa /city {{token}} nombre de ciudad. Usa un idioma admitido por el proveedor. Este token pertenece solo a este proceso de configuraciรณn.", + "coordinates_prompt": "Envรญa /coordinates {{token}} latitud longitud zona-IANA nombre. Ejemplo: /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem Jerusalรฉn. Para compartir el GPS, envรญa /location {{token}} zona-IANA y responde a la solicitud del bot con una ubicaciรณn de Telegram.\nUsa auto en lugar de la zona si GeoNames estรก configurado; la zona IANA propuesta siempre se muestra antes de guardar.", + "timezone_prompt": "Envรญa /timezone {{token}} texto de bรบsqueda para buscar identificadores IANA. La selecciรณn requiere confirmaciรณn y conserva las coordenadas.", + "location_preview": "Vista previa de ubicaciรณn para {{title}}\nCiudad: {{location}}\nPaรญs: {{country}}\nRegiรณn: {{region}}\nLatitud: {{latitude}}\nLongitud: {{longitude}}\nID GeoNames: {{geoname}}\nZona IANA: {{timezone}}\nDesfase UTC actual: {{offset}} (solo visualizaciรณn)\n{{warning}}", + "timezone_warning": "Una zona elegida manualmente puede diferir de la zona geogrรกfica. Las coordenadas no han cambiado.", + "no_results": "No se encontraron ciudades para esta bรบsqueda.", + "search_error": "Bรบsqueda de ubicaciรณn no disponible: {{error}}", + "select_location": "Revisa esta ubicaciรณn; la configuraciรณn se guarda solo despuรฉs de tu confirmaciรณn privada.", + "select_private": "Continuar en el chat privado del bot", + "public_location": "Informaciรณn pรบblica de un lugar. Compartir este resultado nunca cambia la configuraciรณn personal ni la de un grupo.", + "provider_error": "Los horarios verificados no estรกn disponibles: {{error}}. No se inventรณ ningรบn horario.", + "unavailable": "No disponible para esta ubicaciรณn o fecha", + "shabbat_view": "Shabat y fiestas\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nEncendido de velas: {{candles}}\nCierre del grupo: {{close}}\nHavdalรก: {{havdalah}}\nApertura del grupo: {{open}}\nEvento actual: {{current}}\nPrรณximo evento: {{next}}\nCalendario: {{calendar}}\nVelas: {{candle_minutes}} minutos antes de la puesta del sol. Havdalรก: {{value}} {{method}}.\nFuente: Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ Cuenta del ร“mer\n\nLa cuenta del ร“mer estรก fuera de temporada en esta fecha.", + "help_text": "Comandos\n/start - menรบ principal\n/shabat, /shabbat - horarios de Shabat\n/zmanim [YYYY-MM-DD] - horarios diarios\n/daf [YYYY-MM-DD] - Daf Yomi\n/omer - cuenta del ร“mer\n/groups - grupos que administras\n/settings - panel privado de un grupo registrado (administradores del grupo)\n/preferences - configuraciรณn personal\n/language, /lang - selecciรณn del idioma\n/source, /license, /version - informaciรณn de la versiรณn en ejecuciรณn\n/donate - apoyo con Telegram Stars\n\nInline: shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer y loc ciudad. Cambiar la configuraciรณn de un grupo requiere permisos de administraciรณn actuales. La difusiรณn y los controles del operador siguen reservados a los administradores del sistema.", + "mode": "Modo de funcionamiento", + "mode_off": "Desactivado", + "mode_messages": "Solo mensajes", + "mode_lock": "Solo bloqueo", + "mode_lock_messages": "Bloqueo y mensajes", + "shabbat_enabled": "Actividad de Shabat", + "holidays_enabled": "Actividad de las fiestas", + "module_panel": "{{module}} para {{title}}\n{{settings}}\nLos mรณdulos nuevos de notificaciรณn permanecen desactivados hasta que se activan explรญcitamente.", + "edit_prompt": "Editar {{module}} para {{title}}. Envรญa /configure {{token}} seguido de pares campo=valor separados por punto y coma. Campos admitidos y valores actuales:\n{{fields}}\nSepara los elementos de las listas con comas. Revisa y confirma la configuraciรณn propuesta antes de guardarla.", + "changes_preview": "Vista previa de configuraciรณn para {{title}}\n{{changes}}", + "date_prompt": "Envรญa /viewdate {{token}} YYYY-MM-DD. La fecha es explรญcita y usa la zona horaria de este รกmbito.", + "schedule_view": "Prรณximas acciones para {{title}}\n{{content}}\nLa restauraciรณn de permisos tiene prioridad sobre los mensajes habituales.", + "status_view": "Estado de {{title}}\nReloj UTC: {{utc}}\nReloj local: {{local}}\nCobertura de la cachรฉ: {{coverage}}\nรšltima consulta correcta al proveedor: {{fetched}}\nEvento actual: {{current}}\nPrรณximo evento: {{next}}\nIncidencia: {{error}}", + "reset_preview": "ยฟRestablecer solo {{module}} para {{title}}? La ubicaciรณn, el idioma y los demรกs mรณdulos no cambian.", + "suspend": "Pausar los tefilรญn por hoy", + "not_private": "Abre el panel privado del grupo para cambiar la configuraciรณn.", + "date_invalid": "Usa una fecha vรกlida con formato YYYY-MM-DD.", + "hours_invalid": "Usa horas locales HH:mm y opciones admitidas.", + "location_label": "Ubicaciรณn", + "alotHaShachar": "Alba", + "misheyakir": "Primera hora de talit y tefilรญn", + "sunrise": "Salida del sol", + "sofZmanShma": "รšltima hora del Shemรก (GRA)", + "sofZmanShmaMGA": "รšltima hora del Shemรก (MGA)", + "sofZmanTfilla": "รšltima hora de la oraciรณn (GRA)", + "sofZmanTfillaMGA": "รšltima hora de la oraciรณn (MGA)", + "chatzot": "Mediodรญa", + "minchaGedola": "Primera hora de Minjรก", + "minchaKetana": "Minjรก ketanรก", + "plagHaMincha": "Plag haminjรก", + "sunset": "Puesta del sol", + "tzeit85deg": "Anochecer (8,5ยฐ)", + "tzeit42min": "Anochecer (42 minutos fijos)", + "tzeit50min": "Anochecer (50 minutos fijos)", + "tzeit72min": "Anochecer (72 minutos fijos)", + "chatzotNight": "Medianoche", + "settings_search": "Filtrar grupos: /groups texto de bรบsqueda", + "field_language": "Idioma", + "field_candle_minutes": "Minutos para las velas (city = costumbre de la ciudad)", + "field_havdalah_method": "Mรฉtodo de havdalรก", + "field_havdalah_value": "Valor de havdalรก", + "field_close_before_candles_minutes": "Cerrar antes de las velas (minutos)", + "field_open_after_havdalah_minutes": "Abrir despuรฉs de havdalรก (minutos)", + "field_target_topic_id": "ID del tema del foro (off para desactivar)", + "field_enabled": "Activaciรณn", + "field_time": "Hora local", + "field_days": "Dรญas: 0 domingo a 6 sรกbado", + "field_format": "Formato", + "field_template": "Texto de la plantilla", + "field_fixed_times": "Horas fijas", + "field_interval_minutes": "Intervalo en minutos (off para desactivar, mรญnimo 5)", + "field_window_start": "Inicio de la franja local", + "field_window_end": "Fin de la franja local", + "field_sunset_offsets": "Minutos antes de la puesta del sol", + "field_max_per_day": "Mรกximo diario (1โ€“24)", + "field_min_spacing_minutes": "Separaciรณn mรญnima en minutos (5 o mรกs)", + "field_quiet_start": "Inicio del silencio (off para desactivar)", + "field_quiet_end": "Fin del silencio (off para desactivar)", + "field_chol_hamoed": "Recordatorios en Jol Hamoed", + "field_tisha_bav_policy": "Tishรก BeAv", + "field_sunset_safety_minutes": "Margen de seguridad antes de la puesta del sol", + "field_mode": "Horario", + "field_after_nightfall_minutes": "Minutos despuรฉs del anochecer", + "field_nightfall_method": "Mรฉtodo del anochecer", + "field_nightfall_value": "Valor del anochecer", + "field_nusach": "Nรบsaj", + "field_day_completion": "Completar de dรญa", + "field_early_policy": "Demasiado temprano", + "field_after_open_minutes": "Minutos de silencio tras abrir el grupo", + "field_times_alert_enabled": "Anuncio de horarios", + "field_times_alert_time": "Hora local del anuncio", + "field_times_alert_mode": "Horario del anuncio", + "field_times_alert_before_minutes": "Minutos del anuncio antes del cierre", + "field_close_message_enabled": "Mensaje de cierre", + "field_open_message_enabled": "Mensaje de apertura", + "method_degrees": "grados", + "method_minutes": "minutos fijos tras la puesta del sol", + "window_end": "Fin de la franja", + "no_reminders": "No hay recordatorios vรกlidos para este dรญa y esta configuraciรณn.", + "preview_only": "Solo vista previa privada; no se envรญa ningรบn mensaje al grupo.", + "template_panel": "{{title}} - Mensajes y diseรฑo", + "template_menu": "{{title}} - {{kind}}\nLa vista previa privada nunca envรญa mensajes al grupo.", + "template_kind_close": "Mensaje de cierre", + "template_kind_open": "Mensaje de apertura", + "template_kind_zmanim": "Mensaje de horarios diarios", + "template_kind_daf": "Mensaje de Daf Yomi", + "template_kind_tefillin": "Mensaje de tefilรญn", + "template_kind_omer": "Mensaje del ร“mer", + "template_edit": "โœ๏ธ Editar texto / contenido multimedia", + "template_buttons": "โŒจ๏ธ Editar botones", + "template_preview": "๐Ÿ‘€ Vista previa privada", + "template_reset": "Restablecer este mensaje", + "template_test": "Enviar una PRUEBA al grupo (confirmaciรณn)", + "template_edit_prompt": "Envรญa texto o contenido multimedia con este prefijo en el pie, seguido de una nueva lรญnea y el contenido:\n/template {{token}}\nSe conservan las entidades del texto, el contenido multimedia y los botones existentes.", + "template_buttons_prompt": "Envรญa /buttons {{token}}, seguido de una nueva lรญnea y una matriz JSON de filas de botones. Cada botรณn incluye texto y una acciรณn: URL HTTPS, callback_data o cambio al modo inline. Estilos opcionales: primary, success, danger.\nTambiรฉn funciona el formato de texto original: Etiqueta - https://example.org - style:bg_success - icon:123. Usa && para varios botones en una fila o una nueva lรญnea. Otras acciones: alert:texto, popup:texto, copy:texto. Los datos del callback tienen un lรญmite de 64 bytes.", + "template_cleared": "Se restableciรณ el mensaje. Vuelve a abrir el panel.", + "template_confirm_test": "โœ… Confirmar la PRUEBA", + "template_test_confirm": "Confirmaciรณn explรญcita: se enviarรก un mensaje de PRUEBA a {{title}}. Esta acciรณn es distinta de la vista previa privada.", + "template_test_wait": "Espera cinco minutos antes de otra prueba en este grupo.", + "template_cancelled": "Se cancelรณ la prueba; no se enviรณ ningรบn mensaje al grupo.", + "template_saved": "Se guardรณ el mensaje. Vuelve a abrir el panel para ver una vista previa privada.", + "template_remove_text": "๐Ÿ—‘ Quitar solo el texto", + "template_remove_media": "๐Ÿ—‘ Quitar solo el contenido multimedia", + "template_remove_buttons": "๐Ÿ—‘ Quitar solo los botones", + "gps_prompt": "Responde a este mensaje exacto con una ubicaciรณn de Telegram para {{title}}. La vista previa usarรก la zona {{timezone}}. Una ubicaciรณn sin esta respuesta nunca selecciona un proceso de configuraciรณn.", + "gps_reply_required": "Inicia el uso compartido del GPS con /location y el token; despuรฉs responde a la solicitud del bot con tu ubicaciรณn. No se selecciona ningรบn grupo automรกticamente.", + "location_attribution": "Datos de ubicaciรณn: GeoNames (CC-BY-4.0).", + "timezone_auto": "Detectar la zona de la ubicaciรณn con GeoNames", + "auto_timezone_missing": "La detecciรณn automรกtica de zona estรก desactivada porque GeoNames no estรก configurado. Usa un identificador IANA explรญcito, como Asia/Jerusalem, en lugar de auto.", + "command_start": "๐Ÿ  Abrir el menรบ principal", + "command_preferences": "โš™๏ธ Tus preferencias personales", + "command_lang": "๐ŸŒ Elegir tu idioma", + "command_groups": "๐Ÿ‘ฅ Grupos que administras", + "command_help": "โ„น๏ธ Comandos y ayuda", + "command_shabbat": "๐Ÿ•ฏ Horarios de Shabat y fiestas", + "command_zmanim": "๐Ÿ•’ Horarios halรกjicos diarios", + "command_daf": "๐Ÿ“– Daf Yomi de hoy", + "command_omer": "๐ŸŒพ Contar el ร“mer", + "command_source": "๐Ÿ’ป Cรณdigo fuente correspondiente y licencia", + "command_license": "๐Ÿ“œ Licencia del proyecto", + "command_version": "๐Ÿท Versiรณn y commit en ejecuciรณn", + "command_donate": "โญ Apoyar con Telegram Stars", + "command_stats": "๐Ÿ“Š Estadรญsticas del servicio", + "command_add": "โž• Registrar este grupo", + "command_remove": "โž– Retirar este grupo de forma segura", + "command_settings": "โš™๏ธ Configuraciรณn del grupo en privado", + "command_admin": "๐Ÿ›  Panel de administraciรณn del sistema", + "command_testzmanim": "๐Ÿ”Ž Diagnรณstico de horarios y planificaciรณn", + "command_closegroups": "๐Ÿ”’ Cierre manual autorizado de grupos", + "command_opengroups": "๐Ÿ”“ Restauraciรณn autorizada de permisos", + "command_setcommands": "๐Ÿ“‹ Publicar menรบs segรบn el รกmbito", + "command_restart": "๐Ÿ”„ Reiniciar el bot de forma ordenada", + "donate_title": "Apoya a Shabbat Bot", + "donate_description": "Apoyo voluntario: {{amount}} Telegram Stars. ยกGracias!", + "donate_prompt": "Elige un importe de apoyo รบnico en Telegram Stars.", + "donate_invoice_error": "No se pudo crear la factura de donaciรณn. Vuelve a intentarlo con /donate.", + "donate_partial_error": "Algunos importes de apoyo no estรกn disponibles por ahora. Puedes usar una factura creada correctamente.", + "donate_thanks": "ยกGracias por tu donaciรณn de {{amount}} Telegram Stars!", + "donate_received_admin": "Donaciรณn confirmada del usuario {{user_id}}: {{amount}} Telegram Stars.", + "donate_unverified_payment": "No se pudo verificar este pago. Se seรฑalรณ para revisiรณn del operador; no se anuncia una confirmaciรณn.", + "commands_setup_help": "Los menรบs de comandos se publican por separado para chats privados, administradores de grupos y operadores del sistema, en cada idioma admitido. Telegram usa pt para el portuguรฉs de Brasil.", + "commands_setup_result": "Actualizaciones de menรบs: {{total}} solicitudes; {{failed}} fallidas.", + "commands_setup_preview": "Vista previa: se actualizarรกn {{total}} menรบs por รกmbito e idioma.", + "commands_forbidden": "Solo un operador del sistema puede publicar los menรบs de comandos.", + "operator_panel": "๐Ÿ›  Panel del operador del sistema", + "operator_subscribers": "๐Ÿ‘ฅ Suscriptores", + "operator_statistics": "๐Ÿ“Š Estadรญsticas del servicio", + "operator_broadcast": "๐Ÿ“ฃ Difusiรณn", + "operator_preview": "๐Ÿ‘€ Vista previa privada", + "operator_send": "Enviar la difusiรณn", + "operator_confirm_send": "Confirmar el envรญo de esta difusiรณn a {{count}} destinatarios.", + "operator_recipients_private": "Suscriptores en chats privados", + "operator_recipients_groups": "Grupos", + "operator_recipients_all": "Todos los suscriptores", + "operator_pin": "Fijar el mensaje", + "operator_unpin": "Desfijar los mensajes", + "operator_delete_last": "Eliminar la รบltima difusiรณn", + "operator_delete_all": "Eliminar todas las difusiones", + "operator_progress": "Progreso de la difusiรณn", + "operator_cancel": "Cancelar", + "operator_buttons": "Editar los botones", + "operator_edit_text": "Editar el texto", + "operator_edit_media": "Editar el contenido multimedia", + "operator_clear_draft": "Borrar el borrador", + "operator_active_broadcast": "Ya hay una difusiรณn en curso. Espera o cancรฉlala antes de iniciar otra.", + "operator_draft_saved": "Se guardรณ el borrador de la difusiรณn.", + "operator_no_draft": "No hay ningรบn borrador de difusiรณn disponible.", + "operator_page": "Pรกgina {{page}} de {{total}}", + "operator_manual_result": "Acciรณn {{action}}: cambios verificados en {{changed}} grupos, {{failed}} fallidos o sin verificar. Hasta: {{until}}.", + "operator_manual_lock": "Cierre manual de grupos", + "operator_manual_open": "Restaurar los permisos de los grupos", + "group_new_wizard": "Este grupo nuevo empieza con una ubicaciรณn predeterminada visible. Revisa su ubicaciรณn, zona y calendario; despuรฉs elige explรญcitamente un modo de funcionamiento.", + "permission_required": "Se requieren permisos de administraciรณn actuales para este grupo.", + "group_removed": "El grupo se retirรณ del servicio despuรฉs de gestionar sus permisos de forma segura.", + "omer_blessing_eligibility": "La bendiciรณn es para quienes pueden recitarla. El bot no puede determinar si una persona puede hacerlo a partir del historial de mensajes del grupo.", + "omer_day_completion_label": "Completar de dรญa - sin bendiciรณn.", + "omer_translation_note": "Texto ritual en hebreo; la siguiente cuenta es una traducciรณn explicativa.", + "group_close_notice": "Ha comenzado el perรญodo de silencio de Shabat o de la festividad.", + "group_open_notice": "Ha terminado el perรญodo de silencio de Shabat o de la festividad.", + "tefillin_reminder": "ยฟYa te has puesto los tefilรญn hoy?\nEn la ubicaciรณn del grupo {{location}}, la puesta del sol es a las {{sunset}}.", + "no_event": "No hay ningรบn evento en el perรญodo de datos verificados.", + "stats_counts": "Grupos conocidos: {{known}}\nRegistrados en el servicio: {{registered}}\nCon bloqueo activado: {{locking}}\nBloqueados actualmente por el bot: {{locked}}\nPendientes de restauraciรณn: {{recovery}}", + "source_info": "๐Ÿ’ป Shabbat Guardian - cรณdigo, versiรณn y licencia\n\nRepositorio oficial\nWizardLoop/shabbat en GitHub\n\nCompilaciรณn activa\nVersiรณn: {{version}}\nCommit: {{commit}}\n\nLicencia\n{{license}}\nEl cรณdigo se ofrece segรบn su licencia. Las licencias de dependencias y avisos de derechos se incluyen en la oferta de cรณdigo fuente.\n\nCรณdigo correspondiente a esta compilaciรณn\n{{url}}\nEl repositorio oficial es la pรกgina del proyecto; los cambios locales pueden estar solo en el cรณdigo de esta compilaciรณn.", + "version_info": "Versiรณn en ejecuciรณn: {{version}}\nCommit: {{commit}}", + "license_info": "Licencia de la aplicaciรณn: {{license}}\nFuente y licencia de la compilaciรณn: {{url}}", + "source_unpublished": "El operador no ha configurado una URL del cรณdigo fuente correspondiente a esta compilaciรณn.", + "donate_checkout_error": "Esta factura no estรก disponible o sus datos no son vรกlidos. Crea una nueva factura de donaciรณn con /donate.", + "operator_recipients_channels": "Canales", + "operator_subscriber_stats": "Usuarios: {{users}}\nGrupos: {{groups}}\nCanales: {{channels}}\nBots: {{bots}}\nTotal: {{total}}", + "operator_message_prompt": "Envรญa el mensaje para el borrador. Se conservan el formato y los archivos multimedia. Los comandos siguen funcionando mientras este editor estรก abierto.", + "operator_buttons_prompt": "Envรญa filas de botones en JSON o asรญ: Label - https://example.org - style:bg_success. Separa los botones de una misma fila con && y las filas con un salto de lรญnea. Acciones admitidas: enlace HTTPS, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "El borrador estรก vacรญo, es demasiado largo o contiene formato o botones no vรกlidos. No se ha guardado.", + "operator_empty_recipients": "No hay destinatarios registrados que coincidan con esta audiencia.", + "operator_complete": "La difusiรณn ha finalizado. Consulta su progreso para ver los resultados de entrega.", + "operator_pause": "Pausar", + "operator_resume": "Reanudar", + "operator_bc_starting": "{{operation}}: preparando destinatariosโ€ฆ", + "operator_bc_counts": "Procesados: {{processed}} / {{total}}\nEnviados: {{sent}}\nEditados: {{edited}}\nSin cambios: {{unchanged}}\nEliminados: {{deleted}}\nDesfijados: {{unpin}}\nErrores: {{failed}}\nEsperas de red: {{flood}}\nPendientes: {{pending}}\nVelocidad: {{tps}}/s", + "operator_bc_paused": "En pausa", + "operator_bc_cancelled": "Cancelado", + "operator_bc_finished": "Finalizado", + "operator_bc_selfdestruct": "Eliminaciรณn programada", + "operator_bc_legacy_summary": "Este resumen antiguo se conserva en el historial de la biblioteca. Consulta los resultados actuales de difusiรณn para ver los contadores disponibles.", + "operator_bc_edit": "Ediciรณn de la difusiรณn", + "moderation": "๐Ÿ”’ Restricciones y limpieza del grupo", + "moderation_intro": "Elige las restricciones aplicadas mientras el grupo estรก cerrado. Una opciรณn seleccionada significa que la acciรณn estรก bloqueada. Se conservan las restricciones existentes; al reabrir se restauran los permisos originales.", + "moderation_permissions": "Elegir las acciones bloqueadas", + "moderation_visibility_warning": "Bloquear la visibilidad de los mensajes puede ocultar el grupo a sus miembros. Esta restricciรณn no se selecciona de forma predeterminada.", + "moderation_default": "Restaurar las restricciones predeterminadas", + "moderation_none": "No seleccionar restricciones adicionales", + "moderation_all": "Seleccionar todas las restricciones", + "moderation_cleanup_hint": "La limpieza solo afecta a los mensajes recibidos durante un cierre activo de Shabat o festividad. Estas opciones empiezan desactivadas; cada guardado requiere confirmaciรณn. Se excluyen los anuncios de este bot.", + "moderation_bot_mode_hint": "Para recibir mensajes de otros bots, este bot debe ser administrador, Group Privacy Mode debe estar desactivado y Bot-to-Bot Communication Mode debe estar activado en BotFather. No se pueden eliminar mensajes que Telegram no entrega.", + "moderation_capabilities": "Comprobar los permisos del bot", + "moderation_delete_capability": "Permiso verificado para eliminar mensajes: {{state}}", + "field_lock_block_flags": "Acciones adicionales bloqueadas durante el cierre", + "field_delete_holy_user_messages": "Eliminar los mensajes de miembros durante el cierre religioso", + "field_delete_holy_bot_messages": "Eliminar los mensajes de otros bots durante el cierre religioso", + "field_delete_previous": "Eliminar el recordatorio anterior de tefilรญn despuรฉs de enviar el nuevo", + "announcement_schedule": "๐Ÿ•ฐ Anuncio de horarios de Shabat y festividades", + "announcement_schedule_intro": "Elige la hora del anuncio en {{timezone}}. Un horario predefinido configura la hora local sin activar los anuncios. Los ajustes personalizados permiten elegir otra hora o minutos antes del cierre.", + "announcement_custom": "Hora personalizada / antes del cierre", + "template_kind_times_alert": "Anuncio de horarios de Shabat y festividades", + "permission_view_messages": "Ver mensajes", + "permission_send_messages": "Enviar mensajes", + "permission_send_media": "Enviar contenido multimedia", + "permission_send_stickers": "Enviar stickers", + "permission_send_gifs": "Enviar GIF", + "permission_send_games": "Enviar juegos", + "permission_send_inline": "Enviar resultados inline", + "permission_embed_links": "Mostrar vistas previas de enlaces", + "permission_send_polls": "Enviar encuestas", + "permission_change_info": "Cambiar la informaciรณn del grupo", + "permission_invite_users": "Invitar usuarios", + "permission_pin_messages": "Fijar mensajes", + "permission_manage_topics": "Gestionar temas", + "permission_send_photos": "Enviar fotos", + "permission_send_videos": "Enviar vรญdeos", + "permission_send_roundvideos": "Enviar mensajes de vรญdeo", + "permission_send_audios": "Enviar archivos de audio", + "permission_send_voices": "Enviar mensajes de voz", + "permission_send_docs": "Enviar documentos", + "permission_send_plain": "Enviar texto simple", + "permission_edit_rank": "Editar los tรญtulos de los miembros", + "permission_send_reactions": "Enviar reacciones", + "bot_permissions": "๐Ÿ›ก Permisos de administrador del bot", + "bot_permissions_intro": "Diagnรณstico de permisos de Telegram de solo lectura. Concede los permisos necesarios en los ajustes de administradores del grupo; el bot no puede concederse permisos a sรญ mismo.", + "bot_role_creator": "Propietario del grupo", + "bot_role_administrator": "Administrador del grupo", + "admin_right_change_info": "Cambiar la informaciรณn del grupo", + "admin_right_post_messages": "Publicar mensajes en el canal", + "admin_right_edit_messages": "Editar mensajes del canal", + "admin_right_delete_messages": "Eliminar mensajes", + "admin_right_ban_users": "Restringir miembros", + "admin_right_invite_users": "Invitar usuarios", + "admin_right_pin_messages": "Fijar mensajes", + "admin_right_add_admins": "Aรฑadir administradores", + "admin_right_anonymous": "Permanecer anรณnimo", + "admin_right_manage_call": "Gestionar chats de voz y vรญdeo", + "admin_right_other": "Otros privilegios de administrador", + "admin_right_manage_topics": "Gestionar temas", + "admin_right_post_stories": "Publicar historias", + "admin_right_edit_stories": "Editar historias", + "admin_right_delete_stories": "Eliminar historias", + "admin_right_manage_direct_messages": "Gestionar mensajes directos del canal", + "admin_right_manage_ranks": "Gestionar los rangos de miembros", + "welcome": "โœก๏ธ Guardiรกn del Shabat\nHorarios de Shabat y fiestas, zmanim, Daf Yomi y cuenta del ร“mer segรบn tu ubicaciรณn.\n\nElige quรฉ consultar o abre tus preferencias para ajustar el idioma y la ubicaciรณn.\n\nLos administradores pueden gestionar cada grupo desde ยซMis gruposยป.", + "help": "โ„น๏ธ Informaciรณn y ayuda", + "home": "๐Ÿ  Menรบ principal", + "info_intro": "โ„น๏ธ Tu guรญa del Guardiรกn del Shabat\n\nTus horarios, en tu idioma y para tu ubicaciรณn.\nElige una categorรญa con explicaciones y comandos รบtiles. Los administradores tambiรฉn encontrarรกn el cierre automรกtico, los recordatorios y el diseรฑo de mensajes.", + "info_personal_title": "โš™๏ธ Mis preferencias", + "info_personal_text": "/preferences - idioma, ubicaciรณn, calendario de Israel o la diรกspora y mรฉtodos de encendido y havdalรก.\n/lang - elegir entre seis idiomas.\nLos horarios del chat privado usan tus preferencias. Los cambios personales no modifican los grupos.\n/start - volver al menรบ principal.", + "info_calendar_title": "๐Ÿ•ฏ Horarios y calendario judรญo", + "info_calendar_text": "Sus horarios\n/shabbat - prรณximo Shabat, perรญodos continuos de Yom Tov, parashรก, Mevarjim y fechas prรณximas en un mensaje.\n/zmanim - horarios locales; elija fecha con botones.\n/daf - Daf Yomi. /omer - cuenta del ร“mer en su temporada.\n\nDรญas consecutivos\nSe muestran todos los encendidos y la salida final. Los horarios festivos corresponden solo a Yom Tov segรบn Hebcal. Janucรก, Jol Hamoed y otras fechas no son por sรญ mismas Yom Tov.\n\nLugar y mรฉtodos\nSus preferencias controlan el chat privado; cada grupo tiene las suyas. Jerusalรฉn usa 40 minutos antes del ocaso por defecto, configurable. Los horarios usan la zona IANA elegida, incluidos los cambios de horario de verano.\n\nNo se inventan horarios faltantes. Compartir envรญa el resultado a otro chat.\n\nCalendario y horarios: Hebcal.com. El mรฉtodo de salida mostrado es el que ha elegido: grados o minutos despuรฉs de la puesta del sol.", + "info_groups_title": "๐Ÿ‘ฅ Gestionar grupos", + "info_groups_text": "Conexiรณn y panel\nAรฑada el bot como administrador y envรญe /settings en el grupo. Tambiรฉn puede abrir el panel desde Mis grupos en privado. El menรบ del grupo muestra solo ajustes.\n\nOpciones\nUbicaciรณn, idioma, Shabat y fiestas, horarios, Daf Yomi, tefilรญn, ร“mer, mensajes y permisos. Cada grupo es independiente.\n\nModo\nApagado, solo mensajes, solo cierre o ambos. Elija dรญas, mรฉtodos y hora del anuncio en Shabat y fiestas.\n\nGuardar exige permisos actuales de administrador. Revise el resumen antes de activar; la vista privada no envรญa al grupo.", + "info_notifications_title": "๐Ÿ”” Recordatorios y avisos", + "info_notifications_text": "Cada recordatorio es independiente\nElija horarios, Daf Yomi, tefilรญn u ร“mer en el panel, actรญvelo y configure horas locales. Los botones guรญan sin claves tรฉcnicas ni tokens.\n\nTefilรญn\nElija dรญas, horas fijas, intervalos o minutos antes del ocaso. Ajuste horas de silencio, lรญmite diario y borrado del aviso anterior. Se respetan Shabat, Yom Tov y los ajustes de Jol Hamoed y Tishรก BeAv.\n\nMe puse tefilรญn\nEl botรณn cuenta usuarios รบnicos; otro clic cancela su elecciรณn. El administrador puede renombrarlo o desactivarlo.\n\nAnuncio de horarios\nEn Shabat y fiestas, elija hora y diseรฑo. La vista previa es privada; la prueba en grupo requiere confirmaciรณn.", + "info_messages_title": "โœ‰๏ธ Diseรฑo y permisos", + "info_messages_text": "Mensajes y diseรฑo\nElija cierre, apertura, Shabat/fiestas, horarios, Daf Yomi, tefilรญn u ร“mer. Texto, foto/medio y botones se editan por separado.\n\nEdiciรณn natural\nEnvรญe el mensaje como desea mostrarlo. El bot conserva texto y formato de Telegram, incluidas negrita, enlaces y emojis personalizados. Revise y confirme Guardar.\n\nBotones\nAรฑada enlace, elija estilo, quite botones o restablezca los predeterminados. Horarios y Daf Yomi incluyen Compartir. Editar solo botones conserva el contenido calculado.\n\nSi los emojis personalizados no estรกn disponibles, se usan normales. Una prueba en grupo requiere confirmaciรณn explรญcita.", + "info_privacy_title": "๐Ÿ›ก Privacidad y seguridad", + "info_privacy_text": "Control de ajustes\nLa bรบsqueda y ubicaciรณn muestran opciones y vista previa; solo confirmar guarda cambios. Las preferencias personales no cambian grupos. Se verifican permisos actuales antes de administrar.\n\nDatos guardados\nIdentificadores, preferencias, permisos y registros necesarios para el servicio. El botรณn de tefilรญn guarda su elecciรณn para contar usuarios รบnicos; otro clic la cancela.\n\nPrueba y transparencia\nLa vista previa es privada. Las pruebas en grupo requieren confirmaciรณn. No envรญe contraseรฑas ni tokens.\n/source reรบne repositorio oficial, versiรณn, licencia y cรณdigo de la compilaciรณn.", + "info_operators_title": "๐Ÿ›  Administraciรณn del bot", + "info_operators_text": "/admin - suscriptores, estadรญsticas y difusiones.\n/setcommands - menรบs por idioma y tipo de chat; revisa la vista previa antes de publicar.\n/testzmanim - comprobar cรกlculos y programaciรณn.\n/closegroups y /opengroups - acciones manuales para grupos autorizados.\n/restart - reiniciar de forma ordenada.\nEstas funciones son solo para operadores del bot. Administrar un grupo no concede acceso a la administraciรณn del bot.", + "callback_panel_unavailable": "No se pudo actualizar este menรบ. รbrelo de nuevo con /start o /settings.", + "provider_unavailable": "Los datos horarios verificados no estรกn disponibles temporalmente. Vuelve a intentarlo en unos minutos.", + "operator_subscriber_stats_full": "๐Ÿ“Š Suscriptores del bot de Shabat\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค Usuarios: {{users}}\n๐Ÿ‘ฅ Grupos: {{chats}}\n๐Ÿ‘ฅ Supergrupos: {{supergroups}}\n๐Ÿ“ข Canales: {{channels}}\n๐Ÿค– Bots: {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nTotal: {{total}}", + "operator_group_stats": "๐Ÿ”’ Actividad de los grupos\nGrupos conocidos: {{known}}\nRegistrados: {{registered}}\nCierre activado: {{locking}}\nCerrados ahora por el bot: {{locked}}\nPendientes de restauraciรณn: {{recovery}}", + "times_section_schedule": "Horarios de inicio y fin", + "times_section_calendar": "Detalles del calendario", + "times_stale": "โ„น๏ธ Se muestran datos guardados de la รบltima consulta correcta; el proveedor no estรก disponible ahora.", + "times_day_name_0": "Domingo", + "times_day_name_1": "Lunes", + "times_day_name_2": "Martes", + "times_day_name_3": "Miรฉrcoles", + "times_day_name_4": "Jueves", + "times_day_name_5": "Viernes", + "times_day_name_6": "Sรกbado", + "times_heading": "๐Ÿ•ฏ Horarios de Shabat y festividades", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… Dรญas sagrados: {{dates}}", + "times_candles": "๐Ÿ•ฏ Encendido de velas - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ Salida / Havdalรก: {{time}}", + "times_close": "๐Ÿ”’ Cierre del grupo: {{time}}", + "times_open": "๐Ÿ”“ Apertura del grupo: {{time}}", + "times_sequence": "โ„น๏ธ Dรญas sagrados consecutivos: el grupo permanece cerrado hasta la apertura final.", + "times_parasha": "๐Ÿ•ฏ Parashรก: {{title}}", + "times_mevarchim": "๐ŸŒ™ Shabat Mevarjim: {{title}}", + "times_holiday": "โœก๏ธ Evento del calendario: {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "Velas: {{minutes}} minutos antes de la puesta del sol\nSalida: {{method}} {{value}}", + "times_source": "Fuente: Hebcal ยท Actualizado: {{time}}", + "times_data_missing": "Algunos datos horarios no estรกn disponibles ahora.", + "location_menu_intro": "๐Ÿ“ Ubicaciรณn de {{title}}\nUbicaciรณn actual: {{location}}\nZona horaria: {{timezone}}\nElige cรณmo buscar la ubicaciรณn. Revisarรกs y confirmarรกs los detalles antes de guardarlos.", + "location_input_city_title": "๐Ÿ”Ž Buscar ciudad", + "location_input_city_prompt": "Escribe el nombre de una ciudad. Puedes buscar en espaรฑol y otros idiomas. Elige un resultado de la lista.", + "location_input_timezone_title": "๐ŸŒ Zona horaria", + "location_input_timezone_prompt": "Escribe una ciudad o zona horaria, como Jerusalem o Asia/Jerusalem. Cambiar la zona no modifica las coordenadas.", + "location_input_geoname_title": "๐Ÿ”ข ID de GeoNames", + "location_input_geoname_prompt": "Escribe el identificador numรฉrico del lugar en GeoNames, por ejemplo 281184 para Jerusalรฉn.", + "location_input_coordinates_title": "๐Ÿงญ Coordenadas", + "location_input_coordinates_prompt": "Escribe latitud y longitud, por ejemplo 31.76904, 35.21633. Puedes aรฑadir una zona horaria, como Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ Compartir ubicaciรณn", + "location_input_gps_prompt": "Responde a este mensaje con una ubicaciรณn de Telegram. Revisarรกs y confirmarรกs el lugar y la zona horaria.", + "location_input_invalid": "No se pudo interpretar la ubicaciรณn. Escribe una ciudad, ID de GeoNames, coordenadas o zona horaria segรบn la opciรณn elegida.", + "location_inline_hint": "Tambiรฉn puedes buscar en un campo de mensaje: @{{bot}} seguido del nombre de la ciudad.", + "location_input_cancelled": "Se cancelรณ la selecciรณn de ubicaciรณn. Los ajustes no cambiaron.", + "daf_open_page": "๐Ÿ”— Leer la pรกgina", + "timezone_inline_hint": "Tambiรฉn puedes buscar en un campo de mensaje: @{{bot}} tz Jerusalem o @{{bot}} Asia/Jerusalem. Para elegir en los ajustes actuales, usa el botรณn de bรบsqueda de aquรญ.", + "donate_other": "Otro", + "donate_custom_prompt": "โญ Elige tu aportaciรณn\n\nEnvรญa un nรบmero entero entre 1 y 10.000 estrellas.\nRecibirรกs un enlace de pago antes de confirmar tu aportaciรณn en Telegram.", + "donate_custom_invalid": "Introduce un nรบmero entero entre 1 y 10.000 estrellas, sin sรญmbolos ni fracciones.", + "donate_custom_ready": "โญ Para aportar {{amount}} estrellas, abre el enlace de pago y confirma en Telegram.", + "donate_custom_cancel": "โ†ฉ๏ธ Volver a los importes", + "times_upcoming": "๐Ÿ•ฏ Prรณximos Shabat y fiestas", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "Pรกgina {{page}} de {{pages}}", + "times_period_exit": "๐ŸŒ™ Fin de este periodo: {{time}}", + "times_no_events": "No hay mรกs Shabat ni fiestas en el intervalo de fechas verificado.", + "times_lighting_after": "Esta noche las velas se encienden despuรฉs del dรญa sagrado anterior, con una llama encendida antes de comenzar la fiesta.", + "times_event_link": "Horarios de {{title}}", + "omer_nightfall_label": "Anochecer", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "Elige un ajuste. Cada opciรณn muestra su valor actual.", + "wizard_custom": "โœ๏ธ Valor personalizado", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nActual: {{current}}\n\n{{hint}}", + "wizard_invalid": "El valor no corresponde a este ajuste. Sigue el ejemplo o vuelve a la lista.", + "wizard_done": "โœ… Revisar selecciรณn", + "wizard_more": "โš™๏ธ Todos los ajustes", + "wizard_choose_option": "Elige una opciรณn y revisa el cambio antes de guardarlo.", + "wizard_template": "Personaliza texto, formato, medios y botones en el editor. La vista previa permanece en privado.", + "wizard_hint_time": "Envรญa una hora local, por ejemplo 09:30.", + "wizard_hint_times": "Envรญa horas separadas por comas, por ejemplo 09:00, 13:30.", + "wizard_hint_integer": "Envรญa un nรบmero entero. Comprobaremos el intervalo permitido.", + "wizard_hint_float": "Envรญa un nรบmero, por ejemplo 8.5 para grados o 50 para minutos fijos.", + "wizard_hint_minutes_list": "Envรญa minutos separados por comas, por ejemplo 60, 30, 15.", + "wizard_hint_text": "Envรญa el texto que quieres guardar.", + "wizard_hint_days": "Selecciona los dรญas y pulsa ยซRevisar selecciรณnยป.", + "template_edit_guided_prompt": "Envรญa aquรญ el nuevo mensaje. Puedes adjuntar medios y dar formato en Telegram. Revisa la vista previa antes de guardar.", + "template_buttons_guided_prompt": "๐Ÿ”— Botones del mensaje\n\nPara un enlace sencillo, elige ยซAรฑadir botรณn de enlaceยป. Preguntaremos el texto y la direcciรณn por separado.\n\nTambiรฉn puedes enviar varios botones con este ejemplo:\nInformaciรณn - https://example.org - style:bg_primary\nApoyo - https://example.org/support\n\nUna lรญnea nueva crea otra fila. Separa los botones de una misma fila con &&. Tambiรฉn se admite JSON. Revisa la vista previa antes de guardar.", + "operator_audience_authorize": "โœ… Confirmar audiencia", + "command_adminstats": "๐Ÿ“Š Estadรญsticas de administraciรณn", + "command_subscribers": "๐Ÿ‘ฅ Suscriptores y bรบsqueda", + "command_broadcast": "๐Ÿ“ฃ Difundir mensaje", + "command_managers": "๐Ÿ‘ฅ Administradores y permisos", + "command_systemlinks": "โš™๏ธ Enlaces del sistema", + "command_trackinglinks": "โ„น๏ธ Enlaces de seguimiento", + "times_upcoming_calendar": "๐Ÿ•ฏ Prรณximas fiestas y celebraciones", + "times_sequence_calendar": "Periodo continuo de Shabat y fiestas: se muestra cada encendido y la salida al final de todo el periodo.", + "template_default_buttons": "โ†ฉ๏ธ Restaurar botones predeterminados", + "tefillin_ack": "Me puse los tefilรญn", + "field_ack_enabled": "Botรณn de confirmaciรณn de tefilรญn", + "field_ack_label": "Texto de confirmaciรณn de tefilรญn", + "field_tefillin_ack_enabled": "Botรณn de confirmaciรณn de tefilรญn", + "field_tefillin_ack_label": "Texto de confirmaciรณn de tefilรญn", + "template_add_link": "โž• Aรฑadir botรณn de enlace", + "template_link_text_prompt": "๐Ÿ”— Nuevo botรณn\n\nEnvรญa un texto corto, por ejemplo ยซMรกs informaciรณnยป.", + "template_link_url_prompt": "๐Ÿ”— ยฟA dรณnde lleva el botรณn?\n\nEnvรญa una direcciรณn completa que empiece por https://, por ejemplo https://example.org.\nRevisa el botรณn antes de guardar.", + "template_save_review": "โœ… Guardar mensaje", + "template_ack_preview": "Esto es una vista previa. Pulsar aquรญ no cambia el contador.", + "template_restore_text": "๐Ÿ“ Restaurar texto automรกtico", + "template_remove_caption": "๐Ÿ—‘ Quitar pie del archivo", + "template_text_restored": "Se restaurรณ el texto automรกtico. Se conservaron el archivo y los botones.", + "template_caption_removed": "Se quitรณ el pie del archivo. Se conservaron el archivo y los botones.", + "settings_mode_intro": "โš™๏ธ Modo de funcionamiento de {{title}}\n\nDesactivado - sin mensajes ni bloqueo automรกtico de Shabat o fiestas.\nSolo mensajes - publicaciones sin cambiar los permisos.\nSolo bloqueo - cierre y apertura a las horas configuradas, sin mensajes de fiesta.\nMensajes y bloqueo - publicaciones y control de acceso.\n\nSe aplica a los Shabat y dรญas de Yom Tov activados.", + "settings_mode_button": "Modo: {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ Entrada, salida y cierre", + "settings_shabbat_messages": "โœ๏ธ Mensajes y diseรฑo", + "settings_shabbat_timing_intro": "Elige la costumbre de velas, el mรฉtodo de salida y los mรกrgenes de cierre/apertura. Las horas usan la ubicaciรณn del grupo. Shabat y Yom Tov consecutivos permanecen cerrados hasta el final de todo el perรญodo.", + "settings_shabbat_messages_intro": "Elige mensajes de cierre/apertura, horas de silencio y el tema del grupo. Edita por separado el texto, el formato, los archivos y los botones.", + "settings_shabbat_summary": "โœก๏ธ Shabat y fiestas - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nModo: {{mode}}\nShabat: {{shabbat}} ยท Yom Tov: {{holidays}}\n\n๐Ÿ•ฏ๏ธ Entrada y salida\nVelas: {{candles}} minutos antes de la puesta del sol\nSalida: {{havdalah}}\nCierre: {{close}} minutos antes de las velas\nApertura: {{open}} minutos despuรฉs de la salida\n\nElige una secciรณn. Al confirmar el cambio volverรกs a la misma secciรณn.", + "template_current_summary": "Diseรฑo actual\nTexto: {{text}}\nArchivo: {{media}}\nBotones: {{buttons}}\n\nEdita el contenido o los botones, revisa la vista previa y guarda. El envรญo de prueba al grupo requiere una confirmaciรณn adicional.", + "template_text_default": "Mensaje automรกtico", + "template_text_custom": "Texto personalizado", + "template_buttons_inherit": "Botones predeterminados", + "template_buttons_custom": "Botones personalizados", + "template_buttons_none": "Sin botones", + "template_panel_hint": "Elige un tipo de mensaje. Cada mensaje admite texto con formato, archivos y botones. Los cambios solo afectan a este grupo.", + "times_period_enter": "๐Ÿ•ฏ Inicio / encendido de velas: {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ Los siguientes Shabat", + "times_yomtov_unavailable": "Los horarios verificados de inicio y fin de este Yom Tov aรบn no estรกn disponibles.", + "info_permissions_title": "๐Ÿ›ก Permisos, cierre y apertura", + "info_permissions_text": "ยฟQuรฉ grupos son compatibles?\nTelegram permite cambiar permisos predeterminados en grupos normales y supergrupos. El bot debe ser administrador con permiso para restringir miembros. Las restricciones afectan a miembros normales; los administradores pueden seguir escribiendo.\n\nAntes de activar\nElija ciudad, zona horaria y calendario de Israel o diรกspora. En Shabat y fiestas, active solo cierre o mensajes y cierre. Revise velas, ajustes de cierre y apertura y permisos del bot.\n\nAl abrir\nEl bot guarda los permisos antes del cierre y los restaura despuรฉs. Shabat y Yom Tov consecutivos permanecen cerrados hasta la รบltima salida configurada. Cambios externos o permisos insuficientes pueden requerir revisiรณn.\n\nBorrado\nSe necesita permiso para borrar mensajes. Los mensajes de otros bots tienen una opciรณn separada y dependen de las actualizaciones que Telegram entregue.", + "template_buttons_simple_prompt": "Aรฑade un botรณn de enlace: elige el texto y despuรฉs una direcciรณn que comience por https://. Revisa la vista previa antes de guardar.\n\nPara otras acciones o varias filas, abre las opciones avanzadas.", + "template_advanced_buttons": "โš™๏ธ Opciones avanzadas", + "operator_manual_details": "Detalles por grupo", + "operator_manual_pending": "En espera: {{count}}", + "operator_manual_more": "Y {{count}} grupos mรกs", + "permission_error_admin": "Otorga al bot permiso para restringir miembros.", + "permission_error_access": "El bot no pertenece al grupo o no tiene acceso.", + "permission_error_flags": "Restricciones invรกlidas. Restablรฉcelas en Shabat y fiestas.", + "permission_error_snapshot": "No se pueden verificar los permisos originales. Requiere revisiรณn del administrador antes de abrir.", + "permission_error_conflict": "Los permisos actuales difieren del estado guardado. Los cambios automรกticos se detuvieron para que un administrador los revise.", + "permission_error_provider": "El servicio del calendario no estรก disponible.", + "permission_error_unknown": "Operaciรณn no confirmada. Revisa el registro de diagnรณstico del grupo.", + "permission_status_wait": "Esperando otro intento tras el lรญmite de Telegram", + "permission_status_busy": "Hay otra operaciรณn en curso para este grupo", + "permission_status_prelocked": "El grupo se cerrรณ fuera del bot y no se abrirรก automรกticamente", + "permission_status_idle": "No hay un cierre del bot para retirar", + "permission_status_unchanged": "El grupo ya estรก en el estado solicitado", + "restart_requested": "โ™ป๏ธ Iniciando un reinicio ordenado. El bot volverรก cuando se inicie de nuevo.", + "permission_conflict_fields": "Diferencias: {{fields}}.", + "permission_until_date": "Caducidad de los permisos", + "permission_unknown": "Permiso desconocido", + "times_shabbat_title": "Shabat", + "permission_recovery": "๐Ÿ›  Resolver problema de permisos", + "recovery_title": "๐Ÿ›  Resolver problema de permisos\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "El bot guarda los permisos habituales antes del bloqueo y los restaura al abrir. Aquรญ puedes revisar un intento pendiente y decidir cรณmo continuar.", + "recovery_compare": "Comparaciรณn con los permisos guardados", + "recovery_difference": "โ€ข {{permission}}: ahora {{current}} ยท guardado {{saved}}", + "recovery_more": "{{count}} diferencias mรกs. La acciรณn abarca todos los permisos, incluidos los que no se muestran aquรญ.", + "recovery_allowed": "permitido", + "recovery_blocked": "bloqueado", + "recovery_no_differences": "No hay diferencias con los permisos originales guardados.", + "recovery_adopt": "โœ… Aceptar permisos actuales", + "recovery_restore": "โ†ฉ๏ธ Restaurar permisos guardados", + "recovery_preview_adopt": "Aceptar permisos actuales\nLos permisos actuales del grupo pasarรกn a ser su estado habitual. Esta acciรณn no cambia los permisos. Cierra el intento anterior; el siguiente bloqueo guardarรก el estado actualizado.", + "recovery_preview_restore": "Restaurar permisos guardados\nEl bot solicitarรก todos los permisos guardados antes del problema y desharรก los cambios posteriores. La restauraciรณn solo se considera correcta tras verificarla con Telegram.", + "recovery_confirm_adopt": "โœ… Confirmar permisos actuales", + "recovery_confirm_restore": "โœ… Confirmar restauraciรณn", + "recovery_preview_notice": "Revisa el grupo y tu elecciรณn antes de confirmar. La confirmaciรณn caduca en cinco minutos. Se comprobarรกn de nuevo los permisos y el estado; la recuperaciรณn manual no estรก disponible durante un bloqueo activo, Shabat o festividad.", + "recovery_result_adopted": "โœ… Permisos actuales aceptados como habituales. El intento anterior estรก cerrado.", + "recovery_result_restored": "โœ… Permisos guardados restaurados y verificados. El intento anterior estรก cerrado.", + "recovery_block_active_lock": "๐Ÿ”’ Ahora no se puede verificar un momento seguro para la recuperaciรณn: puede haber un bloqueo, Shabat o festividad, o faltar datos del calendario. Puedes consultar y actualizar; los cambios manuales requieren confirmar que no haya un perรญodo protegido activo.", + "recovery_block_no_problem": "โœ… No hay problemas de permisos pendientes. El siguiente bloqueo guardarรก los permisos actualizados del grupo.", + "recovery_block_missing_snapshot": "โš ๏ธ No existe un estado original verificado para restaurar. Si puedes aceptar los permisos actuales, comprueba primero que sean los permisos habituales deseados.", + "recovery_block_locked_current": "๐Ÿ”’ El grupo parece estar bloqueado. No se puede aceptar este estado como habitual. Revisa la restauraciรณn o contacta con el operador del bot.", + "recovery_block_unknown": "โš ๏ธ Ahora no se puede verificar una recuperaciรณn segura. Actualiza; si continรบa, contacta con el operador del bot.", + "recovery_result_stale": "โŒ› La confirmaciรณn caducรณ o el estado cambiรณ. Revisa el estado actual y solicita otra confirmaciรณn.", + "recovery_result_forbidden": "Se requieren permisos actuales de administrador de este grupo.", + "recovery_result_busy": "โณ Hay otra operaciรณn en curso en el grupo. Actualiza en un momento.", + "recovery_result_conflict": "โš ๏ธ Telegram devolviรณ un estado inesperado. La informaciรณn original se conserva; revisa la comparaciรณn antes de reintentar.", + "recovery_result_error": "No se pudo completar o verificar la recuperaciรณn. La informaciรณn guardada no se borrรณ. Actualiza antes de reintentar.", + "recovery_result_retry_wait": "โณ Telegram pidiรณ esperar. Evita pulsar repetidamente; actualiza mรกs tarde.", + "recovery_refresh": "๐Ÿ”„ Actualizar estado", + "recovery_cancelled": "Elecciรณn cancelada. No se realizรณ ninguna recuperaciรณn manual.", + "recovery_state_problem": "โš ๏ธ Hay un intento de bloqueo pendiente. Revisa las diferencias y elige el estado deseado. Cada opciรณn requiere una confirmaciรณn adicional antes de ejecutarse.", + "diagnostics_title": "๐Ÿงช Diagnรณstico de horarios y permisos", + "diagnostics_read_only": "Solo lectura. Instantรกnea vรกlida durante 15 minutos; actualice para obtener datos actuales.", + "diagnostics_context": "Contexto", + "diagnostics_build": "Versiรณn en ejecuciรณn", + "diagnostics_location": "Ubicaciรณn y zona horaria", + "diagnostics_methods": "Minutos de velas / mรฉtodo de salida y valor", + "diagnostics_lock_now": "Cierre requerido ahora", + "diagnostics_quiet_now": "Shabat, festividad o cierre activo", + "diagnostics_stale": "Datos del proveedor obsoletos", + "diagnostics_evaluated_at": "Hora del cรกlculo", + "diagnostics_fetched_at": "Datos recibidos el", + "diagnostics_period": "Intervalo de cierre y apertura", + "diagnostics_provider_error": "Error del proveedor del calendario", + "diagnostics_queue": "Cola de tareas", + "diagnostics_group": "Grupo", + "diagnostics_limited": "Grupos mostrados / total. Otro grupo: /testzmanim y su ID", + "diagnostics_lock_state": "Estado del cierre guardado", + "diagnostics_original": "Permisos normales guardados: restricciones activas", + "diagnostics_requested": "Permisos solicitados: restricciones activas", + "diagnostics_current": "Permisos actuales de Telegram: restricciones activas", + "diagnostics_compare_original": "Diferencias con los permisos normales guardados", + "diagnostics_compare_requested": "Diferencias con los permisos solicitados", + "diagnostics_all": "Todo", + "diagnostics_problems": "โš ๏ธ Problemas y advertencias", + "diagnostics_summary": "Resumen", + "diagnostics_calendar": "Calendario", + "diagnostics_permissions": "Permisos", + "diagnostics_groups": "๐Ÿ‘ฅ Grupos", + "diagnostics_busy": "En proceso o realizado recientemente. Actualizaciรณn cada 30 segundos y exportaciรณn una vez por minuto.", + "diagnostics_unavailable": "Diagnรณstico no disponible, caducado o sin permiso. Repita /testzmanim en privado en 30 segundos.", + "diagnostics_export_caption": "๐Ÿ“„ Diagnรณstico de horarios y permisos. Instantรกnea de solo lectura.", + "diagnostics_export_sent": "Archivo JSON enviado.", + "diagnostics_previous": "โ—€๏ธ Anterior", + "diagnostics_next": "Siguiente โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž Buscar", + "diagnostics_filters": "๐Ÿ”ฝ Filtrar", + "diagnostics_refresh": "๐Ÿ”„ Actualizar", + "diagnostics_clear": "Borrar bรบsqueda y filtros", + "diagnostics_export": "๐Ÿ“„ Exportar JSON", + "diagnostics_back": "Volver a administraciรณn", + "diagnostics_cancel": "Cancelar", + "diagnostics_empty": "No hay resultados para esta bรบsqueda y filtro.", + "diagnostics_page_prompt": "Envรญe un nรบmero de pรกgina entre 1 y {{pages}}. Se actualizarรก el panel actual.", + "diagnostics_search_prompt": "Envรญe una palabra, fecha o ID de grupo para buscar en la instantรกnea (hasta 80 caracteres).", + "diagnostics_invalid_page": "Nรบmero de pรกgina no vรกlido. Elija dentro del intervalo mostrado.", + "diagnostics_is_work_prohibited_now": "Trabajo prohibido ahora", + "diagnostics_is_shabbat": "Shabat ahora", + "diagnostics_is_yom_tov": "Yom Tov ahora", + "diagnostics_next_close_at": "Prรณximo cierre", + "diagnostics_next_open_at": "Prรณxima apertura", + "diagnostics_baseline": "Permisos normales recuperados: restricciones activas", + "diagnostics_compare_previous": "Diferencias con los permisos intermedios", + "diagnostics_compare_baseline": "Diferencias con la base de recuperaciรณn", + "diagnostics_concurrent_change": "Los permisos guardados cambiaron durante la comprobaciรณn. Actualice antes de comparar.", + "diagnostics_operation": "รšltima operaciรณn: acciรณn / fase / resultado", + "diagnostics_previous_rights": "Permisos intermedios anteriores: restricciones activas", + "diagnostics_invalid_search": "Bรบsqueda demasiado larga. Introduzca hasta 80 caracteres.", + "recovery_block_unverified_snapshot": "โš ๏ธ Los permisos guardados proceden de una versiรณn anterior y no se verificaron con la nueva lectura. Pueden diferir de la configuraciรณn anterior del grupo, por lo que su restauraciรณn estรก desactivada.\nFuera de Shabat, Yom Tov o un bloqueo activo: configura los permisos deseados en Telegram, pulsa Actualizar aquรญ, revรญsalos y confirma el uso de los permisos actuales. El estado anterior se conserva para el diagnรณstico.", + "permission_error_unverified": "El estado de la versiรณn anterior requiere revisiรณn de un administrador. Abre la resoluciรณn de problemas de permisos en los ajustes del grupo; no restaures ese estado anterior.", + "diagnostics_snapshot_verification": "Verificaciรณn de permisos guardados", + "diagnostics_snapshot_verified": "Guardados tras una lectura verificada", + "diagnostics_snapshot_unverified": "Estado de una versiรณn anterior - requiere revisiรณn del administrador", + "permission_error_readback": "โš ๏ธ Se enviรณ el cambio de permisos, pero no se verificรณ el resultado. Los permisos pueden haber cambiado. Revรญsalos en Telegram y en la resoluciรณn de problemas de permisos antes de reintentar." +} diff --git a/resources/locales/fr.json b/resources/locales/fr.json new file mode 100644 index 0000000..bc08449 --- /dev/null +++ b/resources/locales/fr.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ Chabbat et fรชtes", + "zmanim": "๐Ÿ•’ Horaires quotidiens", + "daf": "๐Ÿ“– Daf Yomi", + "omer": "๐ŸŒพ Compte du Omer", + "tefillin": "๐Ÿ”” Rappels de tรฉfilines", + "add": "โž• Ajouter le bot ร  un groupe", + "groups": "๐Ÿ‘ฅ Mes groupes", + "preferences": "โš™๏ธ Mes prรฉfรฉrences", + "donate": "โญ Soutenir le bot", + "source": "๐Ÿ’ป Code source et licence", + "back": "โ†ฉ๏ธ Retour", + "cancel": "Annuler", + "confirm": "โœ… Confirmer lโ€™enregistrement", + "language": "๐ŸŒ Langue", + "location": "๐Ÿ“ Lieu et fuseau horaire", + "candles": "๐Ÿ•ฏ Allumage des bougies", + "havdalah": "๐ŸŒ™ Havdala", + "messages": "โœ‰๏ธ Messages et prรฉsentation", + "schedule": "๐Ÿ—“ Actions ร  venir", + "status": "๐Ÿ›  ร‰tat et problรจmes", + "preview": "๐Ÿ‘€ Aperรงu privรฉ", + "reset": "Rรฉinitialiser ce module", + "edit": "โœ๏ธ Modifier les rรฉglages", + "on": "Activรฉ", + "off": "Dรฉsactivรฉ", + "today": "Aujourdโ€™hui", + "tomorrow": "Demain", + "other_date": "Autre date", + "share": "Partager", + "short": "Court", + "full": "Complet", + "next": "Page suivante", + "previous": "Page prรฉcรฉdente", + "no_groups": "Aucun groupe connu que vous administrez actuellement nโ€™a รฉtรฉ trouvรฉ. Dans un groupe dรฉjร  inscrit, utilisez /settings.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nMode : {{mode}}\nProchaine action : {{next}}\n\nChoisissez une rubrique pour configurer ce groupe. Vรฉrifiez et confirmez chaque modification avant de lโ€™enregistrer.", + "personal_panel": "โš™๏ธ Mes prรฉfรฉrences\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nChoisissez votre langue, votre lieu et les mรฉthodes de calcul. Ces prรฉfรฉrences dรฉterminent vos horaires personnels.", + "denied": "Cette action nโ€™est plus disponible : lโ€™autorisation ou le contexte des rรฉglages a expirรฉ. Rouvrez le panneau du groupe.", + "saved": "โœ… Enregistrรฉ pour {{title}}. Les actions futures ont รฉtรฉ recalculรฉes ; la restauration des permissions est conservรฉe.", + "language_choose": "Choisissez la langue de ce contexte. Le panneau privรฉ conserve votre langue personnelle.", + "candle_choose": "Choisissez le nombre rรฉel de minutes avant le coucher du soleil, ou lโ€™usage documentรฉ de la ville. Aucune minute supplรฉmentaire cachรฉe nโ€™est ajoutรฉe.", + "city_custom": "Usage de la ville", + "havdalah_choose": "Choisissez les degrรฉs ou un nombre fixe de minutes aprรจs le coucher du soleil. 72 minutes signifie 72 minutes fixes ; la mรฉthode et la valeur sont enregistrรฉes sรฉparรฉment.", + "degrees": "8,5 degrรฉs", + "minutes": "{{value}} minutes fixes", + "location_choose": "Recherchez une ville, sรฉlectionnez un rรฉsultat, vรฉrifiez le fuseau horaire, puis confirmez pour le contexte indiquรฉ. Rechercher ou partager ne modifie jamais les rรฉglages.", + "inline_search": "Rechercher une ville en mode inline", + "city_search": "Rechercher en privรฉ", + "coordinates": "Coordonnรฉes / position partagรฉe", + "timezone": "Fuseau horaire manuel", + "calendar_israel": "Calendrier dโ€™Israรซl", + "calendar_diaspora": "Calendrier de la diaspora", + "copy_personal": "Copier mes prรฉfรฉrences (aperรงu)", + "geonames_missing": "La recherche GeoNames est dรฉsactivรฉe. Lโ€™opรฉrateur doit configurer GEONAMES_USERNAME et activer les services web. Utilisez une ville proposรฉe ou les coordonnรฉes ci-dessous.", + "city_prompt": "Envoyez /city {{token}} nom de la ville. Utilisez une langue prise en charge par le fournisseur. Ce jeton appartient uniquement ร  ce parcours de rรฉglages.", + "coordinates_prompt": "Envoyez /coordinates {{token}} latitude longitude zone-IANA nom. Exemple : /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem Jรฉrusalem. Pour partager le GPS, envoyez /location {{token}} zone-IANA, puis rรฉpondez ร  la demande du bot avec une position Telegram.\nUtilisez auto ร  la place du fuseau si GeoNames est configurรฉ ; le fuseau IANA proposรฉ est toujours affichรฉ avant lโ€™enregistrement.", + "timezone_prompt": "Envoyez /timezone {{token}} texte de recherche pour rechercher des identifiants IANA. La sรฉlection exige une confirmation et conserve les coordonnรฉes.", + "location_preview": "Aperรงu du lieu pour {{title}}\nVille : {{location}}\nPays : {{country}}\nRรฉgion : {{region}}\nLatitude : {{latitude}}\nLongitude : {{longitude}}\nIdentifiant GeoNames : {{geoname}}\nFuseau IANA : {{timezone}}\nDรฉcalage UTC actuel : {{offset}} (affichage uniquement)\n{{warning}}", + "timezone_warning": "Un fuseau choisi manuellement peut diffรฉrer du fuseau gรฉographique. Les coordonnรฉes nโ€™ont pas changรฉ.", + "no_results": "Aucune ville trouvรฉe pour cette recherche.", + "search_error": "Recherche de lieu indisponible : {{error}}", + "select_location": "Vรฉrifiez ce lieu ; les rรฉglages ne sont enregistrรฉs quโ€™aprรจs votre confirmation privรฉe.", + "select_private": "Continuer dans la conversation privรฉe du bot", + "public_location": "Informations publiques sur un lieu. Partager ce rรฉsultat ne modifie jamais les rรฉglages personnels ou ceux dโ€™un groupe.", + "provider_error": "Les horaires vรฉrifiรฉs ne sont pas disponibles : {{error}}. Aucun horaire nโ€™a รฉtรฉ inventรฉ.", + "unavailable": "Indisponible pour ce lieu ou cette date", + "shabbat_view": "Chabbat et fรชtes\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nAllumage des bougies : {{candles}}\nFermeture du groupe : {{close}}\nHavdala : {{havdalah}}\nOuverture du groupe : {{open}}\nร‰vรฉnement en cours : {{current}}\nProchain รฉvรฉnement : {{next}}\nCalendrier : {{calendar}}\nBougies : {{candle_minutes}} minutes avant le coucher du soleil. Havdala : {{value}} {{method}}.\nSource : Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ Compte du Omer\n\nLe compte du Omer nโ€™est pas en cours ร  cette date.", + "help_text": "Commandes\n/start - menu principal\n/shabat, /shabbat - horaires de Chabbat\n/zmanim [YYYY-MM-DD] - horaires quotidiens\n/daf [YYYY-MM-DD] - Daf Yomi\n/omer - compte du Omer\n/groups - groupes que vous administrez\n/settings - panneau privรฉ dโ€™un groupe connu (administrateurs du groupe)\n/preferences - rรฉglages personnels\n/language, /lang - choix de la langue\n/source, /license, /version - informations de la version en cours\n/donate - soutien avec Telegram Stars\n\nInline : shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer et loc ville. Les changements de rรฉglages dโ€™un groupe exigent une permission dโ€™administration actuelle. La diffusion et les commandes dโ€™opรฉrateur restent rรฉservรฉes aux administrateurs du systรจme.", + "mode": "Mode de fonctionnement", + "mode_off": "Dรฉsactivรฉ", + "mode_messages": "Messages uniquement", + "mode_lock": "Verrouillage uniquement", + "mode_lock_messages": "Verrouillage et messages", + "shabbat_enabled": "Activitรฉ de Chabbat", + "holidays_enabled": "Activitรฉ des fรชtes", + "module_panel": "{{module}} pour {{title}}\n{{settings}}\nLes nouveaux modules de notification restent dรฉsactivรฉs jusquโ€™ร  leur activation explicite.", + "edit_prompt": "Modifier {{module}} pour {{title}}. Envoyez /configure {{token}} suivi de paires champ=valeur sรฉparรฉes par des points-virgules. Champs disponibles et valeurs actuelles :\n{{fields}}\nSรฉparez les รฉlรฉments dโ€™une liste par des virgules. Vรฉrifiez et confirmez les rรฉglages proposรฉs avant leur enregistrement.", + "changes_preview": "Aperรงu des rรฉglages pour {{title}}\n{{changes}}", + "date_prompt": "Envoyez /viewdate {{token}} YYYY-MM-DD. La date est explicite et utilise le fuseau horaire de ce contexte.", + "schedule_view": "Actions ร  venir pour {{title}}\n{{content}}\nLa restauration des permissions est prioritaire sur les messages habituels.", + "status_view": "ร‰tat de {{title}}\nHorloge UTC : {{utc}}\nHorloge locale : {{local}}\nPรฉriode couverte par le cache : {{coverage}}\nDerniรจre vรฉrification rรฉussie du fournisseur : {{fetched}}\nร‰vรฉnement en cours : {{current}}\nProchain รฉvรฉnement : {{next}}\nProblรจme : {{error}}", + "reset_preview": "Rรฉinitialiser uniquement {{module}} pour {{title}} ? Le lieu, la langue et les autres modules restent inchangรฉs.", + "suspend": "Suspendre les tรฉfilines pour aujourdโ€™hui", + "not_private": "Ouvrez le panneau privรฉ du groupe pour modifier les rรฉglages.", + "date_invalid": "Utilisez une date rรฉelle au format YYYY-MM-DD.", + "hours_invalid": "Utilisez des heures locales HH:mm et les choix disponibles.", + "location_label": "Lieu", + "alotHaShachar": "Aube", + "misheyakir": "Premiรจre heure du talit et des tรฉfilines", + "sunrise": "Lever du soleil", + "sofZmanShma": "Derniรจre heure du Chรฉma (GRA)", + "sofZmanShmaMGA": "Derniรจre heure du Chรฉma (MGA)", + "sofZmanTfilla": "Derniรจre heure de la priรจre (GRA)", + "sofZmanTfillaMGA": "Derniรจre heure de la priรจre (MGA)", + "chatzot": "Midi", + "minchaGedola": "Premiรจre heure de minโ€™ha", + "minchaKetana": "Minโ€™ha ketana", + "plagHaMincha": "Plag haminโ€™ha", + "sunset": "Coucher du soleil", + "tzeit85deg": "Tombรฉe de la nuit (8,5ยฐ)", + "tzeit42min": "Tombรฉe de la nuit (42 minutes fixes)", + "tzeit50min": "Tombรฉe de la nuit (50 minutes fixes)", + "tzeit72min": "Tombรฉe de la nuit (72 minutes fixes)", + "chatzotNight": "Minuit", + "settings_search": "Filtrer les groupes : /groups texte de recherche", + "field_language": "Langue", + "field_candle_minutes": "Minutes des bougies (city = usage de la ville)", + "field_havdalah_method": "Mรฉthode de havdala", + "field_havdalah_value": "Valeur de havdala", + "field_close_before_candles_minutes": "Fermer avant les bougies (minutes)", + "field_open_after_havdalah_minutes": "Ouvrir aprรจs la havdala (minutes)", + "field_target_topic_id": "Identifiant du sujet du forum (off pour dรฉsactiver)", + "field_enabled": "Activation", + "field_time": "Heure locale", + "field_days": "Jours : 0 dimanche ร  6 samedi", + "field_format": "Format", + "field_template": "Texte du modรจle", + "field_fixed_times": "Heures fixes", + "field_interval_minutes": "Intervalle en minutes (off pour dรฉsactiver, minimum 5)", + "field_window_start": "Dรฉbut de la plage locale", + "field_window_end": "Fin de la plage locale", + "field_sunset_offsets": "Minutes avant le coucher du soleil", + "field_max_per_day": "Maximum quotidien (1โ€“24)", + "field_min_spacing_minutes": "ร‰cart minimum en minutes (5 ou plus)", + "field_quiet_start": "Dรฉbut du silence (off pour dรฉsactiver)", + "field_quiet_end": "Fin du silence (off pour dรฉsactiver)", + "field_chol_hamoed": "Rappels pendant โ€˜hol hamoed", + "field_tisha_bav_policy": "Ticha Beav", + "field_sunset_safety_minutes": "Marge de sรฉcuritรฉ avant le coucher du soleil", + "field_mode": "Horaire", + "field_after_nightfall_minutes": "Minutes aprรจs la tombรฉe de la nuit", + "field_nightfall_method": "Mรฉthode de la nuit", + "field_nightfall_value": "Valeur de la nuit", + "field_nusach": "Nussaโ€™h", + "field_day_completion": "Complรฉment en journรฉe", + "field_early_policy": "Trop tรดt", + "field_after_open_minutes": "Minutes de silence aprรจs lโ€™ouverture du groupe", + "field_times_alert_enabled": "Annonce des horaires", + "field_times_alert_time": "Heure locale de lโ€™annonce", + "field_times_alert_mode": "Horaire de lโ€™annonce", + "field_times_alert_before_minutes": "Minutes de lโ€™annonce avant la fermeture", + "field_close_message_enabled": "Message de fermeture", + "field_open_message_enabled": "Message dโ€™ouverture", + "method_degrees": "degrรฉs", + "method_minutes": "minutes fixes aprรจs le coucher du soleil", + "window_end": "Fin de la plage", + "no_reminders": "Aucun rappel admissible pour cette journรฉe et ces rรฉglages.", + "preview_only": "Aperรงu privรฉ uniquement ; aucun message nโ€™est envoyรฉ au groupe.", + "template_panel": "{{title}} - Messages et prรฉsentation", + "template_menu": "{{title}} - {{kind}}\nUn aperรงu privรฉ nโ€™envoie jamais de message au groupe.", + "template_kind_close": "Message de fermeture", + "template_kind_open": "Message dโ€™ouverture", + "template_kind_zmanim": "Message des horaires quotidiens", + "template_kind_daf": "Message du Daf Yomi", + "template_kind_tefillin": "Message des tรฉfilines", + "template_kind_omer": "Message du Omer", + "template_edit": "โœ๏ธ Modifier texte / mรฉdia", + "template_buttons": "โŒจ๏ธ Modifier les boutons", + "template_preview": "๐Ÿ‘€ Aperรงu privรฉ", + "template_reset": "Rรฉinitialiser ce message", + "template_test": "Envoyer un TEST au groupe (confirmation)", + "template_edit_prompt": "Envoyez du texte ou un mรฉdia avec ce prรฉfixe de lรฉgende, puis un saut de ligne et le contenu :\n/template {{token}}\nLes entitรฉs de texte, le mรฉdia et les boutons existants sont conservรฉs.", + "template_buttons_prompt": "Envoyez /buttons {{token}}, puis un saut de ligne et un tableau JSON de rangรฉes de boutons. Chaque bouton contient un texte et une action : URL HTTPS, callback_data ou bascule inline. Styles facultatifs : primary, success, danger.\nLe format texte original fonctionne aussi : Libellรฉ - https://example.org - style:bg_success - icon:123. Utilisez && pour plusieurs boutons sur une rangรฉe, ou un saut de ligne. Autres actions : alert:texte, popup:texte, copy:texte. Les donnรฉes du callback sont limitรฉes ร  64 octets.", + "template_cleared": "Le message a รฉtรฉ rรฉinitialisรฉ. Rouvrez le panneau.", + "template_confirm_test": "โœ… Confirmer le TEST", + "template_test_confirm": "Confirmation explicite : un message TEST sera envoyรฉ ร  {{title}}. Cette action est distincte dโ€™un aperรงu privรฉ.", + "template_test_wait": "Attendez cinq minutes avant un autre test dans ce groupe.", + "template_cancelled": "Le test a รฉtรฉ annulรฉ ; aucun message nโ€™a รฉtรฉ envoyรฉ au groupe.", + "template_saved": "Le message a รฉtรฉ enregistrรฉ. Rouvrez le panneau pour un aperรงu privรฉ.", + "template_remove_text": "๐Ÿ—‘ Supprimer uniquement le texte", + "template_remove_media": "๐Ÿ—‘ Supprimer uniquement le mรฉdia", + "template_remove_buttons": "๐Ÿ—‘ Supprimer uniquement les boutons", + "gps_prompt": "Rรฉpondez ร  ce message prรฉcis avec une position Telegram pour {{title}}. Lโ€™aperรงu utilisera le fuseau {{timezone}}. Une position sans cette rรฉponse ne choisit jamais un parcours de rรฉglages.", + "gps_reply_required": "Commencez le partage GPS avec /location et le jeton, puis rรฉpondez ร  la demande du bot avec votre position. Aucun groupe nโ€™est choisi automatiquement.", + "location_attribution": "Donnรฉes de lieu : GeoNames (CC-BY-4.0).", + "timezone_auto": "Dรฉtecter le fuseau du lieu avec GeoNames", + "auto_timezone_missing": "La dรฉtection automatique du fuseau est dรฉsactivรฉe car GeoNames nโ€™est pas configurรฉ. Utilisez un identifiant IANA explicite, par exemple Asia/Jerusalem, ร  la place de auto.", + "command_start": "๐Ÿ  Ouvrir le menu principal", + "command_preferences": "โš™๏ธ Vos prรฉfรฉrences personnelles", + "command_lang": "๐ŸŒ Choisir votre langue", + "command_groups": "๐Ÿ‘ฅ Groupes que vous administrez", + "command_help": "โ„น๏ธ Commandes et aide", + "command_shabbat": "๐Ÿ•ฏ Horaires de Chabbat et des fรชtes", + "command_zmanim": "๐Ÿ•’ Horaires halakhiques quotidiens", + "command_daf": "๐Ÿ“– Daf Yomi dโ€™aujourdโ€™hui", + "command_omer": "๐ŸŒพ Compter le Omer", + "command_source": "๐Ÿ’ป Source correspondant ร  la version et licence", + "command_license": "๐Ÿ“œ Licence du projet", + "command_version": "๐Ÿท Version et commit en cours", + "command_donate": "โญ Soutenir avec Telegram Stars", + "command_stats": "๐Ÿ“Š Statistiques du service", + "command_add": "โž• Inscrire ce groupe", + "command_remove": "โž– Retirer ce groupe en sรฉcuritรฉ", + "command_settings": "โš™๏ธ Rรฉglages du groupe en privรฉ", + "command_admin": "๐Ÿ›  Panneau dโ€™administration du systรจme", + "command_testzmanim": "๐Ÿ”Ž Diagnostic des horaires et du planificateur", + "command_closegroups": "๐Ÿ”’ Fermeture manuelle autorisรฉe des groupes", + "command_opengroups": "๐Ÿ”“ Restauration autorisรฉe des permissions", + "command_setcommands": "๐Ÿ“‹ Publier les menus selon le contexte", + "command_restart": "๐Ÿ”„ Redรฉmarrer le bot proprement", + "donate_title": "Soutenez Shabbat Bot", + "donate_description": "Soutien volontaire : {{amount}} Telegram Stars. Merci !", + "donate_prompt": "Choisissez un montant de soutien ponctuel en Telegram Stars.", + "donate_invoice_error": "Impossible de crรฉer la facture de don. Rรฉessayez avec /donate.", + "donate_partial_error": "Certains montants de soutien sont indisponibles pour le moment. Vous pouvez utiliser une facture crรฉรฉe correctement.", + "donate_thanks": "Merci pour votre don de {{amount}} Telegram Stars !", + "donate_received_admin": "Don confirmรฉ de lโ€™utilisateur {{user_id}} : {{amount}} Telegram Stars.", + "donate_unverified_payment": "Ce paiement nโ€™a pas pu รชtre vรฉrifiรฉ. Il a รฉtรฉ signalรฉ ร  lโ€™opรฉrateur pour examen ; aucune rรฉussite nโ€™est annoncรฉe.", + "commands_setup_help": "Les menus de commandes sont publiรฉs sรฉparรฉment pour les conversations privรฉes, les administrateurs des groupes et les opรฉrateurs du systรจme, dans chaque langue disponible. Telegram utilise pt pour le portugais du Brรฉsil.", + "commands_setup_result": "Mises ร  jour des menus : {{total}} requรชtes ; {{failed}} รฉchecs.", + "commands_setup_preview": "Aperรงu : {{total}} menus par contexte et langue seront mis ร  jour.", + "commands_forbidden": "Seul un opรฉrateur du systรจme peut publier les menus de commandes.", + "operator_panel": "๐Ÿ›  Panneau de lโ€™opรฉrateur du systรจme", + "operator_subscribers": "๐Ÿ‘ฅ Abonnรฉs", + "operator_statistics": "๐Ÿ“Š Statistiques du service", + "operator_broadcast": "๐Ÿ“ฃ Diffusion", + "operator_preview": "๐Ÿ‘€ Aperรงu privรฉ", + "operator_send": "Envoyer la diffusion", + "operator_confirm_send": "Confirmer lโ€™envoi de cette diffusion ร  {{count}} destinataires.", + "operator_recipients_private": "Abonnรฉs en conversation privรฉe", + "operator_recipients_groups": "Groupes", + "operator_recipients_all": "Tous les abonnรฉs", + "operator_pin": "ร‰pingler le message", + "operator_unpin": "Dรฉsรฉpingler les messages", + "operator_delete_last": "Supprimer la derniรจre diffusion", + "operator_delete_all": "Supprimer toutes les diffusions", + "operator_progress": "Progression de la diffusion", + "operator_cancel": "Annuler", + "operator_buttons": "Modifier les boutons", + "operator_edit_text": "Modifier le texte", + "operator_edit_media": "Modifier le mรฉdia", + "operator_clear_draft": "Effacer le brouillon", + "operator_active_broadcast": "Une diffusion est dรฉjร  en cours. Attendez ou annulez-la avant dโ€™en lancer une autre.", + "operator_draft_saved": "Le brouillon de diffusion a รฉtรฉ enregistrรฉ.", + "operator_no_draft": "Aucun brouillon de diffusion nโ€™est disponible.", + "operator_page": "Page {{page}} sur {{total}}", + "operator_manual_result": "Action {{action}} : changements vรฉrifiรฉs dans {{changed}} groupes, {{failed}} รฉchecs ou rรฉsultats non vรฉrifiรฉs. Jusquโ€™ร  : {{until}}.", + "operator_manual_lock": "Fermeture manuelle des groupes", + "operator_manual_open": "Restaurer les permissions des groupes", + "group_new_wizard": "Ce nouveau groupe commence avec un lieu par dรฉfaut affichรฉ. Vรฉrifiez son lieu, son fuseau et son calendrier, puis choisissez explicitement un mode de fonctionnement.", + "permission_required": "Une permission dโ€™administration actuelle est requise pour ce groupe.", + "group_removed": "Le groupe a รฉtรฉ retirรฉ du service aprรจs le traitement sรฉcurisรฉ de ses permissions.", + "omer_blessing_eligibility": "La bรฉnรฉdiction est rรฉservรฉe aux personnes autorisรฉes ร  la rรฉciter. Le bot ne peut pas dรฉterminer lโ€™admissibilitรฉ individuelle ร  partir de lโ€™historique des messages dโ€™un groupe.", + "omer_day_completion_label": "Complรฉment en journรฉe - sans bรฉnรฉdiction.", + "omer_translation_note": "Texte rituel en hรฉbreu ; le compte suivant est une traduction explicative.", + "group_close_notice": "La pรฉriode de calme du Chabbat ou de la fรชte a commencรฉ.", + "group_open_notice": "La pรฉriode de calme du Chabbat ou de la fรชte est terminรฉe.", + "tefillin_reminder": "Avez-vous mis les tรฉfilines aujourdโ€™hui ?\nPour le lieu du groupe {{location}}, le coucher du soleil est ร  {{sunset}}.", + "no_event": "Aucun รฉvรฉnement nโ€™est disponible dans la pรฉriode de donnรฉes vรฉrifiรฉes.", + "stats_counts": "Groupes connus : {{known}}\nInscrits au service : {{registered}}\nVerrouillage activรฉ : {{locking}}\nActuellement verrouillรฉs par le bot : {{locked}}\nEn attente de restauration : {{recovery}}", + "source_info": "๐Ÿ’ป Shabbat Guardian - code, version et licence\n\nDรฉpรดt officiel\nWizardLoop/shabbat sur GitHub\n\nVersion en cours\nVersion : {{version}}\nCommit : {{commit}}\n\nLicence\n{{license}}\nLe code est disponible selon les conditions de sa licence. Les licences des dรฉpendances et mentions de droits accompagnent l'offre de source.\n\nSource correspondant ร  cette version\n{{url}}\nLe dรฉpรดt officiel est la page du projet ; les modifications locales peuvent figurer uniquement dans la source de cette version.", + "version_info": "Version en cours : {{version}}\nCommit : {{commit}}", + "license_info": "Licence de lโ€™application : {{license}}\nSource et licence de cette version : {{url}}", + "source_unpublished": "Lโ€™opรฉrateur nโ€™a pas configurรฉ dโ€™URL du code source correspondant ร  cette version.", + "donate_checkout_error": "Cette facture nโ€™est pas disponible ou ses informations sont invalides. Crรฉez une nouvelle facture de don avec /donate.", + "operator_recipients_channels": "Chaรฎnes", + "operator_subscriber_stats": "Utilisateurs : {{users}}\nGroupes : {{groups}}\nChaรฎnes : {{channels}}\nBots : {{bots}}\nTotal : {{total}}", + "operator_message_prompt": "Envoyez le message ร  enregistrer comme brouillon. La mise en forme et les mรฉdias sont conservรฉs. Les commandes restent disponibles pendant que cet รฉditeur est ouvert.", + "operator_buttons_prompt": "Envoyez des rangรฉes de boutons en JSON ou ainsi : Label - https://example.org - style:bg_success. Sรฉparez les boutons dโ€™une mรชme rangรฉe par && et les rangรฉes par un retour ร  la ligne. Actions prises en charge : lien HTTPS, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "Le brouillon est vide, trop long, ou contient une mise en forme ou des boutons invalides. Il nโ€™a pas รฉtรฉ enregistrรฉ.", + "operator_empty_recipients": "Aucun destinataire enregistrรฉ ne correspond ร  cette audience.", + "operator_complete": "La diffusion est terminรฉe. Consultez sa progression pour connaรฎtre les rรฉsultats de livraison.", + "operator_pause": "Suspendre", + "operator_resume": "Reprendre", + "operator_bc_starting": "{{operation}} : prรฉparation des destinatairesโ€ฆ", + "operator_bc_counts": "Traitรฉs : {{processed}} / {{total}}\nEnvoyรฉs : {{sent}}\nModifiรฉs : {{edited}}\nSans modification : {{unchanged}}\nSupprimรฉs : {{deleted}}\nDรฉsรฉpinglรฉs : {{unpin}}\nร‰checs : {{failed}}\nAttentes rรฉseau : {{flood}}\nEn attente : {{pending}}\nDรฉbit : {{tps}}/s", + "operator_bc_paused": "En pause", + "operator_bc_cancelled": "Annulรฉ", + "operator_bc_finished": "Terminรฉ", + "operator_bc_selfdestruct": "Suppression programmรฉe", + "operator_bc_legacy_summary": "Ce compte rendu ancien est conservรฉ dans lโ€™historique de la bibliothรจque. Consultez les rรฉsultats de diffusion actuels pour les compteurs disponibles.", + "operator_bc_edit": "Modification de la diffusion", + "moderation": "๐Ÿ”’ Restrictions et nettoyage du groupe", + "moderation_intro": "Choisissez les restrictions appliquรฉes pendant la fermeture du groupe. Un รฉlรฉment sรฉlectionnรฉ signifie que lโ€™action est bloquรฉe. Les restrictions existantes sont conservรฉes ; la rรฉouverture rรฉtablit les permissions dโ€™origine.", + "moderation_permissions": "Choisir les actions bloquรฉes", + "moderation_visibility_warning": "Bloquer la visibilitรฉ des messages peut masquer le groupe ร  ses membres. Cette restriction nโ€™est pas sรฉlectionnรฉe par dรฉfaut.", + "moderation_default": "Rรฉtablir les restrictions par dรฉfaut", + "moderation_none": "Ne sรฉlectionner aucune restriction supplรฉmentaire", + "moderation_all": "Sรฉlectionner toutes les restrictions", + "moderation_cleanup_hint": "Le nettoyage concerne uniquement les messages reรงus pendant une fermeture active de Chabbat ou de fรชte. Ces options sont dรฉsactivรฉes au dรฉpart ; chaque enregistrement nรฉcessite une confirmation. Les annonces de ce bot sont exclues.", + "moderation_bot_mode_hint": "Pour recevoir les messages dโ€™autres bots, ce bot doit รชtre administrateur, le mode Group Privacy Mode doit รชtre dรฉsactivรฉ et le mode Bot-to-Bot Communication Mode doit รชtre activรฉ dans BotFather. Les messages non transmis par Telegram ne peuvent pas รชtre supprimรฉs.", + "moderation_capabilities": "Vรฉrifier les permissions du bot", + "moderation_delete_capability": "Permission de suppression des messages vรฉrifiรฉe : {{state}}", + "field_lock_block_flags": "Actions supplรฉmentaires bloquรฉes pendant la fermeture", + "field_delete_holy_user_messages": "Supprimer les messages des membres pendant la fermeture religieuse", + "field_delete_holy_bot_messages": "Supprimer les messages dโ€™autres bots pendant la fermeture religieuse", + "field_delete_previous": "Supprimer le rappel de tefillin prรฉcรฉdent aprรจs lโ€™envoi du nouveau", + "announcement_schedule": "๐Ÿ•ฐ Annonce des horaires de Chabbat et des fรชtes", + "announcement_schedule_intro": "Choisissez lโ€™heure de lโ€™annonce dans le fuseau {{timezone}}. Un horaire prรฉdรฉfini configure lโ€™heure locale sans activer les annonces. Les rรฉglages personnalisรฉs permettent de choisir une autre heure ou un nombre de minutes avant la fermeture.", + "announcement_custom": "Heure personnalisรฉe / avant la fermeture", + "template_kind_times_alert": "Annonce des horaires de Chabbat et des fรชtes", + "permission_view_messages": "Voir les messages", + "permission_send_messages": "Envoyer des messages", + "permission_send_media": "Envoyer des mรฉdias", + "permission_send_stickers": "Envoyer des stickers", + "permission_send_gifs": "Envoyer des GIF", + "permission_send_games": "Envoyer des jeux", + "permission_send_inline": "Envoyer des rรฉsultats inline", + "permission_embed_links": "Afficher les aperรงus de liens", + "permission_send_polls": "Envoyer des sondages", + "permission_change_info": "Modifier les informations du groupe", + "permission_invite_users": "Inviter des utilisateurs", + "permission_pin_messages": "ร‰pingler des messages", + "permission_manage_topics": "Gรฉrer les sujets", + "permission_send_photos": "Envoyer des photos", + "permission_send_videos": "Envoyer des vidรฉos", + "permission_send_roundvideos": "Envoyer des messages vidรฉo", + "permission_send_audios": "Envoyer des fichiers audio", + "permission_send_voices": "Envoyer des messages vocaux", + "permission_send_docs": "Envoyer des documents", + "permission_send_plain": "Envoyer du texte simple", + "permission_edit_rank": "Modifier les titres des membres", + "permission_send_reactions": "Envoyer des rรฉactions", + "bot_permissions": "๐Ÿ›ก Droits dโ€™administration du bot", + "bot_permissions_intro": "Diagnostic des permissions Telegram en lecture seule. Accordez les droits nรฉcessaires dans les rรฉglages des administrateurs du groupe ; le bot ne peut pas sโ€™accorder lui-mรชme des droits.", + "bot_role_creator": "Propriรฉtaire du groupe", + "bot_role_administrator": "Administrateur du groupe", + "admin_right_change_info": "Modifier les informations du groupe", + "admin_right_post_messages": "Publier des messages dans le canal", + "admin_right_edit_messages": "Modifier les messages du canal", + "admin_right_delete_messages": "Supprimer des messages", + "admin_right_ban_users": "Restreindre les membres", + "admin_right_invite_users": "Inviter des utilisateurs", + "admin_right_pin_messages": "ร‰pingler des messages", + "admin_right_add_admins": "Ajouter des administrateurs", + "admin_right_anonymous": "Rester anonyme", + "admin_right_manage_call": "Gรฉrer les discussions vocales et vidรฉo", + "admin_right_other": "Autres privilรจges dโ€™administration", + "admin_right_manage_topics": "Gรฉrer les sujets", + "admin_right_post_stories": "Publier des stories", + "admin_right_edit_stories": "Modifier les stories", + "admin_right_delete_stories": "Supprimer des stories", + "admin_right_manage_direct_messages": "Gรฉrer les messages directs du canal", + "admin_right_manage_ranks": "Gรฉrer les rangs des membres", + "welcome": "โœก๏ธ Gardien du Chabbat\nHoraires du Chabbat et des fรชtes, zmanim, Daf Yomi et compte du Omer selon votre lieu.\n\nChoisissez une rubrique ou ouvrez vos prรฉfรฉrences pour rรฉgler la langue et le lieu.\n\nLes administrateurs peuvent gรฉrer chaque groupe dans ยซ Mes groupes ยป.", + "help": "โ„น๏ธ Informations et aide", + "home": "๐Ÿ  Menu principal", + "info_intro": "โ„น๏ธ Votre guide du Gardien du Chabbat\n\nVos horaires, dans votre langue et pour votre lieu.\nChoisissez une rubrique pour consulter les explications et les commandes utiles. Les administrateurs trouveront aussi la fermeture automatique, les rappels et la mise en forme des messages.", + "info_personal_title": "โš™๏ธ Mes prรฉfรฉrences", + "info_personal_text": "/preferences - langue, lieu, calendrier d'Israรซl ou de diaspora, mรฉthodes d'allumage et de havdala.\n/lang - choisir parmi six langues.\nLes horaires en privรฉ utilisent vos prรฉfรฉrences. Les changements personnels ne modifient pas les groupes.\n/start - revenir au menu principal.", + "info_calendar_title": "๐Ÿ•ฏ Horaires et calendrier juif", + "info_calendar_text": "Vos horaires\n/shabbat - prochain Chabbat, pรฉriodes continues de Yom Tov, paracha, Mevarchim et dates proches dans un message.\n/zmanim - horaires locaux ; choisissez la date avec les boutons.\n/daf - Daf Yomi. /omer - compte saisonnier du Omer.\n\nJours consรฉcutifs\nChaque allumage et la sortie finale sont affichรฉs. Les horaires de fรชtes concernent seulement les Yom Tov classรฉs par Hebcal. Hanoucca, Hol Hamoed et les autres dates ne sont pas en eux-mรชmes Yom Tov.\n\nLieu et mรฉthodes\nLes prรฉfรฉrences personnelles rรจglent le privรฉ ; chaque groupe reste indรฉpendant. Jรฉrusalem utilise par dรฉfaut 40 minutes avant le coucher du soleil, modifiables. Les horaires suivent le fuseau IANA choisi, y compris les changements d'heure.\n\nLes horaires manquants ne sont pas estimรฉs. Partager envoie le rรฉsultat vers un autre chat.\n\nCalendrier et horaires : Hebcal.com. La mรฉthode de sortie affichรฉe correspond ร  votre choix : degrรฉs ou minutes aprรจs le coucher du soleil.", + "info_groups_title": "๐Ÿ‘ฅ Gรฉrer les groupes", + "info_groups_text": "Connexion et panneau\nAjoutez le bot comme administrateur puis envoyez /settings dans le groupe. Le panneau est aussi accessible depuis Mes groupes en privรฉ. Le menu du groupe contient seulement les rรฉglages.\n\nOptions\nLieu, langue, Chabbat et fรชtes, horaires, Daf Yomi, tefillin, Omer, messages et droits. Chaque groupe est indรฉpendant.\n\nMode\nDรฉsactivรฉ, messages seuls, verrouillage seul ou les deux. Choisissez les jours, mรฉthodes et heure de l'annonce dans Chabbat et fรชtes.\n\nL'enregistrement exige les droits actuels d'administrateur. Vรฉrifiez le rรฉsumรฉ avant activation ; l'aperรงu privรฉ n'envoie rien au groupe.", + "info_notifications_title": "๐Ÿ”” Rappels et annonces", + "info_notifications_text": "Chaque rappel est indรฉpendant\nDans le panneau, choisissez horaires, Daf Yomi, tefillin ou Omer, activez puis rรฉglez l'heure locale. Les boutons guident sans clรฉs techniques ni jetons.\n\nTefillin\nChoisissez jours, heures fixes, intervalles ou minutes avant le coucher du soleil. Rรฉglez heures calmes, limite quotidienne et suppression du rappel prรฉcรฉdent. Chabbat, Yom Tov et les rรฉglages de Hol Hamoed et Ticha Beav sont respectรฉs.\n\nJ'ai mis les tefillin\nLe bouton compte les utilisateurs uniques ; un second clic annule le choix. L'administrateur peut le renommer ou le dรฉsactiver.\n\nAnnonce des horaires\nDans Chabbat et fรชtes, choisissez l'heure et le message. L'aperรงu reste privรฉ ; le test en groupe demande confirmation.", + "info_messages_title": "โœ‰๏ธ Prรฉsentation et permissions", + "info_messages_text": "Messages et prรฉsentation\nChoisissez fermeture, ouverture, Chabbat/fรชtes, horaires, Daf Yomi, tefillin ou Omer. Texte, photo/mรฉdia et boutons se modifient sรฉparรฉment.\n\nร‰dition naturelle\nEnvoyez le message souhaitรฉ. Le bot conserve texte et format Telegram, dont gras, liens et emoji personnalisรฉs. Vรฉrifiez l'aperรงu puis enregistrez.\n\nBoutons\nAjoutez un lien, un style, supprimez ou restaurez les boutons. Horaires et Daf Yomi ont un bouton de partage par dรฉfaut. Modifier seulement les boutons conserve le contenu calculรฉ.\n\nSi les emoji personnalisรฉs sont indisponibles, des emoji ordinaires sont utilisรฉs. Un test en groupe exige confirmation.", + "info_privacy_title": "๐Ÿ›ก Confidentialitรฉ et sรฉcuritรฉ", + "info_privacy_text": "Contrรดle des rรฉglages\nRecherche et gรฉolocalisation proposent un choix et un aperรงu ; seule la confirmation enregistre. Les prรฉfรฉrences personnelles ne modifient pas les groupes. Les droits actuels sont vรฉrifiรฉs avant la gestion.\n\nDonnรฉes conservรฉes\nIdentifiants, prรฉfรฉrences, droits et journaux nรฉcessaires au service. Le bouton tefillin mรฉmorise votre choix pour compter les utilisateurs uniques ; un second clic l'annule.\n\nTest et transparence\nL'aperรงu reste privรฉ. Les tests en groupe exigent confirmation. N'envoyez ni mots de passe ni jetons.\n/source regroupe dรฉpรดt officiel, version, licence et source correspondante.", + "info_operators_title": "๐Ÿ›  Administration du bot", + "info_operators_text": "/admin - abonnรฉs, statistiques et diffusions.\n/setcommands - menus de commandes par langue et type de chat ; vรฉrifiez l'aperรงu avant publication.\n/testzmanim - vรฉrifier les calculs et la programmation.\n/closegroups et /opengroups - actions manuelles sur les groupes autorisรฉs.\n/restart - redรฉmarrer proprement.\nCes fonctions sont rรฉservรฉes aux opรฉrateurs du bot. Administrer un groupe ne donne pas accรจs ร  l'administration du bot.", + "callback_panel_unavailable": "Impossible de mettre ร  jour ce menu. Rouvrez-le avec /start ou /settings.", + "provider_unavailable": "Les donnรฉes horaires vรฉrifiรฉes sont temporairement indisponibles. Rรฉessayez dans quelques minutes.", + "operator_subscriber_stats_full": "๐Ÿ“Š Abonnรฉs du bot Chabbat\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค Utilisateurs : {{users}}\n๐Ÿ‘ฅ Groupes : {{chats}}\n๐Ÿ‘ฅ Supergroupes : {{supergroups}}\n๐Ÿ“ข Canaux : {{channels}}\n๐Ÿค– Bots : {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nTotal : {{total}}", + "operator_group_stats": "๐Ÿ”’ Activitรฉ des groupes\nGroupes connus : {{known}}\nInscrits au service : {{registered}}\nFermeture activรฉe : {{locking}}\nActuellement fermรฉs par le bot : {{locked}}\nEn attente de restauration : {{recovery}}", + "times_section_schedule": "Horaires d'entrรฉe et de sortie", + "times_section_calendar": "Dรฉtails du calendrier", + "times_stale": "โ„น๏ธ Donnรฉes enregistrรฉes lors de la derniรจre vรฉrification rรฉussie ; le fournisseur est actuellement indisponible.", + "times_day_name_0": "Dimanche", + "times_day_name_1": "Lundi", + "times_day_name_2": "Mardi", + "times_day_name_3": "Mercredi", + "times_day_name_4": "Jeudi", + "times_day_name_5": "Vendredi", + "times_day_name_6": "Samedi", + "times_heading": "๐Ÿ•ฏ Horaires de Chabbat et des fรชtes", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… Jours saints : {{dates}}", + "times_candles": "๐Ÿ•ฏ Allumage des bougies - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ Fin / Havdala : {{time}}", + "times_close": "๐Ÿ”’ Fermeture du groupe : {{time}}", + "times_open": "๐Ÿ”“ Ouverture du groupe : {{time}}", + "times_sequence": "โ„น๏ธ Jours saints consรฉcutifs : le groupe reste fermรฉ jusqu'ร  l'ouverture finale.", + "times_parasha": "๐Ÿ•ฏ Paracha : {{title}}", + "times_mevarchim": "๐ŸŒ™ Chabbat Mevarkhim : {{title}}", + "times_holiday": "โœก๏ธ ร‰vรฉnement : {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "Bougies : {{minutes}} minutes avant le coucher du soleil\nSortie : {{method}} {{value}}", + "times_source": "Source : Hebcal ยท Mise ร  jour : {{time}}", + "times_data_missing": "Certaines donnรฉes horaires sont actuellement indisponibles.", + "location_menu_intro": "๐Ÿ“ Lieu pour {{title}}\nLieu actuel : {{location}}\nFuseau : {{timezone}}\nChoisissez comment trouver le lieu. Vous vรฉrifierez et confirmerez les dรฉtails avant de les enregistrer.", + "location_input_city_title": "๐Ÿ”Ž Chercher une ville", + "location_input_city_prompt": "Saisissez le nom d'une ville. La recherche accepte le franรงais et d'autres langues. Choisissez un rรฉsultat dans la liste.", + "location_input_timezone_title": "๐ŸŒ Fuseau horaire", + "location_input_timezone_prompt": "Saisissez une ville ou un fuseau, par exemple Jerusalem ou Asia/Jerusalem. Changer le fuseau ne modifie pas les coordonnรฉes.", + "location_input_geoname_title": "๐Ÿ”ข Identifiant GeoNames", + "location_input_geoname_prompt": "Saisissez l'identifiant numรฉrique GeoNames du lieu, par exemple 281184 pour Jรฉrusalem.", + "location_input_coordinates_title": "๐Ÿงญ Coordonnรฉes", + "location_input_coordinates_prompt": "Saisissez la latitude et la longitude, par exemple 31.76904, 35.21633. Vous pouvez ajouter un fuseau comme Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ Partager une position", + "location_input_gps_prompt": "Rรฉpondez ร  ce message avec une position Telegram. Vous vรฉrifierez et confirmerez le lieu et le fuseau.", + "location_input_invalid": "Le lieu n'a pas รฉtรฉ compris. Saisissez une ville, un identifiant GeoNames, des coordonnรฉes ou un fuseau selon l'option choisie.", + "location_inline_hint": "Vous pouvez aussi chercher dans un champ de message : @{{bot}} suivi du nom de la ville.", + "location_input_cancelled": "La sรฉlection du lieu a รฉtรฉ annulรฉe. Les rรฉglages n'ont pas changรฉ.", + "daf_open_page": "๐Ÿ”— Lire la page", + "timezone_inline_hint": "Vous pouvez aussi chercher dans un champ de message : @{{bot}} tz Jerusalem ou @{{bot}} Asia/Jerusalem. Pour les rรฉglages actuels, utilisez le bouton de recherche ici.", + "donate_other": "Autre", + "donate_custom_prompt": "โญ Choisissez votre contribution\n\nEnvoyez un nombre entier entre 1 et 10 000 รฉtoiles.\nVous recevrez un lien de paiement avant de confirmer votre contribution dans Telegram.", + "donate_custom_invalid": "Saisissez un nombre entier entre 1 et 10 000 รฉtoiles, sans symboles ni fractions.", + "donate_custom_ready": "โญ Pour contribuer {{amount}} รฉtoiles, ouvrez le lien de paiement et confirmez dans Telegram.", + "donate_custom_cancel": "โ†ฉ๏ธ Retour aux montants", + "times_upcoming": "๐Ÿ•ฏ Prochains Chabbats et fรชtes", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "Page {{page}} sur {{pages}}", + "times_period_exit": "๐ŸŒ™ Fin de la pรฉriode : {{time}}", + "times_no_events": "Aucun autre Chabbat ni fรชte dans la plage de dates vรฉrifiรฉe.", + "times_lighting_after": "Ce soir, lโ€™allumage se fait aprรจs la fin du jour saint prรฉcรฉdent, ร  partir dโ€™une flamme allumรฉe avant la fรชte.", + "times_event_link": "Horaires : {{title}}", + "omer_nightfall_label": "Tombรฉe de la nuit", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "Choisissez un rรฉglage. Sa valeur actuelle est indiquรฉe ร  cรดtรฉ.", + "wizard_custom": "โœ๏ธ Valeur personnalisรฉe", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nActuellement : {{current}}\n\n{{hint}}", + "wizard_invalid": "Cette valeur ne convient pas. Suivez lโ€™exemple ou revenez ร  la liste des rรฉglages.", + "wizard_done": "โœ… Vรฉrifier la sรฉlection", + "wizard_more": "โš™๏ธ Tous les rรฉglages", + "wizard_choose_option": "Choisissez une option puis vรฉrifiez la modification avant de lโ€™enregistrer.", + "wizard_template": "Personnalisez le texte, la mise en forme, les mรฉdias et les boutons dans lโ€™รฉditeur. Lโ€™aperรงu reste privรฉ.", + "wizard_hint_time": "Envoyez une heure locale, par exemple 09:30.", + "wizard_hint_times": "Envoyez les heures sรฉparรฉes par des virgules, par exemple 09:00, 13:30.", + "wizard_hint_integer": "Envoyez un nombre entier. Il sera vรฉrifiรฉ selon les limites de ce rรฉglage.", + "wizard_hint_float": "Envoyez un nombre, par exemple 8.5 pour les degrรฉs ou 50 pour les minutes fixes.", + "wizard_hint_minutes_list": "Envoyez les minutes sรฉparรฉes par des virgules, par exemple 60, 30, 15.", + "wizard_hint_text": "Envoyez le texte ร  enregistrer.", + "wizard_hint_days": "Sรฉlectionnez les jours puis ยซ Vรฉrifier la sรฉlection ยป.", + "template_edit_guided_prompt": "Envoyez le nouveau message ici. Vous pouvez joindre des mรฉdias et le mettre en forme dans Telegram. Vรฉrifiez lโ€™aperรงu avant de lโ€™enregistrer.", + "template_buttons_guided_prompt": "๐Ÿ”— Boutons du message\n\nPour un lien simple, choisissez ยซ Ajouter un bouton de lien ยป. Nous demanderons sรฉparรฉment le texte et lโ€™adresse.\n\nVous pouvez aussi envoyer plusieurs boutons selon cet exemple :\nInformation - https://example.org - style:bg_primary\nSoutien - https://example.org/support\n\nUne nouvelle ligne crรฉe une nouvelle rangรฉe. Sรฉparez les boutons dโ€™une mรชme rangรฉe avec &&. JSON est รฉgalement acceptรฉ. Vรฉrifiez lโ€™aperรงu avant de lโ€™enregistrer.", + "operator_audience_authorize": "โœ… Confirmer le public", + "command_adminstats": "๐Ÿ“Š Statistiques dโ€™administration", + "command_subscribers": "๐Ÿ‘ฅ Abonnรฉs et recherche", + "command_broadcast": "๐Ÿ“ฃ Diffuser un message", + "command_managers": "๐Ÿ‘ฅ Administrateurs et permissions", + "command_systemlinks": "โš™๏ธ Liens systรจme", + "command_trackinglinks": "โ„น๏ธ Liens de suivi", + "times_upcoming_calendar": "๐Ÿ•ฏ Fรชtes et dates ร  venir", + "times_sequence_calendar": "Pรฉriode continue de Chabbat et de fรชtes : chaque allumage est indiquรฉ, avec la sortie finale ร  la fin.", + "template_default_buttons": "โ†ฉ๏ธ Rรฉtablir les boutons par dรฉfaut", + "tefillin_ack": "Jโ€™ai mis les tรฉfilines", + "field_ack_enabled": "Bouton de confirmation des tรฉfilines", + "field_ack_label": "Texte de confirmation des tรฉfilines", + "field_tefillin_ack_enabled": "Bouton de confirmation des tรฉfilines", + "field_tefillin_ack_label": "Texte de confirmation des tรฉfilines", + "template_add_link": "โž• Ajouter un bouton de lien", + "template_link_text_prompt": "๐Ÿ”— Nouveau bouton\n\nEnvoyez un texte court, par exemple ยซ En savoir plus ยป.", + "template_link_url_prompt": "๐Ÿ”— Vers quelle adresse ?\n\nEnvoyez une adresse complรจte commenรงant par https://, par exemple https://example.org.\nVรฉrifiez le bouton avant de lโ€™enregistrer.", + "template_save_review": "โœ… Enregistrer le message", + "template_ack_preview": "Ceci est un aperรงu. Appuyer ici ne modifie pas le compteur.", + "template_restore_text": "๐Ÿ“ Rรฉtablir le texte automatique", + "template_remove_caption": "๐Ÿ—‘ Retirer la lรฉgende du mรฉdia", + "template_text_restored": "Le texte automatique a รฉtรฉ rรฉtabli. Le mรฉdia et les boutons sont conservรฉs.", + "template_caption_removed": "La lรฉgende a รฉtรฉ retirรฉe. Le mรฉdia et les boutons sont conservรฉs.", + "settings_mode_intro": "โš™๏ธ Mode de fonctionnement pour {{title}}\n\nDรฉsactivรฉ - aucun message ni verrouillage automatique de Chabbat ou de fรชte.\nMessages seuls - publications sans modification des permissions.\nVerrouillage seul - fermeture et rรฉouverture aux heures dรฉfinies, sans messages de fรชte.\nMessages et verrouillage - publications et gestion de l'accรจs.\n\nS'applique aux Chabbats et jours de Yom Tov activรฉs.", + "settings_mode_button": "Mode : {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ Entrรฉe, sortie et fermeture", + "settings_shabbat_messages": "โœ๏ธ Messages et prรฉsentation", + "settings_shabbat_timing_intro": "Choisissez la coutume d'allumage, la mรฉthode de sortie et les dรฉcalages de fermeture/ouverture. Les heures suivent le lieu du groupe. Un Chabbat et un Yom Tov consรฉcutifs restent fermรฉs jusqu'ร  la fin de toute la pรฉriode.", + "settings_shabbat_messages_intro": "Choisissez les messages de fermeture/ouverture, les heures calmes et le sujet du groupe. Modifiez sรฉparรฉment le texte, la mise en forme, les mรฉdias et les boutons.", + "settings_shabbat_summary": "โœก๏ธ Chabbat et fรชtes - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nMode : {{mode}}\nChabbat : {{shabbat}} ยท Yom Tov : {{holidays}}\n\n๐Ÿ•ฏ๏ธ Entrรฉe et sortie\nAllumage : {{candles}} minutes avant le coucher du soleil\nSortie : {{havdalah}}\nFermeture : {{close}} minutes avant l'allumage\nRรฉouverture : {{open}} minutes aprรจs la sortie\n\nChoisissez une rubrique. Aprรจs confirmation, vous revenez ร  cette rubrique.", + "template_current_summary": "Prรฉsentation actuelle\nTexte : {{text}}\nMรฉdia : {{media}}\nBoutons : {{buttons}}\n\nModifiez le contenu ou les boutons, vรฉrifiez l'aperรงu, puis enregistrez. Un envoi de test au groupe demande une confirmation distincte.", + "template_text_default": "Message automatique", + "template_text_custom": "Texte personnalisรฉ", + "template_buttons_inherit": "Boutons du bot", + "template_buttons_custom": "Boutons personnalisรฉs", + "template_buttons_none": "Sans boutons", + "template_panel_hint": "Choisissez un type de message. Chaque message accepte du texte mis en forme, des mรฉdias et des boutons. Les changements concernent uniquement ce groupe.", + "times_period_enter": "๐Ÿ•ฏ Entrรฉe / allumage des bougies : {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ Les prochains Chabbats", + "times_yomtov_unavailable": "Les horaires vรฉrifiรฉs de dรฉbut et de fin de ce Yom Tov ne sont pas encore disponibles.", + "info_permissions_title": "๐Ÿ›ก Droits, fermeture et ouverture", + "info_permissions_text": "Quels groupes sont compatibles ?\nTelegram autorise les bots ร  modifier les droits par dรฉfaut des groupes ordinaires et des supergroupes. Le bot doit รชtre administrateur et pouvoir restreindre les membres. Ces restrictions visent les membres ordinaires ; les administrateurs peuvent encore รฉcrire.\n\nAvant l'activation\nChoisissez la ville, le fuseau et le calendrier Israรซl ou diaspora. Dans Chabbat et fรชtes, activez le verrouillage seul ou avec messages. Vรฉrifiez les bougies, les dรฉcalages de fermeture et d'ouverture et les droits du bot.\n\nร€ la rรฉouverture\nLe bot enregistre les droits avant la fermeture puis les restaure. Un Chabbat et un Yom Tov consรฉcutifs restent fermรฉs jusqu'ร  la derniรจre sortie configurรฉe. Des droits modifiรฉs en dehors du bot ou manquants peuvent nรฉcessiter un contrรดle.\n\nSuppression\nLe droit de supprimer des messages est nรฉcessaire. Les messages des autres bots ont une option sรฉparรฉe, selon les mises ร  jour reรงues de Telegram.", + "template_buttons_simple_prompt": "Ajoutez un bouton de lien : choisissez son libellรฉ puis une adresse commenรงant par https://. Vรฉrifiez l'aperรงu avant d'enregistrer.\n\nLes autres actions et plusieurs lignes sont disponibles dans les options avancรฉes.", + "template_advanced_buttons": "โš™๏ธ Options avancรฉes", + "operator_manual_details": "Dรฉtails par groupe", + "operator_manual_pending": "En attente : {{count}}", + "operator_manual_more": "Et {{count}} autres groupes", + "permission_error_admin": "Accordez au bot le droit de restreindre les membres.", + "permission_error_access": "Le bot ne fait pas partie du groupe ou ne peut pas y accรฉder.", + "permission_error_flags": "Restrictions invalides. Rรฉinitialisez-les dans Chabbat et fรชtes.", + "permission_error_snapshot": "Impossible de vรฉrifier les permissions initiales. Un administrateur doit vรฉrifier avant la rรฉouverture.", + "permission_error_conflict": "Les permissions actuelles diffรจrent de l'รฉtat enregistrรฉ. Les modifications automatiques ont รฉtรฉ arrรชtรฉes pour vรฉrification par un administrateur.", + "permission_error_provider": "Le service du calendrier est indisponible.", + "permission_error_unknown": "Action non confirmรฉe. Consultez le journal de diagnostic du groupe.", + "permission_status_wait": "En attente du prochain essai aprรจs la limite Telegram", + "permission_status_busy": "Une autre action est en cours pour ce groupe", + "permission_status_prelocked": "Le groupe a รฉtรฉ fermรฉ hors du bot et ne sera pas rouvert automatiquement", + "permission_status_idle": "Aucun verrouillage du bot ร  retirer", + "permission_status_unchanged": "Le groupe est dรฉjร  dans la configuration demandรฉe", + "restart_requested": "โ™ป๏ธ Redรฉmarrage propre en cours. Le bot reviendra aprรจs son redรฉmarrage.", + "permission_conflict_fields": "Diffรฉrences : {{fields}}.", + "permission_until_date": "Expiration des permissions", + "permission_unknown": "Permission inconnue", + "times_shabbat_title": "Chabbat", + "permission_recovery": "๐Ÿ›  Rรฉsoudre un problรจme de droits", + "recovery_title": "๐Ÿ›  Rรฉsoudre un problรจme de droits\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "Le bot enregistre les droits habituels avant le verrouillage et les rรฉtablit ร  la rรฉouverture. Examinez ici une tentative inachevรฉe et choisissez la suite.", + "recovery_compare": "Comparaison avec les droits enregistrรฉs", + "recovery_difference": "โ€ข {{permission}} : maintenant {{current}} ยท enregistrรฉ {{saved}}", + "recovery_more": "{{count}} autres diffรฉrences. Lโ€™action concerne tous les droits, y compris ceux qui ne sont pas affichรฉs ici.", + "recovery_allowed": "autorisรฉ", + "recovery_blocked": "bloquรฉ", + "recovery_no_differences": "Aucune diffรฉrence avec les droits dโ€™origine enregistrรฉs.", + "recovery_adopt": "โœ… Accepter les droits actuels", + "recovery_restore": "โ†ฉ๏ธ Rรฉtablir les droits enregistrรฉs", + "recovery_preview_adopt": "Accepter les droits actuels\nLes droits actuels du groupe deviendront son รฉtat habituel, sans รชtre modifiรฉs. Lโ€™ancienne tentative sera close ; le prochain verrouillage enregistrera les droits ร  jour.", + "recovery_preview_restore": "Rรฉtablir les droits enregistrรฉs\nLe bot demandera tous les droits enregistrรฉs avant le problรจme, annulant les changements ultรฉrieurs. Le rรฉtablissement ne rรฉussit quโ€™aprรจs vรฉrification auprรจs de Telegram.", + "recovery_confirm_adopt": "โœ… Confirmer les droits actuels", + "recovery_confirm_restore": "โœ… Confirmer le rรฉtablissement", + "recovery_preview_notice": "Vรฉrifiez le nom du groupe et votre choix. La confirmation expire aprรจs cinq minutes. Les droits et lโ€™รฉtat seront vรฉrifiรฉs ร  nouveau ; la rรฉcupรฉration manuelle est indisponible pendant un verrouillage actif, Chabbat ou une fรชte.", + "recovery_result_adopted": "โœ… Les droits actuels sont acceptรฉs comme รฉtat habituel. Lโ€™ancienne tentative est close.", + "recovery_result_restored": "โœ… Les droits enregistrรฉs ont รฉtรฉ rรฉtablis et vรฉrifiรฉs. Lโ€™ancienne tentative est close.", + "recovery_block_active_lock": "๐Ÿ”’ Une pรฉriode sรปre de rรฉcupรฉration ne peut pas รชtre confirmรฉe : un verrouillage, Chabbat ou une fรชte peut รชtre en cours, ou les donnรฉes du calendrier indisponibles. Vous pouvez consulter et actualiser ; toute modification manuelle exige la vรฉrification quโ€™aucune pรฉriode protรฉgรฉe nโ€™est active.", + "recovery_block_no_problem": "โœ… Aucun problรจme de droits nโ€™attend de rรฉsolution. Le prochain verrouillage enregistrera les droits ร  jour du groupe.", + "recovery_block_missing_snapshot": "โš ๏ธ Aucun รฉtat dโ€™origine vรฉrifiรฉ ne peut รชtre rรฉtabli. Si lโ€™acceptation des droits actuels est proposรฉe, vรฉrifiez dโ€™abord quโ€™ils correspondent ร  lโ€™รฉtat habituel souhaitรฉ.", + "recovery_block_locked_current": "๐Ÿ”’ Le groupe semble actuellement verrouillรฉ. Cet รฉtat ne peut pas รชtre acceptรฉ comme habituel. Examinez le rรฉtablissement ou contactez lโ€™opรฉrateur du bot.", + "recovery_block_unknown": "โš ๏ธ La sรปretรฉ de la rรฉcupรฉration ne peut pas รชtre vรฉrifiรฉe actuellement. Actualisez ; si le problรจme persiste, contactez lโ€™opรฉrateur du bot.", + "recovery_result_stale": "โŒ› La confirmation a expirรฉ ou lโ€™รฉtat a changรฉ. Examinez lโ€™รฉtat actuel puis crรฉez une nouvelle confirmation.", + "recovery_result_forbidden": "Des droits dโ€™administration actuels sur ce groupe sont requis.", + "recovery_result_busy": "โณ Une autre opรฉration est en cours dans le groupe. Actualisez dans un instant.", + "recovery_result_conflict": "โš ๏ธ Telegram a renvoyรฉ un รฉtat inattendu. Les informations dโ€™origine sont conservรฉes ; examinez la comparaison avant de rรฉessayer.", + "recovery_result_error": "La rรฉcupรฉration nโ€™a pas pu รชtre terminรฉe ou vรฉrifiรฉe. Les informations enregistrรฉes sont conservรฉes. Actualisez avant de rรฉessayer.", + "recovery_result_retry_wait": "โณ Telegram demande de patienter. ร‰vitez les clics rรฉpรฉtรฉs ; actualisez plus tard.", + "recovery_refresh": "๐Ÿ”„ Actualiser lโ€™รฉtat", + "recovery_cancelled": "Choix annulรฉ. Aucune rรฉcupรฉration manuelle nโ€™a รฉtรฉ effectuรฉe.", + "recovery_state_problem": "โš ๏ธ Une tentative de verrouillage est inachevรฉe. Examinez les diffรฉrences et choisissez lโ€™รฉtat souhaitรฉ. Chaque option ouvre une confirmation avant exรฉcution.", + "diagnostics_title": "๐Ÿงช Diagnostic des horaires et permissions", + "diagnostics_read_only": "Lecture seule. Instantanรฉ valable 15 minutes ; actualisez pour les donnรฉes actuelles.", + "diagnostics_context": "Contexte", + "diagnostics_build": "Version exรฉcutรฉe", + "diagnostics_location": "Lieu et fuseau horaire", + "diagnostics_methods": "Minutes des bougies / mรฉthode de sortie et valeur", + "diagnostics_lock_now": "Verrouillage requis maintenant", + "diagnostics_quiet_now": "Chabbat, fรชte ou verrouillage actif", + "diagnostics_stale": "Donnรฉes du fournisseur pรฉrimรฉes", + "diagnostics_evaluated_at": "Heure du calcul", + "diagnostics_fetched_at": "Donnรฉes reรงues le", + "diagnostics_period": "Plage de fermeture et ouverture", + "diagnostics_provider_error": "Erreur du fournisseur de calendrier", + "diagnostics_queue": "File des tรขches", + "diagnostics_group": "Groupe", + "diagnostics_limited": "Groupes affichรฉs / total. Autre groupe : /testzmanim et son ID", + "diagnostics_lock_state": "ร‰tat du verrouillage enregistrรฉ", + "diagnostics_original": "Permissions normales enregistrรฉes : restrictions actives", + "diagnostics_requested": "Permissions demandรฉes : restrictions actives", + "diagnostics_current": "Permissions Telegram actuelles : restrictions actives", + "diagnostics_compare_original": "Diffรฉrences avec les permissions normales enregistrรฉes", + "diagnostics_compare_requested": "Diffรฉrences avec les permissions demandรฉes", + "diagnostics_all": "Tout", + "diagnostics_problems": "โš ๏ธ Problรจmes et avertissements", + "diagnostics_summary": "Rรฉsumรฉ", + "diagnostics_calendar": "Calendrier", + "diagnostics_permissions": "Permissions", + "diagnostics_groups": "๐Ÿ‘ฅ Groupes", + "diagnostics_busy": "Action en cours ou rรฉcente. Actualisation toutes les 30 secondes, export une fois par minute.", + "diagnostics_unavailable": "Diagnostic indisponible, expirรฉ ou accรจs refusรฉ. Rรฉessayez /testzmanim en privรฉ dans 30 secondes.", + "diagnostics_export_caption": "๐Ÿ“„ Diagnostic des horaires et permissions. Instantanรฉ en lecture seule.", + "diagnostics_export_sent": "Fichier JSON envoyรฉ.", + "diagnostics_previous": "โ—€๏ธ Prรฉcรฉdent", + "diagnostics_next": "Suivant โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž Rechercher", + "diagnostics_filters": "๐Ÿ”ฝ Filtrer", + "diagnostics_refresh": "๐Ÿ”„ Actualiser", + "diagnostics_clear": "Effacer recherche et filtres", + "diagnostics_export": "๐Ÿ“„ Exporter JSON", + "diagnostics_back": "Retour ร  la gestion", + "diagnostics_cancel": "Annuler", + "diagnostics_empty": "Aucun rรฉsultat pour cette recherche et ce filtre.", + "diagnostics_page_prompt": "Envoyez un numรฉro de page entre 1 et {{pages}}. Le panneau actuel sera mis ร  jour.", + "diagnostics_search_prompt": "Envoyez un mot, une date ou un ID de groupe ร  rechercher dans lโ€™instantanรฉ (80 caractรจres maximum).", + "diagnostics_invalid_page": "Numรฉro de page invalide. Choisissez dans la plage affichรฉe.", + "diagnostics_is_work_prohibited_now": "Travail interdit maintenant", + "diagnostics_is_shabbat": "Chabbat actuellement", + "diagnostics_is_yom_tov": "Yom Tov actuellement", + "diagnostics_next_close_at": "Prochaine fermeture", + "diagnostics_next_open_at": "Prochaine ouverture", + "diagnostics_baseline": "Permissions normales aprรจs rรฉcupรฉration : restrictions actives", + "diagnostics_compare_previous": "Diffรฉrences avec les permissions intermรฉdiaires", + "diagnostics_compare_baseline": "Diffรฉrences avec la base de rรฉcupรฉration", + "diagnostics_concurrent_change": "Les permissions enregistrรฉes ont changรฉ pendant la vรฉrification. Actualisez avant de comparer.", + "diagnostics_operation": "Derniรจre opรฉration : action / รฉtape / rรฉsultat", + "diagnostics_previous_rights": "Permissions intermรฉdiaires prรฉcรฉdentes : restrictions actives", + "diagnostics_invalid_search": "Recherche trop longue. Saisissez 80 caractรจres maximum.", + "recovery_block_unverified_snapshot": "โš ๏ธ Les droits enregistrรฉs proviennent dโ€™une ancienne version et nโ€™ont pas รฉtรฉ vรฉrifiรฉs par la nouvelle lecture. Ils peuvent diffรฉrer des anciens rรฉglages du groupe ; leur restauration est donc dรฉsactivรฉe.\nEn dehors de Chabbat, de Yom Tov ou dโ€™un verrouillage actif : dรฉfinissez les droits souhaitรฉs dans Telegram, actualisez ici, vรฉrifiez-les, puis confirmez lโ€™adoption des droits actuels. Lโ€™ancien รฉtat est conservรฉ pour le diagnostic.", + "permission_error_unverified": "Lโ€™รฉtat enregistrรฉ par lโ€™ancienne version doit รชtre vรฉrifiรฉ par un administrateur. Ouvrez la rรฉsolution des problรจmes de droits dans les rรฉglages du groupe ; ne restaurez pas cet ancien รฉtat.", + "diagnostics_snapshot_verification": "Vรฉrification des droits enregistrรฉs", + "diagnostics_snapshot_verified": "Enregistrรฉs aprรจs une lecture vรฉrifiรฉe", + "diagnostics_snapshot_unverified": "ร‰tat dโ€™une ancienne version - vรฉrification par un administrateur requise", + "permission_error_readback": "โš ๏ธ La demande de modification des droits a รฉtรฉ envoyรฉe, mais son rรฉsultat nโ€™a pas รฉtรฉ vรฉrifiรฉ. Les droits ont peut-รชtre dรฉjร  changรฉ. Vรฉrifiez-les dans Telegram et dans la rรฉsolution des problรจmes de droits avant de rรฉessayer." +} diff --git a/resources/locales/he.json b/resources/locales/he.json new file mode 100644 index 0000000..c2a03e3 --- /dev/null +++ b/resources/locales/he.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ ืฉื‘ืช ื•ื—ื’ื™ื", + "zmanim": "๐Ÿ•’ ื–ืžื ื™ ื”ื™ื•ื", + "daf": "๐Ÿ“– ื“ืฃ ื™ื•ืžื™", + "omer": "๐ŸŒพ ืกืคื™ืจืช ื”ืขื•ืžืจ", + "tefillin": "๐Ÿ”” ืชื–ื›ื•ืจื•ืช ืชืคื™ืœื™ืŸ", + "add": "โž• ื”ื•ืกืฃ ืื•ืชื™ ืœืงื‘ื•ืฆื”", + "groups": "๐Ÿ‘ฅ ื”ืงื‘ื•ืฆื•ืช ืฉืœื™", + "preferences": "โš™๏ธ ื”ื”ืขื“ืคื•ืช ืฉืœื™", + "donate": "โญ ืชืžื™ื›ื” ื‘ื‘ื•ื˜", + "source": "๐Ÿ’ป ืงื•ื“ ืžืงื•ืจ ื•ืจื™ืฉื™ื•ืŸ", + "back": "โ†ฉ๏ธ ื—ื–ืจื”", + "cancel": "ื‘ื™ื˜ื•ืœ", + "confirm": "โœ… ืื™ืฉื•ืจ ืฉืžื™ืจื”", + "language": "๐ŸŒ ืฉืคื”", + "location": "๐Ÿ“ ืžื™ืงื•ื ื•ืื–ื•ืจ ื–ืžืŸ", + "candles": "๐Ÿ•ฏ ื”ื“ืœืงืช ื ืจื•ืช", + "havdalah": "๐ŸŒ™ ื”ื‘ื“ืœื”", + "messages": "โœ‰๏ธ ื”ื•ื“ืขื•ืช ื•ืขื™ืฆื•ื‘", + "schedule": "๐Ÿ—“ ืชื–ืžื•ื ื™ื ืงืจื•ื‘ื™ื", + "status": "๐Ÿ›  ืžืฆื‘ ื•ืชืงืœื•ืช", + "preview": "๐Ÿ‘€ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™", + "reset": "ืื™ืคื•ืก ืžื•ื“ื•ืœ ื–ื”", + "edit": "โš™๏ธ ืขืจื™ื›ืช ื”ื’ื“ืจื•ืช", + "on": "ืคืขื™ืœ", + "off": "ื›ื‘ื•ื™", + "today": "ื”ื™ื•ื", + "tomorrow": "ืžื—ืจ", + "other_date": "ืชืืจื™ืš ืื—ืจ", + "share": "ืฉื™ืชื•ืฃ", + "short": "ืžืงื•ืฆืจ", + "full": "ืžืœื", + "next": "ื”ื“ืฃ ื”ื‘ื", + "previous": "ื”ื“ืฃ ื”ืงื•ื“ื", + "no_groups": "ืœื ื ืžืฆืื• ืงื‘ื•ืฆื•ืช ืžื•ื›ืจื•ืช ืฉื™ืฉ ืœืš ื›ืจื’ืข ื”ืจืฉืืช ื ื™ื”ื•ืœ ื‘ื”ืŸ. ื‘ืงื‘ื•ืฆื” ืžื•ื›ืจืช ื”ืฉืชืžืฉื• ื‘ึพ/settings.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nืžืฆื‘ ืคืขื™ืœื•ืช: {{mode}}\nื”ืคืขื•ืœื” ื”ื‘ืื”: {{next}}\n\nื‘ื—ืจื• ื ื•ืฉื ืœื”ื’ื“ืจืช ื”ืงื‘ื•ืฆื”. ื›ืœ ืฉื™ื ื•ื™ ื™ื•ืฆื’ ืœื‘ื“ื™ืงื” ื•ืœืื™ืฉื•ืจ.", + "personal_panel": "โš™๏ธ ื”ื”ืขื“ืคื•ืช ืฉืœื™\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nื‘ื—ืจื• ืฉืคื”, ืžื™ืงื•ื ื•ืฉื™ื˜ื•ืช ื—ื™ืฉื•ื‘. ื”ื”ืขื“ืคื•ืช ื”ืืœื” ืžืฉืžืฉื•ืช ืœื”ืฆื’ืช ื”ื–ืžื ื™ื ื”ืื™ืฉื™ื™ื ืฉืœื›ื.", + "denied": "ื”ืคืขื•ืœื” ืื™ื ื” ื–ืžื™ื ื”: ื”ื”ืจืฉืื” ืื• ืชื•ืงืฃ ื”ื”ื’ื“ืจื•ืช ืคื’ื•. ืคืชื—ื• ืžื—ื“ืฉ ืืช ืคืื ืœ ื”ืงื‘ื•ืฆื”.", + "saved": "โœ… ื ืฉืžืจ ืขื‘ื•ืจ {{title}}. ืคืขื•ืœื•ืช ืขืชื™ื“ื™ื•ืช ื—ื•ืฉื‘ื• ืžื—ื“ืฉ; ื—ื•ื‘ ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช ื ืฉืžืจ.", + "language_choose": "ื‘ื—ืจื• ืืช ื”ืฉืคื” ืฉืœ ื”ื”ืงืฉืจ ื”ื–ื”. ืฉืคืช ื”ืคืื ืœ ื”ืคืจื˜ื™ ื ืฉืืจืช ื”ืฉืคื” ื”ืื™ืฉื™ืช ืฉืœืš.", + "candle_choose": "ื‘ื—ืจื• ืืช ืžืกืคืจ ื”ื“ืงื•ืช ื‘ืคื•ืขืœ ืœืคื ื™ ื”ืฉืงื™ืขื”, ืื• ืžื ื”ื’ ืขื™ืจ ืžืชื•ืขื“. ืื™ืŸ ื”ื•ืกืคืช ื“ืงื” ื ืกืชืจืช.", + "city_custom": "ืžื ื”ื’ ื”ืขื™ืจ", + "havdalah_choose": "ื‘ื—ืจื• ืžืขืœื•ืช ืื• ื“ืงื•ืช ืงื‘ื•ืขื•ืช ืื—ืจื™ ื”ืฉืงื™ืขื”. 72 ื“ืงื•ืช ืคื™ืจื•ืฉืŸ 72 ื“ืงื•ืช ืงื‘ื•ืขื•ืช; ื”ืฉื™ื˜ื” ื•ื”ืขืจืš ื ืฉืžืจื™ื ื‘ื ืคืจื“.", + "degrees": "8.5 ืžืขืœื•ืช", + "minutes": "{{value}} ื“ืงื•ืช ืงื‘ื•ืขื•ืช", + "location_choose": "ื—ืคืฉื• ืขื™ืจ, ื‘ื—ืจื• ืชื•ืฆืื”, ื‘ื“ืงื• ืืช ืื–ื•ืจ ื”ื–ืžืŸ ื•ืืฉืจื• ืขื‘ื•ืจ ื”ื”ืงืฉืจ ืฉืฉืžื• ืžื•ืฆื’. ื—ื™ืคื•ืฉ ื•ืฉื™ืชื•ืฃ ืื™ื ื ืฉื•ืžืจื™ื ื”ื’ื“ืจื•ืช.", + "inline_search": "ื—ืคืฉ ืขื™ืจ ื‘ืื™ื ืœื™ื™ืŸ", + "city_search": "ื—ื™ืคื•ืฉ ื‘ืคืจื˜ื™", + "coordinates": "ืงื•ืื•ืจื“ื™ื ื˜ื•ืช / ืฉื™ืชื•ืฃ ืžื™ืงื•ื", + "timezone": "ืื–ื•ืจ ื–ืžืŸ ื™ื“ื ื™", + "calendar_israel": "ืœื•ื— ื™ืฉืจืืœ", + "calendar_diaspora": "ืœื•ื— ื—ื•ืดืœ", + "copy_personal": "ื”ืขืชืง ืืช ื”ื”ืขื“ืคื•ืช ืฉืœื™ (ืชืฆื•ื’ื” ืžืงื“ื™ืžื”)", + "geonames_missing": "ื—ื™ืคื•ืฉ GeoNames ื›ื‘ื•ื™. ื”ืžืคืขื™ืœ ืฆืจื™ืš ืœื”ื’ื“ื™ืจ GEONAMES_USERNAME ื•ืœื”ืคืขื™ืœ ืฉื™ืจื•ืชื™ ืจืฉืช. ืืคืฉืจ ืœื‘ื—ื•ืจ ืขื™ืจ ืžื•ื›ืจืช ืื• ืงื•ืื•ืจื“ื™ื ื˜ื•ืช.", + "city_prompt": "ืฉืœื—ื• /city {{token}} ืฉื ืขื™ืจ ื‘ื›ืœ ืฉืคื” ื”ื ืชืžื›ืช ืืฆืœ ื”ืกืคืง. ื”ื˜ื•ืงืŸ ืฉื™ื™ืš ืœืชื”ืœื™ืš ื”ื”ื’ื“ืจื” ื”ื–ื” ื‘ืœื‘ื“.", + "coordinates_prompt": "ืฉืœื—ื• /coordinates {{token}} ืงื•ึพืจื•ื—ื‘ ืงื•ึพืื•ืจืš ืื–ื•ืจึพื–ืžืŸึพIANA ืฉื. ืœื“ื•ื’ืžื”: /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem ื™ืจื•ืฉืœื™ื. ืœืฉื™ืชื•ืฃ GPS, ืฉืœื—ื• /location {{token}} ืื–ื•ืจึพื–ืžืŸึพIANA ื•ืื– ื”ืฉื™ื‘ื• ืœื”ื•ื“ืขืช ื”ื‘ื•ื˜ ืขื ืžื™ืงื•ื Telegram.\nืืคืฉืจ ืœื”ืฉืชืžืฉ ื‘ึพauto ื‘ืžืงื•ื ืื–ื•ืจ ื”ื–ืžืŸ ื›ืฉื”ืžืคืขื™ืœ ื”ื’ื“ื™ืจ GeoNames; ืชืžื™ื“ ืชื•ืฆื’ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืคื ื™ ืฉืžื™ืจื”.", + "timezone_prompt": "ืฉืœื—ื• /timezone {{token}} ื˜ืงืกื˜ ืœื—ื™ืคื•ืฉ ื‘ืžื–ื”ื™ ืื–ื•ืจื™ ื”ื–ืžืŸ ืฉืœ IANA. ื”ื‘ื—ื™ืจื” ื“ื•ืจืฉืช ืื™ืฉื•ืจ ื•ืื™ื ื” ืžืฉื ื” ืงื•ืื•ืจื“ื™ื ื˜ื•ืช.", + "location_preview": "ืชืฆื•ื’ืช ืžื™ืงื•ื ืขื‘ื•ืจ {{title}}\nืขื™ืจ: {{location}}\nืžื“ื™ื ื”: {{country}}\nืื–ื•ืจ ืžื ื”ืœื™: {{region}}\nืงื• ืจื•ื—ื‘: {{latitude}}\nืงื• ืื•ืจืš: {{longitude}}\nืžื–ื”ื” GeoNames: {{geoname}}\nืื–ื•ืจ ื–ืžืŸ IANA: {{timezone}}\nื”ื™ืกื˜ UTC ื ื•ื›ื—ื™: {{offset}} (ืœืชืฆื•ื’ื” ื‘ืœื‘ื“)\n{{warning}}", + "timezone_warning": "ืื–ื•ืจ ื–ืžืŸ ืฉื ื‘ื—ืจ ื™ื“ื ื™ืช ืขืฉื•ื™ ืœื”ื™ื•ืช ืฉื•ื ื” ืžืื–ื•ืจ ื”ื–ืžืŸ ื”ื’ืื•ื’ืจืคื™. ื”ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ืœื ื”ืฉืชื ื•.", + "no_results": "ืœื ื ืžืฆืื• ืขืจื™ื ืœืฉืื™ืœืชื” ื”ื–ืืช.", + "search_error": "ื—ื™ืคื•ืฉ ืžื™ืงื•ื ืื™ื ื• ื–ืžื™ืŸ: {{error}}", + "select_location": "ื‘ื“ืงื• ืืช ื”ืžืงื•ื; ื”ื”ื’ื“ืจื•ืช ื™ื™ืฉืžืจื• ืจืง ืื—ืจื™ ืื™ืฉื•ืจ ืคืจื˜ื™ ืฉืœืš.", + "select_private": "ื”ืžืฉืš ื‘ืฆืณืื˜ ื”ืคืจื˜ื™ ืขื ื”ื‘ื•ื˜", + "public_location": "ืžื™ื“ืข ืฆื™ื‘ื•ืจื™ ืขืœ ืžืงื•ื. ืฉื™ืชื•ืฃ ื”ืชื•ืฆืื” ืื™ื ื• ืžืฉื ื” ื”ื’ื“ืจื•ืช ืื™ืฉื™ื•ืช ืื• ืงื‘ื•ืฆืชื™ื•ืช.", + "provider_error": "ืื™ืŸ ื–ืžื ื™ื ืžืื•ืžืชื™ื ื–ืžื™ื ื™ื: {{error}}. ืœื ื”ื•ืžืฆื ื–ืžืŸ ื—ืœื•ืคื™.", + "unavailable": "ืœื ื–ืžื™ืŸ ืœืžื™ืงื•ื/ืœืชืืจื™ืš ื”ื–ื”", + "shabbat_view": "ืฉื‘ืช ื•ื—ื’ื™ื\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nื”ื“ืœืงืช ื ืจื•ืช: {{candles}}\nื ืขื™ืœืช ื”ืงื‘ื•ืฆื”: {{close}}\nื”ื‘ื“ืœื”: {{havdalah}}\nืคืชื™ื—ืช ื”ืงื‘ื•ืฆื”: {{open}}\nืื™ืจื•ืข ืคืขื™ืœ: {{current}}\nืื™ืจื•ืข ื”ื‘ื: {{next}}\nืœื•ื—: {{calendar}}\nื”ื“ืœืงื”: {{candle_minutes}} ื“ืงื•ืช ืœืคื ื™ ืฉืงื™ืขื”. ื”ื‘ื“ืœื”: {{value}} {{method}}.\nืžืงื•ืจ: Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ ืกืคื™ืจืช ื”ืขื•ืžืจ\n\nืกืคื™ืจืช ื”ืขื•ืžืจ ืื™ื ื” ื‘ืขื•ื ื” ืœืชืืจื™ืš ื”ื–ื”.", + "help_text": "ืคืงื•ื“ื•ืช\n/start - ืชืคืจื™ื˜ ืจืืฉื™\n/shabat, /shabbat - ื–ืžื ื™ ืฉื‘ืช\n/zmanim [YYYY-MM-DD] - ื–ืžื ื™ ื”ื™ื•ื\n/daf [YYYY-MM-DD] - ื“ืฃ ื™ื•ืžื™\n/omer - ืกืคื™ืจืช ื”ืขื•ืžืจ\n/groups - ืงื‘ื•ืฆื•ืช ืฉื‘ื ื™ื”ื•ืœืš\n/settings - ืคืื ืœ ืคืจื˜ื™ ืฉืœ ืงื‘ื•ืฆื” ืžื•ื›ืจืช (ืœืžื ื”ืœื™ ืงื‘ื•ืฆื”)\n/preferences - ื”ืขื“ืคื•ืช ืื™ืฉื™ื•ืช\n/language, /lang - ื‘ื—ื™ืจืช ืฉืคื”\n/source, /license, /version - ืžื™ื“ืข ืขืœ ื”ื’ืจืกื” ื”ืคืขื™ืœื”\n/donate - ืชืžื™ื›ื” ื‘ื›ื•ื›ื‘ื™ Telegram\n\nืื™ื ืœื™ื™ืŸ: shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer ื•ึพloc ืฉื ืขื™ืจ. ื”ื’ื“ืจื•ืช ืงื‘ื•ืฆื” ื“ื•ืจืฉื•ืช ื”ืจืฉืืช ืžื ื”ืœ ืขื“ื›ื ื™ืช. ืฉื™ื“ื•ืจื™ื ื•ื›ืœื™ ืžืคืขื™ืœ ื ืฉืืจื™ื ืœืžื ื”ืœื™ ืžืขืจื›ืช ื‘ืœื‘ื“.", + "mode": "ืžืฆื‘ ืคืขื™ืœื•ืช", + "mode_off": "ื›ื‘ื•ื™", + "mode_messages": "ื”ื•ื“ืขื•ืช ื‘ืœื‘ื“", + "mode_lock": "ื ืขื™ืœื” ื‘ืœื‘ื“", + "mode_lock_messages": "ื ืขื™ืœื” ื•ื”ื•ื“ืขื•ืช", + "shabbat_enabled": "ืคืขื™ืœื•ืช ืฉื‘ืชื•ืช", + "holidays_enabled": "ืคืขื™ืœื•ืช ื—ื’ื™ื", + "module_panel": "{{module}} ืขื‘ื•ืจ {{title}}\n{{settings}}\nืžื•ื“ื•ืœื™ ื”ืชืจืื•ืช ื—ื“ืฉื™ื ื›ื‘ื•ื™ื™ื ืขื“ ืœื”ืคืขืœื” ืžืคื•ืจืฉืช.", + "edit_prompt": "ืขืจื™ื›ืช {{module}} ืขื‘ื•ืจ {{title}}. ืฉืœื—ื• /configure {{token}} ื•ืœืื—ืจื™ื• ืฆืžื“ื™ field=value ืขื ื ืงื•ื“ื”ึพืคืกื™ืง ื‘ื™ื ื™ื”ื. ื”ืฉื“ื•ืช ื”ื ืชืžื›ื™ื ื•ื”ืขืจื›ื™ื ื”ื ื•ื›ื—ื™ื™ื:\n{{fields}}\nืจืฉื™ืžื•ืช ืžื•ืคืจื“ื•ืช ื‘ืคืกื™ืงื™ื. ื‘ื“ืงื• ื•ืืฉืจื• ืืช ื”ืฉื™ื ื•ื™ื™ื ืœืคื ื™ ื”ืฉืžื™ืจื”.", + "changes_preview": "ืชืฆื•ื’ืช ื”ื’ื“ืจื•ืช ืขื‘ื•ืจ {{title}}\n{{changes}}", + "date_prompt": "ืฉืœื—ื• /viewdate {{token}} YYYY-MM-DD. ื”ืชืืจื™ืš ืžืคื•ืจืฉ ื•ืžืฉืชืžืฉ ื‘ืื–ื•ืจ ื”ื–ืžืŸ ืฉืœ ื”ื”ืงืฉืจ ื”ื–ื”.", + "schedule_view": "ืชื–ืžื•ื ื™ื ืงืจื•ื‘ื™ื ืขื‘ื•ืจ {{title}}\n{{content}}\nืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช ืงื•ื“ื ืœื”ื•ื“ืขื•ืช ืฉื’ืจืชื™ื•ืช.", + "status_view": "ืžืฆื‘ ืขื‘ื•ืจ {{title}}\nืฉืขื•ืŸ UTC: {{utc}}\nืฉืขื•ืŸ ืžืงื•ืžื™: {{local}}\nื›ื™ืกื•ื™ ื”ืžื˜ืžื•ืŸ: {{coverage}}\nื‘ื“ื™ืงืช ืกืคืง ืžื•ืฆืœื—ืช ืื—ืจื•ื ื”: {{fetched}}\nืื™ืจื•ืข ืคืขื™ืœ: {{current}}\nืื™ืจื•ืข ื”ื‘ื: {{next}}\nืชืงืœื”: {{error}}", + "reset_preview": "ืœืืคืก ืจืง ืืช {{module}} ืขื‘ื•ืจ {{title}}? ื”ืžื™ืงื•ื, ื”ืฉืคื” ื•ืฉืืจ ื”ืžื•ื“ื•ืœื™ื ืœื ื™ืฉืชื ื•.", + "suspend": "ื”ืฉื”ื” ืชืคื™ืœื™ืŸ ืœื”ื™ื•ื", + "not_private": "ืคืชื—ื• ืืช ืคืื ืœ ื”ืงื‘ื•ืฆื” ื”ืคืจื˜ื™ ื›ื“ื™ ืœืฉื ื•ืช ื”ื’ื“ืจื•ืช.", + "date_invalid": "ื”ืฉืชืžืฉื• ื‘ืชืืจื™ืš ืืžื™ืชื™ ื‘ืชื‘ื ื™ืช YYYY-MM-DD.", + "hours_invalid": "ื”ืฉืชืžืฉื• ื‘ืฉืขื•ืช ืžืงื•ืžื™ื•ืช HH:mm ื•ื‘ืืคืฉืจื•ื™ื•ืช ื ืชืžื›ื•ืช.", + "location_label": "ืžื™ืงื•ื", + "alotHaShachar": "ืขืœื•ืช ื”ืฉื—ืจ", + "misheyakir": "ื–ืžืŸ ื˜ืœื™ืช ื•ืชืคื™ืœื™ืŸ - ืžืฉื™ื›ื™ืจ", + "sunrise": "ื”ื ืฅ ื”ื—ืžื”", + "sofZmanShma": "ืกื•ืฃ ื–ืžืŸ ืงืจื™ืืช ืฉืžืข - ื’ืจืดื", + "sofZmanShmaMGA": "ืกื•ืฃ ื–ืžืŸ ืงืจื™ืืช ืฉืžืข - ืžื’ืŸ ืื‘ืจื”ื", + "sofZmanTfilla": "ืกื•ืฃ ื–ืžืŸ ืชืคื™ืœื” - ื’ืจืดื", + "sofZmanTfillaMGA": "ืกื•ืฃ ื–ืžืŸ ืชืคื™ืœื” - ืžื’ืŸ ืื‘ืจื”ื", + "chatzot": "ื—ืฆื•ืช ื”ื™ื•ื", + "minchaGedola": "ืžื ื—ื” ื’ื“ื•ืœื”", + "minchaKetana": "ืžื ื—ื” ืงื˜ื ื”", + "plagHaMincha": "ืคืœื’ ื”ืžื ื—ื”", + "sunset": "ืฉืงื™ืขื”", + "tzeit85deg": "ืฆืืช ื”ื›ื•ื›ื‘ื™ื - 8.5 ืžืขืœื•ืช", + "tzeit42min": "ืฆืืช ื”ื›ื•ื›ื‘ื™ื - 42 ื“ืงื•ืช ืงื‘ื•ืขื•ืช", + "tzeit50min": "ืฆืืช ื”ื›ื•ื›ื‘ื™ื - 50 ื“ืงื•ืช ืงื‘ื•ืขื•ืช", + "tzeit72min": "72 ื“ืงื•ืช ืงื‘ื•ืขื•ืช ืื—ืจื™ ื”ืฉืงื™ืขื”", + "chatzotNight": "ื—ืฆื•ืช ื”ืœื™ืœื”", + "settings_search": "ืกื™ื ื•ืŸ ืงื‘ื•ืฆื•ืช: /groups ื˜ืงืกื˜ ืœื—ื™ืคื•ืฉ", + "field_language": "ืฉืคื”", + "field_candle_minutes": "ื”ื“ืœืงืช ื ืจื•ืช ืœืคื ื™ ื”ืฉืงื™ืขื”", + "field_havdalah_method": "ืฉื™ื˜ืช ืฆืืช ืฉื‘ืช ื•ื—ื’", + "field_havdalah_value": "ื–ืžืŸ ืฆืืช ืฉื‘ืช ื•ื—ื’", + "field_close_before_candles_minutes": "ื“ืงื•ืช ื ืขื™ืœื” ืœืคื ื™ ื”ื“ืœืงืช ื ืจื•ืช", + "field_open_after_havdalah_minutes": "ื“ืงื•ืช ืคืชื™ื—ื” ืื—ืจื™ ื”ื‘ื“ืœื”", + "field_target_topic_id": "ื ื•ืฉื ื‘ืคื•ืจื•ื ืœืฉืœื™ื—ืช ื”ื”ื•ื“ืขื•ืช", + "field_enabled": "ืžืฆื‘ ืคืขื™ืœื•ืช", + "field_time": "ืฉืขื” ืžืงื•ืžื™ืช", + "field_days": "ื™ืžื™ ืคืขื™ืœื•ืช", + "field_format": "ืื•ืจืš ื”ื”ื•ื“ืขื”", + "field_template": "ื˜ืงืกื˜ ืชื‘ื ื™ืช", + "field_fixed_times": "ืฉืขื•ืช ืงื‘ื•ืขื•ืช", + "field_interval_minutes": "ืชื–ื›ื•ืจืช ื—ื•ื–ืจืช ื‘ื›ืœ ืžืกืคืจ ื“ืงื•ืช", + "field_window_start": "ืชื—ื™ืœืช ืฉืขื•ืช ื”ืคืขื™ืœื•ืช", + "field_window_end": "ืกื™ื•ื ืฉืขื•ืช ื”ืคืขื™ืœื•ืช", + "field_sunset_offsets": "ื“ืงื•ืช ืœืคื ื™ ื”ืฉืงื™ืขื”", + "field_max_per_day": "ืžืกืคืจ ื”ื•ื“ืขื•ืช ืžืจื‘ื™ ื‘ื™ื•ื (1โ€“24)", + "field_min_spacing_minutes": "ื”ืคืกืงื” ืžื™ื ื™ืžืœื™ืช ื‘ื™ืŸ ืชื–ื›ื•ืจื•ืช", + "field_quiet_start": "ืชื—ื™ืœืช ืฉืขื•ืช ื”ืฉืงื˜", + "field_quiet_end": "ืกื™ื•ื ืฉืขื•ืช ื”ืฉืงื˜", + "field_chol_hamoed": "ืชื–ื›ื•ืจื•ืช ื‘ื—ื•ืœ ื”ืžื•ืขื“", + "field_tisha_bav_policy": "ืชื–ื›ื•ืจื•ืช ื‘ืชืฉืขื” ื‘ืื‘", + "field_sunset_safety_minutes": "ื“ืงื•ืช ื‘ื™ื˜ื—ื•ืŸ ืœืคื ื™ ื”ืฉืงื™ืขื”", + "field_mode": "ืžื•ืขื“ ื”ืฉืœื™ื—ื”", + "field_after_nightfall_minutes": "ื“ืงื•ืช ืื—ืจื™ ืฆืืช ื”ื›ื•ื›ื‘ื™ื", + "field_nightfall_method": "ืฉื™ื˜ืช ื—ื™ืฉื•ื‘ ืฆืืช ื”ื›ื•ื›ื‘ื™ื", + "field_nightfall_value": "ื–ืžืŸ ืฆืืช ื”ื›ื•ื›ื‘ื™ื", + "field_nusach": "ื ื•ืกื— ื”ืกืคื™ืจื”", + "field_day_completion": "ืชื–ื›ื•ืจืช ืœื”ืฉืœืžืช ืกืคื™ืจื” ื‘ื™ื•ื", + "field_early_policy": "ื›ืืฉืจ ื”ืฉืขื” ืžื•ืงื“ืžืช ืžื“ื™", + "field_after_open_minutes": "ื“ืงื•ืช ืฉืงื˜ ืื—ืจื™ ืคืชื™ื—ืช ืงื‘ื•ืฆื”", + "field_times_alert_enabled": "ืฉืœื™ื—ืช ื”ื•ื“ืขืช ื–ืžื ื™ื ืžืงื“ื™ืžื”", + "field_times_alert_time": "ืฉืขื” ืžืงื•ืžื™ืช ืœื”ื•ื“ืขืช ื–ืžื ื™ื", + "field_times_alert_mode": "ืžื•ืขื“ ืฉืœื™ื—ืช ื”ื•ื“ืขืช ื”ื–ืžื ื™ื", + "field_times_alert_before_minutes": "ื”ื•ื“ืขืช ื–ืžื ื™ื ื‘ื“ืงื•ืช ืœืคื ื™ ื ืขื™ืœื”", + "field_close_message_enabled": "ืฉืœื™ื—ืช ื”ื•ื“ืขื” ื‘ืขืช ื”ืกื’ื™ืจื”", + "field_open_message_enabled": "ืฉืœื™ื—ืช ื”ื•ื“ืขื” ื‘ืขืช ื”ืคืชื™ื—ื”", + "method_degrees": "ืžืขืœื•ืช", + "method_minutes": "ื“ืงื•ืช ืงื‘ื•ืขื•ืช ืื—ืจื™ ืฉืงื™ืขื”", + "window_end": "ืกื™ื•ื ื”ื—ืœื•ืŸ", + "no_reminders": "ืื™ืŸ ืชื–ื›ื•ืจื•ืช ืžื•ืชืจื•ืช ื‘ื™ื•ื ื”ื–ื” ืœืคื™ ื”ื”ื’ื“ืจื•ืช.", + "preview_only": "ื–ื• ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืœื‘ื“; ืœื ื ืฉืœื—ืช ื”ื•ื“ืขื” ืœืงื‘ื•ืฆื”.", + "template_panel": "{{title}} - ื”ื•ื“ืขื•ืช ื•ืขื™ืฆื•ื‘", + "template_menu": "{{title}} - {{kind}}\nืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™ ืื™ื ื” ืฉื•ืœื—ืช ืœืงื‘ื•ืฆื”.", + "template_kind_close": "ื”ื•ื“ืขืช ืกื’ื™ืจื”", + "template_kind_open": "ื”ื•ื“ืขืช ืคืชื™ื—ื”", + "template_kind_zmanim": "ื”ื•ื“ืขืช ื–ืžื ื™ ื”ื™ื•ื", + "template_kind_daf": "ื”ื•ื“ืขืช ื“ืฃ ื™ื•ืžื™", + "template_kind_tefillin": "ื”ื•ื“ืขืช ืชืคื™ืœื™ืŸ", + "template_kind_omer": "ื”ื•ื“ืขืช ืขื•ืžืจ", + "template_edit": "โœ๏ธ ืขืจื™ื›ืช ื˜ืงืกื˜ / ืžื“ื™ื”", + "template_buttons": "โŒจ๏ธ ืขืจื™ื›ืช ื›ืคืชื•ืจื™ื", + "template_preview": "๐Ÿ‘€ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™", + "template_reset": "ืื™ืคื•ืก ื”ื•ื“ืขื” ื–ื•", + "template_test": "ืฉืœื™ื—ืช ื‘ื“ื™ืงื” ืœืงื‘ื•ืฆื” (ืขื ืื™ืฉื•ืจ)", + "template_edit_prompt": "ืฉืœื—ื• ื˜ืงืกื˜/ืžื“ื™ื” ืขื ื”ื›ื•ืชืจืช ื”ื–ืืช, ื•ืื– ืฉื•ืจื” ื—ื“ืฉื” ื•ื”ืชื•ื›ืŸ:\n/template {{token}}\nื™ืฉื•ื™ื•ืช ื”ื˜ืงืกื˜, ื”ืžื“ื™ื” ื•ื”ื›ืคืชื•ืจื™ื ื”ืงื™ื™ืžื™ื ื ืฉืžืจื™ื.", + "template_buttons_prompt": "ืฉืœื—ื• /buttons {{token}}, ื•ืื– ืฉื•ืจื” ื—ื“ืฉื” ื•ืžืขืจืš JSON ืฉืœ ืฉื•ืจื•ืช ื›ืคืชื•ืจื™ื. ืœื›ืœ ื›ืคืชื•ืจ ื˜ืงืกื˜ ื•ืคืขื•ืœื” ืื—ืช: ืงื™ืฉื•ืจ HTTPS, callback_data ืื• ืžืขื‘ืจ ืœืื™ื ืœื™ื™ืŸ. ืฆื‘ืขื™ื ืืคืฉืจื™ื™ื: primary, success, danger.\nืืคืฉืจ ื’ื ื‘ืคื•ืจืžื˜ ื”ืžืงื•ืจื™: ื›ื™ืชื•ื‘ - https://example.org - style:bg_success - icon:123. ื”ืฉืชืžืฉื• ื‘ึพ&& ืœื›ืžื” ื›ืคืชื•ืจื™ื ื‘ืฉื•ืจื”, ืื• ื‘ืฉื•ืจื” ื—ื“ืฉื”. ืคืขื•ืœื•ืช ื ื•ืกืคื•ืช: alert:ื˜ืงืกื˜, popup:ื˜ืงืกื˜, copy:ื˜ืงืกื˜. ืงื™ื™ืžืช ืžื’ื‘ืœืช 64 ื‘ืชื™ื ืœื ืชื•ื ื™ callback.", + "template_cleared": "ื”ื”ื•ื“ืขื” ืื•ืคืกื”. ืคืชื—ื• ืžื—ื“ืฉ ืืช ื”ืคืื ืœ.", + "template_confirm_test": "โœ… ืื™ืฉื•ืจ ืฉืœื™ื—ืช ื‘ื“ื™ืงื”", + "template_test_confirm": "ืื™ืฉื•ืจ ืžืคื•ืจืฉ: ื”ื•ื“ืขืช ื‘ื“ื™ืงื” ืชื™ืฉืœื— ืืœ {{title}}. ื”ืคืขื•ืœื” ื ืคืจื“ืช ืžืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™.", + "template_test_wait": "ื™ืฉ ืœื”ืžืชื™ืŸ ื—ืžืฉ ื“ืงื•ืช ืœืคื ื™ ื‘ื“ื™ืงื” ื ื•ืกืคืช ื‘ืงื‘ื•ืฆื” ื”ื–ืืช.", + "template_cancelled": "ื”ื‘ื“ื™ืงื” ื‘ื•ื˜ืœื”; ืœื ื ืฉืœื—ื” ื”ื•ื“ืขื” ืœืงื‘ื•ืฆื”.", + "template_saved": "ื”ื”ื•ื“ืขื” ื ืฉืžืจื”. ืคืชื—ื• ืžื—ื“ืฉ ืืช ื”ืคืื ืœ ืœืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™.", + "template_remove_text": "๐Ÿ—‘ ื”ืกืจ ื˜ืงืกื˜ ื‘ืœื‘ื“", + "template_remove_media": "๐Ÿ—‘ ื”ืกืจ ืžื“ื™ื” ื‘ืœื‘ื“", + "template_remove_buttons": "๐Ÿ—‘ ื”ืกืจ ื›ืคืชื•ืจื™ื ื‘ืœื‘ื“", + "gps_prompt": "ื‘ื—ืจื• ื”ืฉื‘ ืœื”ื•ื“ืขื” ื”ื–ืืช ื•ืฉืœื—ื• ืžื™ืงื•ื Telegram ืขื‘ื•ืจ {{title}}. ืื–ื•ืจ ื”ื–ืžืŸ ืฉื™ื•ืฆื’ ืœืื™ืฉื•ืจ: {{timezone}}. ื‘ืœื™ ื”ืฉื‘ื” ืœื”ื•ื“ืขื” ื”ื–ืืช ืœื ื™ื™ื‘ื—ืจ ืชื”ืœื™ืš ื”ื’ื“ืจื”.", + "gps_reply_required": "ืคืชื—ื• ืฉื™ืชื•ืฃ GPS ื‘ืืžืฆืขื•ืช /location ื•ื”ื˜ื•ืงืŸ, ื•ืื– ื”ืฉื™ื‘ื• ืœื”ื•ื“ืขืช ื”ื‘ื•ื˜ ืขื ื”ืžื™ืงื•ื. ืื™ืŸ ื‘ื—ื™ืจื” ืื•ื˜ื•ืžื˜ื™ืช ืฉืœ ืงื‘ื•ืฆื”.", + "location_attribution": "ื ืชื•ื ื™ ืžืงื•ื: GeoNames (CC-BY-4.0).", + "timezone_auto": "ื–ื™ื”ื•ื™ ืื–ื•ืจ ื–ืžืŸ ืœืคื™ ื”ืžื™ืงื•ื ื‘ืืžืฆืขื•ืช GeoNames", + "auto_timezone_missing": "ื–ื™ื”ื•ื™ ืื•ื˜ื•ืžื˜ื™ ืื™ื ื• ืคืขื™ืœ: GeoNames ืœื ืžื•ื’ื“ืจ. ื”ืฉืชืžืฉื• ื‘ืžื–ื”ื” IANA ืžืคื•ืจืฉ ื›ื’ื•ืŸ Asia/Jerusalem ื‘ืžืงื•ื auto.", + "command_start": "๐Ÿ  ืคืชื™ื—ืช ื”ืชืคืจื™ื˜ ื”ืจืืฉื™", + "command_preferences": "โš™๏ธ ื”ื”ืขื“ืคื•ืช ื”ืื™ืฉื™ื•ืช ืฉืœื™", + "command_lang": "๐ŸŒ ื‘ื—ื™ืจืช ืฉืคื”", + "command_groups": "๐Ÿ‘ฅ ื”ืงื‘ื•ืฆื•ืช ืฉื‘ื ื™ื”ื•ืœืš", + "command_help": "โ„น๏ธ ืคืงื•ื“ื•ืช ื•ืขื–ืจื”", + "command_shabbat": "๐Ÿ•ฏ ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’ื™ื", + "command_zmanim": "๐Ÿ•’ ื–ืžื ื™ ื”ื™ื•ื", + "command_daf": "๐Ÿ“– ื”ื“ืฃ ื”ื™ื•ืžื™", + "command_omer": "๐ŸŒพ ืกืคื™ืจืช ื”ืขื•ืžืจ", + "command_source": "๐Ÿ’ป ืงื•ื“ ืžืงื•ืจ ืชื•ืื ื•ืจื™ืฉื™ื•ืŸ", + "command_license": "๐Ÿ“œ ืจื™ืฉื™ื•ืŸ ื”ืคืจื•ื™ืงื˜", + "command_version": "๐Ÿท ื’ืจืกืช ื”ืจื™ืฆื” ื•ืžื–ื”ื” commit", + "command_donate": "โญ ืชืžื™ื›ื” ื‘ื›ื•ื›ื‘ื™ Telegram", + "command_stats": "๐Ÿ“Š ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช ืฉื™ืจื•ืช ื”ื‘ื•ื˜", + "command_add": "โž• ืจื™ืฉื•ื ื”ืงื‘ื•ืฆื” ืœืฉื™ืจื•ืช", + "command_remove": "โž– ื”ืกืจืช ื”ืงื‘ื•ืฆื” ื‘ืื•ืคืŸ ื‘ื˜ื•ื—", + "command_settings": "โš™๏ธ ื”ื’ื“ืจื•ืช ื”ืงื‘ื•ืฆื” ื‘ืคืจื˜ื™", + "command_admin": "๐Ÿ›  ืคืื ืœ ืžื ื”ืœ ืžืขืจื›ืช", + "command_testzmanim": "๐Ÿ”Ž ืื‘ื—ื•ืŸ ื–ืžื ื™ื ื•ืžืชื–ืžืŸ", + "command_closegroups": "๐Ÿ”’ ื ืขื™ืœืช ืงื‘ื•ืฆื•ืช ื™ื“ื ื™ืช ืžื•ืจืฉื™ืช", + "command_opengroups": "๐Ÿ”“ ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช ืžื•ืจืฉื”", + "command_setcommands": "๐Ÿ“‹ ืขื“ื›ื•ืŸ ืชืคืจื™ื˜ื™ ืคืงื•ื“ื•ืช ืœืคื™ ื”ืงืฉืจ", + "command_restart": "๐Ÿ”„ ื”ืคืขืœื” ืžื—ื“ืฉ ืžืกื•ื“ืจืช ืฉืœ ื”ื‘ื•ื˜", + "donate_title": "ืชืžื™ื›ื” ื‘ื‘ื•ื˜ ืฉื‘ืช", + "donate_description": "ืชืžื™ื›ื” ืžืจืฆื•ืŸ: {{amount}} ื›ื•ื›ื‘ื™ Telegram. ืชื•ื“ื”!", + "donate_prompt": "ื‘ื—ืจื• ืกื›ื•ื ืชืžื™ื›ื” ื—ื“ึพืคืขืžื™ืช ื‘ื›ื•ื›ื‘ื™ Telegram.", + "donate_invoice_error": "ืœื ื ื™ืชืŸ ืœื™ืฆื•ืจ ื—ืฉื‘ื•ื ื™ืช ืœืชืžื™ื›ื”. ื ืกื• ืฉื•ื‘ ื‘ืืžืฆืขื•ืช /donate.", + "donate_partial_error": "ื—ืœืง ืžืกื›ื•ืžื™ ื”ืชืžื™ื›ื” ืื™ื ื ื–ืžื™ื ื™ื ื›ืจื’ืข. ืืคืฉืจ ืœื”ืฉืชืžืฉ ื‘ื—ืฉื‘ื•ื ื™ืช ืฉื ื•ืฆืจื” ื‘ื”ืฆืœื—ื”.", + "donate_thanks": "ืชื•ื“ื” ืขืœ ืชืจื•ืžืชืš ื‘ืกืš {{amount}} ื›ื•ื›ื‘ื™ Telegram!", + "donate_received_admin": "ื”ืชืงื‘ืœื” ืชืจื•ืžื” ืžืื•ืžืชืช ืžื”ืžืฉืชืžืฉ {{user_id}}: {{amount}} ื›ื•ื›ื‘ื™ Telegram.", + "donate_unverified_payment": "ืœื ื ื™ืชืŸ ืœืืžืช ืืช ื”ืชืฉืœื•ื. ื”ื•ื ืกื•ืžืŸ ืœื‘ื“ื™ืงืช ืžืคืขื™ืœ; ืœื ืžื•ืฆื’ืช ื”ื•ื“ืขืช ื”ืฆืœื—ื”.", + "commands_setup_help": "ืชืคืจื™ื˜ื™ ื”ืคืงื•ื“ื•ืช ืžืชืขื“ื›ื ื™ื ื‘ื ืคืจื“ ืœืฆืณืื˜ื™ื ืคืจื˜ื™ื™ื, ืžื ื”ืœื™ ืงื‘ื•ืฆื•ืช ื•ืžืคืขื™ืœื™ ืžืขืจื›ืช, ื‘ื›ืœ ืฉืคื” ื ืชืžื›ืช. Telegram ืžืฉืชืžืฉ ื‘ึพpt ืœืคื•ืจื˜ื•ื’ื–ื™ืช ื‘ืจื–ื™ืœืื™ืช.", + "commands_setup_result": "ืขื“ื›ื•ืŸ ืชืคืจื™ื˜ื™ ืคืงื•ื“ื•ืช: {{total}} ื‘ืงืฉื•ืช; {{failed}} ื ื›ืฉืœื•.", + "commands_setup_preview": "ืชืฆื•ื’ื” ืžืงื“ื™ืžื”: {{total}} ืชืคืจื™ื˜ื™ ืฉืคื” ื•ื”ืงืฉืจ ื™ืขื•ื“ื›ื ื•.", + "commands_forbidden": "ืจืง ืžืคืขื™ืœ ืžืขืจื›ืช ืจืฉืื™ ืœืขื“ื›ืŸ ืชืคืจื™ื˜ื™ ืคืงื•ื“ื•ืช.", + "operator_panel": "๐Ÿ›  ืคืื ืœ ืžืคืขื™ืœ ืžืขืจื›ืช", + "operator_subscribers": "๐Ÿ‘ฅ ืžื ื•ื™ื™ื", + "operator_statistics": "๐Ÿ“Š ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช ืฉื™ืจื•ืช", + "operator_broadcast": "๐Ÿ“ฃ ืฉื™ื“ื•ืจ", + "operator_preview": "๐Ÿ‘€ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™", + "operator_send": "ืฉืœื™ื—ืช ืฉื™ื“ื•ืจ", + "operator_confirm_send": "ืื™ืฉื•ืจ ืฉืœื™ื—ืช ื”ืฉื™ื“ื•ืจ ื”ื–ื” ืืœ {{count}} ื ืžืขื ื™ื.", + "operator_recipients_private": "ืžื ื•ื™ื™ื ื‘ืคืจื˜ื™", + "operator_recipients_groups": "ืงื‘ื•ืฆื•ืช", + "operator_recipients_all": "ื›ืœ ื”ืžื ื•ื™ื™ื", + "operator_pin": "ื ืขื™ืฆืช ื”ื•ื“ืขื”", + "operator_unpin": "ื‘ื™ื˜ื•ืœ ื ืขื™ืฆืช ื”ื•ื“ืขื•ืช", + "operator_delete_last": "ืžื—ื™ืงืช ื”ืฉื™ื“ื•ืจ ื”ืื—ืจื•ืŸ", + "operator_delete_all": "ืžื—ื™ืงืช ื›ืœ ื”ืฉื™ื“ื•ืจื™ื", + "operator_progress": "ื”ืชืงื“ืžื•ืช ื”ืฉื™ื“ื•ืจ", + "operator_cancel": "ื‘ื™ื˜ื•ืœ", + "operator_buttons": "ืขืจื™ื›ืช ื›ืคืชื•ืจื™ื", + "operator_edit_text": "ืขืจื™ื›ืช ื˜ืงืกื˜", + "operator_edit_media": "ืขืจื™ื›ืช ืžื“ื™ื”", + "operator_clear_draft": "ื ื™ืงื•ื™ ื˜ื™ื•ื˜ื”", + "operator_active_broadcast": "ืฉื™ื“ื•ืจ ื›ื‘ืจ ืžืชื‘ืฆืข. ื”ืžืชื™ื ื• ืœืกื™ื•ืžื• ืื• ื‘ื˜ืœื• ืื•ืชื• ืœืคื ื™ ื”ืชื—ืœืช ืฉื™ื“ื•ืจ ื ื•ืกืฃ.", + "operator_draft_saved": "ื˜ื™ื•ื˜ืช ื”ืฉื™ื“ื•ืจ ื ืฉืžืจื”.", + "operator_no_draft": "ืื™ืŸ ื˜ื™ื•ื˜ืช ืฉื™ื“ื•ืจ ื–ืžื™ื ื”.", + "operator_page": "ืขืžื•ื“ {{page}} ืžืชื•ืš {{total}}", + "operator_manual_result": "ืคืขื•ืœื” {{action}}: ืฉื™ื ื•ื™ ืื•ืžืช ื‘-{{changed}} ืงื‘ื•ืฆื•ืช, {{failed}} ื ื›ืฉืœื• ืื• ืœื ืื•ืžืชื•. ืขื“: {{until}}.", + "operator_manual_lock": "ื ืขื™ืœืช ืงื‘ื•ืฆื” ื™ื“ื ื™ืช", + "operator_manual_open": "ืฉื—ื–ื•ืจ ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื”", + "group_new_wizard": "ื”ืงื‘ื•ืฆื” ื”ื—ื“ืฉื” ืžืชื—ื™ืœื” ืขื ืžื™ืงื•ื ื‘ืจื™ืจืช ืžื—ื“ืœ ื’ืœื•ื™. ื‘ื“ืงื• ืžื™ืงื•ื, ืื–ื•ืจ ื–ืžืŸ ื•ืœื•ื—, ื•ืื– ื‘ื—ืจื• ืžืฆื‘ ืคืขื™ืœื•ืช ื‘ืžืคื•ืจืฉ.", + "permission_required": "ื ื“ืจืฉืช ื”ืจืฉืืช ืžื ื”ืœ ืขื“ื›ื ื™ืช ืœืงื‘ื•ืฆื” ื”ื–ืืช.", + "group_removed": "ื”ืงื‘ื•ืฆื” ื”ื•ืกืจื” ืžื”ืฉื™ืจื•ืช ืœืื—ืจ ื”ืกื“ืจื” ื‘ื˜ื•ื—ื” ืฉืœ ื”ืจืฉืื•ืชื™ื”.", + "omer_blessing_eligibility": "ื”ื‘ืจื›ื” ืžื™ื•ืขื“ืช ืœืžื™ ืฉืจืฉืื™ ืœื‘ืจืš. ื”ื‘ื•ื˜ ืื™ื ื• ื™ื›ื•ืœ ืœืงื‘ื•ืข ื–ื›ืื•ืช ืื™ืฉื™ืช ืœืคื™ ื”ื™ืกื˜ื•ืจื™ื™ืช ื”ื•ื“ืขื•ืช ื”ืงื‘ื•ืฆื”.", + "omer_day_completion_label": "ื”ืฉืœืžืช ื™ื•ื - ืœืœื ื‘ืจื›ื”.", + "omer_translation_note": "ื ื•ืกื— ื”ืžืฆื•ื•ื” ื‘ืขื‘ืจื™ืช; ืชืจื’ื•ื ื”ืกืคื™ืจื” ืฉืœื”ืœืŸ ืžื™ื•ืขื“ ืœื”ืกื‘ืจ.", + "group_close_notice": "ื”ื—ืœ ื–ืžืŸ ื”ืฉืงื˜ ืฉืœ ืฉื‘ืช ืื• ื—ื’.", + "group_open_notice": "ื”ืกืชื™ื™ื ื–ืžืŸ ื”ืฉืงื˜ ืฉืœ ืฉื‘ืช ืื• ื—ื’.", + "tefillin_reminder": "ืขื“ื™ื™ืŸ ืœื ื”ื ื—ืช ืชืคื™ืœื™ืŸ ื”ื™ื•ื?\nืœืคื™ ื–ืžื ื™ ื”ืงื‘ื•ืฆื” ื‘ึพ{{location}}, ื”ืฉืงื™ืขื” ื‘ืฉืขื” {{sunset}}.", + "no_event": "ืื™ืŸ ืื™ืจื•ืข ื‘ื˜ื•ื•ื— ื”ื ืชื•ื ื™ื ื”ืžืื•ืžืชื™ื.", + "stats_counts": "ืงื‘ื•ืฆื•ืช ืžื•ื›ืจื•ืช: {{known}}\nืจืฉื•ืžื•ืช ืœืฉื™ืจื•ืช: {{registered}}\nื”ืคืขื™ืœื• ื ืขื™ืœื”: {{locking}}\nื ืขื•ืœื•ืช ื‘ืคื•ืขืœ ื‘ื™ื“ื™ ื”ื‘ื•ื˜: {{locked}}\nืžืžืชื™ื ื•ืช ืœืฉื—ื–ื•ืจ: {{recovery}}", + "source_info": "๐Ÿ’ป ืฉื•ืžืจ ืฉื‘ืช - ืงื•ื“ ืžืงื•ืจ, ื’ืจืกื” ื•ืจื™ืฉื™ื•ืŸ\n\nื”ืžืื’ืจ ื”ืจืฉืžื™\nWizardLoop/shabbat ื‘-GitHub\n\nื”ื’ืจืกื” ืฉืคื•ืขืœืช ื›ืืŸ\nื’ืจืกื”: {{version}}\nCommit: {{commit}}\n\nืจื™ืฉื™ื•ืŸ\n{{license}}\nืงื•ื“ ื”ื‘ื•ื˜ ื–ืžื™ืŸ ื‘ื›ืคื•ืฃ ืœืชื ืื™ ื”ืจื™ืฉื™ื•ืŸ. ืจื™ืฉื™ื•ื ื•ืช ื”ืชืœื•ื™ื•ืช ื•ื”ื•ื“ืขื•ืช ื”ื–ื›ื•ื™ื•ืช ื ื›ืœืœื™ื ื‘ื”ืฆืขืช ื”ืžืงื•ืจ.\n\nื”ืžืงื•ืจ ื”ืชื•ืื ืœื‘ื ื™ื™ื” ื”ื–ืืช\n{{url}}\nื”ืžืื’ืจ ื”ืจืฉืžื™ ื”ื•ื ื‘ื™ืช ื”ืคืจื•ื™ืงื˜; ืฉื™ื ื•ื™ื™ื ืžืงื•ืžื™ื™ื ืขืฉื•ื™ื™ื ืœื”ื™ื•ืช ื›ืœื•ืœื™ื ืจืง ื‘ืžืงื•ืจ ืฉืœ ื”ื‘ื ื™ื™ื” ื”ื–ืืช.", + "version_info": "ื’ืจืกืช ื”ืจื™ืฆื”: {{version}}\nCommit: {{commit}}", + "license_info": "ืจื™ืฉื™ื•ืŸ ื”ื™ื™ืฉื•ื: {{license}}\nืžืงื•ืจ ื•ืจื™ืฉื™ื•ืŸ ื”ื’ืจืกื”: {{url}}", + "source_unpublished": "ื”ืžืคืขื™ืœ ื˜ืจื ื”ื’ื“ื™ืจ ืงื™ืฉื•ืจ ืœืงื•ื“ ื”ืžืงื•ืจ ื”ืชื•ืื ืœื‘ื ื™ื™ื” ื”ื–ืืช.", + "donate_checkout_error": "ื”ื—ืฉื‘ื•ื ื™ืช ืื™ื ื” ื–ืžื™ื ื” ืื• ืฉืคืจื˜ื™ื” ืื™ื ื ืชืงื™ื ื™ื. ืฆืจื• ื—ืฉื‘ื•ื ื™ืช ื—ื“ืฉื” ื‘ืืžืฆืขื•ืช /donate.", + "operator_recipients_channels": "ืขืจื•ืฆื™ื", + "operator_subscriber_stats": "ืžืฉืชืžืฉื™ื: {{users}}\nืงื‘ื•ืฆื•ืช: {{groups}}\nืขืจื•ืฆื™ื: {{channels}}\nื‘ื•ื˜ื™ื: {{bots}}\nืกืš ื”ื›ื•ืœ: {{total}}", + "operator_message_prompt": "ืฉืœื— ืืช ื”ื”ื•ื“ืขื” ืœื˜ื™ื•ื˜ื”. ืขื™ืฆื•ื‘ ื•ืžื“ื™ื” ื ืฉืžืจื™ื. ื”ืคืงื•ื“ื•ืช ืžืžืฉื™ื›ื•ืช ืœืคืขื•ืœ ื‘ื–ืžืŸ ืฉื”ืขื•ืจืš ืคืชื•ื—.", + "operator_buttons_prompt": "ืฉืœื— ืฉื•ืจื•ืช ื›ืคืชื•ืจื™ื ื‘ึพJSON ืื• ื›ืš: Label - https://example.org - style:bg_success. ื”ืคืจื“ ื›ืคืชื•ืจื™ื ื‘ืื•ืชื” ืฉื•ืจื” ืขื && ื•ืฉื•ืจื•ืช ืฉื•ื ื•ืช ื‘ื™ืจื™ื“ืช ืฉื•ืจื”. ืคืขื•ืœื•ืช ื ืชืžื›ื•ืช: ืงื™ืฉื•ืจ HTTPS, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "ื”ื˜ื™ื•ื˜ื” ืจื™ืงื”, ืืจื•ื›ื” ืžื“ื™ ืื• ื›ื•ืœืœืช ืขื™ืฆื•ื‘ ืื• ื›ืคืชื•ืจื™ื ืœื ืชืงื™ื ื™ื. ื”ื™ื ืœื ื ืฉืžืจื”.", + "operator_empty_recipients": "ืื™ืŸ ื ืžืขื ื™ื ืจืฉื•ืžื™ื ืฉืžืชืื™ืžื™ื ืœืชืคื•ืฆื” ื”ื–ืืช.", + "operator_complete": "ื”ืฉื™ื“ื•ืจ ื”ืกืชื™ื™ื. ื‘ื“ื•ืง ืืช ื”ื”ืชืงื“ืžื•ืช ืœืงื‘ืœืช ืชื•ืฆืื•ืช ื”ืžืกื™ืจื”.", + "operator_pause": "ื”ืฉื”ื”", + "operator_resume": "ื”ืžืฉืš", + "operator_bc_starting": "{{operation}}: ืื™ืกื•ืฃ ื ืžืขื ื™ืโ€ฆ", + "operator_bc_counts": "ื˜ื•ืคืœื•: {{processed}} / {{total}}\nื ืฉืœื—ื•: {{sent}}\nื ืขืจื›ื•: {{edited}}\nืœืœื ืฉื™ื ื•ื™: {{unchanged}}\nื ืžื—ืงื•: {{deleted}}\nื‘ื•ื˜ืœื” ื ืขื™ืฆื”: {{unpin}}\nื ื›ืฉืœื•: {{failed}}\nื”ืžืชื ื•ืช ืขืงื‘ ืžื’ื‘ืœืช ืฉืœื™ื—ื”: {{flood}}\nืžืžืชื™ื ื™ื: {{pending}}\nืงืฆื‘: {{tps}}/ืฉื ื™ื™ื”", + "operator_bc_paused": "ืžื•ืฉื”ื”", + "operator_bc_cancelled": "ื‘ื•ื˜ืœ", + "operator_bc_finished": "ื”ืกืชื™ื™ื", + "operator_bc_selfdestruct": "ืžื—ื™ืงื” ืžืชื•ื–ืžื ืช", + "operator_bc_legacy_summary": "ื”ื“ื•ื— ื”ื™ืฉืŸ ื ืฉืžืจ ื‘ื”ื™ืกื˜ื•ืจื™ื™ืช ื”ืกืคืจื™ื™ื”. ื‘ื“ื•ืง ืืช ืชื•ืฆืื•ืช ื”ืฉื™ื“ื•ืจ ื”ื ื•ื›ื—ื™ื•ืช ื›ื“ื™ ืœืจืื•ืช ืืช ื”ืžื•ื ื™ื ื”ื–ืžื™ื ื™ื.", + "operator_bc_edit": "ืขืจื™ื›ืช ืฉื™ื“ื•ืจ", + "moderation": "๐Ÿ”’ ื”ื’ื‘ืœื•ืช ื•ืžื—ื™ืงืช ื”ื•ื“ืขื•ืช ื‘ืงื‘ื•ืฆื”", + "moderation_intro": "ื‘ื—ืจื• ื”ื’ื‘ืœื•ืช ืฉื™ื—ื•ืœื• ื›ืฉื”ืงื‘ื•ืฆื” ืกื’ื•ืจื”. ืกื™ืžื•ืŸ ืคื™ืจื•ืฉื• ื—ืกื™ืžืช ื”ืคืขื•ืœื”. ื”ื’ื‘ืœื•ืช ืงื•ื“ืžื•ืช ื ืฉืžืจื•ืช ื•ื‘ืคืชื™ื—ื” ืžืฉื•ื—ื–ืจ ื”ืžืฆื‘ ื”ืžืงื•ืจื™.", + "moderation_permissions": "ื‘ื—ื™ืจืช ืคืขื•ืœื•ืช ื—ืกื•ืžื•ืช", + "moderation_visibility_warning": "ื—ืกื™ืžืช ืฆืคื™ื™ื” ื‘ื”ื•ื“ืขื•ืช ืขืœื•ืœื” ืœื”ืกืชื™ืจ ืืช ื”ืงื‘ื•ืฆื” ืžื—ื‘ืจื™ื”. ื”ื™ื ืื™ื ื” ื›ืœื•ืœื” ื‘ื‘ืจื™ืจืช ื”ืžื—ื“ืœ.", + "moderation_default": "ื”ื’ื‘ืœื•ืช ื‘ืจื™ืจืช ืžื—ื“ืœ", + "moderation_none": "ืœืœื ื”ื’ื‘ืœื•ืช ื ื•ืกืคื•ืช", + "moderation_all": "ื‘ื—ื™ืจืช ื›ืœ ื”ื”ื’ื‘ืœื•ืช", + "moderation_cleanup_hint": "ื”ืžื—ื™ืงื” ื—ืœื” ืจืง ืขืœ ื”ื•ื“ืขื•ืช ืฉื”ืชืงื‘ืœื• ื‘ื–ืžืŸ ืกื’ื™ืจื” ืคืขื™ืœื” ื‘ืฉื‘ืช ืื• ื‘ื—ื’. ื”ืžืชื’ื™ื ื›ื‘ื•ื™ื™ื ืชื—ื™ืœื” ื•ื ื“ืจืฉืช ืฉืžื™ืจื” ืžืื•ืฉืจืช. ื”ื•ื“ืขื•ืช ืฉื”ื‘ื•ื˜ ืขืฆืžื• ืฉื•ืœื— ืžื•ื—ืจื’ื•ืช.", + "moderation_bot_mode_hint": "ืœืงื‘ืœืช ื”ื•ื“ืขื•ืช ืžื‘ื•ื˜ื™ื ืื—ืจื™ื ื”ื‘ื•ื˜ ืฆืจื™ืš ืœื”ื™ื•ืช ืžื ื”ืœ, ืขื Group Privacy Mode ื›ื‘ื•ื™ ื•-Bot-to-Bot Communication Mode ืžื•ืคืขืœ ื‘-BotFather. ืื™ ืืคืฉืจ ืœืžื—ื•ืง ื”ื•ื“ืขื•ืช ืฉื˜ืœื’ืจื ืœื ืžืกืจ ืœื‘ื•ื˜.", + "moderation_capabilities": "ื‘ื“ื™ืงืช ื”ืจืฉืื•ืช ื”ื‘ื•ื˜", + "moderation_delete_capability": "ื”ืจืฉืื” ืžืื•ืžืชืช ืœืžื—ื™ืงืช ื”ื•ื“ืขื•ืช: {{state}}", + "field_lock_block_flags": "ืคืขื•ืœื•ืช ื ื•ืกืคื•ืช ืฉื™ื™ื—ืกืžื• ื‘ื–ืžืŸ ืกื’ื™ืจื”", + "field_delete_holy_user_messages": "ืžื—ื™ืงืช ื”ื•ื“ืขื•ืช ื—ื‘ืจื™ื ื‘ื–ืžืŸ ืกื’ื™ืจืช ืฉื‘ืช ื•ื—ื’", + "field_delete_holy_bot_messages": "ืžื—ื™ืงืช ื”ื•ื“ืขื•ืช ื‘ื•ื˜ื™ื ืื—ืจื™ื ื‘ื–ืžืŸ ืกื’ื™ืจืช ืฉื‘ืช ื•ื—ื’", + "field_delete_previous": "ืžื—ื™ืงืช ืชื–ื›ื•ืจืช ื”ืชืคื™ืœื™ืŸ ื”ืงื•ื“ืžืช ืœืื—ืจ ืฉืœื™ื—ืช ื—ื“ืฉื”", + "announcement_schedule": "๐Ÿ•ฐ ื”ื•ื“ืขืช ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’", + "announcement_schedule_intro": "ื‘ื—ืจื• ืฉืขืช ื”ื•ื“ืขื” ืœืคื™ {{timezone}}. ืฉืขื” ืžื•ื›ื ื” ืงื•ื‘ืขืช ืชื–ืžื•ืŸ ืžืงื•ืžื™ ื‘ืœื™ ืœื”ืคืขื™ืœ ื”ื•ื“ืขื•ืช. ื‘ื”ื’ื“ืจื” ืžื•ืชืืžืช ื ื™ืชืŸ ืœื‘ื—ื•ืจ ืฉืขื” ืื—ืจืช ืื• ื“ืงื•ืช ืœืคื ื™ ื”ืกื’ื™ืจื”.", + "announcement_custom": "ืฉืขื” ืžื•ืชืืžืช / ืœืคื ื™ ืกื’ื™ืจื”", + "template_kind_times_alert": "ื”ื•ื“ืขืช ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’", + "permission_view_messages": "ืฆืคื™ื™ื” ื‘ื”ื•ื“ืขื•ืช", + "permission_send_messages": "ืฉืœื™ื—ืช ื”ื•ื“ืขื•ืช", + "permission_send_media": "ืฉืœื™ื—ืช ืžื“ื™ื”", + "permission_send_stickers": "ืฉืœื™ื—ืช ืžื“ื‘ืงื•ืช", + "permission_send_gifs": "ืฉืœื™ื—ืช GIF", + "permission_send_games": "ืฉืœื™ื—ืช ืžืฉื—ืงื™ื", + "permission_send_inline": "ืฉืœื™ื—ืช ืชื•ืฆืื•ืช ืื™ื ืœื™ื™ืŸ", + "permission_embed_links": "ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืงื™ืฉื•ืจื™ื", + "permission_send_polls": "ืฉืœื™ื—ืช ืกืงืจื™ื", + "permission_change_info": "ืฉื™ื ื•ื™ ืคืจื˜ื™ ื”ืงื‘ื•ืฆื”", + "permission_invite_users": "ื”ื–ืžื ืช ืžืฉืชืžืฉื™ื", + "permission_pin_messages": "ื ืขื™ืฆืช ื”ื•ื“ืขื•ืช", + "permission_manage_topics": "ื ื™ื”ื•ืœ ื ื•ืฉืื™ื", + "permission_send_photos": "ืฉืœื™ื—ืช ืชืžื•ื ื•ืช", + "permission_send_videos": "ืฉืœื™ื—ืช ืกืจื˜ื•ื ื™ื", + "permission_send_roundvideos": "ืฉืœื™ื—ืช ื”ื•ื“ืขื•ืช ื•ื™ื“ืื• ืขื’ื•ืœื•ืช", + "permission_send_audios": "ืฉืœื™ื—ืช ืื•ื“ื™ื•", + "permission_send_voices": "ืฉืœื™ื—ืช ื”ื•ื“ืขื•ืช ืงื•ืœื™ื•ืช", + "permission_send_docs": "ืฉืœื™ื—ืช ืžืกืžื›ื™ื", + "permission_send_plain": "ืฉืœื™ื—ืช ื˜ืงืกื˜ ืจื’ื™ืœ", + "permission_edit_rank": "ืขืจื™ื›ืช ืชืืจื™ ื—ื‘ืจื™ื", + "permission_send_reactions": "ื”ื•ืกืคืช ืชื’ื•ื‘ื•ืช", + "bot_permissions": "๐Ÿ›ก ื”ืจืฉืื•ืช ืžื ื”ืœ ืฉืœ ื”ื‘ื•ื˜", + "bot_permissions_intro": "ืื‘ื—ื•ืŸ ื”ืจืฉืื•ืช ื˜ืœื’ืจื ืœืงืจื™ืื” ื‘ืœื‘ื“. ื”ืจืฉืื•ืช ื ื“ืจืฉื•ืช ืžืขื ื™ืงื™ื ื‘ื”ื’ื“ืจื•ืช ืžื ื”ืœื™ ื”ืงื‘ื•ืฆื”; ื”ื‘ื•ื˜ ืื™ื ื• ื™ื›ื•ืœ ืœื”ืขื ื™ืง ืœืขืฆืžื• ื”ืจืฉืื•ืช.", + "bot_role_creator": "ื‘ืขืœ ื”ืงื‘ื•ืฆื”", + "bot_role_administrator": "ืžื ื”ืœ ืงื‘ื•ืฆื”", + "admin_right_change_info": "ืฉื™ื ื•ื™ ืคืจื˜ื™ ื”ืงื‘ื•ืฆื”", + "admin_right_post_messages": "ืคืจืกื•ื ื”ื•ื“ืขื•ืช ื‘ืขืจื•ืฅ", + "admin_right_edit_messages": "ืขืจื™ื›ืช ื”ื•ื“ืขื•ืช ื‘ืขืจื•ืฅ", + "admin_right_delete_messages": "ืžื—ื™ืงืช ื”ื•ื“ืขื•ืช", + "admin_right_ban_users": "ื”ื’ื‘ืœืช ื—ื‘ืจื™ื", + "admin_right_invite_users": "ื”ื–ืžื ืช ืžืฉืชืžืฉื™ื", + "admin_right_pin_messages": "ื ืขื™ืฆืช ื”ื•ื“ืขื•ืช", + "admin_right_add_admins": "ื”ื•ืกืคืช ืžื ื”ืœื™ื", + "admin_right_anonymous": "ืคืขื•ืœื” ื‘ืื ื•ื ื™ืžื™ื•ืช", + "admin_right_manage_call": "ื ื™ื”ื•ืœ ืฉื™ื—ื•ืช ืงื•ืœ ื•ื•ื™ื“ืื•", + "admin_right_other": "ื”ืจืฉืื•ืช ืžื ื”ืœ ืื—ืจื•ืช", + "admin_right_manage_topics": "ื ื™ื”ื•ืœ ื ื•ืฉืื™ื", + "admin_right_post_stories": "ืคืจืกื•ื ืกื™ืคื•ืจื™ื", + "admin_right_edit_stories": "ืขืจื™ื›ืช ืกื™ืคื•ืจื™ื", + "admin_right_delete_stories": "ืžื—ื™ืงืช ืกื™ืคื•ืจื™ื", + "admin_right_manage_direct_messages": "ื ื™ื”ื•ืœ ื”ื•ื“ืขื•ืช ื™ืฉื™ืจื•ืช ื‘ืขืจื•ืฅ", + "admin_right_manage_ranks": "ื ื™ื”ื•ืœ ืชืืจื™ ื—ื‘ืจื™ื", + "welcome": "โœก๏ธ ืฉื•ืžืจ ืฉื‘ืช\nื–ืžื ื™ ืฉื‘ืช ื•ื—ื’, ื–ืžื ื™ ื”ื™ื•ื, ื“ืฃ ื™ื•ืžื™ ื•ืกืคื™ืจืช ื”ืขื•ืžืจ, ืœืคื™ ื”ืžื™ืงื•ื ืฉืœืš.\n\nื‘ื—ืจื• ืžื” ืœื”ืฆื™ื’, ืื• ืคืชื—ื• ืืช ื”ื”ืขื“ืคื•ืช ืฉืœื›ื ืœื ื™ื”ื•ืœ ื”ืฉืคื” ื•ื”ืžื™ืงื•ื.\n\nืžื ื”ืœื™ ืงื‘ื•ืฆื•ืช ื™ื›ื•ืœื™ื ืœื ื”ืœ ื›ืœ ืงื‘ื•ืฆื” ื“ืจืš ืดื”ืงื‘ื•ืฆื•ืช ืฉืœื™ืด.", + "help": "โ„น๏ธ ืžื™ื“ืข ื•ืขื–ืจื”", + "home": "๐Ÿ  ืชืคืจื™ื˜ ืจืืฉื™", + "info_intro": "โ„น๏ธ ื›ืœ ืžื” ืฉืฆืจื™ืš ืœื“ืขืช ืขืœ ืฉื•ืžืจ ืฉื‘ืช\n\nื”ื–ืžื ื™ื ืฉืœื›ื, ื‘ืฉืคื” ืฉืœื›ื ื•ื‘ืžืงื•ื ืฉืœื›ื.\nื‘ื—ืจื• ื ื•ืฉื ืœืงื‘ืœืช ื”ืกื‘ืจ ื•ืคืงื•ื“ื•ืช ืฉื™ืžื•ืฉื™ื•ืช. ืžื ื”ืœื™ ืงื‘ื•ืฆื•ืช ื™ืžืฆืื• ื›ืืŸ ื’ื ืžื™ื“ืข ืขืœ ืกื’ื™ืจื” ืื•ื˜ื•ืžื˜ื™ืช, ืชื–ื›ื•ืจื•ืช ื•ืขื™ืฆื•ื‘ ื”ื•ื“ืขื•ืช.", + "info_personal_title": "โš™๏ธ ื”ื”ืขื“ืคื•ืช ืฉืœื™", + "info_personal_text": "/preferences - ืฉืคื”, ืžื™ืงื•ื, ืœื•ื— ื™ืฉืจืืœ ืื• ื—ื•ืดืœ ื•ืฉื™ื˜ื•ืช ื›ื ื™ืกื” ื•ื™ืฆื™ืื”.\n/lang - ื‘ื—ื™ืจืช ืฉืคื” ืžืชื•ืš ืฉืฉ ื”ืฉืคื•ืช ื”ื ืชืžื›ื•ืช.\nื”ื–ืžื ื™ื ื‘ืคืจื˜ื™ ืžื—ื•ืฉื‘ื™ื ืœืคื™ ื”ื”ืขื“ืคื•ืช ืฉืœื›ื. ืฉื™ื ื•ื™ ืื™ืฉื™ ืื™ื ื• ืžืฉื ื” ืืช ื”ื’ื“ืจื•ืช ื”ืงื‘ื•ืฆื•ืช.\n/start - ื—ื–ืจื” ืœืชืคืจื™ื˜ ื”ืจืืฉื™.", + "info_calendar_title": "๐Ÿ•ฏ ื–ืžื ื™ื ื•ืœื•ื— ื™ื”ื•ื“ื™", + "info_calendar_text": "ื”ื–ืžื ื™ื ืฉืœื›ื\n/shabbat - ืฉื‘ืช ืงืจื•ื‘ื”, ืจืฆืคื™ ื™ื•ื ื˜ื•ื‘, ืคืจืฉืช ื”ืฉื‘ื•ืข, ืžื‘ืจื›ื™ื ื•ืžื•ืขื“ื™ื ืงืจื•ื‘ื™ื, ื‘ื”ื•ื“ืขื” ืื—ืช.\n/zmanim - ื–ืžื ื™ ื”ื™ื•ื ืœืคื™ ื”ืžื™ืงื•ื; ืืคืฉืจ ืœื‘ื—ื•ืจ ืชืืจื™ืš ื‘ื›ืคืชื•ืจื™ื.\n/daf - ื“ืฃ ื™ื•ืžื™. /omer - ืกืคื™ืจืช ื”ืขื•ืžืจ ื‘ืขื•ื ื”.\n\nืฉื‘ืช ื•ื—ื’ ื‘ืจืฆืฃ\nืžื•ืฆื’ื•ืช ื›ืœ ื”ื“ืœืงื•ืช ื”ื ืจื•ืช ื•ื”ื™ืฆื™ืื” ื”ืื—ืจื•ื ื”. ื–ืžื ื™ ื›ื ื™ืกื” ื•ื™ืฆื™ืื” ืœื—ื’ ืžื•ืฆื’ื™ื ืจืง ืœื™ื•ื ื˜ื•ื‘, ืœืคื™ ืกื™ื•ื•ื’ Hebcal. ื—ื ื•ื›ื”, ื—ื•ืœ ื”ืžื•ืขื“ ื•ืžื•ืขื“ื™ื ืื—ืจื™ื ืื™ื ื ื™ื•ื ื˜ื•ื‘ ื‘ืคื ื™ ืขืฆืžื.\n\nืžื™ืงื•ื ื•ืฉื™ื˜ื•ืช\nื”ื”ืขื“ืคื•ืช ื”ืื™ืฉื™ื•ืช ืงื•ื‘ืขื•ืช ืืช ื”ืชืฆื•ื’ื” ื‘ืคืจื˜ื™; ืœื›ืœ ืงื‘ื•ืฆื” ื”ื’ื“ืจื•ืช ื ืคืจื“ื•ืช. ื‘ื™ืจื•ืฉืœื™ื ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ื”ื™ื 40 ื“ืงื•ืช ืœื ืจื•ืช, ื•ื ื™ืชืŸ ืœืฉื ื•ืช ื–ืืช. ื”ืฉืขื•ืช ืžื•ืฆื’ื•ืช ืœืคื™ ืื–ื•ืจ ื”ื–ืžืŸ ืฉื ื‘ื—ืจ, ื›ื•ืœืœ ืžืขื‘ืจ ืฉืขื•ืŸ ืงื™ืฅ.\n\nื ืชื•ื ื™ื ื—ืกืจื™ื ืื™ื ื ืžื ื•ื—ืฉื™ื. ื›ืคืชื•ืจ ื”ืฉื™ืชื•ืฃ ืžืืคืฉืจ ืœืฉืœื•ื— ืชื•ืฆืื” ืœืฆืณืื˜ ืื—ืจ.\n\nื ืชื•ื ื™ ื”ืœื•ื— ื•ื”ื–ืžื ื™ื: Hebcal.com. ืฉื™ื˜ืช ื”ื™ืฆื™ืื” ืฉืžื•ืฆื’ืช ื‘ื”ื•ื“ืขื” ื”ื™ื ื–ื• ืฉื‘ื—ืจืชื ื‘ื”ื’ื“ืจื•ืช, ืœืคื™ ืžืขืœื•ืช ืื• ื“ืงื•ืช ืื—ืจื™ ื”ืฉืงื™ืขื”.", + "info_groups_title": "๐Ÿ‘ฅ ื ื™ื”ื•ืœ ืงื‘ื•ืฆื•ืช", + "info_groups_text": "ื—ื™ื‘ื•ืจ ื•ืคืชื™ื—ืช ื”ืคืื ืœ\nื”ื•ืกื™ืคื• ืืช ื”ื‘ื•ื˜ ืœืงื‘ื•ืฆื” ื›ืžื ื”ืœ, ื•ืื– ืฉืœื—ื• ื‘ื” /settings. ืžื ื”ืœ ืงื‘ื•ืฆื” ื™ื›ื•ืœ ืœืคืชื•ื— ืืช ืื•ืชื• ืคืื ืœ ื’ื ื“ืจืš ืดื”ืงื‘ื•ืฆื•ืช ืฉืœื™ืด ื‘ืคืจื˜ื™. ื”ืชืคืจื™ื˜ ื‘ืงื‘ื•ืฆื•ืช ืžืฆื™ื’ ืจืง ืืช ืคืงื•ื“ืช ื”ื”ื’ื“ืจื•ืช.\n\nืžื” ืžื’ื“ื™ืจื™ื?\nืžื™ืงื•ื ื•ืฉืคื”, ืฉื‘ืช ื•ื—ื’ื™ื, ื–ืžื ื™ ื”ื™ื•ื, ื“ืฃ ื™ื•ืžื™, ืชืคื™ืœื™ืŸ, ืขื•ืžืจ, ื”ื•ื“ืขื•ืช ื•ืขื™ืฆื•ื‘ ื•ื”ืจืฉืื•ืช. ืœื›ืœ ืงื‘ื•ืฆื” ื”ื’ื“ืจื•ืช ื ืคืจื“ื•ืช.\n\nื‘ื—ื™ืจืช ืžืฆื‘\nื›ื‘ื•ื™, ื”ื•ื“ืขื•ืช ื‘ืœื‘ื“, ื ืขื™ืœื” ื‘ืœื‘ื“ ืื• ื”ื•ื“ืขื•ืช ื•ื ืขื™ืœื”. ื‘ืชื•ืš ืฉื‘ืช ื•ื—ื’ื™ื ื‘ื•ื—ืจื™ื ื™ืžื™ื, ืฉื™ื˜ื•ืช ื–ืžื ื™ื ื•ืฉืขืช ื”ื•ื“ืขืช ื”ื–ืžื ื™ื.\n\nื”ืฉืžื™ืจื” ื“ื•ืจืฉืช ื”ืจืฉืืช ืžื ื”ืœ ืขื“ื›ื ื™ืช. ื‘ื“ืงื• ืืช ื”ืกื™ื›ื•ื ืœืคื ื™ ื”ืคืขืœื”; ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืคืจื˜ื™ ืื™ื ื” ืฉื•ืœื—ืช ืœืงื‘ื•ืฆื”.", + "info_notifications_title": "๐Ÿ”” ืชื–ื›ื•ืจื•ืช ื•ื”ื•ื“ืขื•ืช", + "info_notifications_text": "ื›ืœ ืชื–ื›ื•ืจืช ื‘ื ืคืจื“\nื‘ืคืื ืœ ื”ืงื‘ื•ืฆื” ื‘ื•ื—ืจื™ื ื–ืžื ื™ ื”ื™ื•ื, ื“ืฃ ื™ื•ืžื™, ืชืคื™ืœื™ืŸ ืื• ืขื•ืžืจ, ืžืคืขื™ืœื™ื ื‘ืžืคื•ืจืฉ ื•ืื– ืžื’ื“ื™ืจื™ื ืฉืขื•ืช ืœืคื™ ืื–ื•ืจ ื”ื–ืžืŸ ืฉืœ ื”ืงื‘ื•ืฆื”. ืื™ืŸ ืฆื•ืจืš ืœื›ืชื•ื‘ ืฉืžื•ืช ื”ื’ื“ืจื•ืช ืื• ื˜ื•ืงื ื™ื.\n\nืชืคื™ืœื™ืŸ\nื‘ื—ืจื• ื™ืžื™ ืคืขื™ืœื•ืช, ืฉืขื•ืช ืงื‘ื•ืขื•ืช, ืžืจื•ื•ื— ื‘ื™ืŸ ืชื–ื›ื•ืจื•ืช ืื• ื“ืงื•ืช ืœืคื ื™ ื”ืฉืงื™ืขื”. ืืคืฉืจ ืœื”ื’ื“ื™ืจ ืฉืขื•ืช ืฉืงื˜, ืžื›ืกื” ื™ื•ืžื™ืช ื•ืžื—ื™ืงืช ื”ื”ื•ื“ืขื” ื”ืงื•ื“ืžืช. ื”ื‘ื•ื˜ ืžืชื—ืฉื‘ ื‘ืฉื‘ืช ื•ื‘ื™ื•ื ื˜ื•ื‘ ื•ื‘ื”ื’ื“ืจื•ืช ื—ื•ืœ ื”ืžื•ืขื“ ื•ืชืฉืขื” ื‘ืื‘.\n\nื”ื ื—ืชื™\nื›ืคืชื•ืจ ื”ืชื–ื›ื•ืจืช ืกื•ืคืจ ืžืฉืชืžืฉื™ื ื™ื™ื—ื•ื“ื™ื™ื; ืœื—ื™ืฆื” ื ื•ืกืคืช ืžื‘ื˜ืœืช ืืช ื”ื‘ื—ื™ืจื”. ืžื ื”ืœ ื™ื›ื•ืœ ืœืฉื ื•ืช ืืช ื”ื›ื™ืชื•ื‘ ืื• ืœื›ื‘ื•ืช ืืช ื”ื›ืคืชื•ืจ.\n\nื”ื•ื“ืขืช ื”ื–ืžื ื™ื\nื‘ืฉื‘ืช ื•ื—ื’ื™ื ื‘ื•ื—ืจื™ื ืืช ืฉืขืช ื”ืคืจืกื•ื ื•ืืช ื ื•ืกื— ื”ื”ื•ื“ืขื”. ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืžื•ืคื™ืขื” ื‘ืคืจื˜ื™; ืฉืœื™ื—ืช ื‘ื“ื™ืงื” ืœืงื‘ื•ืฆื” ื“ื•ืจืฉืช ืื™ืฉื•ืจ.", + "info_messages_title": "โœ‰๏ธ ืขื™ืฆื•ื‘ ื•ื”ืจืฉืื•ืช", + "info_messages_text": "ื”ื•ื“ืขื•ืช ื•ืขื™ืฆื•ื‘\nื‘ื—ืจื• ืืช ืกื•ื’ ื”ื”ื•ื“ืขื”: ืกื’ื™ืจื”, ืคืชื™ื—ื”, ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’, ื–ืžื ื™ ื”ื™ื•ื, ื“ืฃ ื™ื•ืžื™, ืชืคื™ืœื™ืŸ ืื• ืขื•ืžืจ. ืืคืฉืจ ืœืขืจื•ืš ื˜ืงืกื˜, ืชืžื•ื ื” ืื• ืžื“ื™ื” ื•ื›ืคืชื•ืจื™ื ื‘ื ืคืจื“.\n\nืขืจื™ื›ื” ื˜ื‘ืขื™ืช\nืฉืœื—ื• ืœื‘ื•ื˜ ืืช ื”ื”ื•ื“ืขื” ื›ืคื™ ืฉืชืจืฆื• ืฉืชื•ืคื™ืข. ื”ื•ื ืฉื•ืžืจ ืืช ื”ื˜ืงืกื˜ ื•ื”ืขื™ืฆื•ื‘ ืฉืœ Telegram, ื›ื•ืœืœ ื”ื“ื’ืฉื”, ืงื™ืฉื•ืจื™ื ื•ืื™ืžื•ื’ืณื™ื ืžื•ื ืคืฉื™ื. ื‘ื“ืงื• ืืช ื”ืชืฆื•ื’ื” ื”ืžืงื“ื™ืžื” ื•ืืฉืจื• ืฉืžื™ืจื”.\n\nื›ืคืชื•ืจื™ื\nืืคืฉืจ ืœื”ื•ืกื™ืฃ ืงื™ืฉื•ืจ, ืœื‘ื—ื•ืจ ืกื’ื ื•ืŸ, ืœื”ืกื™ืจ ื›ืคืชื•ืจื™ื ืื• ืœื”ื—ื–ื™ืจ ืœื‘ืจื™ืจืช ื”ืžื—ื“ืœ. ื”ื•ื“ืขื•ืช ื–ืžื ื™ื ื•ื“ืฃ ื™ื•ืžื™ ื›ื•ืœืœื•ืช ื›ืคืชื•ืจ ืฉื™ืชื•ืฃ ื›ื‘ืจื™ืจืช ืžื—ื“ืœ. ืขืจื™ื›ืช ื›ืคืชื•ืจื™ื ื‘ืœื‘ื“ ืฉื•ืžืจืช ืืช ื”ืชื•ื›ืŸ ื”ืžื—ื•ืฉื‘.\n\nื›ืฉืื™ืžื•ื’ืณื™ื ืžื•ื ืคืฉื™ื ืื™ื ื ื–ืžื™ื ื™ื, ื”ื‘ื•ื˜ ืžืฉืชืžืฉ ื‘ืื™ืžื•ื’ืณื™ ืจื’ื™ืœ. ื‘ื“ื™ืงื” ื‘ืงื‘ื•ืฆื” ื ืฉืœื—ืช ืจืง ืœืื—ืจ ืื™ืฉื•ืจ ืžืคื•ืจืฉ.", + "info_privacy_title": "๐Ÿ›ก ืคืจื˜ื™ื•ืช ื•ื‘ื˜ื™ื—ื•ืช", + "info_privacy_text": "ืฉืœื™ื˜ื” ื‘ื”ื’ื“ืจื•ืช\nื—ื™ืคื•ืฉ ืขื™ืจ ื•ืฉื™ืชื•ืฃ ืžื™ืงื•ื ืžืฆื™ื’ื™ื ื‘ื—ื™ืจื” ื•ืชืฆื•ื’ื” ืžืงื“ื™ืžื”; ืจืง ืื™ืฉื•ืจ ืฉื•ืžืจ ืฉื™ื ื•ื™. ื”ื”ืขื“ืคื•ืช ื”ืื™ืฉื™ื•ืช ืื™ื ืŸ ืžืฉื ื•ืช ืงื‘ื•ืฆื”. ื”ืจืฉืืช ืžื ื”ืœ ื ื‘ื“ืงืช ืœืคื ื™ ืคืขื•ืœื•ืช ื ื™ื”ื•ืœ.\n\nืžื” ื ืฉืžืจ?\nืžื–ื”ื™ ืžืฉืชืžืฉื™ื ื•ืงื‘ื•ืฆื•ืช, ื”ืขื“ืคื•ืช, ื”ืจืฉืื•ืช ื•ืชื™ืขื•ื“ ื”ืคืขื•ืœื•ืช ื”ื“ืจื•ืฉื™ื ืœืฉื™ืจื•ืช. ืœื—ื™ืฆื” ืขืœ ืดื”ื ื—ืชื™ืด ืฉื•ืžืจืช ื‘ื—ื™ืจื” ืื™ืฉื™ืช ืœืฆื•ืจืš ืกืคื™ืจื” ื™ื™ื—ื•ื“ื™ืช; ืœื—ื™ืฆื” ื—ื•ื–ืจืช ืžื‘ื˜ืœืช ืื•ืชื”.\n\nื‘ื“ื™ืงื” ื•ืฉืงื™ืคื•ืช\nืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื ืฉืืจืช ื‘ืคืจื˜ื™. ื‘ื“ื™ืงื” ื‘ืงื‘ื•ืฆื” ื“ื•ืจืฉืช ืื™ืฉื•ืจ ืžืคื•ืจืฉ. ืืœ ืชืฉืœื—ื• ืกื™ืกืžืื•ืช ืื• ื˜ื•ืงื ื™ื.\n/source ืžืจื›ื– ืืช ื”ืžืื’ืจ ื”ืจืฉืžื™, ื’ืจืกืช ื”ืจื™ืฆื”, ื”ืจื™ืฉื™ื•ืŸ ื•ื”ืžืงื•ืจ ื”ืชื•ืื ืœื‘ื ื™ื™ื”.", + "info_operators_title": "๐Ÿ›  ื ื™ื”ื•ืœ ื”ื‘ื•ื˜", + "info_operators_text": "/admin - ืžื ื•ื™ื™ื, ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช ื•ื”ื•ื“ืขื•ืช ืชืคื•ืฆื”.\n/setcommands - ืชืคืจื™ื˜ื™ ืคืงื•ื“ื•ืช ืœืคื™ ืฉืคื” ื•ืกื•ื’ ืฆืณืื˜; ื‘ื“ืงื• ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืคื ื™ ืคืจืกื•ื.\n/testzmanim - ื‘ื“ื™ืงืช ื—ื™ืฉื•ื‘ ื”ื–ืžื ื™ื ื•ื”ืชื–ืžื•ืŸ.\n/closegroups ื•ึพ/opengroups - ืคืขื•ืœื•ืช ื™ื“ื ื™ื•ืช ืœืงื‘ื•ืฆื•ืช ืžื•ืจืฉื•ืช.\n/restart - ื”ืคืขืœื” ืžื—ื“ืฉ ืžืกื•ื“ืจืช.\nื”ืืคืฉืจื•ื™ื•ืช ื”ืืœื” ื–ืžื™ื ื•ืช ืœืžื ื”ืœื™ ื”ื‘ื•ื˜ ื‘ืœื‘ื“; ื ื™ื”ื•ืœ ืงื‘ื•ืฆื” ืื™ื ื• ืžืงื ื” ื”ืจืฉืื” ืœื ื™ื”ื•ืœ ื”ื‘ื•ื˜.", + "callback_panel_unavailable": "ืœื ื ื™ืชืŸ ืœืขื“ื›ืŸ ืืช ื”ืชืคืจื™ื˜ ื”ื–ื”. ืคืชื—ื• ืžื—ื“ืฉ ืขื /start ืื• /settings.", + "provider_unavailable": "ืœื ื ื™ืชืŸ ืœืงื‘ืœ ื›ืจื’ืข ื ืชื•ื ื™ ื–ืžื ื™ื ืžืื•ืžืชื™ื. ื ืกื• ืฉื•ื‘ ื‘ืขื•ื“ ื›ืžื” ื“ืงื•ืช.", + "operator_subscriber_stats_full": "๐Ÿ“Š ืžื ื•ื™ื™ ืฉื•ืžืจ ืฉื‘ืช\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค ืžืฉืชืžืฉื™ื: {{users}}\n๐Ÿ‘ฅ ืงื‘ื•ืฆื•ืช: {{chats}}\n๐Ÿ‘ฅ ืกื•ืคืจึพืงื‘ื•ืฆื•ืช: {{supergroups}}\n๐Ÿ“ข ืขืจื•ืฆื™ื: {{channels}}\n๐Ÿค– ื‘ื•ื˜ื™ื: {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nืกื”ืดื›: {{total}}", + "operator_group_stats": "๐Ÿ”’ ืคืขื™ืœื•ืช ื”ืงื‘ื•ืฆื•ืช\nืงื‘ื•ืฆื•ืช ืžื•ื›ืจื•ืช: {{known}}\nืจืฉื•ืžื•ืช ืœืฉื™ืจื•ืช: {{registered}}\nืกื’ื™ืจื” ืคืขื™ืœื”: {{locking}}\nืกื’ื•ืจื•ืช ื›ืจื’ืข ืขืœ ื™ื“ื™ ื”ื‘ื•ื˜: {{locked}}\nืžืžืชื™ื ื•ืช ืœืฉื—ื–ื•ืจ: {{recovery}}", + "times_section_schedule": "ื–ืžื ื™ ื›ื ื™ืกื” ื•ื™ืฆื™ืื”", + "times_section_calendar": "ืคืจื˜ื™ ื”ืœื•ื—", + "times_stale": "โ„น๏ธ ืžื•ืฆื’ื™ื ื ืชื•ื ื™ื ืฉืžื•ืจื™ื ืžื”ื‘ื“ื™ืงื” ื”ืื—ืจื•ื ื”; ื”ืกืคืง ืื™ื ื• ื–ืžื™ืŸ ื›ืจื’ืข.", + "times_day_name_0": "ื™ื•ื ืจืืฉื•ืŸ", + "times_day_name_1": "ื™ื•ื ืฉื ื™", + "times_day_name_2": "ื™ื•ื ืฉืœื™ืฉื™", + "times_day_name_3": "ื™ื•ื ืจื‘ื™ืขื™", + "times_day_name_4": "ื™ื•ื ื—ืžื™ืฉื™", + "times_day_name_5": "ื™ื•ื ืฉื™ืฉื™", + "times_day_name_6": "ืฉื‘ืช", + "times_heading": "๐Ÿ•ฏ ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… ื™ืžื™ื ืงื“ื•ืฉื™ื: {{dates}}", + "times_candles": "๐Ÿ•ฏ ื”ื“ืœืงืช ื ืจื•ืช - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ ื™ืฆื™ืื”/ื”ื‘ื“ืœื”: {{time}}", + "times_close": "๐Ÿ”’ ืกื’ื™ืจืช ื”ืงื‘ื•ืฆื”: {{time}}", + "times_open": "๐Ÿ”“ ืคืชื™ื—ืช ื”ืงื‘ื•ืฆื”: {{time}}", + "times_sequence": "โ„น๏ธ ืฉื‘ืช ื•ื—ื’ ื‘ืจืฆืฃ: ื”ืงื‘ื•ืฆื” ื ืฉืืจืช ืกื’ื•ืจื” ืขื“ ื–ืžืŸ ื”ืคืชื™ื—ื” ื”ืื—ืจื•ืŸ.", + "times_parasha": "๐Ÿ•ฏ ืคืจืฉืช ื”ืฉื‘ื•ืข: {{title}}", + "times_mevarchim": "๐ŸŒ™ ืฉื‘ืช ืžื‘ืจื›ื™ื: {{title}}", + "times_holiday": "โœก๏ธ ื‘ืœื•ื—: {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "ื”ื“ืœืงืช ื ืจื•ืช: {{minutes}} ื“ืงื•ืช ืœืคื ื™ ื”ืฉืงื™ืขื”\nื™ืฆื™ืื”: {{method}} {{value}}", + "times_source": "ืžืงื•ืจ: Hebcal ยท ืขื•ื“ื›ืŸ: {{time}}", + "times_data_missing": "ื—ืœืง ืžื ืชื•ื ื™ ื”ื–ืžื ื™ื ืื™ื ื• ื–ืžื™ืŸ ื›ืขืช.", + "location_menu_intro": "๐Ÿ“ ืžื™ืงื•ื ืขื‘ื•ืจ {{title}}\nื”ืžื™ืงื•ื ื”ื ื•ื›ื—ื™: {{location}}\nืื–ื•ืจ ื–ืžืŸ: {{timezone}}\nื‘ื—ืจื• ืื™ืš ืœืžืฆื•ื ืืช ื”ืžื™ืงื•ื. ื ืฆื™ื’ ืืช ื”ืคืจื˜ื™ื ืœืื™ืฉื•ืจ ืœืคื ื™ ื”ืฉืžื™ืจื”.", + "location_input_city_title": "๐Ÿ”Ž ื—ื™ืคื•ืฉ ืขื™ืจ", + "location_input_city_prompt": "ื›ืชื‘ื• ืืช ืฉื ื”ืขื™ืจ. ืืคืฉืจ ืœื—ืคืฉ ื‘ืขื‘ืจื™ืช ืื• ื‘ืฉืคื•ืช ืื—ืจื•ืช. ื ืฆื™ื’ ืชื•ืฆืื•ืช ืœื‘ื—ื™ืจื”.", + "location_input_timezone_title": "๐ŸŒ ืื–ื•ืจ ื–ืžืŸ", + "location_input_timezone_prompt": "ื›ืชื‘ื• ืฉื ืขื™ืจ ืื• ืื–ื•ืจ ื–ืžืŸ, ืœืžืฉืœ Jerusalem ืื• Asia/Jerusalem. ืฉื™ื ื•ื™ ืื–ื•ืจ ื”ื–ืžืŸ ืื™ื ื• ืžืฉื ื” ืืช ื”ืงื•ืื•ืจื“ื™ื ื˜ื•ืช.", + "location_input_geoname_title": "๐Ÿ”ข ืžื–ื”ื” GeoNames", + "location_input_geoname_prompt": "ื›ืชื‘ื• ืืช ื”ืžื–ื”ื” ื”ืžืกืคืจื™ ืฉืœ ื”ืžืงื•ื ื‘ึพGeoNames, ืœืžืฉืœ 281184 ืขื‘ื•ืจ ื™ืจื•ืฉืœื™ื.", + "location_input_coordinates_title": "๐Ÿงญ ืงื•ืื•ืจื“ื™ื ื˜ื•ืช", + "location_input_coordinates_prompt": "ื›ืชื‘ื• ืงื• ืจื•ื—ื‘ ื•ืงื• ืื•ืจืš, ืœืžืฉืœ 31.76904, 35.21633. ืืคืฉืจ ืœื”ื•ืกื™ืฃ ืื–ื•ืจ ื–ืžืŸ, ืœืžืฉืœ Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ ืฉื™ืชื•ืฃ ืžื™ืงื•ื", + "location_input_gps_prompt": "ื”ืฉื™ื‘ื• ืœื”ื•ื“ืขื” ื”ื–ื• ื‘ืืžืฆืขื•ืช ืฉื™ืชื•ืฃ ืžื™ืงื•ื ื‘ื˜ืœื’ืจื. ื ืฆื™ื’ ืืช ื”ืžื™ืงื•ื ื•ืื–ื•ืจ ื”ื–ืžืŸ ืœื‘ื“ื™ืงื” ื•ืœืื™ืฉื•ืจ.", + "location_input_invalid": "ืœื ื”ืฆืœื—ื ื• ืœื”ื‘ื™ืŸ ืืช ื”ืžื™ืงื•ื. ื”ื–ื™ื ื• ืฉื ืขื™ืจ, ืžื–ื”ื” GeoNames, ืงื•ืื•ืจื“ื™ื ื˜ื•ืช ืื• ืื–ื•ืจ ื–ืžืŸ ื‘ื”ืชืื ืœืืคืฉืจื•ืช ืฉื‘ื—ืจืชื.", + "location_inline_hint": "ืืคืฉืจ ืœื—ืคืฉ ื’ื ืžืชื•ืš ืฉื“ื” ื”ื•ื“ืขื”: @{{bot}} ื•ืื—ืจื™ื• ืฉื ื”ืขื™ืจ.", + "location_input_cancelled": "ื‘ื—ื™ืจืช ื”ืžื™ืงื•ื ื‘ื•ื˜ืœื”. ื”ื”ื’ื“ืจื•ืช ืœื ื”ืฉืชื ื•.", + "daf_open_page": "๐Ÿ”— ืคืชื™ื—ืช ื”ื“ืฃ", + "timezone_inline_hint": "ืืคืฉืจ ืœื—ืคืฉ ื’ื ื‘ืฉื“ื” ื”ื•ื“ืขื”: @{{bot}} tz Jerusalem ืื• @{{bot}} Asia/Jerusalem. ืœื‘ื—ื™ืจื” ืขื‘ื•ืจ ื”ื”ื’ื“ืจื•ืช ื”ืคืขื™ืœื•ืช ื”ืฉืชืžืฉื• ื‘ื›ืคืชื•ืจ ื”ื—ื™ืคื•ืฉ ื›ืืŸ.", + "donate_other": "ืื—ืจ", + "donate_custom_prompt": "โญ ืชืจื•ืžื” ื‘ืกื›ื•ื ืœื‘ื—ื™ืจืชื›ื\n\nื›ืชื‘ื• ืžืกืคืจ ืฉืœื ื‘ื™ืŸ 1 ืœึพ10,000 ื›ื•ื›ื‘ื™ื.\nื ืฆื™ื’ ืงื™ืฉื•ืจ ืœืชืฉืœื•ื ืœืคื ื™ ืฉืชืฉืœื™ืžื• ืืช ื”ืชืจื•ืžื” ื‘ื˜ืœื’ืจื.", + "donate_custom_invalid": "ื”ื–ื™ื ื• ืžืกืคืจ ืฉืœื ื‘ื™ืŸ 1 ืœึพ10,000 ื›ื•ื›ื‘ื™ื, ืœืœื ืกื™ืžื ื™ื ืื• ืฉื‘ืจื™ื.", + "donate_custom_ready": "โญ ืœืชืจื•ืžื” ืฉืœ {{amount}} ื›ื•ื›ื‘ื™ื, ืคืชื—ื• ืืช ืงื™ืฉื•ืจ ื”ืชืฉืœื•ื ื•ืืฉืจื• ื‘ื˜ืœื’ืจื.", + "donate_custom_cancel": "โ†ฉ๏ธ ื—ื–ืจื” ืœืกื›ื•ืžื™ื", + "times_upcoming": "๐Ÿ•ฏ ื”ืฉื‘ืชื•ืช ื•ื”ื—ื’ื™ื ื”ืงืจื•ื‘ื™ื", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "ืขืžื•ื“ {{page}} ืžืชื•ืš {{pages}}", + "times_period_exit": "๐ŸŒ™ ืฆืืช ื”ืชืงื•ืคื”: {{time}}", + "times_no_events": "ืื™ืŸ ืื™ืจื•ืขื™ ืฉื‘ืช ืื• ื—ื’ ื ื•ืกืคื™ื ื‘ื˜ื•ื•ื— ื”ื ืชื•ื ื™ื ื”ืžืื•ืžืช.", + "times_lighting_after": "ื”ื“ืœืงืช ื”ื ืจื•ืช ื‘ืขืจื‘ ื–ื” ืื—ืจื™ ืฆืืช ื”ื™ื•ื ื”ืงื•ื“ื; ืžืฉืชืžืฉื™ื ื‘ืืฉ ืฉื”ื•ื“ืœืงื” ืœืคื ื™ ื›ื ื™ืกืช ื”ื—ื’.", + "times_event_link": "ื–ืžื ื™ {{title}}", + "omer_nightfall_label": "ืฆืืช ื”ื›ื•ื›ื‘ื™ื", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "ื‘ื—ืจื• ื”ื’ื“ืจื” ืœืฉื™ื ื•ื™. ื”ืขืจืš ื”ื ื•ื›ื—ื™ ืžื•ืคื™ืข ืœื™ื“ ื›ืœ ืืคืฉืจื•ืช.", + "wizard_custom": "โœ๏ธ ืขืจืš ืื—ืจ", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nื›ืจื’ืข: {{current}}\n\n{{hint}}", + "wizard_invalid": "ื”ืขืจืš ืœื ืžืชืื™ื ืœืืคืฉืจื•ืช ื”ื–ืืช. ื ืกื• ืฉื•ื‘ ืœืคื™ ื”ื“ื•ื’ืžื” ืื• ื—ื–ืจื• ืœื‘ื—ื™ืจืช ื”ื’ื“ืจื”.", + "wizard_done": "โœ… ืกื™ื•ื ื•ื‘ื“ื™ืงื”", + "wizard_more": "โš™๏ธ ื›ืœ ื”ื”ื’ื“ืจื•ืช", + "wizard_choose_option": "ื‘ื—ืจื• ืืคืฉืจื•ืช ืžื”ืจืฉื™ืžื”. ื”ืฉื™ื ื•ื™ ื™ื•ืฆื’ ืœื‘ื“ื™ืงื” ืœืคื ื™ ื”ืฉืžื™ืจื”.", + "wizard_template": "ื”ืชืื™ืžื• ืืช ื”ื”ื•ื“ืขื”, ื”ืขื™ืฆื•ื‘, ื”ืžื“ื™ื” ื•ื”ื›ืคืชื•ืจื™ื ื‘ืขื•ืจืš ื”ื”ื•ื“ืขื•ืช. ื”ืชืฆื•ื’ื” ื”ืžืงื“ื™ืžื” ื ืฉืืจืช ื‘ืคืจื˜ื™.", + "wizard_hint_time": "ื›ืชื‘ื• ืฉืขื” ืžืงื•ืžื™ืช, ืœืžืฉืœ 09:30.", + "wizard_hint_times": "ื›ืชื‘ื• ืฉืขื” ืื• ื›ืžื” ืฉืขื•ืช ืžื•ืคืจื“ื•ืช ื‘ืคืกื™ืงื™ื, ืœืžืฉืœ 09:00, 13:30.", + "wizard_hint_integer": "ื›ืชื‘ื• ืžืกืคืจ ืฉืœื. ื ื‘ื“ื•ืง ืฉื”ื•ื ื‘ื˜ื•ื•ื— ื”ืžืชืื™ื ืœื”ื’ื“ืจื”.", + "wizard_hint_float": "ื›ืชื‘ื• ืžืกืคืจ, ืœืžืฉืœ 8.5 ืœืžืขืœื•ืช ืื• 50 ืœื“ืงื•ืช ืงื‘ื•ืขื•ืช.", + "wizard_hint_minutes_list": "ื›ืชื‘ื• ืžืกืคืจ ื“ืงื•ืช ืื—ื“ ืื• ื›ืžื” ืžืกืคืจื™ื ืžื•ืคืจื“ื™ื ื‘ืคืกื™ืงื™ื, ืœืžืฉืœ 60, 30, 15.", + "wizard_hint_text": "ื›ืชื‘ื• ืืช ื”ื˜ืงืกื˜ ืฉืชืจืฆื• ืœืฉืžื•ืจ.", + "wizard_hint_days": "ืกืžื ื• ืืช ื™ืžื™ ื”ืคืขื™ืœื•ืช ื•ืœื—ืฆื• ืขืœ ืดืกื™ื•ื ื•ื‘ื“ื™ืงื”ืด.", + "template_edit_guided_prompt": "ืฉืœื—ื• ืืช ื”ื”ื•ื“ืขื” ื”ื—ื“ืฉื” ื›ืืŸ. ืืคืฉืจ ืœืฆืจืฃ ืžื“ื™ื” ื•ืœืขืฆื‘ ืืช ื”ื˜ืงืกื˜ ื‘ื˜ืœื’ืจื. ื ืฆื™ื’ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืคื ื™ ืฉืžื™ืจื”.", + "template_buttons_guided_prompt": "๐Ÿ”— ื›ืคืชื•ืจื™ื ืœื”ื•ื“ืขื”\n\nืœื”ื•ืกืคืช ืงื™ืฉื•ืจ ื‘ืงืœื•ืช, ืœื—ืฆื• ืขืœ ืดื”ื•ืกืคืช ื›ืคืชื•ืจ ืงื™ืฉื•ืจืด. ื ืฉืืœ ื‘ื ืคืจื“ ืขืœ ื”ื˜ืงืกื˜ ื•ื”ื›ืชื•ื‘ืช.\n\nืืคืฉืจ ื’ื ืœืฉืœื•ื— ื›ืžื” ื›ืคืชื•ืจื™ื ืœืคื™ ื”ื“ื•ื’ืžื”:\nืžื™ื“ืข - https://example.org - style:bg_primary\nืชืžื™ื›ื” - https://example.org/support\n\nืฉื•ืจื” ื—ื“ืฉื” ื™ื•ืฆืจืช ืฉื•ืจืช ื›ืคืชื•ืจื™ื ื—ื“ืฉื”. ืœื”ื•ืกืคืช ื›ืคืชื•ืจื™ื ื‘ืื•ืชื” ืฉื•ืจื”, ื”ืคืจื™ื“ื• ื‘ืืžืฆืขื•ืช &&. ืืคืฉืจ ื’ื ืœืฉืœื•ื— JSON. ื ืฆื™ื’ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืคื ื™ ื”ืฉืžื™ืจื”.", + "operator_audience_authorize": "โœ… ืื™ืฉื•ืจ ืงื”ืœ ื”ืฉื™ื“ื•ืจ", + "command_adminstats": "๐Ÿ“Š ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช ื ื™ื”ื•ืœ", + "command_subscribers": "๐Ÿ‘ฅ ืžื ื•ื™ื™ื ื•ื—ื™ืคื•ืฉ", + "command_broadcast": "๐Ÿ“ฃ ืฉื™ื“ื•ืจ ื”ื•ื“ืขื”", + "command_managers": "๐Ÿ‘ฅ ืžื ื”ืœื™ื ื•ื”ืจืฉืื•ืช", + "command_systemlinks": "โš™๏ธ ืงื™ืฉื•ืจื™ ืžืขืจื›ืช", + "command_trackinglinks": "โ„น๏ธ ืงื™ืฉื•ืจื™ ืžืขืงื‘", + "times_upcoming_calendar": "๐Ÿ•ฏ ื”ื—ื’ื™ื ื•ื”ืžื•ืขื“ื™ื ื”ืงืจื•ื‘ื™ื", + "times_sequence_calendar": "ืจืฆืฃ ืฉื‘ืช ื•ื—ื’: ื–ืžื ื™ ื”ื ืจื•ืช ืžื•ืฆื’ื™ื ืœื›ืœ ืขืจื‘, ื•ื”ื™ืฆื™ืื” ื”ื™ื ื‘ืกื•ืฃ ื”ืจืฆืฃ.", + "template_default_buttons": "โ†ฉ๏ธ ืฉื—ื–ื•ืจ ื›ืคืชื•ืจื™ ื‘ืจื™ืจืช ืžื—ื“ืœ", + "tefillin_ack": "ื”ื ื—ืชื™", + "field_ack_enabled": "ื›ืคืชื•ืจ ืื™ืฉื•ืจ ื”ื ื—ืช ืชืคื™ืœื™ืŸ", + "field_ack_label": "ื˜ืงืกื˜ ื›ืคืชื•ืจ ืื™ืฉื•ืจ ื”ื”ื ื—ื”", + "field_tefillin_ack_enabled": "ื›ืคืชื•ืจ ืื™ืฉื•ืจ ื”ื ื—ืช ืชืคื™ืœื™ืŸ", + "field_tefillin_ack_label": "ื˜ืงืกื˜ ื›ืคืชื•ืจ ืื™ืฉื•ืจ ื”ื”ื ื—ื”", + "template_add_link": "โž• ื”ื•ืกืคืช ื›ืคืชื•ืจ ืงื™ืฉื•ืจ", + "template_link_text_prompt": "๐Ÿ”— ื›ืคืชื•ืจ ื—ื“ืฉ\n\nืžื” ืœื›ืชื•ื‘ ืขืœ ื”ื›ืคืชื•ืจ? ืฉืœื—ื• ื›ื•ืชืจืช ืงืฆืจื”, ืœืžืฉืœ ืดืžื™ื“ืข ื ื•ืกืฃืด.", + "template_link_url_prompt": "๐Ÿ”— ืœืืŸ ื”ื›ืคืชื•ืจ ื™ื•ื‘ื™ืœ?\n\nืฉืœื—ื• ื›ืชื•ื‘ืช ืžืœืื” ืฉืžืชื—ื™ืœื” ื‘ึพhttps://, ืœืžืฉืœ https://example.org.\nื ืฆื™ื’ ืืช ื”ื›ืคืชื•ืจ ืœื‘ื“ื™ืงื” ืœืคื ื™ ืฉืžื™ืจื”.", + "template_save_review": "โœ… ืฉืžื™ืจืช ื”ื”ื•ื“ืขื”", + "template_ack_preview": "ื–ื• ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื‘ืœื‘ื“. ืœื—ื™ืฆื” ื›ืืŸ ืื™ื ื” ื ืจืฉืžืช ื‘ืžื•ื ื”.", + "template_restore_text": "๐Ÿ“ ืฉื—ื–ื•ืจ ื”ื˜ืงืกื˜ ื”ืื•ื˜ื•ืžื˜ื™", + "template_remove_caption": "๐Ÿ—‘ ื”ืกืจืช ื”ื›ื™ืชื•ื‘ ืžื”ืžื“ื™ื”", + "template_text_restored": "ื”ื˜ืงืกื˜ ื”ืื•ื˜ื•ืžื˜ื™ ืฉื•ื—ื–ืจ. ื”ืžื“ื™ื” ื•ื”ื›ืคืชื•ืจื™ื ื ืฉืžืจื•.", + "template_caption_removed": "ื”ื›ื™ืชื•ื‘ ื”ื•ืกืจ ืžื”ืžื“ื™ื”. ื”ืžื“ื™ื” ื•ื”ื›ืคืชื•ืจื™ื ื ืฉืžืจื•.", + "settings_mode_intro": "โš™๏ธ ืžืฆื‘ ืคืขื™ืœื•ืช ืขื‘ื•ืจ {{title}}\n\nื›ื‘ื•ื™ - ืื™ืŸ ื”ื•ื“ืขื•ืช ืื•ื˜ื•ืžื˜ื™ื•ืช ืื• ื ืขื™ืœื” ืฉืœ ืฉื‘ืช ื•ื—ื’.\nื”ื•ื“ืขื•ืช ื‘ืœื‘ื“ - ื”ื•ื“ืขื•ืช ืœืคื™ ื”ื”ื’ื“ืจื•ืช, ื‘ืœื™ ืฉื™ื ื•ื™ ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื”.\nื ืขื™ืœื” ื‘ืœื‘ื“ - ืกื’ื™ืจื” ื•ืคืชื™ื—ื” ืœืคื™ ื”ื–ืžื ื™ื, ื‘ืœื™ ื”ื•ื“ืขื•ืช ืฉื‘ืช ื•ื—ื’.\nื”ื•ื“ืขื•ืช ื•ื ืขื™ืœื” - ื”ื•ื“ืขื•ืช ื•ื’ื ืกื’ื™ืจื” ื•ืคืชื™ื—ื”.\n\nื”ื‘ื—ื™ืจื” ื—ืœื” ืขืœ ืฉื‘ืชื•ืช ื•ื™ืžื™ ื˜ื•ื‘ ืฉื”ืคืขืœืชื.", + "settings_mode_button": "ืžืฆื‘: {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ ื›ื ื™ืกื”, ื™ืฆื™ืื” ื•ื ืขื™ืœื”", + "settings_shabbat_messages": "โœ๏ธ ื”ื•ื“ืขื•ืช ื•ืขื™ืฆื•ื‘", + "settings_shabbat_timing_intro": "ื‘ื—ืจื• ืืช ืžื ื”ื’ ื”ื“ืœืงืช ื”ื ืจื•ืช, ืฉื™ื˜ืช ื”ื™ืฆื™ืื” ื•ืžืจื•ื•ื—ื™ ื”ืกื’ื™ืจื” ื•ื”ืคืชื™ื—ื”. ื›ืœ ื”ื–ืžื ื™ื ืžื—ื•ืฉื‘ื™ื ืœืคื™ ืžื™ืงื•ื ื”ืงื‘ื•ืฆื”. ื‘ื—ื™ื‘ื•ืจ ืฉืœ ืฉื‘ืช ื•ื™ื•ื ื˜ื•ื‘ ื”ืงื‘ื•ืฆื” ื ืฉืืจืช ืกื’ื•ืจื” ืขื“ ืกื•ืฃ ื”ืจืฆืฃ.", + "settings_shabbat_messages_intro": "ื‘ื—ืจื• ืื™ืœื• ื”ื•ื“ืขื•ืช ืœืฉืœื•ื—, ืืช ืฉืขื•ืช ื”ืฉืงื˜ ื•ืืช ื”ื ื•ืฉื ื‘ืงื‘ื•ืฆื”. ืืคืฉืจ ืœืขืจื•ืš ื‘ื ืคืจื“ ืืช ื”ื˜ืงืกื˜, ื”ืขื™ืฆื•ื‘, ื”ืžื“ื™ื” ื•ื”ื›ืคืชื•ืจื™ื ืฉืœ ื”ื•ื“ืขื•ืช ื”ืกื’ื™ืจื” ื•ื”ืคืชื™ื—ื”.", + "settings_shabbat_summary": "โœก๏ธ ืฉื‘ืช ื•ื—ื’ื™ื - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nืžืฆื‘: {{mode}}\nืฉื‘ืช: {{shabbat}} ยท ื™ืžื™ ื˜ื•ื‘: {{holidays}}\n\n๐Ÿ•ฏ๏ธ ื›ื ื™ืกื” ื•ื™ืฆื™ืื”\nื”ื“ืœืงืช ื ืจื•ืช: {{candles}} ื“ืงื•ืช ืœืคื ื™ ื”ืฉืงื™ืขื”\nื™ืฆื™ืื”: {{havdalah}}\nื ืขื™ืœื”: {{close}} ื“ืงื•ืช ืœืคื ื™ ื”ื“ืœืงืช ื”ื ืจื•ืช\nืคืชื™ื—ื”: {{open}} ื“ืงื•ืช ืื—ืจื™ ื”ื™ืฆื™ืื”\n\nื‘ื—ืจื• ื ื•ืฉื ืœืžื˜ื”. ื›ืœ ืฉื™ื ื•ื™ ื™ื•ืฆื’ ืœืื™ืฉื•ืจ ื•ื™ื—ื–ื™ืจ ืืชื›ื ืœืื•ืชื• ื ื•ืฉื.", + "template_current_summary": "ื”ื”ื’ื“ืจื” ื”ื ื•ื›ื—ื™ืช\nื˜ืงืกื˜: {{text}}\nืžื“ื™ื”: {{media}}\nื›ืคืชื•ืจื™ื: {{buttons}}\n\nืขืจื›ื• ืชื•ื›ืŸ ืื• ื›ืคืชื•ืจื™ื, ื‘ื“ืงื• ื‘ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ื•ืฉืžืจื•. ืฉืœื™ื—ื” ืœื‘ื“ื™ืงื” ื‘ืงื‘ื•ืฆื” ื“ื•ืจืฉืช ืื™ืฉื•ืจ ื ืคืจื“.", + "template_text_default": "ื”ื•ื“ืขื” ืื•ื˜ื•ืžื˜ื™ืช", + "template_text_custom": "ื˜ืงืกื˜ ืžื•ืชืื ืื™ืฉื™ืช", + "template_buttons_inherit": "ื‘ืจื™ืจืช ื”ืžื—ื“ืœ ืฉืœ ื”ื‘ื•ื˜", + "template_buttons_custom": "ื›ืคืชื•ืจื™ื ืžื•ืชืืžื™ื ืื™ืฉื™ืช", + "template_buttons_none": "ืœืœื ื›ืคืชื•ืจื™ื", + "template_panel_hint": "ื‘ื—ืจื• ืืช ืกื•ื’ ื”ื”ื•ื“ืขื”. ืœื›ืœ ื”ื•ื“ืขื” ืืคืฉืจ ืœื”ืชืื™ื ื˜ืงืกื˜ ืžืขื•ืฆื‘, ืžื“ื™ื” ื•ื›ืคืชื•ืจื™ื. ื”ืฉื™ื ื•ื™ื™ื ื ืฉืžืจื™ื ืจืง ืœืงื‘ื•ืฆื” ื”ื–ืืช.", + "times_period_enter": "๐Ÿ•ฏ ื›ื ื™ืกื” / ื”ื“ืœืงืช ื ืจื•ืช: {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ ื”ืฉื‘ืชื•ืช ื”ื‘ืื•ืช", + "times_yomtov_unavailable": "ื–ืžื ื™ ื›ื ื™ืกื” ื•ื™ืฆื™ืื” ืžืื•ืžืชื™ื ืœื™ื•ื ื˜ื•ื‘ ื–ื” ืขื“ื™ื™ืŸ ืื™ื ื ื–ืžื™ื ื™ื.", + "info_permissions_title": "๐Ÿ›ก ื”ืจืฉืื•ืช, ื ืขื™ืœื” ื•ืคืชื™ื—ื”", + "info_permissions_text": "ื‘ืื™ืœื• ืงื‘ื•ืฆื•ืช ื–ื” ืขื•ื‘ื“?\nTelegram ืžืืคืฉืจ ืœื‘ื•ื˜ ืœืฉื ื•ืช ื”ืจืฉืื•ืช ื‘ืจื™ืจืช ืžื—ื“ืœ ื’ื ื‘ืงื‘ื•ืฆื” ืจื’ื™ืœื” ื•ื’ื ื‘ืงื‘ื•ืฆืช ืขืœ. ื”ื‘ื•ื˜ ื—ื™ื™ื‘ ืœื”ื™ื•ืช ืžื ื”ืœ ืขื ื”ืจืฉืื” ืœื”ื’ื‘ืœืช ื—ื‘ืจื™ื. ื”ืจืฉืื•ืช ื‘ืจื™ืจืช ืžื—ื“ืœ ื—ืœื•ืช ืขืœ ื—ื‘ืจื™ื ืจื’ื™ืœื™ื; ืžื ื”ืœื™ื ืขืฉื•ื™ื™ื ืœื”ืžืฉื™ืš ืœื›ืชื•ื‘.\n\nืœืคื ื™ ืฉืžืคืขื™ืœื™ื\nื‘ื—ืจื• ืขื™ืจ, ืื–ื•ืจ ื–ืžืŸ ื•ืœื•ื— ื™ืฉืจืืœ ืื• ื—ื•ืดืœ. ื‘ืดืฉื‘ืช ื•ื—ื’ื™ืืด ื‘ื—ืจื• ื”ื•ื“ืขื•ืช ื•ื ืขื™ืœื” ืื• ื ืขื™ืœื” ื‘ืœื‘ื“. ื‘ื“ืงื• ืืช ื–ืžื ื™ ื”ื”ื“ืœืงื”, ืชื•ืกืคื•ืช ื”ืกื’ื™ืจื” ื•ื”ืคืชื™ื—ื” ื•ื”ืจืฉืื•ืช ื”ื‘ื•ื˜.\n\nืžื” ืงื•ืจื” ื‘ืคืชื™ื—ื”?\nื”ื‘ื•ื˜ ืฉื•ืžืจ ืืช ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื” ืœืคื ื™ ื”ื ืขื™ืœื” ื•ืžืฉื—ื–ืจ ืื•ืชืŸ ื‘ืกื™ื•ื. ืจืฆืฃ ืฉื‘ืช ื•ื™ื•ื ื˜ื•ื‘ ื ืฉืืจ ืกื’ื•ืจ ืขื“ ื”ื™ืฆื™ืื” ื”ืื—ืจื•ื ื” ืฉื ื‘ื—ืจื”. ืฉื™ื ื•ื™ ื”ืจืฉืื•ืช ื—ื™ืฆื•ื ื™ ืื• ื—ื•ืกืจ ื”ืจืฉืื” ืขืœื•ืœื™ื ืœื“ืจื•ืฉ ื‘ื“ื™ืงืช ืžื ื”ืœ.\n\nืžื—ื™ืงืช ื”ื•ื“ืขื•ืช\nืžื—ื™ืงืช ื”ื•ื“ืขื•ืช ื‘ื–ืžืŸ ืกื’ื™ืจื” ื“ื•ืจืฉืช ื”ืจืฉืืช ืžื—ื™ืงื”. ืžื—ื™ืงืช ื”ื•ื“ืขื•ืช ืžื‘ื•ื˜ื™ื ืื—ืจื™ื ื”ื™ื ืืคืฉืจื•ืช ื ืคืจื“ืช, ื•ืชืœื•ื™ื” ื’ื ื‘ื”ื•ื“ืขื•ืช ืฉ-Telegram ืžืขื‘ื™ืจ ืœื‘ื•ื˜.", + "template_buttons_simple_prompt": "ื”ื•ืกื™ืคื• ื›ืคืชื•ืจ ืงื™ืฉื•ืจ ื‘ื›ืžื” ืฆืขื“ื™ื: ื‘ื—ืจื• ื›ื™ืชื•ื‘ ื•ืื– ื›ืชื•ื‘ืช ืฉืžืชื—ื™ืœื” ื‘ึพhttps://. ื ืฆื™ื’ ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืœืคื ื™ ืฉืžื™ืจื”.\n\nืœืกื•ื’ื™ ื›ืคืชื•ืจื™ื ื ื•ืกืคื™ื ื•ืœื›ืžื” ืฉื•ืจื•ืช ื™ื—ื“, ืคืชื—ื• ืดืืคืฉืจื•ื™ื•ืช ืžืชืงื“ืžื•ืชืด.", + "template_advanced_buttons": "โš™๏ธ ืืคืฉืจื•ื™ื•ืช ืžืชืงื“ืžื•ืช", + "operator_manual_details": "ืคื™ืจื•ื˜ ืœืคื™ ืงื‘ื•ืฆื”", + "operator_manual_pending": "ืžืžืชื™ื ื•ืช: {{count}}", + "operator_manual_more": "ื•ืขื•ื“ {{count}} ืงื‘ื•ืฆื•ืช", + "permission_error_admin": "ื™ืฉ ืœืชืช ืœื‘ื•ื˜ ื”ืจืฉืืช ืžื ื”ืœ ืœื—ืกื™ืžืช ืžืฉืชืžืฉื™ื.", + "permission_error_access": "ื”ื‘ื•ื˜ ืื™ื ื• ื—ื‘ืจ ื‘ืงื‘ื•ืฆื” ืื• ืฉืื™ืŸ ืœื• ื’ื™ืฉื” ืืœื™ื”.", + "permission_error_flags": "ื”ื”ืจืฉืื•ืช ืฉื ื‘ื—ืจื• ืื™ื ืŸ ืชืงื™ื ื•ืช. ืืคืกื• ืื•ืชืŸ ื‘ืคืื ืœ ืฉื‘ืช ื•ื—ื’ื™ื.", + "permission_error_snapshot": "ืœื ื ื™ืชืŸ ืœืืžืช ื”ืจืฉืื•ืช ืžืงื•ืจื™ื•ืช. ื ื“ืจืฉืช ื‘ื“ื™ืงืช ืžื ื”ืœ ืœืคื ื™ ืคืชื™ื—ื”.", + "permission_error_conflict": "ื”ื”ืจืฉืื•ืช ื”ื ื•ื›ื—ื™ื•ืช ืฉื•ื ื•ืช ืžื”ืžืฆื‘ ื”ืฉืžื•ืจ. ื”ืฉื™ื ื•ื™ื™ื ื”ืื•ื˜ื•ืžื˜ื™ื™ื ื ืขืฆืจื• ืœื‘ื“ื™ืงืช ืžื ื”ืœ.", + "permission_error_provider": "ืกืคืง ื”ื–ืžื ื™ื ืื™ื ื• ื–ืžื™ืŸ ื›ืจื’ืข.", + "permission_error_unknown": "ื”ืคืขื•ืœื” ืœื ืื•ืฉืจื”. ืขื™ื™ื ื• ื‘ื™ื•ืžืŸ ื”ืื‘ื—ื•ืŸ ืฉืœ ื”ืงื‘ื•ืฆื”.", + "permission_status_wait": "ืžืžืชื™ื ื” ืœื ื™ืกื™ื•ืŸ ื—ื•ื–ืจ ื‘ื”ืชืื ืœืžื’ื‘ืœืช Telegram", + "permission_status_busy": "ืคืขื•ืœื” ืื—ืจืช ื‘ืงื‘ื•ืฆื” ืขื“ื™ื™ืŸ ืžืชื‘ืฆืขืช", + "permission_status_prelocked": "ื”ืงื‘ื•ืฆื” ื ืกื’ืจื” ืžื—ื•ืฅ ืœื‘ื•ื˜ ื•ืœื›ืŸ ืœื ืชื™ืคืชื— ืื•ื˜ื•ืžื˜ื™ืช", + "permission_status_idle": "ืื™ืŸ ื ืขื™ืœื” ืฉืœ ื”ื‘ื•ื˜ ืœืคืชื™ื—ื”", + "permission_status_unchanged": "ื”ืงื‘ื•ืฆื” ื›ื‘ืจ ื‘ืžืฆื‘ ื”ืžื‘ื•ืงืฉ", + "restart_requested": "โ™ป๏ธ ืžืชื—ื™ืœ ืืชื—ื•ืœ ืžืกื•ื“ืจ. ื”ื‘ื•ื˜ ื™ื—ื–ื•ืจ ืœืื—ืจ ื”ืคืขืœืชื• ืžื—ื“ืฉ.", + "permission_conflict_fields": "ื”ื‘ื“ืœื™ื: {{fields}}.", + "permission_until_date": "ืชื•ืงืฃ ื”ื”ืจืฉืื•ืช", + "permission_unknown": "ื”ืจืฉืื” ืœื ืžื•ื›ืจืช", + "times_shabbat_title": "ืฉื‘ืช", + "permission_recovery": "๐Ÿ›  ืคืชืจื•ืŸ ื‘ืขื™ื™ืช ื”ืจืฉืื•ืช", + "recovery_title": "๐Ÿ›  ืคืชืจื•ืŸ ื‘ืขื™ื™ืช ื”ืจืฉืื•ืช\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "ื”ื‘ื•ื˜ ืฉื•ืžืจ ืืช ื”ื”ืจืฉืื•ืช ื”ืจื’ื™ืœื•ืช ืœืคื ื™ ื ืขื™ืœื” ื•ืžื—ื–ื™ืจ ืื•ืชืŸ ื‘ืคืชื™ื—ื”. ื›ืืŸ ืืคืฉืจ ืœื‘ื“ื•ืง ื ื™ืกื™ื•ืŸ ืฉืœื ื”ื•ืฉืœื ื•ืœื‘ื—ื•ืจ ืื™ืš ืœื”ืžืฉื™ืš.", + "recovery_compare": "ื”ืฉื•ื•ืื” ืœืฆื™ืœื•ื ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจ", + "recovery_difference": "โ€ข {{permission}}: ืขื›ืฉื™ื• {{current}} ยท ื‘ืฆื™ืœื•ื ื”ืฉืžื•ืจ {{saved}}", + "recovery_more": "ื•ืขื•ื“ {{count}} ื”ื‘ื“ืœื™ื. ื”ืคืขื•ืœื” ืžืชื™ื™ื—ืกืช ืœื›ืœ ื”ื”ืจืฉืื•ืช, ื’ื ืœืืœื• ืฉืื™ื ืŸ ืžื•ืฆื’ื•ืช ื›ืืŸ.", + "recovery_allowed": "ืžื•ืชืจ", + "recovery_blocked": "ื—ืกื•ื", + "recovery_no_differences": "ืื™ืŸ ื”ื‘ื“ืœื™ื ืžื”ื”ืจืฉืื•ืช ื”ืžืงื•ืจื™ื•ืช ื”ืฉืžื•ืจื•ืช.", + "recovery_adopt": "โœ… ืœืงื‘ืœ ืืช ื”ืžืฆื‘ ื”ื ื•ื›ื—ื™", + "recovery_restore": "โ†ฉ๏ธ ืœืฉื—ื–ืจ ืืช ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจื•ืช", + "recovery_preview_adopt": "ืงื‘ืœืช ื”ื”ืจืฉืื•ืช ื”ื ื•ื›ื—ื™ื•ืช\nื”ื”ืจืฉืื•ืช ืฉืงื™ื™ืžื•ืช ืขื›ืฉื™ื• ื‘ืงื‘ื•ืฆื” ื™ื™ื—ืฉื‘ื• ืœืžืฆื‘ ื”ืจื’ื™ืœ. ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื” ืœื ื™ืฉืชื ื• ื‘ืคืขื•ืœื” ื”ื–ืืช. ื ื™ืกื™ื•ืŸ ื”ื ืขื™ืœื” ื”ื™ืฉืŸ ื™ื™ืกื’ืจ, ื•ื‘ื ืขื™ืœื” ื”ื‘ืื” ื™ื™ืฉืžืจ ื”ืžืฆื‘ ื”ืขื“ื›ื ื™.", + "recovery_preview_restore": "ืฉื—ื–ื•ืจ ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจื•ืช\nื”ื‘ื•ื˜ ื™ื‘ืงืฉ ืœื”ื—ื–ื™ืจ ืืช ื›ืœ ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื” ืœืžืฆื‘ ืฉื ืฉืžืจ ืœืคื ื™ ื”ืชืงืœื”, ื›ื•ืœืœ ื‘ื™ื˜ื•ืœ ืฉื™ื ื•ื™ื™ื ืฉื‘ื•ืฆืขื• ืžืื–. ื”ืฉื—ื–ื•ืจ ื™ื™ื—ืฉื‘ ืžื•ืฆืœื— ืจืง ืื—ืจื™ ืื™ืžื•ืช ืžื•ืœ Telegram.", + "recovery_confirm_adopt": "โœ… ืื™ืฉื•ืจ ืงื‘ืœืช ื”ืžืฆื‘ ื”ื ื•ื›ื—ื™", + "recovery_confirm_restore": "โœ… ืื™ืฉื•ืจ ืฉื—ื–ื•ืจ ื”ื”ืจืฉืื•ืช", + "recovery_preview_notice": "ื‘ื“ืงื• ืืช ืฉื ื”ืงื‘ื•ืฆื” ื•ืืช ื”ื‘ื—ื™ืจื” ืœืคื ื™ ืื™ืฉื•ืจ. ื”ืื™ืฉื•ืจ ืชืงืฃ ืœื—ืžืฉ ื“ืงื•ืช ื‘ืœื‘ื“. ื”ื”ืจืฉืื•ืช ื•ื”ืžืฆื‘ ื™ื™ื‘ื“ืงื• ืฉื•ื‘; ื‘ื–ืžืŸ ื ืขื™ืœื” ืคืขื™ืœื”, ืฉื‘ืช ืื• ื—ื’ ืื™ ืืคืฉืจ ืœื‘ืฆืข ื”ืชืื•ืฉืฉื•ืช ื™ื“ื ื™ืช.", + "recovery_result_adopted": "โœ… ื”ืžืฆื‘ ื”ื ื•ื›ื—ื™ ื”ืชืงื‘ืœ ื›ื”ืจืฉืื•ืช ื”ืจื’ื™ืœื•ืช. ื”ื ื™ืกื™ื•ืŸ ื”ื™ืฉืŸ ื ืกื’ืจ.", + "recovery_result_restored": "โœ… ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจื•ืช ืฉื•ื—ื–ืจื• ื•ืื•ืžืชื•. ื”ื ื™ืกื™ื•ืŸ ื”ื™ืฉืŸ ื ืกื’ืจ.", + "recovery_block_active_lock": "๐Ÿ”’ ื›ืจื’ืข ืื™ืŸ ื—ืœื•ืŸ ืžืื•ืžืช ืœื‘ื™ืฆื•ืข ื”ืชืื•ืฉืฉื•ืช: ื™ื™ืชื›ืŸ ืฉื™ืฉ ื ืขื™ืœื” ืคืขื™ืœื”, ืฉื‘ืช ืื• ื—ื’, ืื• ืฉื ืชื•ื ื™ ื”ื–ืžื ื™ื ืื™ื ื ื–ืžื™ื ื™ื. ืืคืฉืจ ืœืฆืคื•ืช ื•ืœืจืขื ืŸ; ืฉื™ื ื•ื™ ื™ื“ื ื™ ื™ืชืืคืฉืจ ืจืง ื›ืฉื”ื‘ื•ื˜ ื™ื•ื•ื“ื ืฉืื™ืŸ ื–ืžืŸ ืžื•ื’ืŸ ืคืขื™ืœ.", + "recovery_block_no_problem": "โœ… ืื™ืŸ ื›ืจื’ืข ื‘ืขื™ื™ืช ื”ืจืฉืื•ืช ืฉืžืžืชื™ื ื” ืœืคืชืจื•ืŸ. ื‘ื ืขื™ืœื” ื”ื‘ืื” ื”ื‘ื•ื˜ ื™ืฉืžื•ืจ ืืช ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื” ื”ืขื“ื›ื ื™ื•ืช.", + "recovery_block_missing_snapshot": "โš ๏ธ ืื™ืŸ ืขื•ืชืง ืžืงื•ืจื™ ืžืื•ืžืช ืฉืืคืฉืจ ืœืฉื—ื–ืจ. ืื ืžื•ืฆื’ืช ืืคืฉืจื•ืช ืœืงื‘ืœ ืืช ื”ืžืฆื‘ ื”ื ื•ื›ื—ื™, ื•ื“ืื• ืงื•ื“ื ืฉืืœื• ื”ื”ืจืฉืื•ืช ื”ืจื’ื™ืœื•ืช ื”ืจืฆื•ื™ื•ืช.", + "recovery_block_locked_current": "๐Ÿ”’ ื”ืงื‘ื•ืฆื” ื ืจืื™ืช ื ืขื•ืœื” ื›ืจื’ืข. ืื™ ืืคืฉืจ ืœืงื‘ืœ ืžืฆื‘ ื–ื” ื›ื”ืจืฉืื•ืช ื”ืจื’ื™ืœื•ืช. ื‘ื“ืงื• ืืช ืืคืฉืจื•ืช ื”ืฉื—ื–ื•ืจ ืื• ืคื ื• ืœืžืคืขื™ืœ ื”ื‘ื•ื˜.", + "recovery_block_unknown": "โš ๏ธ ืื™ ืืคืฉืจ ืœืืžืช ื›ืขืช ืฉื”ืชืื•ืฉืฉื•ืช ื‘ื˜ื•ื—ื”. ืจืขื ื ื• ืืช ื”ืžืฆื‘; ืื ื”ื‘ืขื™ื” ื—ื•ื–ืจืช, ืคื ื• ืœืžืคืขื™ืœ ื”ื‘ื•ื˜.", + "recovery_result_stale": "โŒ› ื”ืื™ืฉื•ืจ ืคื’ ืื• ืฉื”ืžืฆื‘ ื”ืฉืชื ื”. ื‘ื“ืงื• ืืช ื”ืžืฆื‘ ื”ืขื“ื›ื ื™ ื•ืคืชื—ื• ืื™ืฉื•ืจ ื—ื“ืฉ.", + "recovery_result_forbidden": "ื ื“ืจืฉืช ื”ืจืฉืืช ื ื™ื”ื•ืœ ืขื“ื›ื ื™ืช ื‘ืงื‘ื•ืฆื” ื”ื–ืืช.", + "recovery_result_busy": "โณ ืคืขื•ืœื” ืื—ืจืช ื‘ืงื‘ื•ืฆื” ืžืชื‘ืฆืขืช ื›ืขืช. ืจืขื ื ื• ื‘ืขื•ื“ ืจื’ืข.", + "recovery_result_conflict": "โš ๏ธ Telegram ื”ื—ื–ื™ืจื” ืžืฆื‘ ืฉื•ื ื” ืžื”ืฆืคื•ื™. ื”ืžื™ื“ืข ื”ืžืงื•ืจื™ ื ืฉืžืจ; ื‘ื“ืงื• ืืช ื”ื”ืฉื•ื•ืื” ืœืคื ื™ ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ.", + "recovery_result_error": "ืœื ื ื™ืชืŸ ืœื”ืฉืœื™ื ืื• ืœืืžืช ืืช ื”ื”ืชืื•ืฉืฉื•ืช ื›ืจื’ืข. ื”ืžื™ื“ืข ื”ืฉืžื•ืจ ืœื ื ืžื—ืง. ืจืขื ื ื• ืœืคื ื™ ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ.", + "recovery_result_retry_wait": "โณ Telegram ื‘ื™ืงืฉื” ืœื”ืžืชื™ืŸ. ืื™ืŸ ืฆื•ืจืš ืœืœื—ื•ืฅ ืฉื•ื‘ ื‘ืจืฆืฃ; ืจืขื ื ื• ื‘ื”ืžืฉืš.", + "recovery_refresh": "๐Ÿ”„ ืจืขื ื•ืŸ ืžืฆื‘", + "recovery_cancelled": "ื”ื‘ื—ื™ืจื” ื‘ื•ื˜ืœื”. ืœื ื‘ื•ืฆืขื” ื”ืชืื•ืฉืฉื•ืช ื™ื“ื ื™ืช.", + "recovery_state_problem": "โš ๏ธ ืงื™ื™ื ื ื™ืกื™ื•ืŸ ื ืขื™ืœื” ืฉืœื ื”ื•ืฉืœื. ื‘ื“ืงื• ืืช ื”ื”ื‘ื“ืœื™ื ื•ื‘ื—ืจื• ืืช ื”ืžืฆื‘ ื”ืจืฆื•ื™. ื›ืœ ืืคืฉืจื•ืช ืชืฆื™ื’ ืื™ืฉื•ืจ ื ื•ืกืฃ ืœืคื ื™ ื‘ื™ืฆื•ืข.", + "diagnostics_title": "๐Ÿงช ืื‘ื—ื•ืŸ ื–ืžื ื™ื ื•ื”ืจืฉืื•ืช", + "diagnostics_read_only": "ืชืฆื•ื’ื” ื‘ืœื‘ื“. ืชืžื•ื ืช ื”ืžืฆื‘ ืชืงืคื” ืœ-15 ื“ืงื•ืช; ืจืขื ื ื• ืœืงื‘ืœืช ืžืฆื‘ ืขื“ื›ื ื™.", + "diagnostics_context": "ื”ืงืฉืจ", + "diagnostics_build": "ื’ืจืกืช ืจื™ืฆื”", + "diagnostics_location": "ืžื™ืงื•ื ื•ืื–ื•ืจ ื–ืžืŸ", + "diagnostics_methods": "ื“ืงื•ืช ืœื ืจื•ืช / ืฉื™ื˜ืช ื™ืฆื™ืื” ื•ืขืจืš", + "diagnostics_lock_now": "ื ืขื™ืœื” ื ื“ืจืฉืช ื›ืขืช", + "diagnostics_quiet_now": "ื–ืžืŸ ืฉื‘ืช/ื—ื’ ืื• ื ืขื™ืœื” ืคืขื™ืœื”", + "diagnostics_stale": "ื ืชื•ื ื™ ืกืคืง ื™ืฉื ื™ื", + "diagnostics_evaluated_at": "ืžื•ืขื“ ื”ื—ื™ืฉื•ื‘", + "diagnostics_fetched_at": "ืžื•ืขื“ ืงื‘ืœืช ื”ื ืชื•ื ื™ื", + "diagnostics_period": "ื—ืœื•ืŸ ืกื’ื™ืจื” ื•ืคืชื™ื—ื”", + "diagnostics_provider_error": "ืฉื’ื™ืื” ื‘ืกืคืง ื”ื–ืžื ื™ื", + "diagnostics_queue": "ืชื•ืจ ืžืฉื™ืžื•ืช", + "diagnostics_group": "ืงื‘ื•ืฆื”", + "diagnostics_limited": "ืžืกืคืจ ืงื‘ื•ืฆื•ืช ืžื•ืฆื’ื•ืช / ืกืš ื”ื›ื•ืœ. ืœืงื‘ื•ืฆื” ืื—ืจืช: /testzmanim ื•ืžื–ื”ื” ื”ืงื‘ื•ืฆื”", + "diagnostics_lock_state": "ืžืฆื‘ ื”ื ืขื™ืœื” ื”ืฉืžื•ืจ", + "diagnostics_original": "ื”ืจืฉืื•ืช ืจื’ื™ืœื•ืช ืฉื ืฉืžืจื•: ื—ืกื™ืžื•ืช ืคืขื™ืœื•ืช", + "diagnostics_requested": "ื”ื”ืจืฉืื•ืช ืฉื”ื‘ื•ื˜ ื‘ื™ืงืฉ: ื—ืกื™ืžื•ืช ืคืขื™ืœื•ืช", + "diagnostics_current": "ื”ืจืฉืื•ืช ื ื•ื›ื—ื™ื•ืช ืžื˜ืœื’ืจื: ื—ืกื™ืžื•ืช ืคืขื™ืœื•ืช", + "diagnostics_compare_original": "ื”ื‘ื“ืœื™ื ื‘ื™ืŸ ื”ื ื•ื›ื—ื™ ืœืžืฆื‘ ื”ืจื’ื™ืœ ื”ืฉืžื•ืจ", + "diagnostics_compare_requested": "ื”ื‘ื“ืœื™ื ื‘ื™ืŸ ื”ื ื•ื›ื—ื™ ืœืžื” ืฉื”ืชื‘ืงืฉ", + "diagnostics_all": "ื”ื›ื•ืœ", + "diagnostics_problems": "โš ๏ธ ื‘ืขื™ื•ืช ื•ืื–ื”ืจื•ืช", + "diagnostics_summary": "ืกื™ื›ื•ื", + "diagnostics_calendar": "ื–ืžื ื™ื", + "diagnostics_permissions": "ื”ืจืฉืื•ืช", + "diagnostics_groups": "๐Ÿ‘ฅ ืงื‘ื•ืฆื•ืช", + "diagnostics_busy": "ื”ืคืขื•ืœื” ื‘ื˜ื™ืคื•ืœ ืื• ื‘ื•ืฆืขื” ืœืื—ืจื•ื ื”. ืจืขื ื•ืŸ ื›ืœ 30 ืฉื ื™ื•ืช ื•ื™ื™ืฆื•ื ืคืขื ื‘ื“ืงื”.", + "diagnostics_unavailable": "ื”ืื‘ื—ื•ืŸ ืื™ื ื• ื–ืžื™ืŸ, ืคื’ ืชื•ืงืคื• ืื• ืฉืื™ืŸ ื”ืจืฉืื”. ื ืกื• /testzmanim ื‘ืคืจื˜ื™ ื‘ืขื•ื“ 30 ืฉื ื™ื•ืช.", + "diagnostics_export_caption": "๐Ÿ“„ ืื‘ื—ื•ืŸ ื–ืžื ื™ื ื•ื”ืจืฉืื•ืช. ืชืžื•ื ืช ืžืฆื‘ ืœืงืจื™ืื” ื‘ืœื‘ื“.", + "diagnostics_export_sent": "ืงื•ื‘ืฅ JSON ื ืฉืœื—.", + "diagnostics_previous": "โ—€๏ธ ื”ืงื•ื“ื", + "diagnostics_next": "ื”ื‘ื โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž ื—ื™ืคื•ืฉ", + "diagnostics_filters": "๐Ÿ”ฝ ืกื™ื ื•ืŸ", + "diagnostics_refresh": "๐Ÿ”„ ืจืขื ื•ืŸ", + "diagnostics_clear": "ื ื™ืงื•ื™ ื—ื™ืคื•ืฉ ื•ืกื™ื ื•ืŸ", + "diagnostics_export": "๐Ÿ“„ ื™ื™ืฆื•ื JSON", + "diagnostics_back": "ื—ื–ืจื” ืœื ื™ื”ื•ืœ", + "diagnostics_cancel": "ื‘ื™ื˜ื•ืœ", + "diagnostics_empty": "ืื™ืŸ ืชื•ืฆืื•ืช ื”ืชื•ืืžื•ืช ืœื—ื™ืคื•ืฉ ื•ืœืกื™ื ื•ืŸ.", + "diagnostics_page_prompt": "ืฉืœื—ื• ืžืกืคืจ ื“ืฃ ื‘ื™ืŸ 1 ืœ-{{pages}}. ื”ื”ื•ื“ืขื” ื”ืงื™ื™ืžืช ืชืชืขื“ื›ืŸ.", + "diagnostics_search_prompt": "ืฉืœื—ื• ืžื™ืœื”, ืชืืจื™ืš ืื• ืžื–ื”ื” ืงื‘ื•ืฆื” ืœื—ื™ืคื•ืฉ ื‘ื ืชื•ื ื™ื ื”ืฉืžื•ืจื™ื (ืขื“ 80 ืชื•ื•ื™ื).", + "diagnostics_invalid_page": "ืžืกืคืจ ื”ื“ืฃ ืื™ื ื• ืชืงื™ืŸ. ื‘ื—ืจื• ืžืกืคืจ ืžืชื•ืš ื”ื˜ื•ื•ื— ืฉื”ื•ืฆื’.", + "diagnostics_is_work_prohibited_now": "ืื™ืกื•ืจ ืžืœืื›ื” ื›ืขืช", + "diagnostics_is_shabbat": "ืฉื‘ืช ื›ืขืช", + "diagnostics_is_yom_tov": "ื™ื•ื ื˜ื•ื‘ ื›ืขืช", + "diagnostics_next_close_at": "ื”ืกื’ื™ืจื” ื”ื‘ืื”", + "diagnostics_next_open_at": "ื”ืคืชื™ื—ื” ื”ื‘ืื”", + "diagnostics_baseline": "ื”ืจืฉืื•ืช ืจื’ื™ืœื•ืช ืœืื—ืจ ื”ืชืื•ืฉืฉื•ืช: ื—ืกื™ืžื•ืช ืคืขื™ืœื•ืช", + "diagnostics_compare_previous": "ื”ื‘ื“ืœื™ื ื‘ื™ืŸ ื”ื ื•ื›ื—ื™ ืœืžืฆื‘ ื”ื‘ื™ื ื™ื™ื", + "diagnostics_compare_baseline": "ื”ื‘ื“ืœื™ื ื‘ื™ืŸ ื”ื ื•ื›ื—ื™ ืœื‘ืกื™ืก ื”ื”ืชืื•ืฉืฉื•ืช", + "diagnostics_concurrent_change": "ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจื•ืช ื”ืฉืชื ื• ื‘ืžื”ืœืš ื”ื‘ื“ื™ืงื”. ืจืขื ื ื• ืœืคื ื™ ื”ืฉื•ื•ืื”.", + "diagnostics_operation": "ื”ืคืขื•ืœื” ื”ืื—ืจื•ื ื”: ืกื•ื’ / ืฉืœื‘ / ืชื•ืฆืื”", + "diagnostics_previous_rights": "ื”ืจืฉืื•ืช ื‘ื™ื ื™ื™ื ืงื•ื“ืžื•ืช: ื—ืกื™ืžื•ืช ืคืขื™ืœื•ืช", + "diagnostics_invalid_search": "ื”ื—ื™ืคื•ืฉ ืืจื•ืš ืžื“ื™. ืืคืฉืจ ืœื”ื–ื™ืŸ ืขื“ 80 ืชื•ื•ื™ื.", + "recovery_block_unverified_snapshot": "โš ๏ธ ื”ื”ืจืฉืื•ืช ื”ืฉืžื•ืจื•ืช ื ื•ืฆืจื• ื‘ื’ืจืกื” ืงื•ื“ืžืช ื•ืœื ืื•ืžืชื• ื‘ืงืจื™ืื” ื”ื—ื“ืฉื”. ื”ืŸ ืขืฉื•ื™ื•ืช ืœื”ื™ื•ืช ืฉื•ื ื•ืช ืžื”ืžืฆื‘ ืฉื”ื™ื” ื‘ืงื‘ื•ืฆื”, ื•ืœื›ืŸ ื”ืฉื—ื–ื•ืจ ืฉืœื”ืŸ ื—ืกื•ื.\nืžื—ื•ืฅ ืœืฉื‘ืช, ื—ื’ ืื• ื ืขื™ืœื” ืคืขื™ืœื”: ื”ื’ื“ื™ืจื• ื‘ื˜ืœื’ืจื ืืช ื”ืจืฉืื•ืช ื”ืงื‘ื•ืฆื” ื”ืจืฆื•ื™ื•ืช, ืœื—ืฆื• ื›ืืŸ ืขืœ ืดืจืขื ื•ืŸ ืžืฆื‘ืด, ื‘ื“ืงื• ืืช ื”ื”ืจืฉืื•ืช ื•ืืฉืจื• ืดืœืงื‘ืœ ืืช ื”ืžืฆื‘ ื”ื ื•ื›ื—ื™ืด. ื”ืฆื™ืœื•ื ื”ื™ืฉืŸ ื™ื™ืฉืžืจ ืœืฆื•ืจืš ื‘ื“ื™ืงื”.", + "permission_error_unverified": "ื”ืฆื™ืœื•ื ื”ืฉืžื•ืจ ืžื”ื’ืจืกื” ื”ืงื•ื“ืžืช ื“ื•ืจืฉ ืื™ืฉื•ืจ ืžื—ื“ืฉ. ืคืชื—ื• ื‘ื”ื’ื“ืจื•ืช ื”ืงื‘ื•ืฆื” ืืช ืดืคืชืจื•ืŸ ื‘ืขื™ื™ืช ื”ืจืฉืื•ืชืด; ืืœ ืชืฉื—ื–ืจื• ืžืžื ื• ืืช ื”ืžืฆื‘ ื”ื™ืฉืŸ.", + "diagnostics_snapshot_verification": "ืื™ืžื•ืช ืฆื™ืœื•ื ื”ื”ืจืฉืื•ืช", + "diagnostics_snapshot_verified": "ื ืฉืžืจ ืœืื—ืจ ืงืจื™ืื” ืžืื•ืžืชืช", + "diagnostics_snapshot_unverified": "ืฆื™ืœื•ื ืžื’ืจืกื” ืงื•ื“ืžืช - ื ื“ืจืฉ ืื™ืฉื•ืจ ืžื ื”ืœ", + "permission_error_readback": "โš ๏ธ ื”ื‘ืงืฉื” ืœืฉื™ื ื•ื™ ื”ืจืฉืื•ืช ื ืฉืœื—ื”, ืืš ื”ืชื•ืฆืื” ืœื ืื•ืžืชื”. ื™ื™ืชื›ืŸ ืฉื”ื”ืจืฉืื•ืช ื›ื‘ืจ ื”ืฉืชื ื•. ื‘ื“ืงื• ืื•ืชืŸ ื‘ื˜ืœื’ืจื ื•ื‘ืคืื ืœ ืดืคืชืจื•ืŸ ื‘ืขื™ื™ืช ื”ืจืฉืื•ืชืด ืœืคื ื™ ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ." +} diff --git a/resources/locales/pt-br.json b/resources/locales/pt-br.json new file mode 100644 index 0000000..91af90d --- /dev/null +++ b/resources/locales/pt-br.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ Shabat e festas", + "zmanim": "๐Ÿ•’ Horรกrios do dia", + "daf": "๐Ÿ“– Daf Yomi", + "omer": "๐ŸŒพ Contagem do ร”mer", + "tefillin": "๐Ÿ”” Lembretes de tefilin", + "add": "โž• Adicionar ao grupo", + "groups": "๐Ÿ‘ฅ Meus grupos", + "preferences": "โš™๏ธ Minhas preferรชncias", + "donate": "โญ Apoiar o bot", + "source": "๐Ÿ’ป Cรณdigo-fonte e licenรงa", + "back": "โ†ฉ๏ธ Voltar", + "cancel": "Cancelar", + "confirm": "โœ… Confirmar e salvar", + "language": "๐ŸŒ Idioma", + "location": "๐Ÿ“ Localizaรงรฃo e fuso horรกrio", + "candles": "๐Ÿ•ฏ Acendimento das velas", + "havdalah": "๐ŸŒ™ Havdalรก", + "messages": "โœ‰๏ธ Mensagens e aparรชncia", + "schedule": "๐Ÿ—“ Prรณximas aรงรตes", + "status": "๐Ÿ›  Estado e problemas", + "preview": "๐Ÿ‘€ Prรฉvia no privado", + "reset": "Redefinir este mรณdulo", + "edit": "โœ๏ธ Editar configuraรงรตes", + "on": "Ativado", + "off": "Desativado", + "today": "Hoje", + "tomorrow": "Amanhรฃ", + "other_date": "Outra data", + "share": "Compartilhar", + "short": "Resumido", + "full": "Completo", + "next": "Prรณxima pรกgina", + "previous": "Pรกgina anterior", + "no_groups": "Nenhum grupo conhecido em que vocรช seja administrador foi encontrado. Use /settings em um grupo jรก cadastrado.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nModo: {{mode}}\nPrรณxima aรงรฃo: {{next}}\n\nEscolha uma seรงรฃo para configurar este grupo. Revise e confirme cada alteraรงรฃo antes de salvar.", + "personal_panel": "โš™๏ธ Minhas preferรชncias\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nEscolha seu idioma, localizaรงรฃo e mรฉtodos de cรกlculo. Essas preferรชncias definem seus horรกrios pessoais.", + "denied": "Esta aรงรฃo nรฃo estรก disponรญvel: a autorizaรงรฃo ou o contexto das configuraรงรตes expirou. Abra o painel do grupo novamente.", + "saved": "โœ… Salvo para {{title}}. As aรงรตes futuras foram recalculadas; a obrigaรงรฃo de restaurar as permissรตes foi mantida.", + "language_choose": "Escolha o idioma deste contexto. O painel privado mantรฉm seu idioma pessoal.", + "candle_choose": "Escolha os minutos efetivos antes do pรดr do sol ou o costume documentado da cidade. Nenhum minuto adicional รฉ aplicado de forma oculta.", + "city_custom": "Costume da cidade", + "havdalah_choose": "Escolha graus ou minutos fixos apรณs o pรดr do sol. 72 minutos significam exatamente 72 minutos fixos; o mรฉtodo e o valor sรฃo armazenados separadamente.", + "degrees": "8,5 graus", + "minutes": "{{value}} minutos fixos", + "location_choose": "Busque uma cidade, selecione um resultado, confira o fuso horรกrio e confirme para o contexto identificado. A busca e o compartilhamento nunca salvam configuraรงรตes.", + "inline_search": "Buscar cidade no modo inline", + "city_search": "Buscar no privado", + "coordinates": "Coordenadas / compartilhar localizaรงรฃo", + "timezone": "Fuso horรกrio manual", + "calendar_israel": "Calendรกrio de Israel", + "calendar_diaspora": "Calendรกrio da diรกspora", + "copy_personal": "Copiar minhas preferรชncias (prรฉvia)", + "geonames_missing": "A busca GeoNames estรก desativada. O operador precisa configurar GEONAMES_USERNAME e ativar os serviรงos web. Use uma cidade conhecida ou as coordenadas abaixo.", + "city_prompt": "Envie /city {{token}} nome-da-cidade em um idioma aceito pelo provedor. Este token pertence somente a este processo de configuraรงรฃo.", + "coordinates_prompt": "Envie /coordinates {{token}} latitude longitude fuso-IANA nome. Exemplo: /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem Jerusalรฉm. Para GPS, envie /location {{token}} fuso-IANA e responda ร  mensagem do bot com uma localizaรงรฃo do Telegram.\nUse auto no lugar do fuso quando GeoNames estiver configurado; o fuso IANA sugerido sempre รฉ exibido antes de salvar.", + "timezone_prompt": "Envie /timezone {{token}} texto-de-busca para pesquisar identificadores IANA. A escolha exige confirmaรงรฃo e mantรฉm as coordenadas.", + "location_preview": "Prรฉvia da localizaรงรฃo para {{title}}\nCidade: {{location}}\nPaรญs: {{country}}\nRegiรฃo: {{region}}\nLatitude: {{latitude}}\nLongitude: {{longitude}}\nID GeoNames: {{geoname}}\nFuso horรกrio IANA: {{timezone}}\nDiferenรงa atual em relaรงรฃo ao UTC: {{offset}} (apenas para exibiรงรฃo)\n{{warning}}", + "timezone_warning": "Um fuso escolhido manualmente pode ser diferente do fuso geogrรกfico. As coordenadas nรฃo foram alteradas.", + "no_results": "Nenhuma cidade foi encontrada para esta busca.", + "search_error": "Busca de localizaรงรฃo indisponรญvel: {{error}}", + "select_location": "Confira este lugar; as configuraรงรตes serรฃo salvas somente apรณs sua confirmaรงรฃo no privado.", + "select_private": "Continuar no chat privado com o bot", + "public_location": "Informaรงรตes pรบblicas sobre o lugar. Compartilhar este resultado nunca altera preferรชncias pessoais ou configuraรงรตes de grupos.", + "provider_error": "Os horรกrios verificados estรฃo indisponรญveis: {{error}}. Nenhum horรกrio foi presumido.", + "unavailable": "Indisponรญvel para este local ou esta data", + "shabbat_view": "Shabat e festas\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nAcendimento das velas: {{candles}}\nFechamento do grupo: {{close}}\nHavdalรก: {{havdalah}}\nAbertura do grupo: {{open}}\nEvento atual: {{current}}\nPrรณximo evento: {{next}}\nCalendรกrio: {{calendar}}\nVelas: {{candle_minutes}} minutos antes do pรดr do sol. Havdalรก: {{value}} {{method}}.\nFonte: Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ Contagem do ร”mer\n\nA contagem do ร”mer estรก fora do perรญodo para esta data.", + "help_text": "Comandos\n/start - menu principal\n/shabat, /shabbat - horรกrios de Shabat\n/zmanim [YYYY-MM-DD] - horรกrios do dia\n/daf [YYYY-MM-DD] - Daf Yomi\n/omer - contagem do ร”mer\n/groups - grupos que vocรช administra\n/settings - painel privado de um grupo conhecido (para administradores do grupo)\n/preferences - preferรชncias pessoais\n/language, /lang - escolha de idioma\n/source, /license, /version - informaรงรตes da versรฃo em execuรงรฃo\n/donate - apoio com Estrelas do Telegram\n\nInline: shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer e loc nome-da-cidade. Alterar configuraรงรตes de grupo exige permissรฃo atual de administrador. Transmissรตes e ferramentas do operador sรฃo exclusivas dos administradores do sistema.", + "mode": "Modo de operaรงรฃo", + "mode_off": "Desativado", + "mode_messages": "Somente mensagens", + "mode_lock": "Somente bloqueio", + "mode_lock_messages": "Bloqueio e mensagens", + "shabbat_enabled": "Atividade em Shabat", + "holidays_enabled": "Atividade nas festas", + "module_panel": "{{module}} para {{title}}\n{{settings}}\nNovos mรณdulos de notificaรงรฃo ficam desativados atรฉ serem ativados explicitamente.", + "edit_prompt": "Edite {{module}} para {{title}}. Envie /configure {{token}} seguido de pares field=value separados por ponto e vรญrgula. Campos aceitos e valores atuais:\n{{fields}}\nSepare as listas por vรญrgulas. Confira e confirme as alteraรงรตes antes de salvar.", + "changes_preview": "Prรฉvia das configuraรงรตes para {{title}}\n{{changes}}", + "date_prompt": "Envie /viewdate {{token}} YYYY-MM-DD. A data รฉ explรญcita e usa o fuso deste contexto.", + "schedule_view": "Prรณximas aรงรตes para {{title}}\n{{content}}\nA restauraรงรฃo de permissรตes tem prioridade sobre mensagens comuns.", + "status_view": "Estado de {{title}}\nRelรณgio UTC: {{utc}}\nRelรณgio local: {{local}}\nCobertura do cache: {{coverage}}\nรšltima verificaรงรฃo bem-sucedida do provedor: {{fetched}}\nEvento atual: {{current}}\nPrรณximo evento: {{next}}\nProblema: {{error}}", + "reset_preview": "Redefinir somente {{module}} para {{title}}? A localizaรงรฃo, o idioma e os outros mรณdulos nรฃo serรฃo alterados.", + "suspend": "Pausar tefilin hoje", + "not_private": "Abra o painel privado do grupo para alterar as configuraรงรตes.", + "date_invalid": "Use uma data vรกlida no formato YYYY-MM-DD.", + "hours_invalid": "Use horรกrios locais HH:mm e opรงรตes aceitas.", + "location_label": "Localizaรงรฃo", + "alotHaShachar": "Alvorada", + "misheyakir": "Primeiro horรกrio para talit e tefilin", + "sunrise": "Nascer do sol", + "sofZmanShma": "รšltimo horรกrio do Shemรก (GRA)", + "sofZmanShmaMGA": "รšltimo horรกrio do Shemรก (Maguen Avraham)", + "sofZmanTfilla": "รšltimo horรกrio da oraรงรฃo (GRA)", + "sofZmanTfillaMGA": "รšltimo horรกrio da oraรงรฃo (Maguen Avraham)", + "chatzot": "Meio-dia", + "minchaGedola": "Primeiro horรกrio de Minchรก", + "minchaKetana": "Minchรก Ketanรก", + "plagHaMincha": "Plag Haminchรก", + "sunset": "Pรดr do sol", + "tzeit85deg": "Saรญda das estrelas (8,5ยฐ)", + "tzeit42min": "Saรญda das estrelas (42 minutos fixos)", + "tzeit50min": "Saรญda das estrelas (50 minutos fixos)", + "tzeit72min": "Saรญda das estrelas (72 minutos fixos)", + "chatzotNight": "Meia-noite", + "settings_search": "Filtrar grupos: /groups texto-de-busca", + "field_language": "Idioma", + "field_candle_minutes": "Minutos das velas (city = costume da cidade)", + "field_havdalah_method": "Mรฉtodo de Havdalรก", + "field_havdalah_value": "Valor de Havdalรก", + "field_close_before_candles_minutes": "Fechar antes das velas (minutos)", + "field_open_after_havdalah_minutes": "Abrir depois de Havdalรก (minutos)", + "field_target_topic_id": "ID do tรณpico do fรณrum (off desativa)", + "field_enabled": "Ativado", + "field_time": "Horรกrio local", + "field_days": "Dias da semana: 0 domingo a 6 sรกbado", + "field_format": "Formato", + "field_template": "Texto do modelo", + "field_fixed_times": "Horรกrios fixos", + "field_interval_minutes": "Intervalo em minutos (off desativa, mรญnimo 5)", + "field_window_start": "Inรญcio da janela local", + "field_window_end": "Fim da janela local", + "field_sunset_offsets": "Minutos antes do pรดr do sol", + "field_max_per_day": "Mรกximo diรกrio (1โ€“24)", + "field_min_spacing_minutes": "Intervalo mรญnimo em minutos (5 ou mais)", + "field_quiet_start": "Inรญcio do perรญodo de silรชncio (off desativa)", + "field_quiet_end": "Fim do perรญodo de silรชncio (off desativa)", + "field_chol_hamoed": "Lembretes em Chol Hamoed", + "field_tisha_bav_policy": "Tishรก Beav", + "field_sunset_safety_minutes": "Margem de seguranรงa antes do pรดr do sol", + "field_mode": "Agendamento", + "field_after_nightfall_minutes": "Minutos apรณs a saรญda das estrelas", + "field_nightfall_method": "Mรฉtodo da saรญda das estrelas", + "field_nightfall_value": "Valor da saรญda das estrelas", + "field_nusach": "Nusach", + "field_day_completion": "Completar durante o dia", + "field_early_policy": "Horรกrio cedo demais", + "field_after_open_minutes": "Silรชncio apรณs a abertura do grupo (minutos)", + "field_times_alert_enabled": "Aviso prรฉvio dos horรกrios", + "field_times_alert_time": "Horรกrio local do aviso", + "field_times_alert_mode": "Agendamento do aviso", + "field_times_alert_before_minutes": "Aviso em minutos antes do fechamento", + "field_close_message_enabled": "Mensagem de fechamento", + "field_open_message_enabled": "Mensagem de abertura", + "method_degrees": "graus", + "method_minutes": "minutos fixos apรณs o pรดr do sol", + "window_end": "Fim da janela", + "no_reminders": "Nรฃo hรก lembretes permitidos para este dia e estas configuraรงรตes.", + "preview_only": "Apenas uma prรฉvia privada; nenhuma mensagem รฉ enviada ao grupo.", + "template_panel": "{{title}} - Mensagens e aparรชncia", + "template_menu": "{{title}} - {{kind}}\nA prรฉvia privada nunca envia mensagens ao grupo.", + "template_kind_close": "Mensagem de fechamento", + "template_kind_open": "Mensagem de abertura", + "template_kind_zmanim": "Mensagem dos horรกrios do dia", + "template_kind_daf": "Mensagem de Daf Yomi", + "template_kind_tefillin": "Mensagem de tefilin", + "template_kind_omer": "Mensagem do ร”mer", + "template_edit": "โœ๏ธ Editar texto / mรญdia", + "template_buttons": "โŒจ๏ธ Editar botรตes", + "template_preview": "๐Ÿ‘€ Prรฉvia privada", + "template_reset": "Redefinir esta mensagem", + "template_test": "Enviar TESTE ao grupo (confirmar)", + "template_edit_prompt": "Envie texto ou mรญdia com este prefixo de legenda, depois uma nova linha e o conteรบdo:\n/template {{token}}\nAs entidades de formataรงรฃo, a mรญdia e os botรตes existentes sรฃo preservados.", + "template_buttons_prompt": "Envie /buttons {{token}}, depois uma nova linha e um array JSON de linhas de botรตes. Cada botรฃo tem texto e uma aรงรฃo: URL HTTPS, callback_data ou alternรขncia inline. Estilos opcionais: primary, success, danger.\nO formato de texto original tambรฉm funciona: Rรณtulo - https://example.org - style:bg_success - icon:123. Use && para botรตes na mesma linha ou uma nova linha. Outras aรงรตes: alert:texto, popup:texto, copy:texto. Os dados de callback tรชm limite de 64 bytes.", + "template_cleared": "A mensagem foi redefinida. Abra o painel novamente.", + "template_confirm_test": "โœ… Confirmar TESTE", + "template_test_confirm": "Confirmaรงรฃo explรญcita: uma mensagem de TESTE serรก enviada a {{title}}. Esta aรงรฃo รฉ separada da prรฉvia privada.", + "template_test_wait": "Aguarde cinco minutos antes de outro teste neste grupo.", + "template_cancelled": "O teste foi cancelado; nenhuma mensagem foi enviada ao grupo.", + "template_saved": "A mensagem foi salva. Abra o painel novamente para uma prรฉvia privada.", + "template_remove_text": "๐Ÿ—‘ Remover somente o texto", + "template_remove_media": "๐Ÿ—‘ Remover somente a mรญdia", + "template_remove_buttons": "๐Ÿ—‘ Remover somente os botรตes", + "gps_prompt": "Responda exatamente a esta mensagem com uma localizaรงรฃo do Telegram para {{title}}. A prรฉvia usarรก o fuso {{timezone}}. Uma localizaรงรฃo sem essa resposta nunca seleciona um processo de configuraรงรฃo.", + "gps_reply_required": "Inicie o GPS com /location e o token, depois responda ร  mensagem do bot com sua localizaรงรฃo. Nenhum grupo รฉ selecionado automaticamente.", + "location_attribution": "Dados de lugares: GeoNames (CC-BY-4.0).", + "timezone_auto": "Detectar o fuso da localizaรงรฃo com GeoNames", + "auto_timezone_missing": "A detecรงรฃo automรกtica estรก desativada porque GeoNames nรฃo foi configurado. Use um identificador IANA, como Asia/Jerusalem, em vez de auto.", + "command_start": "๐Ÿ  Abrir o menu principal", + "command_preferences": "โš™๏ธ Suas preferรชncias pessoais", + "command_lang": "๐ŸŒ Escolher seu idioma", + "command_groups": "๐Ÿ‘ฅ Grupos que vocรช administra", + "command_help": "โ„น๏ธ Comandos e ajuda", + "command_shabbat": "๐Ÿ•ฏ Horรกrios de Shabat e das festas", + "command_zmanim": "๐Ÿ•’ Horรกrios halรกchicos do dia", + "command_daf": "๐Ÿ“– Daf Yomi de hoje", + "command_omer": "๐ŸŒพ Contar o ร”mer", + "command_source": "๐Ÿ’ป Cรณdigo-fonte correspondente e licenรงa", + "command_license": "๐Ÿ“œ Licenรงa do projeto", + "command_version": "๐Ÿท Versรฃo e commit em execuรงรฃo", + "command_donate": "โญ Apoiar com Estrelas do Telegram", + "command_stats": "๐Ÿ“Š Estatรญsticas do serviรงo do bot", + "command_add": "โž• Cadastrar este grupo", + "command_remove": "โž– Remover este grupo com seguranรงa", + "command_settings": "โš™๏ธ Configuraรงรตes do grupo no privado", + "command_admin": "๐Ÿ›  Painel do administrador do sistema", + "command_testzmanim": "๐Ÿ”Ž Diagnรณstico dos horรกrios e do agendador", + "command_closegroups": "๐Ÿ”’ Fechamento manual autorizado dos grupos", + "command_opengroups": "๐Ÿ”“ Restauraรงรฃo autorizada das permissรตes", + "command_setcommands": "๐Ÿ“‹ Publicar menus por contexto", + "command_restart": "๐Ÿ”„ Reiniciar o bot de forma organizada", + "donate_title": "Apoiar o Shabbat Bot", + "donate_description": "Apoio voluntรกrio: {{amount}} Estrelas do Telegram. Obrigado!", + "donate_prompt": "Escolha um valor de apoio รบnico em Estrelas do Telegram.", + "donate_invoice_error": "Nรฃo foi possรญvel criar a cobranรงa de apoio. Tente /donate novamente.", + "donate_partial_error": "Alguns valores de apoio estรฃo indisponรญveis. Vocรช pode usar uma cobranรงa que foi criada com sucesso.", + "donate_thanks": "Obrigado pela doaรงรฃo de {{amount}} Estrelas do Telegram!", + "donate_received_admin": "Doaรงรฃo confirmada do usuรกrio {{user_id}}: {{amount}} Estrelas do Telegram.", + "donate_unverified_payment": "Nรฃo foi possรญvel verificar este pagamento. Ele foi marcado para anรกlise do operador; o sucesso nรฃo foi confirmado.", + "commands_setup_help": "Os menus de comandos sรฃo publicados separadamente para chats privados, administradores de grupos e operadores do sistema, em cada idioma aceito. O Telegram usa pt para portuguรชs brasileiro.", + "commands_setup_result": "Atualizaรงรตes dos menus: {{total}} solicitaรงรตes; {{failed}} falharam.", + "commands_setup_preview": "Prรฉvia: serรฃo atualizados {{total}} menus de idioma e contexto.", + "commands_forbidden": "Somente um operador do sistema pode publicar menus de comandos.", + "operator_panel": "๐Ÿ›  Painel do operador do sistema", + "operator_subscribers": "๐Ÿ‘ฅ Assinantes", + "operator_statistics": "๐Ÿ“Š Estatรญsticas do serviรงo", + "operator_broadcast": "๐Ÿ“ฃ Transmissรฃo", + "operator_preview": "๐Ÿ‘€ Prรฉvia privada", + "operator_send": "Enviar transmissรฃo", + "operator_confirm_send": "Confirme o envio desta transmissรฃo a {{count}} destinatรกrios.", + "operator_recipients_private": "Assinantes no privado", + "operator_recipients_groups": "Grupos", + "operator_recipients_all": "Todos os assinantes", + "operator_pin": "Fixar mensagem", + "operator_unpin": "Desafixar mensagens", + "operator_delete_last": "Excluir a รบltima transmissรฃo", + "operator_delete_all": "Excluir todas as transmissรตes", + "operator_progress": "Progresso da transmissรฃo", + "operator_cancel": "Cancelar", + "operator_buttons": "Editar botรตes", + "operator_edit_text": "Editar texto", + "operator_edit_media": "Editar mรญdia", + "operator_clear_draft": "Limpar rascunho", + "operator_active_broadcast": "Jรก hรก uma transmissรฃo em andamento. Aguarde ou cancele antes de iniciar outra.", + "operator_draft_saved": "O rascunho da transmissรฃo foi salvo.", + "operator_no_draft": "Nรฃo hรก rascunho de transmissรฃo disponรญvel.", + "operator_page": "Pรกgina {{page}} de {{total}}", + "operator_manual_result": "Aรงรฃo {{action}}: alteraรงรตes verificadas em {{changed}} grupos, {{failed}} falhas ou resultados nรฃo verificados. Atรฉ: {{until}}.", + "operator_manual_lock": "Fechamento manual do grupo", + "operator_manual_open": "Restaurar permissรตes do grupo", + "group_new_wizard": "O novo grupo comeรงa com uma localizaรงรฃo padrรฃo visรญvel. Confira a localizaรงรฃo, o fuso e o calendรกrio, depois escolha explicitamente um modo de operaรงรฃo.", + "permission_required": "ร‰ necessรกria permissรฃo atual de administrador neste grupo.", + "group_removed": "O grupo foi removido do serviรงo depois que suas permissรตes foram tratadas com seguranรงa.", + "omer_blessing_eligibility": "A bรชnรงรฃo รฉ para quem tem permissรฃo para recitรก-la. O bot nรฃo pode determinar a elegibilidade individual pelo histรณrico de mensagens do grupo.", + "omer_day_completion_label": "Completar durante o dia - sem bรชnรงรฃo.", + "omer_translation_note": "Texto ritual em hebraico; a contagem a seguir รฉ uma traduรงรฃo explicativa.", + "group_close_notice": "O perรญodo de silรชncio de Shabat ou da festa comeรงou.", + "group_open_notice": "O perรญodo de silรชncio de Shabat ou da festa terminou.", + "tefillin_reminder": "Vocรช jรก colocou tefilin hoje?\nNa localizaรงรฃo do grupo {{location}}, o pรดr do sol serรก ร s {{sunset}}.", + "no_event": "Nรฃo hรก eventos no perรญodo de dados verificados.", + "stats_counts": "Grupos conhecidos: {{known}}\nCadastrados no serviรงo: {{registered}}\nCom bloqueio ativado: {{locking}}\nBloqueados atualmente pelo bot: {{locked}}\nAguardando restauraรงรฃo: {{recovery}}", + "source_info": "๐Ÿ’ป Shabbat Guardian - cรณdigo, versรฃo e licenรงa\n\nRepositรณrio oficial\nWizardLoop/shabbat no GitHub\n\nVersรฃo em execuรงรฃo\nVersรฃo: {{version}}\nCommit: {{commit}}\n\nLicenรงa\n{{license}}\nO cรณdigo estรก disponรญvel conforme sua licenรงa. Licenรงas das dependรชncias e avisos de direitos acompanham a oferta de cรณdigo-fonte.\n\nCรณdigo correspondente a esta versรฃo\n{{url}}\nO repositรณrio oficial รฉ a pรกgina do projeto; alteraรงรตes locais podem estar somente no cรณdigo desta versรฃo.", + "version_info": "Versรฃo em execuรงรฃo: {{version}}\nCommit: {{commit}}", + "license_info": "Licenรงa do aplicativo: {{license}}\nFonte e licenรงa da compilaรงรฃo: {{url}}", + "source_unpublished": "O operador ainda nรฃo configurou a URL do cรณdigo-fonte correspondente a esta compilaรงรฃo.", + "donate_checkout_error": "Esta cobranรงa estรก indisponรญvel ou os dados sรฃo invรกlidos. Crie uma nova cobranรงa de apoio com /donate.", + "operator_recipients_channels": "Canais", + "operator_subscriber_stats": "Usuรกrios: {{users}}\nGrupos: {{groups}}\nCanais: {{channels}}\nBots: {{bots}}\nTotal: {{total}}", + "operator_message_prompt": "Envie a mensagem para o rascunho. A formataรงรฃo e a mรญdia sรฃo preservadas. Os comandos continuam funcionando enquanto este editor estรก aberto.", + "operator_buttons_prompt": "Envie linhas de botรตes em JSON ou assim: Label - https://example.org - style:bg_success. Separe botรตes na mesma linha com && e linhas diferentes com uma quebra de linha. Aรงรตes disponรญveis: link HTTPS, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "O rascunho estรก vazio, รฉ longo demais ou contรฉm formataรงรฃo ou botรตes invรกlidos. Ele nรฃo foi salvo.", + "operator_empty_recipients": "Nenhum destinatรกrio registrado corresponde a este pรบblico.", + "operator_complete": "A transmissรฃo terminou. Confira o andamento para ver os resultados de entrega.", + "operator_pause": "Pausar", + "operator_resume": "Retomar", + "operator_bc_starting": "{{operation}}: reunindo destinatรกriosโ€ฆ", + "operator_bc_counts": "Processados: {{processed}} / {{total}}\nEnviados: {{sent}}\nEditados: {{edited}}\nSem alteraรงรตes: {{unchanged}}\nExcluรญdos: {{deleted}}\nDesafixados: {{unpin}}\nFalhas: {{failed}}\nEsperas por limite de envio: {{flood}}\nPendentes: {{pending}}\nTaxa: {{tps}}/s", + "operator_bc_paused": "Pausado", + "operator_bc_cancelled": "Cancelado", + "operator_bc_finished": "Concluรญdo", + "operator_bc_selfdestruct": "Exclusรฃo programada", + "operator_bc_legacy_summary": "Este relatรณrio antigo foi preservado no histรณrico da biblioteca. Confira os resultados atuais da transmissรฃo para ver os contadores disponรญveis.", + "operator_bc_edit": "Ediรงรฃo da transmissรฃo", + "moderation": "๐Ÿ”’ Restriรงรตes e limpeza do grupo", + "moderation_intro": "Escolha as restriรงรตes durante o fechamento do grupo. Um item marcado bloqueia a aรงรฃo. Restriรงรตes anteriores sรฃo preservadas e restauradas na reabertura.", + "moderation_permissions": "Escolher aรงรตes bloqueadas", + "moderation_visibility_warning": "Bloquear a visualizaรงรฃo de mensagens pode ocultar o grupo dos membros. Essa opรงรฃo nรฃo faz parte do padrรฃo.", + "moderation_default": "Restaurar restriรงรตes padrรฃo", + "moderation_none": "Sem restriรงรตes adicionais", + "moderation_all": "Selecionar todas as restriรงรตes", + "moderation_cleanup_hint": "A limpeza abrange apenas mensagens recebidas durante o fechamento ativo de Shabat ou feriado. Comeรงa desativada e exige confirmaรงรฃo. Avisos enviados pelo prรณprio bot sรฃo excluรญdos.", + "moderation_bot_mode_hint": "Para receber mensagens de outros bots, este bot precisa ser administrador, ter Group Privacy Mode desativado e Bot-to-Bot Communication Mode ativado no BotFather. Mensagens nรฃo entregues pelo Telegram nรฃo podem ser excluรญdas.", + "moderation_capabilities": "Verificar permissรตes do bot", + "moderation_delete_capability": "Permissรฃo verificada para excluir mensagens: {{state}}", + "field_lock_block_flags": "Aรงรตes adicionais bloqueadas durante o fechamento", + "field_delete_holy_user_messages": "Excluir mensagens de membros durante o fechamento sagrado", + "field_delete_holy_bot_messages": "Excluir mensagens de outros bots durante o fechamento sagrado", + "field_delete_previous": "Excluir o lembrete anterior de tefilin apรณs enviar um novo", + "announcement_schedule": "๐Ÿ•ฐ Aviso de horรกrios de Shabat e feriados", + "announcement_schedule_intro": "Escolha o horรกrio do aviso em {{timezone}}. Uma opรงรฃo pronta define o horรกrio local sem ativar os avisos. Personalize para outro horรกrio ou minutos antes do fechamento.", + "announcement_custom": "Horรกrio personalizado / antes do fechamento", + "template_kind_times_alert": "Aviso de horรกrios de Shabat e feriados", + "permission_view_messages": "Ver mensagens", + "permission_send_messages": "Enviar mensagens", + "permission_send_media": "Enviar mรญdia", + "permission_send_stickers": "Enviar figurinhas", + "permission_send_gifs": "Enviar GIFs", + "permission_send_games": "Enviar jogos", + "permission_send_inline": "Enviar resultados inline", + "permission_embed_links": "Prรฉ-visualizar links", + "permission_send_polls": "Enviar enquetes", + "permission_change_info": "Alterar informaรงรตes do grupo", + "permission_invite_users": "Convidar usuรกrios", + "permission_pin_messages": "Fixar mensagens", + "permission_manage_topics": "Gerenciar tรณpicos", + "permission_send_photos": "Enviar fotos", + "permission_send_videos": "Enviar vรญdeos", + "permission_send_roundvideos": "Enviar mensagens de vรญdeo", + "permission_send_audios": "Enviar รกudio", + "permission_send_voices": "Enviar mensagens de voz", + "permission_send_docs": "Enviar documentos", + "permission_send_plain": "Enviar texto simples", + "permission_edit_rank": "Editar tรญtulos de membros", + "permission_send_reactions": "Enviar reaรงรตes", + "bot_permissions": "๐Ÿ›ก Direitos de administrador do bot", + "bot_permissions_intro": "Diagnรณstico de permissรตes do Telegram somente para leitura. Conceda os direitos nas configuraรงรตes dos administradores do grupo; o bot nรฃo pode concedรช-los a si mesmo.", + "bot_role_creator": "Dono do grupo", + "bot_role_administrator": "Administrador do grupo", + "admin_right_change_info": "Alterar informaรงรตes do grupo", + "admin_right_post_messages": "Publicar mensagens no canal", + "admin_right_edit_messages": "Editar mensagens no canal", + "admin_right_delete_messages": "Excluir mensagens", + "admin_right_ban_users": "Restringir membros", + "admin_right_invite_users": "Convidar usuรกrios", + "admin_right_pin_messages": "Fixar mensagens", + "admin_right_add_admins": "Adicionar administradores", + "admin_right_anonymous": "Permanecer anรดnimo", + "admin_right_manage_call": "Gerenciar chats de voz e vรญdeo", + "admin_right_other": "Outros privilรฉgios de administrador", + "admin_right_manage_topics": "Gerenciar tรณpicos", + "admin_right_post_stories": "Publicar stories", + "admin_right_edit_stories": "Editar stories", + "admin_right_delete_stories": "Excluir stories", + "admin_right_manage_direct_messages": "Gerenciar mensagens diretas do canal", + "admin_right_manage_ranks": "Gerenciar tรญtulos de membros", + "welcome": "โœก๏ธ Guardiรฃo do Shabat\nHorรกrios de Shabat e festas, zmanim, Daf Yomi e contagem do ร”mer para sua localizaรงรฃo.\n\nEscolha o que consultar ou abra suas preferรชncias para ajustar o idioma e a localizaรงรฃo.\n\nAdministradores podem gerenciar cada grupo em โ€œMeus gruposโ€.", + "help": "โ„น๏ธ Informaรงรตes e ajuda", + "home": "๐Ÿ  Menu principal", + "info_intro": "โ„น๏ธ Seu guia do Guardiรฃo do Shabat\n\nSeus horรกrios, no seu idioma e para sua localizaรงรฃo.\nEscolha uma categoria com explicaรงรตes e comandos รบteis. Administradores tambรฉm encontram o fechamento automรกtico, os lembretes e a formataรงรฃo de mensagens.", + "info_personal_title": "โš™๏ธ Minhas preferรชncias", + "info_personal_text": "/preferences - idioma, localizaรงรฃo, calendรกrio de Israel ou da diรกspora e mรฉtodos de acendimento e havdalรก.\n/lang - escolher entre seis idiomas.\nOs horรกrios no privado seguem suas preferรชncias. Mudanรงas pessoais nรฃo alteram os grupos.\n/start - voltar ao menu principal.", + "info_calendar_title": "๐Ÿ•ฏ Horรกrios e calendรกrio judaico", + "info_calendar_text": "Seus horรกrios\n/shabbat - prรณximo Shabat, perรญodos contรญnuos de Yom Tov, parashรก, Mevarchim e prรณximas datas em uma mensagem.\n/zmanim - horรกrios locais; escolha a data pelos botรตes.\n/daf - Daf Yomi. /omer - contagem sazonal do ร”mer.\n\nDias consecutivos\nTodos os acendimentos e a saรญda final sรฃo exibidos. Horรกrios de festas sรฃo mostrados somente para Yom Tov classificado pelo Hebcal. Chanucรก, Chol Hamoed e outras datas nรฃo sรฃo, por si sรณ, Yom Tov.\n\nLocal e mรฉtodos\nAs preferรชncias pessoais controlam o privado; cada grupo tem as suas. Jerusalรฉm usa 40 minutos antes do pรดr do sol por padrรฃo, com personalizaรงรฃo. Os horรกrios usam o fuso IANA escolhido, incluindo mudanรงas do horรกrio de verรฃo.\n\nHorรกrios ausentes nรฃo sรฃo estimados. Compartilhar envia o resultado a outro chat.\n\nCalendรกrio e horรกrios: Hebcal.com. O mรฉtodo de tรฉrmino exibido รฉ o escolhido por vocรช: graus ou minutos apรณs o pรดr do sol.", + "info_groups_title": "๐Ÿ‘ฅ Gerenciar grupos", + "info_groups_text": "Conexรฃo e painel\nAdicione o bot como administrador e envie /settings no grupo. O painel tambรฉm abre em Meus grupos no privado. O menu do grupo mostra somente configuraรงรตes.\n\nOpรงรตes\nLocal, idioma, Shabat e festas, horรกrios, Daf Yomi, tefilin, ร”mer, mensagens e permissรตes. Cada grupo รฉ independente.\n\nModo\nDesligado, somente mensagens, somente bloqueio ou ambos. Escolha dias, mรฉtodos e horรกrio do anรบncio em Shabat e festas.\n\nSalvar exige permissรฃo atual de administrador. Revise o resumo antes de ativar; a prรฉvia privada nรฃo envia ao grupo.", + "info_notifications_title": "๐Ÿ”” Lembretes e avisos", + "info_notifications_text": "Cada lembrete รฉ independente\nEscolha horรกrios, Daf Yomi, tefilin ou ร”mer no painel, ative e defina horรกrios locais. Os botรตes orientam sem chaves tรฉcnicas ou tokens.\n\nTefilin\nEscolha dias, horรกrios fixos, intervalos ou minutos antes do pรดr do sol. Defina silรชncio, limite diรกrio e exclusรฃo do aviso anterior. Shabat, Yom Tov e as opรงรตes de Chol Hamoed e Tishรก BeAv sรฃo respeitados.\n\nColoquei tefilin\nO botรฃo conta usuรกrios รบnicos; outro clique cancela sua escolha. O administrador pode renomear ou desativar.\n\nAnรบncio de horรกrios\nEm Shabat e festas, escolha horรกrio e mensagem. A prรฉvia รฉ privada; o teste no grupo exige confirmaรงรฃo.", + "info_messages_title": "โœ‰๏ธ Aparรชncia e permissรตes", + "info_messages_text": "Mensagens e design\nEscolha fechamento, abertura, Shabat/festas, horรกrios, Daf Yomi, tefilin ou ร”mer. Texto, foto/mรญdia e botรตes sรฃo editados separadamente.\n\nEdiรงรฃo natural\nEnvie a mensagem como deseja exibi-la. O bot preserva texto e formataรงรฃo do Telegram, incluindo negrito, links e emojis personalizados. Revise e confirme Salvar.\n\nBotรตes\nAdicione link, escolha estilo, remova botรตes ou restaure padrรตes. Horรกrios e Daf Yomi incluem Compartilhar. Alterar apenas botรตes preserva o conteรบdo calculado.\n\nSe emojis personalizados estiverem indisponรญveis, sรฃo usados comuns. Um teste no grupo exige confirmaรงรฃo explรญcita.", + "info_privacy_title": "๐Ÿ›ก Privacidade e seguranรงa", + "info_privacy_text": "Controle das configuraรงรตes\nBusca e localizaรงรฃo mostram opรงรตes e prรฉvia; apenas confirmar salva alteraรงรตes. Preferรชncias pessoais nรฃo mudam grupos. Permissรตes atuais sรฃo verificadas antes da administraรงรฃo.\n\nDados salvos\nIdentificadores, preferรชncias, permissรตes e registros necessรกrios ao serviรงo. O botรฃo de tefilin registra sua escolha para contar usuรกrios รบnicos; outro clique cancela.\n\nTeste e transparรชncia\nA prรฉvia รฉ privada. Testes no grupo exigem confirmaรงรฃo. Nรฃo envie senhas ou tokens.\n/source reรบne repositรณrio oficial, versรฃo, licenรงa e cรณdigo da compilaรงรฃo.", + "info_operators_title": "๐Ÿ›  Administraรงรฃo do bot", + "info_operators_text": "/admin - inscritos, estatรญsticas e transmissรตes.\n/setcommands - menus por idioma e tipo de chat; confira a prรฉvia antes de publicar.\n/testzmanim - verificar cรกlculos e agendamento.\n/closegroups e /opengroups - aรงรตes manuais para grupos autorizados.\n/restart - reiniciar de forma organizada.\nEssas funรงรตes sรฃo exclusivas dos operadores do bot. Administrar um grupo nรฃo dรก acesso ร  administraรงรฃo do bot.", + "callback_panel_unavailable": "Nรฃo foi possรญvel atualizar este menu. Abra novamente com /start ou /settings.", + "provider_unavailable": "Os dados de horรกrios verificados estรฃo temporariamente indisponรญveis. Tente novamente em alguns minutos.", + "operator_subscriber_stats_full": "๐Ÿ“Š Inscritos do bot de Shabat\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค Usuรกrios: {{users}}\n๐Ÿ‘ฅ Grupos: {{chats}}\n๐Ÿ‘ฅ Supergrupos: {{supergroups}}\n๐Ÿ“ข Canais: {{channels}}\n๐Ÿค– Bots: {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nTotal: {{total}}", + "operator_group_stats": "๐Ÿ”’ Atividade dos grupos\nGrupos conhecidos: {{known}}\nCadastrados: {{registered}}\nFechamento ativado: {{locking}}\nFechados agora pelo bot: {{locked}}\nAguardando restauraรงรฃo: {{recovery}}", + "times_section_schedule": "Horรกrios de inรญcio e tรฉrmino", + "times_section_calendar": "Detalhes do calendรกrio", + "times_stale": "โ„น๏ธ Exibindo dados salvos da รบltima consulta bem-sucedida; o provedor estรก indisponรญvel no momento.", + "times_day_name_0": "Domingo", + "times_day_name_1": "Segunda-feira", + "times_day_name_2": "Terรงa-feira", + "times_day_name_3": "Quarta-feira", + "times_day_name_4": "Quinta-feira", + "times_day_name_5": "Sexta-feira", + "times_day_name_6": "Sรกbado", + "times_heading": "๐Ÿ•ฏ Horรกrios de Shabat e festas", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… Dias sagrados: {{dates}}", + "times_candles": "๐Ÿ•ฏ Acendimento das velas - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ Tรฉrmino / Havdalรก: {{time}}", + "times_close": "๐Ÿ”’ Fechamento do grupo: {{time}}", + "times_open": "๐Ÿ”“ Abertura do grupo: {{time}}", + "times_sequence": "โ„น๏ธ Dias sagrados consecutivos: o grupo permanece fechado atรฉ a abertura final.", + "times_parasha": "๐Ÿ•ฏ Parashรก: {{title}}", + "times_mevarchim": "๐ŸŒ™ Shabat Mevarchim: {{title}}", + "times_holiday": "โœก๏ธ Evento do calendรกrio: {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "Velas: {{minutes}} minutos antes do pรดr do sol\nSaรญda: {{method}} {{value}}", + "times_source": "Fonte: Hebcal ยท Atualizado: {{time}}", + "times_data_missing": "Alguns dados de horรกrios estรฃo indisponรญveis no momento.", + "location_menu_intro": "๐Ÿ“ Localizaรงรฃo de {{title}}\nLocalizaรงรฃo atual: {{location}}\nFuso: {{timezone}}\nEscolha como encontrar a localizaรงรฃo. Vocรช vai conferir e confirmar os detalhes antes de salvar.", + "location_input_city_title": "๐Ÿ”Ž Buscar cidade", + "location_input_city_prompt": "Digite o nome da cidade. Vocรช pode buscar em portuguรชs e outros idiomas. Escolha um resultado da lista.", + "location_input_timezone_title": "๐ŸŒ Fuso horรกrio", + "location_input_timezone_prompt": "Digite uma cidade ou fuso, como Jerusalem ou Asia/Jerusalem. Alterar o fuso nรฃo muda as coordenadas.", + "location_input_geoname_title": "๐Ÿ”ข ID do GeoNames", + "location_input_geoname_prompt": "Digite o identificador numรฉrico do local no GeoNames, por exemplo 281184 para Jerusalรฉm.", + "location_input_coordinates_title": "๐Ÿงญ Coordenadas", + "location_input_coordinates_prompt": "Digite latitude e longitude, por exemplo 31.76904, 35.21633. Vocรช pode acrescentar um fuso como Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ Compartilhar localizaรงรฃo", + "location_input_gps_prompt": "Responda a esta mensagem com uma localizaรงรฃo do Telegram. Vocรช vai conferir e confirmar o local e o fuso.", + "location_input_invalid": "Nรฃo foi possรญvel entender a localizaรงรฃo. Digite uma cidade, ID do GeoNames, coordenadas ou fuso conforme a opรงรฃo escolhida.", + "location_inline_hint": "Vocรช tambรฉm pode buscar no campo de mensagem: @{{bot}} seguido do nome da cidade.", + "location_input_cancelled": "A seleรงรฃo de localizaรงรฃo foi cancelada. As configuraรงรตes nรฃo mudaram.", + "daf_open_page": "๐Ÿ”— Ler a pรกgina", + "timezone_inline_hint": "Vocรช tambรฉm pode buscar no campo de mensagem: @{{bot}} tz Jerusalem ou @{{bot}} Asia/Jerusalem. Para escolher nas configuraรงรตes atuais, use o botรฃo de busca aqui.", + "donate_other": "Outro", + "donate_custom_prompt": "โญ Escolha sua contribuiรงรฃo\n\nEnvie um nรบmero inteiro entre 1 e 10.000 estrelas.\nVocรช receberรก um link de pagamento antes de confirmar sua contribuiรงรฃo no Telegram.", + "donate_custom_invalid": "Digite um nรบmero inteiro entre 1 e 10.000 estrelas, sem sรญmbolos ou fraรงรตes.", + "donate_custom_ready": "โญ Para contribuir com {{amount}} estrelas, abra o link de pagamento e confirme no Telegram.", + "donate_custom_cancel": "โ†ฉ๏ธ Voltar aos valores", + "times_upcoming": "๐Ÿ•ฏ Prรณximos Shabats e festas", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "Pรกgina {{page}} de {{pages}}", + "times_period_exit": "๐ŸŒ™ Fim deste perรญodo: {{time}}", + "times_no_events": "Nรฃo hรก mais Shabats ou festas no intervalo de datas verificado.", + "times_lighting_after": "Nesta noite, as velas sรฃo acesas apรณs o fim do dia sagrado anterior, usando uma chama acesa antes do inรญcio da festa.", + "times_event_link": "Horรกrios de {{title}}", + "omer_nightfall_label": "Anoitecer", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "Escolha uma configuraรงรฃo. Cada opรงรฃo mostra o valor atual.", + "wizard_custom": "โœ๏ธ Valor personalizado", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nAtual: {{current}}\n\n{{hint}}", + "wizard_invalid": "O valor nรฃo corresponde ร  configuraรงรฃo. Siga o exemplo ou volte ร  lista.", + "wizard_done": "โœ… Revisar seleรงรฃo", + "wizard_more": "โš™๏ธ Todas as configuraรงรตes", + "wizard_choose_option": "Escolha uma opรงรฃo e revise a alteraรงรฃo antes de salvar.", + "wizard_template": "Personalize texto, formataรงรฃo, mรญdia e botรตes no editor. A prรฉvia permanece no privado.", + "wizard_hint_time": "Envie um horรกrio local, por exemplo 09:30.", + "wizard_hint_times": "Envie horรกrios separados por vรญrgulas, por exemplo 09:00, 13:30.", + "wizard_hint_integer": "Envie um nรบmero inteiro. Ele serรก verificado no intervalo permitido.", + "wizard_hint_float": "Envie um nรบmero, por exemplo 8.5 para graus ou 50 para minutos fixos.", + "wizard_hint_minutes_list": "Envie minutos separados por vรญrgulas, por exemplo 60, 30, 15.", + "wizard_hint_text": "Envie o texto que deseja salvar.", + "wizard_hint_days": "Selecione os dias e toque em โ€œRevisar seleรงรฃoโ€.", + "template_edit_guided_prompt": "Envie a nova mensagem aqui. Vocรช pode anexar mรญdia e formatar no Telegram. Revise a prรฉvia antes de salvar.", + "template_buttons_guided_prompt": "๐Ÿ”— Botรตes da mensagem\n\nPara um link simples, escolha โ€œAdicionar botรฃo de linkโ€. Perguntaremos o texto e o endereรงo separadamente.\n\nVocรช tambรฉm pode enviar vรกrios botรตes conforme este exemplo:\nInformaรงรตes - https://example.org - style:bg_primary\nApoio - https://example.org/support\n\nUma nova linha cria outra fileira. Separe os botรตes da mesma fileira com &&. JSON tambรฉm รฉ aceito. Revise a prรฉvia antes de salvar.", + "operator_audience_authorize": "โœ… Confirmar pรบblico", + "command_adminstats": "๐Ÿ“Š Estatรญsticas de administraรงรฃo", + "command_subscribers": "๐Ÿ‘ฅ Assinantes e busca", + "command_broadcast": "๐Ÿ“ฃ Transmitir mensagem", + "command_managers": "๐Ÿ‘ฅ Administradores e permissรตes", + "command_systemlinks": "โš™๏ธ Links do sistema", + "command_trackinglinks": "โ„น๏ธ Links de rastreamento", + "times_upcoming_calendar": "๐Ÿ•ฏ Prรณximas festas e datas especiais", + "times_sequence_calendar": "Perรญodo contรญnuo de Shabat e festas: cada acendimento รฉ mostrado, com a saรญda final no fim do perรญodo.", + "template_default_buttons": "โ†ฉ๏ธ Restaurar botรตes padrรฃo", + "tefillin_ack": "Coloquei tefilin", + "field_ack_enabled": "Botรฃo de confirmaรงรฃo de tefilin", + "field_ack_label": "Texto de confirmaรงรฃo de tefilin", + "field_tefillin_ack_enabled": "Botรฃo de confirmaรงรฃo de tefilin", + "field_tefillin_ack_label": "Texto de confirmaรงรฃo de tefilin", + "template_add_link": "โž• Adicionar botรฃo de link", + "template_link_text_prompt": "๐Ÿ”— Novo botรฃo\n\nEnvie um texto curto, por exemplo โ€œMais informaรงรตesโ€.", + "template_link_url_prompt": "๐Ÿ”— Para onde vai o botรฃo?\n\nEnvie um endereรงo completo comeรงando com https://, por exemplo https://example.org.\nRevise o botรฃo antes de salvar.", + "template_save_review": "โœ… Salvar mensagem", + "template_ack_preview": "Esta รฉ uma prรฉvia. Tocar aqui nรฃo altera o contador.", + "template_restore_text": "๐Ÿ“ Restaurar texto automรกtico", + "template_remove_caption": "๐Ÿ—‘ Remover legenda da mรญdia", + "template_text_restored": "O texto automรกtico foi restaurado. A mรญdia e os botรตes foram mantidos.", + "template_caption_removed": "A legenda foi removida. A mรญdia e os botรตes foram mantidos.", + "settings_mode_intro": "โš™๏ธ Modo de funcionamento de {{title}}\n\nDesativado - sem mensagens nem bloqueio automรกtico de Shabat ou festas.\nSomente mensagens - publicaรงรตes sem alterar permissรตes.\nSomente bloqueio - fechamento e abertura nos horรกrios definidos, sem mensagens de festas.\nMensagens e bloqueio - publicaรงรตes e controle de acesso.\n\nAplica-se aos Shabat e dias de Yom Tov ativados.", + "settings_mode_button": "Modo: {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ Entrada, saรญda e bloqueio", + "settings_shabbat_messages": "โœ๏ธ Mensagens e aparรชncia", + "settings_shabbat_timing_intro": "Escolha o costume de velas, o mรฉtodo de saรญda e as margens de fechamento/abertura. Os horรกrios usam o local do grupo. Shabat e Yom Tov consecutivos permanecem fechados atรฉ o fim de todo o perรญodo.", + "settings_shabbat_messages_intro": "Escolha mensagens de fechamento/abertura, horรกrios de silรชncio e o tรณpico do grupo. Edite separadamente o texto, a formataรงรฃo, a mรญdia e os botรตes.", + "settings_shabbat_summary": "โœก๏ธ Shabat e festas - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nModo: {{mode}}\nShabat: {{shabbat}} ยท Yom Tov: {{holidays}}\n\n๐Ÿ•ฏ๏ธ Entrada e saรญda\nVelas: {{candles}} minutos antes do pรดr do sol\nSaรญda: {{havdalah}}\nFechamento: {{close}} minutos antes das velas\nAbertura: {{open}} minutos apรณs a saรญda\n\nEscolha uma seรงรฃo. Ao confirmar, vocรช volta para a mesma seรงรฃo.", + "template_current_summary": "Aparรชncia atual\nTexto: {{text}}\nMรญdia: {{media}}\nBotรตes: {{buttons}}\n\nEdite o conteรบdo ou os botรตes, confira a prรฉvia e salve. Um envio de teste ao grupo exige confirmaรงรฃo separada.", + "template_text_default": "Mensagem automรกtica", + "template_text_custom": "Texto personalizado", + "template_buttons_inherit": "Padrรตes do bot", + "template_buttons_custom": "Botรตes personalizados", + "template_buttons_none": "Sem botรตes", + "template_panel_hint": "Escolha um tipo de mensagem. Cada mensagem aceita texto formatado, mรญdia e botรตes. As alteraรงรตes valem apenas para este grupo.", + "times_period_enter": "๐Ÿ•ฏ Inรญcio / acendimento das velas: {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ Os prรณximos Shabat", + "times_yomtov_unavailable": "Os horรกrios verificados de inรญcio e fim deste Yom Tov ainda nรฃo estรฃo disponรญveis.", + "info_permissions_title": "๐Ÿ›ก Permissรตes, fechamento e abertura", + "info_permissions_text": "Quais grupos sรฃo compatรญveis?\nO Telegram permite alterar permissรตes padrรฃo de grupos comuns e supergrupos. O bot precisa ser administrador com permissรฃo para restringir membros. As restriรงรตes se aplicam a membros comuns; administradores podem continuar escrevendo.\n\nAntes de ativar\nEscolha cidade, fuso e calendรกrio de Israel ou diรกspora. Em Shabat e festas, escolha bloqueio ou mensagens e bloqueio. Revise velas, ajustes de fechamento e abertura e permissรตes do bot.\n\nAo reabrir\nO bot salva as permissรตes antes de fechar e as restaura depois. Shabat e Yom Tov consecutivos permanecem fechados atรฉ a รบltima saรญda configurada. Alteraรงรตes externas ou permissรตes ausentes podem exigir revisรฃo.\n\nExclusรฃo\nร‰ necessรกria permissรฃo para excluir mensagens. Mensagens de outros bots tรชm opรงรฃo separada e dependem das atualizaรงรตes entregues pelo Telegram.", + "template_buttons_simple_prompt": "Adicione um botรฃo de link: escolha o texto e depois um endereรงo iniciado por https://. Confira a prรฉvia antes de salvar.\n\nPara outras aรงรตes ou vรกrias linhas, abra as opรงรตes avanรงadas.", + "template_advanced_buttons": "โš™๏ธ Opรงรตes avanรงadas", + "operator_manual_details": "Detalhes por grupo", + "operator_manual_pending": "Aguardando: {{count}}", + "operator_manual_more": "E mais {{count}} grupos", + "permission_error_admin": "Dรช ao bot permissรฃo para restringir membros.", + "permission_error_access": "O bot nรฃo estรก no grupo ou nรฃo tem acesso.", + "permission_error_flags": "Restriรงรตes invรกlidas. Redefina-as em Shabat e festas.", + "permission_error_snapshot": "Nรฃo foi possรญvel verificar permissรตes originais. Um administrador deve revisar antes da abertura.", + "permission_error_conflict": "As permissรตes atuais diferem do estado salvo. As alteraรงรตes automรกticas foram interrompidas para revisรฃo de um administrador.", + "permission_error_provider": "O serviรงo de calendรกrio estรก indisponรญvel.", + "permission_error_unknown": "Operaรงรฃo nรฃo confirmada. Consulte o registro de diagnรณstico do grupo.", + "permission_status_wait": "Aguardando nova tentativa apรณs o limite do Telegram", + "permission_status_busy": "Outra operaรงรฃo estรก em andamento para este grupo", + "permission_status_prelocked": "O grupo foi fechado fora do bot e nรฃo serรก aberto automaticamente", + "permission_status_idle": "Nรฃo hรก bloqueio do bot para remover", + "permission_status_unchanged": "O grupo jรก estรก no estado solicitado", + "restart_requested": "โ™ป๏ธ Iniciando uma reinicializaรงรฃo segura. O bot voltarรก quando iniciar novamente.", + "permission_conflict_fields": "Diferenรงas: {{fields}}.", + "permission_until_date": "Validade das permissรตes", + "permission_unknown": "Permissรฃo desconhecida", + "times_shabbat_title": "Shabat", + "permission_recovery": "๐Ÿ›  Resolver problema de permissรตes", + "recovery_title": "๐Ÿ›  Resolver problema de permissรตes\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "O bot salva as permissรตes habituais antes do bloqueio e as restaura na abertura. Confira aqui uma tentativa incompleta e escolha como continuar.", + "recovery_compare": "Comparaรงรฃo com as permissรตes salvas", + "recovery_difference": "โ€ข {{permission}}: agora {{current}} ยท salvo {{saved}}", + "recovery_more": "Mais {{count}} diferenรงas. A aรงรฃo inclui todas as permissรตes, mesmo as que nรฃo aparecem aqui.", + "recovery_allowed": "permitido", + "recovery_blocked": "bloqueado", + "recovery_no_differences": "Nรฃo hรก diferenรงas em relaรงรฃo ร s permissรตes originais salvas.", + "recovery_adopt": "โœ… Aceitar permissรตes atuais", + "recovery_restore": "โ†ฉ๏ธ Restaurar permissรตes salvas", + "recovery_preview_adopt": "Aceitar permissรตes atuais\nAs permissรตes atuais do grupo passarรฃo a ser o estado habitual. Esta aรงรฃo nรฃo altera as permissรตes. Ela encerra a tentativa anterior; o prรณximo bloqueio salvarรก o estado atualizado.", + "recovery_preview_restore": "Restaurar permissรตes salvas\nO bot solicitarรก todas as permissรตes salvas antes do problema, desfazendo alteraรงรตes posteriores. A restauraรงรฃo sรณ รฉ considerada concluรญda apรณs a verificaรงรฃo com o Telegram.", + "recovery_confirm_adopt": "โœ… Confirmar permissรตes atuais", + "recovery_confirm_restore": "โœ… Confirmar restauraรงรฃo", + "recovery_preview_notice": "Confira o nome do grupo e sua escolha. A confirmaรงรฃo expira em cinco minutos. As permissรตes e o estado serรฃo verificados novamente; a recuperaรงรฃo manual fica indisponรญvel durante um bloqueio ativo, Shabat ou feriado.", + "recovery_result_adopted": "โœ… Permissรตes atuais aceitas como habituais. A tentativa anterior foi encerrada.", + "recovery_result_restored": "โœ… Permissรตes salvas restauradas e verificadas. A tentativa anterior foi encerrada.", + "recovery_block_active_lock": "๐Ÿ”’ Nรฃo รฉ possรญvel confirmar um momento seguro para a recuperaรงรฃo: pode haver um bloqueio, Shabat ou feriado, ou os dados do calendรกrio podem estar indisponรญveis. Vocรช pode consultar e atualizar; alteraรงรตes manuais exigem confirmar que nenhum perรญodo protegido esteja ativo.", + "recovery_block_no_problem": "โœ… Nรฃo hรก problemas de permissรตes aguardando soluรงรฃo. O prรณximo bloqueio salvarรก as permissรตes atualizadas do grupo.", + "recovery_block_missing_snapshot": "โš ๏ธ Nรฃo hรก um estado original verificado para restaurar. Se for possรญvel aceitar as permissรตes atuais, confirme primeiro que elas sejam as permissรตes habituais desejadas.", + "recovery_block_locked_current": "๐Ÿ”’ O grupo parece estar bloqueado. Nรฃo รฉ possรญvel aceitar esse estado como habitual. Confira a restauraรงรฃo ou fale com o operador do bot.", + "recovery_block_unknown": "โš ๏ธ Nรฃo foi possรญvel verificar uma recuperaรงรฃo segura agora. Atualize; se continuar, fale com o operador do bot.", + "recovery_result_stale": "โŒ› A confirmaรงรฃo expirou ou o estado mudou. Confira o estado atual e solicite uma nova confirmaรงรฃo.", + "recovery_result_forbidden": "ร‰ necessรกrio ter permissรฃo atual de administrador deste grupo.", + "recovery_result_busy": "โณ Outra operaรงรฃo estรก em andamento no grupo. Atualize em instantes.", + "recovery_result_conflict": "โš ๏ธ O Telegram retornou um estado inesperado. Os dados originais foram preservados; confira a comparaรงรฃo antes de tentar novamente.", + "recovery_result_error": "Nรฃo foi possรญvel concluir ou verificar a recuperaรงรฃo. Os dados salvos nรฃo foram apagados. Atualize antes de tentar novamente.", + "recovery_result_retry_wait": "โณ O Telegram pediu para aguardar. Evite cliques repetidos; atualize mais tarde.", + "recovery_refresh": "๐Ÿ”„ Atualizar estado", + "recovery_cancelled": "Escolha cancelada. Nenhuma recuperaรงรฃo manual foi realizada.", + "recovery_state_problem": "โš ๏ธ Uma tentativa de bloqueio estรก incompleta. Confira as diferenรงas e escolha o estado desejado. Cada opรงรฃo abre uma confirmaรงรฃo adicional antes da execuรงรฃo.", + "diagnostics_title": "๐Ÿงช Diagnรณstico de horรกrios e permissรตes", + "diagnostics_read_only": "Somente leitura. Retrato vรกlido por 15 minutos; atualize para obter dados atuais.", + "diagnostics_context": "Contexto", + "diagnostics_build": "Versรฃo em execuรงรฃo", + "diagnostics_location": "Local e fuso horรกrio", + "diagnostics_methods": "Minutos das velas / mรฉtodo de saรญda e valor", + "diagnostics_lock_now": "Bloqueio necessรกrio agora", + "diagnostics_quiet_now": "Shabat, festa ou bloqueio ativo", + "diagnostics_stale": "Dados do provedor desatualizados", + "diagnostics_evaluated_at": "Hora do cรกlculo", + "diagnostics_fetched_at": "Dados recebidos em", + "diagnostics_period": "Intervalo de fechamento e abertura", + "diagnostics_provider_error": "Erro do provedor do calendรกrio", + "diagnostics_queue": "Fila de tarefas", + "diagnostics_group": "Grupo", + "diagnostics_limited": "Grupos exibidos / total. Outro grupo: /testzmanim e seu ID", + "diagnostics_lock_state": "Estado do bloqueio salvo", + "diagnostics_original": "Permissรตes normais salvas: restriรงรตes ativas", + "diagnostics_requested": "Permissรตes solicitadas: restriรงรตes ativas", + "diagnostics_current": "Permissรตes atuais do Telegram: restriรงรตes ativas", + "diagnostics_compare_original": "Diferenรงas com as permissรตes normais salvas", + "diagnostics_compare_requested": "Diferenรงas com as permissรตes solicitadas", + "diagnostics_all": "Tudo", + "diagnostics_problems": "โš ๏ธ Problemas e avisos", + "diagnostics_summary": "Resumo", + "diagnostics_calendar": "Calendรกrio", + "diagnostics_permissions": "Permissรตes", + "diagnostics_groups": "๐Ÿ‘ฅ Grupos", + "diagnostics_busy": "Em andamento ou realizado recentemente. Atualizaรงรฃo a cada 30 segundos e exportaรงรฃo uma vez por minuto.", + "diagnostics_unavailable": "Diagnรณstico indisponรญvel, expirado ou sem acesso. Repita /testzmanim no privado em 30 segundos.", + "diagnostics_export_caption": "๐Ÿ“„ Diagnรณstico de horรกrios e permissรตes. Retrato somente para leitura.", + "diagnostics_export_sent": "Arquivo JSON enviado.", + "diagnostics_previous": "โ—€๏ธ Anterior", + "diagnostics_next": "Prรณximo โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž Buscar", + "diagnostics_filters": "๐Ÿ”ฝ Filtrar", + "diagnostics_refresh": "๐Ÿ”„ Atualizar", + "diagnostics_clear": "Limpar busca e filtros", + "diagnostics_export": "๐Ÿ“„ Exportar JSON", + "diagnostics_back": "Voltar ร  administraรงรฃo", + "diagnostics_cancel": "Cancelar", + "diagnostics_empty": "Nenhum resultado para esta busca e filtro.", + "diagnostics_page_prompt": "Envie um nรบmero de pรกgina entre 1 e {{pages}}. O painel atual serรก atualizado.", + "diagnostics_search_prompt": "Envie uma palavra, data ou ID de grupo para buscar no retrato (atรฉ 80 caracteres).", + "diagnostics_invalid_page": "Nรบmero de pรกgina invรกlido. Escolha dentro do intervalo exibido.", + "diagnostics_is_work_prohibited_now": "Trabalho proibido agora", + "diagnostics_is_shabbat": "Shabat agora", + "diagnostics_is_yom_tov": "Yom Tov agora", + "diagnostics_next_close_at": "Prรณximo fechamento", + "diagnostics_next_open_at": "Prรณxima abertura", + "diagnostics_baseline": "Permissรตes normais recuperadas: restriรงรตes ativas", + "diagnostics_compare_previous": "Diferenรงas com as permissรตes intermediรกrias", + "diagnostics_compare_baseline": "Diferenรงas com a base de recuperaรงรฃo", + "diagnostics_concurrent_change": "As permissรตes salvas mudaram durante a verificaรงรฃo. Atualize antes de comparar.", + "diagnostics_operation": "รšltima operaรงรฃo: aรงรฃo / etapa / resultado", + "diagnostics_previous_rights": "Permissรตes intermediรกrias anteriores: restriรงรตes ativas", + "diagnostics_invalid_search": "Busca muito longa. Digite atรฉ 80 caracteres.", + "recovery_block_unverified_snapshot": "โš ๏ธ As permissรตes salvas vรชm de uma versรฃo anterior e nรฃo foram verificadas pela nova leitura. Elas podem diferir das configuraรงรตes anteriores do grupo, por isso a restauraรงรฃo estรก desativada.\nFora do Shabat, de Yom Tov ou de um bloqueio ativo: configure as permissรตes desejadas no Telegram, toque em Atualizar aqui, confira-as e confirme o uso das permissรตes atuais. O estado anterior serรก mantido para diagnรณstico.", + "permission_error_unverified": "O estado salvo pela versรฃo anterior precisa de revisรฃo de um administrador. Abra a resoluรงรฃo de problemas de permissรตes nas configuraรงรตes do grupo; nรฃo restaure esse estado anterior.", + "diagnostics_snapshot_verification": "Verificaรงรฃo das permissรตes salvas", + "diagnostics_snapshot_verified": "Salvas apรณs uma leitura verificada", + "diagnostics_snapshot_unverified": "Estado de uma versรฃo anterior - requer revisรฃo do administrador", + "permission_error_readback": "โš ๏ธ A alteraรงรฃo de permissรตes foi enviada, mas o resultado nรฃo foi verificado. As permissรตes podem jรก ter mudado. Confira no Telegram e na resoluรงรฃo de problemas de permissรตes antes de tentar novamente." +} diff --git a/resources/locales/ru.json b/resources/locales/ru.json new file mode 100644 index 0000000..948e895 --- /dev/null +++ b/resources/locales/ru.json @@ -0,0 +1,592 @@ +{ + "shabbat": "๐Ÿ•ฏ ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ", + "zmanim": "๐Ÿ•’ ะ’ั€ะตะผะตะฝะฐ ะดะฝั", + "daf": "๐Ÿ“– ะ”ะฐั„ ะนะพะผะธ", + "omer": "๐ŸŒพ ะกั‡ั‘ั‚ ะžะผะตั€ะฐ", + "tefillin": "๐Ÿ”” ะะฐะฟะพะผะธะฝะฐะฝะธั ะพ ั‚ั„ะธะปะธะฝ", + "add": "โž• ะ”ะพะฑะฐะฒะธั‚ัŒ ะผะตะฝั ะฒ ะณั€ัƒะฟะฟัƒ", + "groups": "๐Ÿ‘ฅ ะœะพะธ ะณั€ัƒะฟะฟั‹", + "preferences": "โš™๏ธ ะœะพะธ ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั", + "donate": "โญ ะŸะพะดะดะตั€ะถะฐั‚ัŒ ะฑะพั‚ะฐ", + "source": "๐Ÿ’ป ะ˜ัั…ะพะดะฝั‹ะน ะบะพะด ะธ ะปะธั†ะตะฝะทะธั", + "back": "โ†ฉ๏ธ ะะฐะทะฐะด", + "cancel": "ะžั‚ะผะตะฝะฐ", + "confirm": "โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ัะพั…ั€ะฐะฝะตะฝะธะต", + "language": "๐ŸŒ ะฏะทั‹ะบ", + "location": "๐Ÿ“ ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต ะธ ั‡ะฐัะพะฒะพะน ะฟะพัั", + "candles": "๐Ÿ•ฏ ะ—ะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน", + "havdalah": "๐ŸŒ™ ะะฒะดะฐะปะฐ", + "messages": "โœ‰๏ธ ะกะพะพะฑั‰ะตะฝะธั ะธ ะพั„ะพั€ะผะปะตะฝะธะต", + "schedule": "๐Ÿ—“ ะ‘ะปะธะถะฐะนัˆะธะต ะดะตะนัั‚ะฒะธั", + "status": "๐Ÿ›  ะกะพัั‚ะพัะฝะธะต ะธ ะพัˆะธะฑะบะธ", + "preview": "๐Ÿ‘€ ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต", + "reset": "ะกะฑั€ะพัะธั‚ัŒ ัั‚ะพั‚ ะผะพะดัƒะปัŒ", + "edit": "โœ๏ธ ะ˜ะทะผะตะฝะธั‚ัŒ ะฝะฐัั‚ั€ะพะนะบะธ", + "on": "ะ’ะบะปัŽั‡ะตะฝะพ", + "off": "ะ’ั‹ะบะปัŽั‡ะตะฝะพ", + "today": "ะกะตะณะพะดะฝั", + "tomorrow": "ะ—ะฐะฒั‚ั€ะฐ", + "other_date": "ะ”ั€ัƒะณะฐั ะดะฐั‚ะฐ", + "share": "ะŸะพะดะตะปะธั‚ัŒัั", + "short": "ะšั€ะฐั‚ะบะพ", + "full": "ะŸะพะปะฝะพัั‚ัŒัŽ", + "next": "ะกะปะตะดัƒัŽั‰ะฐั ัั‚ั€ะฐะฝะธั†ะฐ", + "previous": "ะŸั€ะตะดั‹ะดัƒั‰ะฐั ัั‚ั€ะฐะฝะธั†ะฐ", + "no_groups": "ะะต ะฝะฐะนะดะตะฝั‹ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹, ะฒ ะบะพั‚ะพั€ั‹ั… ะฒั‹ ัะตะนั‡ะฐั ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€. ะ’ ะทะฝะฐะบะพะผะพะน ะฑะพั‚ัƒ ะณั€ัƒะฟะฟะต ะธัะฟะพะปัŒะทัƒะนั‚ะต /settings.", + "group_panel": "๐Ÿ‘ฅ {{title}}\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nะ ะตะถะธะผ: {{mode}}\nะกะปะตะดัƒัŽั‰ะตะต ะดะตะนัั‚ะฒะธะต: {{next}}\n\nะ’ั‹ะฑะตั€ะธั‚ะต ั€ะฐะทะดะตะป ะฝะฐัั‚ั€ะพะนะบะธ ะณั€ัƒะฟะฟั‹. ะŸั€ะพะฒะตั€ัŒั‚ะต ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะธะทะผะตะฝะตะฝะธั ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", + "personal_panel": "โš™๏ธ ะœะพะธ ะฝะฐัั‚ั€ะพะนะบะธ\n\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nะ’ั‹ะฑะตั€ะธั‚ะต ัะทั‹ะบ, ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต ะธ ะผะตั‚ะพะดั‹ ั€ะฐัั‡ั‘ั‚ะฐ. ะžะฝะธ ะพะฟั€ะตะดะตะปััŽั‚ ะฒะฐัˆะต ะปะธั‡ะฝะพะต ั€ะฐัะฟะธัะฐะฝะธะต.", + "denied": "ะ”ะตะนัั‚ะฒะธะต ะฝะตะดะพัั‚ัƒะฟะฝะพ: ะฟั€ะฐะฒะฐ ะธะปะธ ัั€ะพะบ ะดะตะนัั‚ะฒะธั ะฝะฐัั‚ั€ะพะตะบ ะธะทะผะตะฝะธะปะธััŒ. ะžั‚ะบั€ะพะนั‚ะต ะฟะฐะฝะตะปัŒ ะณั€ัƒะฟะฟั‹ ะทะฐะฝะพะฒะพ.", + "saved": "โœ… ะกะพั…ั€ะฐะฝะตะฝะพ ะดะปั {{title}}. ะ‘ัƒะดัƒั‰ะธะต ะดะตะนัั‚ะฒะธั ะฟะตั€ะตัั‡ะธั‚ะฐะฝั‹; ะพะฑัะทะฐั‚ะตะปัŒัั‚ะฒะพ ะฒะพััั‚ะฐะฝะพะฒะธั‚ัŒ ะฟั€ะฐะฒะฐ ัะพั…ั€ะฐะฝะตะฝะพ.", + "language_choose": "ะ’ั‹ะฑะตั€ะธั‚ะต ัะทั‹ะบ ะดะปั ัั‚ะธั… ะฝะฐัั‚ั€ะพะตะบ. ะ›ะธั‡ะฝะฐั ะฟะฐะฝะตะปัŒ ัะพั…ั€ะฐะฝัะตั‚ ะฒะฐัˆ ะปะธั‡ะฝั‹ะน ัะทั‹ะบ.", + "candle_choose": "ะ’ั‹ะฑะตั€ะธั‚ะต ั„ะฐะบั‚ะธั‡ะตัะบะพะต ั‡ะธัะปะพ ะผะธะฝัƒั‚ ะดะพ ะทะฐั…ะพะดะฐ ัะพะปะฝั†ะฐ ะธะปะธ ะดะพะบัƒะผะตะฝั‚ะธั€ะพะฒะฐะฝะฝั‹ะน ะพะฑั‹ั‡ะฐะน ะณะพั€ะพะดะฐ. ะกะบั€ั‹ั‚ะฐั ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝะฐั ะผะธะฝัƒั‚ะฐ ะฝะต ะฟั€ะธะฑะฐะฒะปัะตั‚ัั.", + "city_custom": "ะžะฑั‹ั‡ะฐะน ะณะพั€ะพะดะฐ", + "havdalah_choose": "ะ’ั‹ะฑะตั€ะธั‚ะต ัƒะณะพะป ัะพะปะฝั†ะฐ ะธะปะธ ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ะผะธะฝัƒั‚ั‹ ะฟะพัะปะต ะทะฐะบะฐั‚ะฐ. 72 ะผะธะฝัƒั‚ั‹ ะพะทะฝะฐั‡ะฐัŽั‚ ะธะผะตะฝะฝะพ 72 ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ะผะธะฝัƒั‚ั‹; ะผะตั‚ะพะด ะธ ะทะฝะฐั‡ะตะฝะธะต ั…ั€ะฐะฝัั‚ัั ะพั‚ะดะตะปัŒะฝะพ.", + "degrees": "8,5 ะณั€ะฐะดัƒัะฐ", + "minutes": "{{value}} ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ั… ะผะธะฝัƒั‚", + "location_choose": "ะะฐะนะดะธั‚ะต ะณะพั€ะพะด, ะฒั‹ะฑะตั€ะธั‚ะต ั€ะตะทัƒะปัŒั‚ะฐั‚, ะฟั€ะพะฒะตั€ัŒั‚ะต ั‡ะฐัะพะฒะพะน ะฟะพัั ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ัะพั…ั€ะฐะฝะตะฝะธะต ะดะปั ัƒะบะฐะทะฐะฝะฝะพะน ะณั€ัƒะฟะฟั‹ ะธะปะธ ะปะธั‡ะฝั‹ั… ะฝะฐัั‚ั€ะพะตะบ. ะŸะพะธัะบ ะธ ะฟัƒะฑะปะธะบะฐั†ะธั ะฝะธั‡ะตะณะพ ะฝะต ัะพั…ั€ะฐะฝััŽั‚.", + "inline_search": "ะะฐะนั‚ะธ ะณะพั€ะพะด ั‡ะตั€ะตะท ะธะฝะปะฐะนะฝ", + "city_search": "ะŸะพะธัะบ ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต", + "coordinates": "ะšะพะพั€ะดะธะฝะฐั‚ั‹ / ะพั‚ะฟั€ะฐะฒะบะฐ ะณะตะพะฟะพะทะธั†ะธะธ", + "timezone": "ะงะฐัะพะฒะพะน ะฟะพัั ะฒั€ัƒั‡ะฝัƒัŽ", + "calendar_israel": "ะšะฐะปะตะฝะดะฐั€ัŒ ะ˜ะทั€ะฐะธะปั", + "calendar_diaspora": "ะšะฐะปะตะฝะดะฐั€ัŒ ะดะธะฐัะฟะพั€ั‹", + "copy_personal": "ะกะบะพะฟะธั€ะพะฒะฐั‚ัŒ ะผะพะธ ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั (ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€)", + "geonames_missing": "ะŸะพะธัะบ GeoNames ะฒั‹ะบะปัŽั‡ะตะฝ. ะžะฟะตั€ะฐั‚ะพั€ ะดะพะปะถะตะฝ ะทะฐะดะฐั‚ัŒ GEONAMES_USERNAME ะธ ะฒะบะปัŽั‡ะธั‚ัŒ ะฒะตะฑ-ัะตั€ะฒะธัั‹. ะ’ั‹ะฑะตั€ะธั‚ะต ะธะทะฒะตัั‚ะฝั‹ะน ะณะพั€ะพะด ะธะปะธ ัƒะบะฐะถะธั‚ะต ะบะพะพั€ะดะธะฝะฐั‚ั‹ ะฝะธะถะต.", + "city_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต /city {{token}} ะฝะฐะทะฒะฐะฝะธะต ะณะพั€ะพะดะฐ ะฝะฐ ัะทั‹ะบะต, ะฟะพะดะดะตั€ะถะธะฒะฐะตะผะพะผ ะฟะพัั‚ะฐะฒั‰ะธะบะพะผ. ะญั‚ะพั‚ ั‚ะพะบะตะฝ ะพั‚ะฝะพัะธั‚ัั ั‚ะพะปัŒะบะพ ะบ ั‚ะตะบัƒั‰ะตะผัƒ ะฟั€ะพั†ะตัััƒ ะฝะฐัั‚ั€ะพะนะบะธ.", + "coordinates_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต /coordinates {{token}} ัˆะธั€ะพั‚ะฐ ะดะพะปะณะพั‚ะฐ ั‡ะฐัะพะฒะพะน-ะฟะพัั-IANA ะฝะฐะทะฒะฐะฝะธะต. ะŸั€ะธะผะตั€: /coordinates {{token}} 31.76904 35.21633 Asia/Jerusalem ะ˜ะตั€ัƒัะฐะปะธะผ. ะ”ะปั GPS ะพั‚ะฟั€ะฐะฒัŒั‚ะต /location {{token}} ั‡ะฐัะพะฒะพะน-ะฟะพัั-IANA, ะทะฐั‚ะตะผ ะพั‚ะฒะตั‚ัŒั‚ะต ะฝะฐ ัะพะพะฑั‰ะตะฝะธะต ะฑะพั‚ะฐ ะณะตะพะฟะพะทะธั†ะธะตะน Telegram.\nะ•ัะปะธ GeoNames ะฝะฐัั‚ั€ะพะตะฝ, ะฒะผะตัั‚ะพ ั‡ะฐัะพะฒะพะณะพ ะฟะพััะฐ ะผะพะถะฝะพ ัƒะบะฐะทะฐั‚ัŒ auto; ะฟั€ะตะดะปะฐะณะฐะตะผั‹ะน ะฟะพัั IANA ะฒัะตะณะดะฐ ะฟะพะบะฐะทั‹ะฒะฐะตั‚ัั ะดะพ ัะพั…ั€ะฐะฝะตะฝะธั.", + "timezone_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต /timezone {{token}} ั‚ะตะบัั‚ ะดะปั ะฟะพะธัะบะฐ ะธะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ะฐ ั‡ะฐัะพะฒะพะณะพ ะฟะพััะฐ IANA. ะ’ั‹ะฑะพั€ ั‚ั€ะตะฑัƒะตั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะธ ะฝะต ะผะตะฝัะตั‚ ะบะพะพั€ะดะธะฝะฐั‚ั‹.", + "location_preview": "ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธั ะดะปั {{title}}\nะ“ะพั€ะพะด: {{location}}\nะกั‚ั€ะฐะฝะฐ: {{country}}\nะ ะตะณะธะพะฝ: {{region}}\nะจะธั€ะพั‚ะฐ: {{latitude}}\nะ”ะพะปะณะพั‚ะฐ: {{longitude}}\nะ˜ะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ GeoNames: {{geoname}}\nะงะฐัะพะฒะพะน ะฟะพัั IANA: {{timezone}}\nะขะตะบัƒั‰ะตะต ัะผะตั‰ะตะฝะธะต UTC: {{offset}} (ั‚ะพะปัŒะบะพ ะดะปั ะพั‚ะพะฑั€ะฐะถะตะฝะธั)\n{{warning}}", + "timezone_warning": "ะ’ั‹ะฑั€ะฐะฝะฝั‹ะน ะฒั€ัƒั‡ะฝัƒัŽ ั‡ะฐัะพะฒะพะน ะฟะพัั ะผะพะถะตั‚ ะพั‚ะปะธั‡ะฐั‚ัŒัั ะพั‚ ะณะตะพะณั€ะฐั„ะธั‡ะตัะบะพะณะพ. ะšะพะพั€ะดะธะฝะฐั‚ั‹ ะฝะต ะธะทะผะตะฝะธะปะธััŒ.", + "no_results": "ะŸะพ ัั‚ะพะผัƒ ะทะฐะฟั€ะพััƒ ะณะพั€ะพะดะฐ ะฝะต ะฝะฐะนะดะตะฝั‹.", + "search_error": "ะŸะพะธัะบ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธั ะฝะตะดะพัั‚ัƒะฟะตะฝ: {{error}}", + "select_location": "ะŸั€ะพะฒะตั€ัŒั‚ะต ะผะตัั‚ะพ; ะฝะฐัั‚ั€ะพะนะบะธ ัะพั…ั€ะฐะฝััŽั‚ัั ั‚ะพะปัŒะบะพ ะฟะพัะปะต ะฒะฐัˆะตะณะพ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต.", + "select_private": "ะŸั€ะพะดะพะปะถะธั‚ัŒ ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต ั ะฑะพั‚ะพะผ", + "public_location": "ะžะฑั‰ะตะดะพัั‚ัƒะฟะฝะฐั ะธะฝั„ะพั€ะผะฐั†ะธั ะพ ะผะตัั‚ะต. ะŸัƒะฑะปะธะบะฐั†ะธั ั€ะตะทัƒะปัŒั‚ะฐั‚ะฐ ะฝะธะบะพะณะดะฐ ะฝะต ะผะตะฝัะตั‚ ะปะธั‡ะฝั‹ะต ะฝะฐัั‚ั€ะพะนะบะธ ะธะปะธ ะฝะฐัั‚ั€ะพะนะบะธ ะณั€ัƒะฟะฟั‹.", + "provider_error": "ะŸั€ะพะฒะตั€ะตะฝะฝั‹ะต ะฒั€ะตะผะตะฝะฐ ะฝะตะดะพัั‚ัƒะฟะฝั‹: {{error}}. ะ’ั€ะตะผั ะฝะต ะฑั‹ะปะพ ะฟั€ะธะดัƒะผะฐะฝะพ.", + "unavailable": "ะะตะดะพัั‚ัƒะฟะฝะพ ะดะปั ัั‚ะพะณะพ ะผะตัั‚ะฐ ะธะปะธ ะดะฐั‚ั‹", + "shabbat_view": "ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ\n๐Ÿ“ {{location}} โ€ข {{timezone}}\nะ—ะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน: {{candles}}\nะ—ะฐะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟั‹: {{close}}\nะะฒะดะฐะปะฐ: {{havdalah}}\nะžั‚ะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟั‹: {{open}}\nะขะตะบัƒั‰ะตะต ัะพะฑั‹ั‚ะธะต: {{current}}\nะกะปะตะดัƒัŽั‰ะตะต ัะพะฑั‹ั‚ะธะต: {{next}}\nะšะฐะปะตะฝะดะฐั€ัŒ: {{calendar}}\nะกะฒะตั‡ะธ: ะทะฐ {{candle_minutes}} ะผะธะฝัƒั‚ ะดะพ ะทะฐะบะฐั‚ะฐ. ะะฒะดะฐะปะฐ: {{value}} {{method}}.\nะ˜ัั‚ะพั‡ะฝะธะบ: Hebcal", + "date_view": "{{module}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n๐Ÿ“… {{date}} ยท {{hdate}}\n\n{{content}}", + "omer_inactive": "๐ŸŒพ ะกั‡ั‘ั‚ ะžะผะตั€ะฐ\n\nะะฐ ัั‚ัƒ ะดะฐั‚ัƒ ัะตะทะพะฝ ัั‡ั‘ั‚ะฐ ะžะผะตั€ะฐ ะฝะต ะดะตะนัั‚ะฒัƒะตั‚.", + "help_text": "ะšะพะผะฐะฝะดั‹\n/start - ะณะปะฐะฒะฝะพะต ะผะตะฝัŽ\n/shabat, /shabbat - ะฒั€ะตะผั ะจะฐะฑะฑะฐั‚ะฐ\n/zmanim [YYYY-MM-DD] - ะฒั€ะตะผะตะฝะฐ ะดะฝั\n/daf [YYYY-MM-DD] - ะ”ะฐั„ ะนะพะผะธ\n/omer - ัั‡ั‘ั‚ ะžะผะตั€ะฐ\n/groups - ะณั€ัƒะฟะฟั‹, ะบะพั‚ะพั€ั‹ะผะธ ะฒั‹ ัƒะฟั€ะฐะฒะปัะตั‚ะต\n/settings - ะปะธั‡ะฝะฐั ะฟะฐะฝะตะปัŒ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝะพะน ะณั€ัƒะฟะฟั‹ (ะดะปั ะตั‘ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ)\n/preferences - ะปะธั‡ะฝั‹ะต ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั\n/language, /lang - ะฒั‹ะฑะพั€ ัะทั‹ะบะฐ\n/source, /license, /version - ัะฒะตะดะตะฝะธั ะพ ั‚ะตะบัƒั‰ะตะน ัะฑะพั€ะบะต\n/donate - ะฟะพะดะดะตั€ะถะบะฐ ะทะฒั‘ะทะดะฐะผะธ Telegram\n\nะ˜ะฝะปะฐะนะฝ: shabat / shabbat / ืฉื‘ืช, zmanim, daf, omer ะธ loc ะฝะฐะทะฒะฐะฝะธะต-ะณะพั€ะพะดะฐ. ะ˜ะทะผะตะฝะตะฝะธะต ะฝะฐัั‚ั€ะพะตะบ ะณั€ัƒะฟะฟั‹ ั‚ั€ะตะฑัƒะตั‚ ะดะตะนัั‚ะฒัƒัŽั‰ะธั… ะฟั€ะฐะฒ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ. ะ ะฐััั‹ะปะบะธ ะธ ะธะฝัั‚ั€ัƒะผะตะฝั‚ั‹ ะพะฟะตั€ะฐั‚ะพั€ะฐ ะดะพัั‚ัƒะฟะฝั‹ ั‚ะพะปัŒะบะพ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐะผ ัะธัั‚ะตะผั‹.", + "mode": "ะ ะตะถะธะผ ั€ะฐะฑะพั‚ั‹", + "mode_off": "ะ’ั‹ะบะปัŽั‡ะตะฝะพ", + "mode_messages": "ะขะพะปัŒะบะพ ัะพะพะฑั‰ะตะฝะธั", + "mode_lock": "ะขะพะปัŒะบะพ ะฑะปะพะบะธั€ะพะฒะบะฐ", + "mode_lock_messages": "ะ‘ะปะพะบะธั€ะพะฒะบะฐ ะธ ัะพะพะฑั‰ะตะฝะธั", + "shabbat_enabled": "ะ ะฐะฑะพั‚ะฐ ะฒ ะจะฐะฑะฑะฐั‚", + "holidays_enabled": "ะ ะฐะฑะพั‚ะฐ ะฒ ะฟั€ะฐะทะดะฝะธะบะธ", + "module_panel": "{{module}} ะดะปั {{title}}\n{{settings}}\nะะพะฒั‹ะต ะผะพะดัƒะปะธ ัƒะฒะตะดะพะผะปะตะฝะธะน ะฒั‹ะบะปัŽั‡ะตะฝั‹ ะดะพ ัะฒะฝะพะณะพ ะฒะบะปัŽั‡ะตะฝะธั.", + "edit_prompt": "ะะฐัั‚ั€ะพะนะบะฐ {{module}} ะดะปั {{title}}. ะžั‚ะฟั€ะฐะฒัŒั‚ะต /configure {{token}}, ะทะฐั‚ะตะผ ะฟะฐั€ั‹ field=value ั‡ะตั€ะตะท ั‚ะพั‡ะบัƒ ั ะทะฐะฟัั‚ะพะน. ะŸะพะดะดะตั€ะถะธะฒะฐะตะผั‹ะต ะฟะพะปั ะธ ั‚ะตะบัƒั‰ะธะต ะทะฝะฐั‡ะตะฝะธั:\n{{fields}}\nะญะปะตะผะตะฝั‚ั‹ ัะฟะธัะบะพะฒ ั€ะฐะทะดะตะปััŽั‚ัั ะทะฐะฟัั‚ั‹ะผะธ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะธะทะผะตะฝะตะฝะธั ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", + "changes_preview": "ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฝะฐัั‚ั€ะพะตะบ ะดะปั {{title}}\n{{changes}}", + "date_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต /viewdate {{token}} YYYY-MM-DD. ะ”ะฐั‚ะฐ ะทะฐะดะฐั‘ั‚ัั ัะฒะฝะพ, ั ัƒั‡ั‘ั‚ะพะผ ั‡ะฐัะพะฒะพะณะพ ะฟะพััะฐ ัั‚ะธั… ะฝะฐัั‚ั€ะพะตะบ.", + "schedule_view": "ะ‘ะปะธะถะฐะนัˆะธะต ะดะตะนัั‚ะฒะธั ะดะปั {{title}}\n{{content}}\nะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะฟั€ะฐะฒ ะธะผะตะตั‚ ะฟั€ะธะพั€ะธั‚ะตั‚ ะฟะตั€ะตะด ะพะฑั‹ั‡ะฝั‹ะผะธ ัะพะพะฑั‰ะตะฝะธัะผะธ.", + "status_view": "ะกะพัั‚ะพัะฝะธะต {{title}}\nะ’ั€ะตะผั UTC: {{utc}}\nะœะตัั‚ะฝะพะต ะฒั€ะตะผั: {{local}}\nะŸะพะบั€ั‹ั‚ะธะต ะบััˆะฐ: {{coverage}}\nะŸะพัะปะตะดะฝัั ัƒัะฟะตัˆะฝะฐั ะฟั€ะพะฒะตั€ะบะฐ ะฟะพัั‚ะฐะฒั‰ะธะบะฐ: {{fetched}}\nะขะตะบัƒั‰ะตะต ัะพะฑั‹ั‚ะธะต: {{current}}\nะกะปะตะดัƒัŽั‰ะตะต ัะพะฑั‹ั‚ะธะต: {{next}}\nะžัˆะธะฑะบะฐ: {{error}}", + "reset_preview": "ะกะฑั€ะพัะธั‚ัŒ ั‚ะพะปัŒะบะพ {{module}} ะดะปั {{title}}? ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต, ัะทั‹ะบ ะธ ะดั€ัƒะณะธะต ะผะพะดัƒะปะธ ะฝะต ะธะทะผะตะฝัั‚ัั.", + "suspend": "ะŸั€ะธะพัั‚ะฐะฝะพะฒะธั‚ัŒ ั‚ั„ะธะปะธะฝ ะฝะฐ ัะตะณะพะดะฝั", + "not_private": "ะžั‚ะบั€ะพะนั‚ะต ะปะธั‡ะฝัƒัŽ ะฟะฐะฝะตะปัŒ ะณั€ัƒะฟะฟั‹, ั‡ั‚ะพะฑั‹ ะธะทะผะตะฝะธั‚ัŒ ะฝะฐัั‚ั€ะพะนะบะธ.", + "date_invalid": "ะฃะบะฐะถะธั‚ะต ััƒั‰ะตัั‚ะฒัƒัŽั‰ัƒัŽ ะดะฐั‚ัƒ ะฒ ั„ะพั€ะผะฐั‚ะต YYYY-MM-DD.", + "hours_invalid": "ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะผะตัั‚ะฝะพะต ะฒั€ะตะผั HH:mm ะธ ะฟะพะดะดะตั€ะถะธะฒะฐะตะผั‹ะต ะทะฝะฐั‡ะตะฝะธั.", + "location_label": "ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต", + "alotHaShachar": "ะ ะฐััะฒะตั‚", + "misheyakir": "ะกะฐะผะพะต ั€ะฐะฝะฝะตะต ะฒั€ะตะผั ั‚ะฐะปะธั‚ะฐ ะธ ั‚ั„ะธะปะธะฝ", + "sunrise": "ะ’ะพัั…ะพะด ัะพะปะฝั†ะฐ", + "sofZmanShma": "ะŸะพัะปะตะดะฝะตะต ะฒั€ะตะผั ะจะผะฐ (ะ“ะ ะ)", + "sofZmanShmaMGA": "ะŸะพัะปะตะดะฝะตะต ะฒั€ะตะผั ะจะผะฐ (ะœะฐะณะตะฝ ะะฒั€ะฐะฐะผ)", + "sofZmanTfilla": "ะŸะพัะปะตะดะฝะตะต ะฒั€ะตะผั ะผะพะปะธั‚ะฒั‹ (ะ“ะ ะ)", + "sofZmanTfillaMGA": "ะŸะพัะปะตะดะฝะตะต ะฒั€ะตะผั ะผะพะปะธั‚ะฒั‹ (ะœะฐะณะตะฝ ะะฒั€ะฐะฐะผ)", + "chatzot": "ะŸะพะปะดะตะฝัŒ", + "minchaGedola": "ะกะฐะผะพะต ั€ะฐะฝะฝะตะต ะฒั€ะตะผั ะผะธะฝั…ะธ", + "minchaKetana": "ะœะธะฝั…ะฐ ะบั‚ะฐะฝะฐ", + "plagHaMincha": "ะŸะปะฐะณ ะผะธะฝั…ะฐ", + "sunset": "ะ—ะฐั…ะพะด ัะพะปะฝั†ะฐ", + "tzeit85deg": "ะ’ั‹ั…ะพะด ะทะฒั‘ะทะด (8,5ยฐ)", + "tzeit42min": "ะ’ั‹ั…ะพะด ะทะฒั‘ะทะด (42 ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ะผะธะฝัƒั‚ั‹)", + "tzeit50min": "ะ’ั‹ั…ะพะด ะทะฒั‘ะทะด (50 ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ั… ะผะธะฝัƒั‚)", + "tzeit72min": "ะ’ั‹ั…ะพะด ะทะฒั‘ะทะด (72 ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ะผะธะฝัƒั‚ั‹)", + "chatzotNight": "ะŸะพะปะฝะพั‡ัŒ", + "settings_search": "ะคะธะปัŒั‚ั€ ะณั€ัƒะฟะฟ: /groups ั‚ะตะบัั‚-ะฟะพะธัะบะฐ", + "field_language": "ะฏะทั‹ะบ", + "field_candle_minutes": "ะœะธะฝัƒั‚ั‹ ะดะพ ะทะฐะถะธะณะฐะฝะธั ัะฒะตั‡ะตะน (city = ะพะฑั‹ั‡ะฐะน ะณะพั€ะพะดะฐ)", + "field_havdalah_method": "ะœะตั‚ะพะด ะฐะฒะดะฐะปั‹", + "field_havdalah_value": "ะ—ะฝะฐั‡ะตะฝะธะต ะดะปั ะฐะฒะดะฐะปั‹", + "field_close_before_candles_minutes": "ะ—ะฐะบั€ั‹ั‚ัŒ ะดะพ ัะฒะตั‡ะตะน (ะผะธะฝัƒั‚ั‹)", + "field_open_after_havdalah_minutes": "ะžั‚ะบั€ั‹ั‚ัŒ ะฟะพัะปะต ะฐะฒะดะฐะปั‹ (ะผะธะฝัƒั‚ั‹)", + "field_target_topic_id": "ะ˜ะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ ั‚ะตะผั‹ ั„ะพั€ัƒะผะฐ (off ะฒั‹ะบะปัŽั‡ะฐะตั‚)", + "field_enabled": "ะ’ะบะปัŽั‡ะตะฝะพ", + "field_time": "ะœะตัั‚ะฝะพะต ะฒั€ะตะผั", + "field_days": "ะ”ะฝะธ ะฝะตะดะตะปะธ: 0 ะฒะพัะบั€ะตัะตะฝัŒะต - 6 ััƒะฑะฑะพั‚ะฐ", + "field_format": "ะคะพั€ะผะฐั‚", + "field_template": "ะขะตะบัั‚ ัˆะฐะฑะปะพะฝะฐ", + "field_fixed_times": "ะคะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ั‡ะฐัั‹", + "field_interval_minutes": "ะ˜ะฝั‚ะตั€ะฒะฐะป ะฒ ะผะธะฝัƒั‚ะฐั… (off ะฒั‹ะบะปัŽั‡ะฐะตั‚, ะผะธะฝะธะผัƒะผ 5)", + "field_window_start": "ะะฐั‡ะฐะปะพ ะผะตัั‚ะฝะพะณะพ ะพะบะฝะฐ", + "field_window_end": "ะšะพะฝะตั† ะผะตัั‚ะฝะพะณะพ ะพะบะฝะฐ", + "field_sunset_offsets": "ะœะธะฝัƒั‚ั‹ ะดะพ ะทะฐะบะฐั‚ะฐ", + "field_max_per_day": "ะœะฐะบัะธะผัƒะผ ะฒ ะดะตะฝัŒ (1โ€“24)", + "field_min_spacing_minutes": "ะœะธะฝะธะผะฐะปัŒะฝั‹ะน ะธะฝั‚ะตั€ะฒะฐะป ะฒ ะผะธะฝัƒั‚ะฐั… (ะฝะต ะผะตะฝะตะต 5)", + "field_quiet_start": "ะะฐั‡ะฐะปะพ ั‚ะธั…ะธั… ั‡ะฐัะพะฒ (off ะฒั‹ะบะปัŽั‡ะฐะตั‚)", + "field_quiet_end": "ะšะพะฝะตั† ั‚ะธั…ะธั… ั‡ะฐัะพะฒ (off ะฒั‹ะบะปัŽั‡ะฐะตั‚)", + "field_chol_hamoed": "ะะฐะฟะพะผะธะฝะฐะฝะธั ะฒ ั…ะพะปัŒ ะฐ-ะผะพัะด", + "field_tisha_bav_policy": "ะ”ะตะฒัั‚ะพะต ะฐะฒะฐ", + "field_sunset_safety_minutes": "ะ—ะฐะฟะฐั ะผะธะฝัƒั‚ ะดะพ ะทะฐะบะฐั‚ะฐ", + "field_mode": "ะ ะฐัะฟะธัะฐะฝะธะต", + "field_after_nightfall_minutes": "ะœะธะฝัƒั‚ั‹ ะฟะพัะปะต ะฒั‹ั…ะพะดะฐ ะทะฒั‘ะทะด", + "field_nightfall_method": "ะœะตั‚ะพะด ะฒั‹ั…ะพะดะฐ ะทะฒั‘ะทะด", + "field_nightfall_value": "ะ—ะฝะฐั‡ะตะฝะธะต ะดะปั ะฒั‹ั…ะพะดะฐ ะทะฒั‘ะทะด", + "field_nusach": "ะัƒัะฐั…", + "field_day_completion": "ะ”ะฝะตะฒะฝะพะต ะฒะพัะฟะพะปะฝะตะฝะธะต", + "field_early_policy": "ะกะปะธัˆะบะพะผ ั€ะฐะฝะพ", + "field_after_open_minutes": "ะขะธั…ะธะน ะธะฝั‚ะตั€ะฒะฐะป ะฟะพัะปะต ะพั‚ะบั€ั‹ั‚ะธั ะณั€ัƒะฟะฟั‹", + "field_times_alert_enabled": "ะŸั€ะตะดะฒะฐั€ะธั‚ะตะปัŒะฝะพะต ัะพะพะฑั‰ะตะฝะธะต ะพ ะฒั€ะตะผะตะฝะธ", + "field_times_alert_time": "ะœะตัั‚ะฝะพะต ะฒั€ะตะผั ัะพะพะฑั‰ะตะฝะธั", + "field_times_alert_mode": "ะ ะฐัะฟะธัะฐะฝะธะต ัะพะพะฑั‰ะตะฝะธั", + "field_times_alert_before_minutes": "ะœะธะฝัƒั‚ั‹ ัะพะพะฑั‰ะตะฝะธั ะดะพ ะทะฐะบั€ั‹ั‚ะธั", + "field_close_message_enabled": "ะกะพะพะฑั‰ะตะฝะธะต ะพ ะทะฐะบั€ั‹ั‚ะธะธ", + "field_open_message_enabled": "ะกะพะพะฑั‰ะตะฝะธะต ะพะฑ ะพั‚ะบั€ั‹ั‚ะธะธ", + "method_degrees": "ะณั€ะฐะดัƒัะพะฒ", + "method_minutes": "ั„ะธะบัะธั€ะพะฒะฐะฝะฝั‹ั… ะผะธะฝัƒั‚ ะฟะพัะปะต ะทะฐะบะฐั‚ะฐ", + "window_end": "ะšะพะฝะตั† ะพะบะฝะฐ", + "no_reminders": "ะ”ะปั ัั‚ะพะณะพ ะดะฝั ะธ ะฝะฐัั‚ั€ะพะตะบ ะฝะตั‚ ะดะพะฟัƒัั‚ะธะผั‹ั… ะฝะฐะฟะพะผะธะฝะฐะฝะธะน.", + "preview_only": "ะขะพะปัŒะบะพ ะปะธั‡ะฝั‹ะน ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€; ัะพะพะฑั‰ะตะฝะธะต ะฒ ะณั€ัƒะฟะฟัƒ ะฝะต ะพั‚ะฟั€ะฐะฒะปัะตั‚ัั.", + "template_panel": "{{title}} - ะกะพะพะฑั‰ะตะฝะธั ะธ ะพั„ะพั€ะผะปะตะฝะธะต", + "template_menu": "{{title}} - {{kind}}\nะ›ะธั‡ะฝั‹ะน ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฝะธั‡ะตะณะพ ะฝะต ะพั‚ะฟั€ะฐะฒะปัะตั‚ ะฒ ะณั€ัƒะฟะฟัƒ.", + "template_kind_close": "ะกะพะพะฑั‰ะตะฝะธะต ะพ ะทะฐะบั€ั‹ั‚ะธะธ", + "template_kind_open": "ะกะพะพะฑั‰ะตะฝะธะต ะพะฑ ะพั‚ะบั€ั‹ั‚ะธะธ", + "template_kind_zmanim": "ะกะพะพะฑั‰ะตะฝะธะต ะพ ะฒั€ะตะผะตะฝะฐั… ะดะฝั", + "template_kind_daf": "ะกะพะพะฑั‰ะตะฝะธะต ะ”ะฐั„ ะนะพะผะธ", + "template_kind_tefillin": "ะกะพะพะฑั‰ะตะฝะธะต ะพ ั‚ั„ะธะปะธะฝ", + "template_kind_omer": "ะกะพะพะฑั‰ะตะฝะธะต ะžะผะตั€ะฐ", + "template_edit": "โœ๏ธ ะ˜ะทะผะตะฝะธั‚ัŒ ั‚ะตะบัั‚ / ะผะตะดะธะฐ", + "template_buttons": "โŒจ๏ธ ะ˜ะทะผะตะฝะธั‚ัŒ ะบะฝะพะฟะบะธ", + "template_preview": "๐Ÿ‘€ ะ›ะธั‡ะฝั‹ะน ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€", + "template_reset": "ะกะฑั€ะพัะธั‚ัŒ ัั‚ะพ ัะพะพะฑั‰ะตะฝะธะต", + "template_test": "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ะขะ•ะกะข ะฒ ะณั€ัƒะฟะฟัƒ (ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต)", + "template_edit_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั‚ะตะบัั‚ ะธะปะธ ะผะตะดะธะฐ ั ัั‚ะธะผ ะฟั€ะตั„ะธะบัะพะผ ะฟะพะดะฟะธัะธ, ะทะฐั‚ะตะผ ะฝะพะฒะพะน ัั‚ั€ะพะบะพะน ะธ ัะพะดะตั€ะถะธะผั‹ะผ:\n/template {{token}}\nะกะพั…ั€ะฐะฝััŽั‚ัั ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต ั‚ะตะบัั‚ะฐ, ะผะตะดะธะฐ ะธ ััƒั‰ะตัั‚ะฒัƒัŽั‰ะธะต ะบะฝะพะฟะบะธ.", + "template_buttons_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต /buttons {{token}}, ะทะฐั‚ะตะผ ะฝะพะฒัƒัŽ ัั‚ั€ะพะบัƒ ะธ JSON-ะผะฐััะธะฒ ัั‚ั€ะพะบ ะบะฝะพะฟะพะบ. ะฃ ะบะฐะถะดะพะน ะบะฝะพะฟะบะธ ั‚ะตะบัั‚ ะธ ะพะดะฝะพ ะดะตะนัั‚ะฒะธะต: HTTPS URL, callback_data ะธะปะธ ะฟะตั€ะตั…ะพะด ะฒ ะธะฝะปะฐะนะฝ. ะะตะพะฑัะทะฐั‚ะตะปัŒะฝั‹ะต ัั‚ะธะปะธ: primary, success, danger.\nะ˜ัั…ะพะดะฝั‹ะน ั‚ะตะบัั‚ะพะฒั‹ะน ั„ะพั€ะผะฐั‚ ั‚ะพะถะต ั€ะฐะฑะพั‚ะฐะตั‚: ะŸะพะดะฟะธััŒ - https://example.org - style:bg_success - icon:123. ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต && ะดะปั ะบะฝะพะฟะพะบ ะฒ ะพะดะฝะพะน ัั‚ั€ะพะบะต ะธะปะธ ะฝะพะฒัƒัŽ ัั‚ั€ะพะบัƒ. ะ”ั€ัƒะณะธะต ะดะตะนัั‚ะฒะธั: alert:ั‚ะตะบัั‚, popup:ั‚ะตะบัั‚, copy:ั‚ะตะบัั‚. ะ”ะฐะฝะฝั‹ะต callback ะพะณั€ะฐะฝะธั‡ะตะฝั‹ 64 ะฑะฐะนั‚ะฐะผะธ.", + "template_cleared": "ะกะพะพะฑั‰ะตะฝะธะต ัะฑั€ะพัˆะตะฝะพ. ะžั‚ะบั€ะพะนั‚ะต ะฟะฐะฝะตะปัŒ ะทะฐะฝะพะฒะพ.", + "template_confirm_test": "โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ะขะ•ะกะข", + "template_test_confirm": "ะฏะฒะฝะพะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต: ะขะ•ะกะขะžะ’ะžะ• ัะพะพะฑั‰ะตะฝะธะต ะฑัƒะดะตั‚ ะพั‚ะฟั€ะฐะฒะปะตะฝะพ ะฒ {{title}}. ะญั‚ะพ ะดะตะนัั‚ะฒะธะต ะพั‚ะดะตะปัŒะฝะพ ะพั‚ ะปะธั‡ะฝะพะณะพ ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ะฐ.", + "template_test_wait": "ะŸะพะดะพะถะดะธั‚ะต ะฟัั‚ัŒ ะผะธะฝัƒั‚ ะฟะตั€ะตะด ัะปะตะดัƒัŽั‰ะธะผ ั‚ะตัั‚ะพะผ ะฒ ัั‚ะพะน ะณั€ัƒะฟะฟะต.", + "template_cancelled": "ะขะตัั‚ ะพั‚ะผะตะฝั‘ะฝ; ัะพะพะฑั‰ะตะฝะธะต ะฒ ะณั€ัƒะฟะฟัƒ ะฝะต ะพั‚ะฟั€ะฐะฒะปะตะฝะพ.", + "template_saved": "ะกะพะพะฑั‰ะตะฝะธะต ัะพั…ั€ะฐะฝะตะฝะพ. ะžั‚ะบั€ะพะนั‚ะต ะฟะฐะฝะตะปัŒ ะทะฐะฝะพะฒะพ ะดะปั ะปะธั‡ะฝะพะณะพ ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ะฐ.", + "template_remove_text": "๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ั‚ะพะปัŒะบะพ ั‚ะตะบัั‚", + "template_remove_media": "๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ั‚ะพะปัŒะบะพ ะผะตะดะธะฐ", + "template_remove_buttons": "๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ั‚ะพะปัŒะบะพ ะบะฝะพะฟะบะธ", + "gps_prompt": "ะžั‚ะฒะตั‚ัŒั‚ะต ะธะผะตะฝะฝะพ ะฝะฐ ัั‚ะพ ัะพะพะฑั‰ะตะฝะธะต ะณะตะพะฟะพะทะธั†ะธะตะน Telegram ะดะปั {{title}}. ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะธัะฟะพะปัŒะทัƒะตั‚ ั‡ะฐัะพะฒะพะน ะฟะพัั {{timezone}}. ะ“ะตะพะฟะพะทะธั†ะธั ะฑะตะท ัั‚ะพะณะพ ะพั‚ะฒะตั‚ะฐ ะฝะต ะฒั‹ะฑะธั€ะฐะตั‚ ะฟั€ะพั†ะตัั ะฝะฐัั‚ั€ะพะนะบะธ.", + "gps_reply_required": "ะะฐั‡ะฝะธั‚ะต ะพั‚ะฟั€ะฐะฒะบัƒ GPS ะบะพะผะฐะฝะดะพะน /location ั ั‚ะพะบะตะฝะพะผ, ะทะฐั‚ะตะผ ะพั‚ะฒะตั‚ัŒั‚ะต ะฝะฐ ะทะฐะฟั€ะพั ะฑะพั‚ะฐ ัะฒะพะตะน ะณะตะพะฟะพะทะธั†ะธะตะน. ะ“ั€ัƒะฟะฟะฐ ะฝะต ะฒั‹ะฑะธั€ะฐะตั‚ัั ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ.", + "location_attribution": "ะ”ะฐะฝะฝั‹ะต ะพ ะผะตัั‚ะฐั…: GeoNames (CC-BY-4.0).", + "timezone_auto": "ะžะฟั€ะตะดะตะปะธั‚ัŒ ั‡ะฐัะพะฒะพะน ะฟะพัั ะฟะพ ะผะตัั‚ัƒ ั‡ะตั€ะตะท GeoNames", + "auto_timezone_missing": "ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะต ะพะฟั€ะตะดะตะปะตะฝะธะต ะฒั‹ะบะปัŽั‡ะตะฝะพ: GeoNames ะฝะต ะฝะฐัั‚ั€ะพะตะฝ. ะ’ะผะตัั‚ะพ auto ัƒะบะฐะถะธั‚ะต ะธะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ IANA, ะฝะฐะฟั€ะธะผะตั€ Asia/Jerusalem.", + "command_start": "๐Ÿ  ะžั‚ะบั€ั‹ั‚ัŒ ะณะปะฐะฒะฝะพะต ะผะตะฝัŽ", + "command_preferences": "โš™๏ธ ะ’ะฐัˆะธ ะปะธั‡ะฝั‹ะต ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั", + "command_lang": "๐ŸŒ ะ’ั‹ะฑั€ะฐั‚ัŒ ัะทั‹ะบ", + "command_groups": "๐Ÿ‘ฅ ะ“ั€ัƒะฟะฟั‹, ะบะพั‚ะพั€ั‹ะผะธ ะฒั‹ ัƒะฟั€ะฐะฒะปัะตั‚ะต", + "command_help": "โ„น๏ธ ะšะพะผะฐะฝะดั‹ ะธ ะฟะพะผะพั‰ัŒ", + "command_shabbat": "๐Ÿ•ฏ ะ’ั€ะตะผั ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ", + "command_zmanim": "๐Ÿ•’ ะ“ะฐะปะฐั…ะธั‡ะตัะบะธะต ะฒั€ะตะผะตะฝะฐ ะดะฝั", + "command_daf": "๐Ÿ“– ะกะตะณะพะดะฝััˆะฝะธะน ะ”ะฐั„ ะนะพะผะธ", + "command_omer": "๐ŸŒพ ะกั‡ะธั‚ะฐั‚ัŒ ะžะผะตั€", + "command_source": "๐Ÿ’ป ะกะพะพั‚ะฒะตั‚ัั‚ะฒัƒัŽั‰ะธะน ะธัั…ะพะดะฝั‹ะน ะบะพะด ะธ ะปะธั†ะตะฝะทะธั", + "command_license": "๐Ÿ“œ ะ›ะธั†ะตะฝะทะธั ะฟั€ะพะตะบั‚ะฐ", + "command_version": "๐Ÿท ะขะตะบัƒั‰ะฐั ะฒะตั€ัะธั ะธ commit", + "command_donate": "โญ ะŸะพะดะดะตั€ะถะฐั‚ัŒ ะทะฒั‘ะทะดะฐะผะธ Telegram", + "command_stats": "๐Ÿ“Š ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ัะปัƒะถะฑั‹ ะฑะพั‚ะฐ", + "command_add": "โž• ะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐั‚ัŒ ัั‚ัƒ ะณั€ัƒะฟะฟัƒ", + "command_remove": "โž– ะ‘ะตะทะพะฟะฐัะฝะพ ัƒะดะฐะปะธั‚ัŒ ะณั€ัƒะฟะฟัƒ ะธะท ัะปัƒะถะฑั‹", + "command_settings": "โš™๏ธ ะะฐัั‚ั€ะพะนะบะธ ะณั€ัƒะฟะฟั‹ ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต", + "command_admin": "๐Ÿ›  ะŸะฐะฝะตะปัŒ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ ัะธัั‚ะตะผั‹", + "command_testzmanim": "๐Ÿ”Ž ะ”ะธะฐะณะฝะพัั‚ะธะบะฐ ะฒั€ะตะผะตะฝะธ ะธ ะฟะปะฐะฝะธั€ะพะฒั‰ะธะบะฐ", + "command_closegroups": "๐Ÿ”’ ะ ะฐะทั€ะตัˆั‘ะฝะฝะพะต ั€ัƒั‡ะฝะพะต ะทะฐะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟ", + "command_opengroups": "๐Ÿ”“ ะ ะฐะทั€ะตัˆั‘ะฝะฝะพะต ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะฟั€ะฐะฒ", + "command_setcommands": "๐Ÿ“‹ ะžะฟัƒะฑะปะธะบะพะฒะฐั‚ัŒ ะผะตะฝัŽ ะบะพะผะฐะฝะด ะฟะพ ะบะพะฝั‚ะตะบัั‚ะฐะผ", + "command_restart": "๐Ÿ”„ ะšะพั€ั€ะตะบั‚ะฝะพ ะฟะตั€ะตะทะฐะฟัƒัั‚ะธั‚ัŒ ะฑะพั‚ะฐ", + "donate_title": "ะŸะพะดะดะตั€ะถะฐั‚ัŒ Shabbat Bot", + "donate_description": "ะ”ะพะฑั€ะพะฒะพะปัŒะฝะฐั ะฟะพะดะดะตั€ะถะบะฐ: {{amount}} ะทะฒั‘ะทะด Telegram. ะกะฟะฐัะธะฑะพ!", + "donate_prompt": "ะ’ั‹ะฑะตั€ะธั‚ะต ััƒะผะผัƒ ั€ะฐะทะพะฒะพะน ะฟะพะดะดะตั€ะถะบะธ ะฒ ะทะฒั‘ะทะดะฐั… Telegram.", + "donate_invoice_error": "ะะต ัƒะดะฐะปะพััŒ ัะพะทะดะฐั‚ัŒ ัั‡ั‘ั‚ ะดะปั ะฟะพะดะดะตั€ะถะบะธ. ะŸะพะฒั‚ะพั€ะธั‚ะต /donate.", + "donate_partial_error": "ะะตะบะพั‚ะพั€ั‹ะต ััƒะผะผั‹ ะฟะพะดะดะตั€ะถะบะธ ัะตะนั‡ะฐั ะฝะตะดะพัั‚ัƒะฟะฝั‹. ะœะพะถะฝะพ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ัƒัะฟะตัˆะฝะพ ัะพะทะดะฐะฝะฝั‹ะน ัั‡ั‘ั‚.", + "donate_thanks": "ะกะฟะฐัะธะฑะพ ะทะฐ ะฟะพะถะตั€ั‚ะฒะพะฒะฐะฝะธะต {{amount}} ะทะฒั‘ะทะด Telegram!", + "donate_received_admin": "ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะพ ะฟะพะถะตั€ั‚ะฒะพะฒะฐะฝะธะต ะพั‚ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั {{user_id}}: {{amount}} ะทะฒั‘ะทะด Telegram.", + "donate_unverified_payment": "ะะต ัƒะดะฐะปะพััŒ ะฟั€ะพะฒะตั€ะธั‚ัŒ ะฟะปะฐั‚ั‘ะถ. ะžะฝ ะพั‚ะผะตั‡ะตะฝ ะดะปั ะฟั€ะพะฒะตั€ะบะธ ะพะฟะตั€ะฐั‚ะพั€ะพะผ; ัƒัะฟะตั… ะฝะต ะฟะพะดั‚ะฒะตั€ะถะดั‘ะฝ.", + "commands_setup_help": "ะœะตะฝัŽ ะบะพะผะฐะฝะด ะฟัƒะฑะปะธะบัƒัŽั‚ัั ะพั‚ะดะตะปัŒะฝะพ ะดะปั ะปะธั‡ะฝั‹ั… ั‡ะฐั‚ะพะฒ, ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ ะณั€ัƒะฟะฟ ะธ ะพะฟะตั€ะฐั‚ะพั€ะพะฒ ัะธัั‚ะตะผั‹ ะฝะฐ ะบะฐะถะดะพะผ ะฟะพะดะดะตั€ะถะธะฒะฐะตะผะพะผ ัะทั‹ะบะต. ะ”ะปั ะฑั€ะฐะทะธะปัŒัะบะพะณะพ ะฟะพั€ั‚ัƒะณะฐะปัŒัะบะพะณะพ Telegram ะธัะฟะพะปัŒะทัƒะตั‚ pt.", + "commands_setup_result": "ะžะฑะฝะพะฒะปะตะฝะธะต ะผะตะฝัŽ ะบะพะผะฐะฝะด: {{total}} ะทะฐะฟั€ะพัะพะฒ; ะฝะตัƒะดะฐั‡ะฝั‹ั… - {{failed}}.", + "commands_setup_preview": "ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€: ะฑัƒะดัƒั‚ ะพะฑะฝะพะฒะปะตะฝั‹ {{total}} ะผะตะฝัŽ ัะทั‹ะบะพะฒ ะธ ะบะพะฝั‚ะตะบัั‚ะพะฒ.", + "commands_forbidden": "ะŸัƒะฑะปะธะบะพะฒะฐั‚ัŒ ะผะตะฝัŽ ะบะพะผะฐะฝะด ะผะพะถะตั‚ ั‚ะพะปัŒะบะพ ะพะฟะตั€ะฐั‚ะพั€ ัะธัั‚ะตะผั‹.", + "operator_panel": "๐Ÿ›  ะŸะฐะฝะตะปัŒ ะพะฟะตั€ะฐั‚ะพั€ะฐ ัะธัั‚ะตะผั‹", + "operator_subscribers": "๐Ÿ‘ฅ ะŸะพะดะฟะธัั‡ะธะบะธ", + "operator_statistics": "๐Ÿ“Š ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ัะปัƒะถะฑั‹", + "operator_broadcast": "๐Ÿ“ฃ ะ ะฐััั‹ะปะบะฐ", + "operator_preview": "๐Ÿ‘€ ะ›ะธั‡ะฝั‹ะน ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€", + "operator_send": "ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ั€ะฐััั‹ะปะบัƒ", + "operator_confirm_send": "ะŸะพะดั‚ะฒะตั€ะดะธั‚ะต ะพั‚ะฟั€ะฐะฒะบัƒ ัั‚ะพะน ั€ะฐััั‹ะปะบะธ {{count}} ะฟะพะปัƒั‡ะฐั‚ะตะปัะผ.", + "operator_recipients_private": "ะŸะพะดะฟะธัั‡ะธะบะธ ะฒ ะปะธั‡ะฝั‹ั… ั‡ะฐั‚ะฐั…", + "operator_recipients_groups": "ะ“ั€ัƒะฟะฟั‹", + "operator_recipients_all": "ะ’ัะต ะฟะพะดะฟะธัั‡ะธะบะธ", + "operator_pin": "ะ—ะฐะบั€ะตะฟะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธะต", + "operator_unpin": "ะžั‚ะบั€ะตะฟะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธั", + "operator_delete_last": "ะฃะดะฐะปะธั‚ัŒ ะฟะพัะปะตะดะฝัŽัŽ ั€ะฐััั‹ะปะบัƒ", + "operator_delete_all": "ะฃะดะฐะปะธั‚ัŒ ะฒัะต ั€ะฐััั‹ะปะบะธ", + "operator_progress": "ะฅะพะด ั€ะฐััั‹ะปะบะธ", + "operator_cancel": "ะžั‚ะผะตะฝะฐ", + "operator_buttons": "ะ˜ะทะผะตะฝะธั‚ัŒ ะบะฝะพะฟะบะธ", + "operator_edit_text": "ะ˜ะทะผะตะฝะธั‚ัŒ ั‚ะตะบัั‚", + "operator_edit_media": "ะ˜ะทะผะตะฝะธั‚ัŒ ะผะตะดะธะฐ", + "operator_clear_draft": "ะžั‡ะธัั‚ะธั‚ัŒ ั‡ะตั€ะฝะพะฒะธะบ", + "operator_active_broadcast": "ะ ะฐััั‹ะปะบะฐ ัƒะถะต ะฒั‹ะฟะพะปะฝัะตั‚ัั. ะ”ะพะถะดะธั‚ะตััŒ ะทะฐะฒะตั€ัˆะตะฝะธั ะธะปะธ ะพั‚ะผะตะฝะธั‚ะต ะตั‘ ะฟะตั€ะตะด ะฝะฐั‡ะฐะปะพะผ ะฝะพะฒะพะน.", + "operator_draft_saved": "ะงะตั€ะฝะพะฒะธะบ ั€ะฐััั‹ะปะบะธ ัะพั…ั€ะฐะฝั‘ะฝ.", + "operator_no_draft": "ะะตั‚ ะดะพัั‚ัƒะฟะฝะพะณะพ ั‡ะตั€ะฝะพะฒะธะบะฐ ั€ะฐััั‹ะปะบะธ.", + "operator_page": "ะกั‚ั€ะฐะฝะธั†ะฐ {{page}} ะธะท {{total}}", + "operator_manual_result": "ะ”ะตะนัั‚ะฒะธะต {{action}}: ะธะทะผะตะฝะตะฝะธั ะฟะพะดั‚ะฒะตั€ะถะดะตะฝั‹ ะฒ {{changed}} ะณั€ัƒะฟะฟะฐั…, ะพัˆะธะฑะพะบ ะธะปะธ ะฝะตะฟะพะดั‚ะฒะตั€ะถะดั‘ะฝะฝั‹ั… ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ - {{failed}}. ะ”ะพ: {{until}}.", + "operator_manual_lock": "ะ ัƒั‡ะฝะพะต ะทะฐะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟั‹", + "operator_manual_open": "ะ’ะพััั‚ะฐะฝะพะฒะธั‚ัŒ ะฟั€ะฐะฒะฐ ะณั€ัƒะฟะฟั‹", + "group_new_wizard": "ะะพะฒะฐั ะณั€ัƒะฟะฟะฐ ะฝะฐั‡ะธะฝะฐะตั‚ ั ัะฒะฝะพ ัƒะบะฐะทะฐะฝะฝะพะณะพ ะผะตัั‚ะฐ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะผะตัั‚ะพ, ั‡ะฐัะพะฒะพะน ะฟะพัั ะธ ะบะฐะปะตะฝะดะฐั€ัŒ, ะทะฐั‚ะตะผ ัะฒะฝะพ ะฒั‹ะฑะตั€ะธั‚ะต ั€ะตะถะธะผ ั€ะฐะฑะพั‚ั‹.", + "permission_required": "ะ”ะปั ัั‚ะพะน ะณั€ัƒะฟะฟั‹ ั‚ั€ะตะฑัƒัŽั‚ัั ะดะตะนัั‚ะฒัƒัŽั‰ะธะต ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ.", + "group_removed": "ะ“ั€ัƒะฟะฟะฐ ัƒะดะฐะปะตะฝะฐ ะธะท ัะปัƒะถะฑั‹ ะฟะพัะปะต ะฑะตะทะพะฟะฐัะฝะพะณะพ ัƒั€ะตะณัƒะปะธั€ะพะฒะฐะฝะธั ะตั‘ ะฟั€ะฐะฒ.", + "omer_blessing_eligibility": "ะ‘ะปะฐะณะพัะปะพะฒะตะฝะธะต ะฟั€ะตะดะฝะฐะทะฝะฐั‡ะตะฝะพ ะดะปั ั‚ะตั…, ะบั‚ะพ ะฒะฟั€ะฐะฒะต ะตะณะพ ะฟั€ะพะธะทะฝะพัะธั‚ัŒ. ะ‘ะพั‚ ะฝะต ะผะพะถะตั‚ ะพะฟั€ะตะดะตะปะธั‚ัŒ ะปะธั‡ะฝะพะต ะฟั€ะฐะฒะพ ะฝะฐ ะฑะปะฐะณะพัะปะพะฒะตะฝะธะต ะฟะพ ะธัั‚ะพั€ะธะธ ัะพะพะฑั‰ะตะฝะธะน ะณั€ัƒะฟะฟั‹.", + "omer_day_completion_label": "ะ”ะฝะตะฒะฝะพะต ะฒะพัะฟะพะปะฝะตะฝะธะต - ะฑะตะท ะฑะปะฐะณะพัะปะพะฒะตะฝะธั.", + "omer_translation_note": "ะขะตะบัั‚ ะพะฑั€ัะดะฐ ะฝะฐ ะธะฒั€ะธั‚ะต; ัะปะตะดัƒัŽั‰ะธะน ัั‡ั‘ั‚ ะฟั€ะธะฒะตะดั‘ะฝ ะฒ ะฟะพััะฝััŽั‰ะตะผ ะฟะตั€ะตะฒะพะดะต.", + "group_close_notice": "ะะฐั‡ะฐะปัั ั‚ะธั…ะธะน ะฟะตั€ะธะพะด ะจะฐะฑะฑะฐั‚ะฐ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบะฐ.", + "group_open_notice": "ะ—ะฐะบะพะฝั‡ะธะปัั ั‚ะธั…ะธะน ะฟะตั€ะธะพะด ะจะฐะฑะฑะฐั‚ะฐ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบะฐ.", + "tefillin_reminder": "ะ’ั‹ ัƒะถะต ะฝะฐะดะตะปะธ ั‚ั„ะธะปะธะฝ ัะตะณะพะดะฝั?\nะ”ะปั ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธั ะณั€ัƒะฟะฟั‹ {{location}} ะทะฐั…ะพะด ัะพะปะฝั†ะฐ ะฒ {{sunset}}.", + "no_event": "ะ’ ะฟั€ะพะฒะตั€ะตะฝะฝะพะผ ะดะธะฐะฟะฐะทะพะฝะต ะดะฐะฝะฝั‹ั… ะฝะตั‚ ัะพะฑั‹ั‚ะธะน.", + "stats_counts": "ะ˜ะทะฒะตัั‚ะฝั‹ั… ะณั€ัƒะฟะฟ: {{known}}\nะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะพ ะฒ ัะปัƒะถะฑะต: {{registered}}\nะ‘ะปะพะบะธั€ะพะฒะบะฐ ะฒะบะปัŽั‡ะตะฝะฐ: {{locking}}\nะกะตะนั‡ะฐั ะทะฐะฑะปะพะบะธั€ะพะฒะฐะฝั‹ ะฑะพั‚ะพะผ: {{locked}}\nะžะถะธะดะฐัŽั‚ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั: {{recovery}}", + "source_info": "๐Ÿ’ป Shabbat Guardian - ะบะพะด, ะฒะตั€ัะธั ะธ ะปะธั†ะตะฝะทะธั\n\nะžั„ะธั†ะธะฐะปัŒะฝั‹ะน ั€ะตะฟะพะทะธั‚ะพั€ะธะน\nWizardLoop/shabbat ะฝะฐ GitHub\n\nะขะตะบัƒั‰ะฐั ัะฑะพั€ะบะฐ\nะ’ะตั€ัะธั: {{version}}\nCommit: {{commit}}\n\nะ›ะธั†ะตะฝะทะธั\n{{license}}\nะšะพะด ะดะพัั‚ัƒะฟะตะฝ ะฝะฐ ัƒัะปะพะฒะธัั… ะปะธั†ะตะฝะทะธะธ. ะ›ะธั†ะตะฝะทะธะธ ะทะฐะฒะธัะธะผะพัั‚ะตะน ะธ ัƒะฒะตะดะพะผะปะตะฝะธั ะพะฑ ะฐะฒั‚ะพั€ัะบะธั… ะฟั€ะฐะฒะฐั… ะฒะบะปัŽั‡ะตะฝั‹ ะฒ ะฟั€ะตะดะปะพะถะตะฝะธะต ะธัั…ะพะดะฝะพะณะพ ะบะพะดะฐ.\n\nะ˜ัั…ะพะดะฝั‹ะน ะบะพะด ัั‚ะพะน ัะฑะพั€ะบะธ\n{{url}}\nะžั„ะธั†ะธะฐะปัŒะฝั‹ะน ั€ะตะฟะพะทะธั‚ะพั€ะธะน - ัั‚ั€ะฐะฝะธั†ะฐ ะฟั€ะพะตะบั‚ะฐ; ะปะพะบะฐะปัŒะฝั‹ะต ะธะทะผะตะฝะตะฝะธั ะผะพะณัƒั‚ ะฑั‹ั‚ัŒ ั‚ะพะปัŒะบะพ ะฒ ะธัั…ะพะดะฝะพะผ ะบะพะดะต ัั‚ะพะน ัะฑะพั€ะบะธ.", + "version_info": "ะขะตะบัƒั‰ะฐั ะฒะตั€ัะธั: {{version}}\nCommit: {{commit}}", + "license_info": "ะ›ะธั†ะตะฝะทะธั ะฟั€ะธะปะพะถะตะฝะธั: {{license}}\nะ˜ัั…ะพะดะฝั‹ะน ะบะพะด ะธ ะปะธั†ะตะฝะทะธั ัะฑะพั€ะบะธ: {{url}}", + "source_unpublished": "ะžะฟะตั€ะฐั‚ะพั€ ะฝะต ะฝะฐัั‚ั€ะพะธะป ััั‹ะปะบัƒ ะฝะฐ ะธัั…ะพะดะฝั‹ะน ะบะพะด, ัะพะพั‚ะฒะตั‚ัั‚ะฒัƒัŽั‰ะธะน ัั‚ะพะน ัะฑะพั€ะบะต.", + "donate_checkout_error": "ะญั‚ะพั‚ ัั‡ั‘ั‚ ะฝะตะดะพัั‚ัƒะฟะตะฝ ะธะปะธ ะตะณะพ ะดะฐะฝะฝั‹ะต ะฝะตะฒะตั€ะฝั‹. ะกะพะทะดะฐะนั‚ะต ะฝะพะฒั‹ะน ัั‡ั‘ั‚ ะบะพะผะฐะฝะดะพะน /donate.", + "operator_recipients_channels": "ะšะฐะฝะฐะปั‹", + "operator_subscriber_stats": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปะธ: {{users}}\nะ“ั€ัƒะฟะฟั‹: {{groups}}\nะšะฐะฝะฐะปั‹: {{channels}}\nะ‘ะพั‚ั‹: {{bots}}\nะ’ัะตะณะพ: {{total}}", + "operator_message_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ัะพะพะฑั‰ะตะฝะธะต ะดะปั ั‡ะตั€ะฝะพะฒะธะบะฐ. ะคะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต ะธ ะผะตะดะธะฐ ัะพั…ั€ะฐะฝััŽั‚ัั. ะšะพะผะฐะฝะดั‹ ะฟั€ะพะดะพะปะถะฐัŽั‚ ั€ะฐะฑะพั‚ะฐั‚ัŒ, ะฟะพะบะฐ ะพั‚ะบั€ั‹ั‚ ั€ะตะดะฐะบั‚ะพั€.", + "operator_buttons_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ัั‚ั€ะพะบะธ ะบะฝะพะฟะพะบ ะฒ JSON ะธะปะธ ั‚ะฐะบ: Label - https://example.org - style:bg_success. ะ ะฐะทะดะตะปัะนั‚ะต ะบะฝะพะฟะบะธ ะฒ ะพะดะฝะพะน ัั‚ั€ะพะบะต ะทะฝะฐะบะฐะผะธ &&, ะฐ ั€ะฐะทะฝั‹ะต ัั‚ั€ะพะบะธ - ะฟะตั€ะตะฝะพัะพะผ. ะŸะพะดะดะตั€ะถะธะฒะฐะตะผั‹ะต ะดะตะนัั‚ะฒะธั: ััั‹ะปะบะฐ HTTPS, data:, alert:, popup:, copy:.", + "operator_draft_invalid": "ะงะตั€ะฝะพะฒะธะบ ะฟัƒัั‚, ัะปะธัˆะบะพะผ ะดะปะธะฝะฝั‹ะน ะธะปะธ ัะพะดะตั€ะถะธั‚ ะฝะตะฒะตั€ะฝะพะต ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต ะปะธะฑะพ ะบะฝะพะฟะบะธ. ะžะฝ ะฝะต ัะพั…ั€ะฐะฝั‘ะฝ.", + "operator_empty_recipients": "ะะตั‚ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ั… ะฟะพะปัƒั‡ะฐั‚ะตะปะตะน ะดะปั ะฒั‹ะฑั€ะฐะฝะฝะพะน ะฐัƒะดะธั‚ะพั€ะธะธ.", + "operator_complete": "ะ ะฐััั‹ะปะบะฐ ะทะฐะฒะตั€ัˆะตะฝะฐ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะตั‘ ั…ะพะด, ั‡ั‚ะพะฑั‹ ัƒะฒะธะดะตั‚ัŒ ั€ะตะทัƒะปัŒั‚ะฐั‚ั‹ ะดะพัั‚ะฐะฒะบะธ.", + "operator_pause": "ะŸั€ะธะพัั‚ะฐะฝะพะฒะธั‚ัŒ", + "operator_resume": "ะŸั€ะพะดะพะปะถะธั‚ัŒ", + "operator_bc_starting": "{{operation}}: ัะฑะพั€ ะฟะพะปัƒั‡ะฐั‚ะตะปะตะนโ€ฆ", + "operator_bc_counts": "ะžะฑั€ะฐะฑะพั‚ะฐะฝะพ: {{processed}} / {{total}}\nะžั‚ะฟั€ะฐะฒะปะตะฝะพ: {{sent}}\nะ˜ะทะผะตะฝะตะฝะพ: {{edited}}\nะ‘ะตะท ะธะทะผะตะฝะตะฝะธะน: {{unchanged}}\nะฃะดะฐะปะตะฝะพ: {{deleted}}\nะžั‚ะบั€ะตะฟะปะตะฝะพ: {{unpin}}\nะžัˆะธะฑะบะธ: {{failed}}\nะžะถะธะดะฐะฝะธั ะพะณั€ะฐะฝะธั‡ะตะฝะธั ะพั‚ะฟั€ะฐะฒะบะธ: {{flood}}\nะžะถะธะดะฐัŽั‚: {{pending}}\nะกะบะพั€ะพัั‚ัŒ: {{tps}}/ั", + "operator_bc_paused": "ะŸั€ะธะพัั‚ะฐะฝะพะฒะปะตะฝะพ", + "operator_bc_cancelled": "ะžั‚ะผะตะฝะตะฝะพ", + "operator_bc_finished": "ะ—ะฐะฒะตั€ัˆะตะฝะพ", + "operator_bc_selfdestruct": "ะ—ะฐะฟะปะฐะฝะธั€ะพะฒะฐะฝะฝะพะต ัƒะดะฐะปะตะฝะธะต", + "operator_bc_legacy_summary": "ะญั‚ะพั‚ ัั‚ะฐั€ั‹ะน ะพั‚ั‡ั‘ั‚ ัะพั…ั€ะฐะฝั‘ะฝ ะฒ ะธัั‚ะพั€ะธะธ ะฑะธะฑะปะธะพั‚ะตะบะธ. ะŸั€ะพะฒะตั€ัŒั‚ะต ั‚ะตะบัƒั‰ะธะต ั€ะตะทัƒะปัŒั‚ะฐั‚ั‹ ั€ะฐััั‹ะปะบะธ, ั‡ั‚ะพะฑั‹ ัƒะฒะธะดะตั‚ัŒ ะดะพัั‚ัƒะฟะฝั‹ะต ัั‡ั‘ั‚ั‡ะธะบะธ.", + "operator_bc_edit": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต ั€ะฐััั‹ะปะบะธ", + "moderation": "๐Ÿ”’ ะžะณั€ะฐะฝะธั‡ะตะฝะธั ะธ ะพั‡ะธัั‚ะบะฐ ะณั€ัƒะฟะฟั‹", + "moderation_intro": "ะ’ั‹ะฑะตั€ะธั‚ะต ะพะณั€ะฐะฝะธั‡ะตะฝะธั ะฝะฐ ะฒั€ะตะผั ะทะฐะบั€ั‹ั‚ะธั ะณั€ัƒะฟะฟั‹. ะžั‚ะผะตั‡ะตะฝะฝะพะต ะดะตะนัั‚ะฒะธะต ะทะฐะฟั€ะตั‰ะฐะตั‚ัั. ะ˜ัั…ะพะดะฝั‹ะต ะพะณั€ะฐะฝะธั‡ะตะฝะธั ัะพั…ั€ะฐะฝััŽั‚ัั ะธ ะฒะพััั‚ะฐะฝะฐะฒะปะธะฒะฐัŽั‚ัั ะฟั€ะธ ะพั‚ะบั€ั‹ั‚ะธะธ.", + "moderation_permissions": "ะ’ั‹ะฑั€ะฐั‚ัŒ ะทะฐะฟั€ะตั‰ั‘ะฝะฝั‹ะต ะดะตะนัั‚ะฒะธั", + "moderation_visibility_warning": "ะ—ะฐะฟั€ะตั‚ ะฟั€ะพัะผะพั‚ั€ะฐ ัะพะพะฑั‰ะตะฝะธะน ะผะพะถะตั‚ ัะบั€ั‹ั‚ัŒ ะณั€ัƒะฟะฟัƒ ะพั‚ ัƒั‡ะฐัั‚ะฝะธะบะพะฒ. ะŸะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ะพะฝ ะฝะต ะฒั‹ะฑั€ะฐะฝ.", + "moderation_default": "ะžะณั€ะฐะฝะธั‡ะตะฝะธั ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ", + "moderation_none": "ะ‘ะตะท ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ั… ะพะณั€ะฐะฝะธั‡ะตะฝะธะน", + "moderation_all": "ะ’ั‹ะฑั€ะฐั‚ัŒ ะฒัะต ะพะณั€ะฐะฝะธั‡ะตะฝะธั", + "moderation_cleanup_hint": "ะžั‡ะธัั‚ะบะฐ ะบะฐัะฐะตั‚ัั ั‚ะพะปัŒะบะพ ะฟะพะปัƒั‡ะตะฝะฝั‹ั… ัะพะพะฑั‰ะตะฝะธะน ะฒะพ ะฒั€ะตะผั ะทะฐะบั€ั‹ั‚ะธั ะฝะฐ ะจะฐะฑะฑะฐั‚ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบ. ะ˜ะทะฝะฐั‡ะฐะปัŒะฝะพ ะพะฝะฐ ะฒั‹ะบะปัŽั‡ะตะฝะฐ; ัะพั…ั€ะฐะฝะตะฝะธะต ั‚ั€ะตะฑัƒะตั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั. ะกะพะพะฑั‰ะตะฝะธั ัะฐะผะพะณะพ ะฑะพั‚ะฐ ะธัะบะปัŽั‡ะตะฝั‹.", + "moderation_bot_mode_hint": "ะ”ะปั ะฟะพะปัƒั‡ะตะฝะธั ัะพะพะฑั‰ะตะฝะธะน ะดั€ัƒะณะธั… ะฑะพั‚ะพะฒ ะฝัƒะถะฝั‹ ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ, ะพั‚ะบะปัŽั‡ั‘ะฝะฝั‹ะน Group Privacy Mode ะธ ะฒะบะปัŽั‡ั‘ะฝะฝั‹ะน Bot-to-Bot Communication Mode ะฒ BotFather. ะะตะดะพัั‚ะฐะฒะปะตะฝะฝั‹ะต Telegram ัะพะพะฑั‰ะตะฝะธั ัƒะดะฐะปะธั‚ัŒ ะฝะตะปัŒะทั.", + "moderation_capabilities": "ะŸั€ะพะฒะตั€ะบะฐ ะฟั€ะฐะฒ ะฑะพั‚ะฐ", + "moderation_delete_capability": "ะŸะพะดั‚ะฒะตั€ะถะดั‘ะฝะฝะพะต ะฟั€ะฐะฒะพ ัƒะดะฐะปัั‚ัŒ ัะพะพะฑั‰ะตะฝะธั: {{state}}", + "field_lock_block_flags": "ะ”ะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹ ะฟั€ะธ ะทะฐะบั€ั‹ั‚ะธะธ", + "field_delete_holy_user_messages": "ะฃะดะฐะปัั‚ัŒ ัะพะพะฑั‰ะตะฝะธั ัƒั‡ะฐัั‚ะฝะธะบะพะฒ ะฒะพ ะฒั€ะตะผั ัะฒัั‚ะพะณะพ ะฟะตั€ะธะพะดะฐ", + "field_delete_holy_bot_messages": "ะฃะดะฐะปัั‚ัŒ ัะพะพะฑั‰ะตะฝะธั ะดั€ัƒะณะธั… ะฑะพั‚ะพะฒ ะฒะพ ะฒั€ะตะผั ัะฒัั‚ะพะณะพ ะฟะตั€ะธะพะดะฐ", + "field_delete_previous": "ะฃะดะฐะปัั‚ัŒ ะฟั€ะตะดั‹ะดัƒั‰ะตะต ะฝะฐะฟะพะผะธะฝะฐะฝะธะต ะพ ั‚ั„ะธะปะธะฝ ะฟะพัะปะต ะพั‚ะฟั€ะฐะฒะบะธ ะฝะพะฒะพะณะพ", + "announcement_schedule": "๐Ÿ•ฐ ะžะฑัŠัะฒะปะตะฝะธะต ะฒั€ะตะผะตะฝะธ ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ", + "announcement_schedule_intro": "ะ’ั‹ะฑะตั€ะธั‚ะต ะฒั€ะตะผั ะพะฑัŠัะฒะปะตะฝะธั ะฒ {{timezone}}. ะ“ะพั‚ะพะฒั‹ะน ะฒะฐั€ะธะฐะฝั‚ ะทะฐะดะฐั‘ั‚ ะผะตัั‚ะฝะพะต ะฒั€ะตะผั, ะฝะพ ะฝะต ะฒะบะปัŽั‡ะฐะตั‚ ะพะฑัŠัะฒะปะตะฝะธั. ะ’ั€ัƒั‡ะฝัƒัŽ ะผะพะถะฝะพ ะทะฐะดะฐั‚ัŒ ะดั€ัƒะณะพะต ะฒั€ะตะผั ะธะปะธ ะผะธะฝัƒั‚ั‹ ะดะพ ะทะฐะบั€ั‹ั‚ะธั.", + "announcement_custom": "ะ”ั€ัƒะณะพะต ะฒั€ะตะผั / ะดะพ ะทะฐะบั€ั‹ั‚ะธั", + "template_kind_times_alert": "ะžะฑัŠัะฒะปะตะฝะธะต ะฒั€ะตะผะตะฝะธ ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ", + "permission_view_messages": "ะŸั€ะพัะผะพั‚ั€ ัะพะพะฑั‰ะตะฝะธะน", + "permission_send_messages": "ะžั‚ะฟั€ะฐะฒะบะฐ ัะพะพะฑั‰ะตะฝะธะน", + "permission_send_media": "ะžั‚ะฟั€ะฐะฒะบะฐ ะผะตะดะธะฐ", + "permission_send_stickers": "ะžั‚ะฟั€ะฐะฒะบะฐ ัั‚ะธะบะตั€ะพะฒ", + "permission_send_gifs": "ะžั‚ะฟั€ะฐะฒะบะฐ GIF", + "permission_send_games": "ะžั‚ะฟั€ะฐะฒะบะฐ ะธะณั€", + "permission_send_inline": "ะžั‚ะฟั€ะฐะฒะบะฐ ะธะฝะปะฐะนะฝ-ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ", + "permission_embed_links": "ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ััั‹ะปะพะบ", + "permission_send_polls": "ะžั‚ะฟั€ะฐะฒะบะฐ ะพะฟั€ะพัะพะฒ", + "permission_change_info": "ะ˜ะทะผะตะฝะตะฝะธะต ัะฒะตะดะตะฝะธะน ะณั€ัƒะฟะฟั‹", + "permission_invite_users": "ะŸั€ะธะณะปะฐัˆะตะฝะธะต ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", + "permission_pin_messages": "ะ—ะฐะบั€ะตะฟะปะตะฝะธะต ัะพะพะฑั‰ะตะฝะธะน", + "permission_manage_topics": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ั‚ะตะผะฐะผะธ", + "permission_send_photos": "ะžั‚ะฟั€ะฐะฒะบะฐ ั„ะพั‚ะพ", + "permission_send_videos": "ะžั‚ะฟั€ะฐะฒะบะฐ ะฒะธะดะตะพ", + "permission_send_roundvideos": "ะžั‚ะฟั€ะฐะฒะบะฐ ะฒะธะดะตะพัะพะพะฑั‰ะตะฝะธะน", + "permission_send_audios": "ะžั‚ะฟั€ะฐะฒะบะฐ ะฐัƒะดะธะพ", + "permission_send_voices": "ะžั‚ะฟั€ะฐะฒะบะฐ ะณะพะปะพัะพะฒั‹ั… ัะพะพะฑั‰ะตะฝะธะน", + "permission_send_docs": "ะžั‚ะฟั€ะฐะฒะบะฐ ะดะพะบัƒะผะตะฝั‚ะพะฒ", + "permission_send_plain": "ะžั‚ะฟั€ะฐะฒะบะฐ ะพะฑั‹ั‡ะฝะพะณะพ ั‚ะตะบัั‚ะฐ", + "permission_edit_rank": "ะ˜ะทะผะตะฝะตะฝะธะต ะทะฒะฐะฝะธะน ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", + "permission_send_reactions": "ะžั‚ะฟั€ะฐะฒะบะฐ ั€ะตะฐะบั†ะธะน", + "bot_permissions": "๐Ÿ›ก ะŸั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ ะฑะพั‚ะฐ", + "bot_permissions_intro": "ะ”ะธะฐะณะฝะพัั‚ะธะบะฐ ะฟั€ะฐะฒ Telegram ั‚ะพะปัŒะบะพ ะดะปั ั‡ั‚ะตะฝะธั. ะัƒะถะฝั‹ะต ะฟั€ะฐะฒะฐ ะฒั‹ะดะฐัŽั‚ัั ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั… ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ ะณั€ัƒะฟะฟั‹; ะฑะพั‚ ะฝะต ะผะพะถะตั‚ ะฒั‹ะดะฐั‚ัŒ ะธั… ัะตะฑะต.", + "bot_role_creator": "ะ’ะปะฐะดะตะปะตั† ะณั€ัƒะฟะฟั‹", + "bot_role_administrator": "ะะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ ะณั€ัƒะฟะฟั‹", + "admin_right_change_info": "ะ˜ะทะผะตะฝะตะฝะธะต ัะฒะตะดะตะฝะธะน ะณั€ัƒะฟะฟั‹", + "admin_right_post_messages": "ะŸัƒะฑะปะธะบะฐั†ะธั ัะพะพะฑั‰ะตะฝะธะน ะบะฐะฝะฐะปะฐ", + "admin_right_edit_messages": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต ัะพะพะฑั‰ะตะฝะธะน ะบะฐะฝะฐะปะฐ", + "admin_right_delete_messages": "ะฃะดะฐะปะตะฝะธะต ัะพะพะฑั‰ะตะฝะธะน", + "admin_right_ban_users": "ะžะณั€ะฐะฝะธั‡ะตะฝะธะต ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", + "admin_right_invite_users": "ะŸั€ะธะณะปะฐัˆะตะฝะธะต ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", + "admin_right_pin_messages": "ะ—ะฐะบั€ะตะฟะปะตะฝะธะต ัะพะพะฑั‰ะตะฝะธะน", + "admin_right_add_admins": "ะ”ะพะฑะฐะฒะปะตะฝะธะต ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ", + "admin_right_anonymous": "ะะฝะพะฝะธะผะฝะพัั‚ัŒ", + "admin_right_manage_call": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ะณะพะปะพัะพะฒั‹ะผะธ ะธ ะฒะธะดะตะพั‡ะฐั‚ะฐะผะธ", + "admin_right_other": "ะ”ั€ัƒะณะธะต ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ", + "admin_right_manage_topics": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ั‚ะตะผะฐะผะธ", + "admin_right_post_stories": "ะŸัƒะฑะปะธะบะฐั†ะธั ะธัั‚ะพั€ะธะน", + "admin_right_edit_stories": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต ะธัั‚ะพั€ะธะน", + "admin_right_delete_stories": "ะฃะดะฐะปะตะฝะธะต ะธัั‚ะพั€ะธะน", + "admin_right_manage_direct_messages": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ะปะธั‡ะฝั‹ะผะธ ัะพะพะฑั‰ะตะฝะธัะผะธ ะบะฐะฝะฐะปะฐ", + "admin_right_manage_ranks": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ะทะฒะฐะฝะธัะผะธ ัƒั‡ะฐัั‚ะฝะธะบะพะฒ", + "welcome": "โœก๏ธ ะฅั€ะฐะฝะธั‚ะตะปัŒ ะจะฐะฑะฑะฐั‚ะฐ\nะ’ั€ะตะผั ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ, ะทะผะฐะฝะธะผ, ะ”ะฐั„ ะ™ะพะผะธ ะธ ัั‡ั‘ั‚ ะžะผะตั€ะฐ ะดะปั ะฒะฐัˆะตะณะพ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธั.\n\nะ’ั‹ะฑะตั€ะธั‚ะต ั€ะฐะทะดะตะป ะธะปะธ ะพั‚ะบั€ะพะนั‚ะต ะฝะฐัั‚ั€ะพะนะบะธ, ั‡ั‚ะพะฑั‹ ะธะทะผะตะฝะธั‚ัŒ ัะทั‹ะบ ะธ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต.\n\nะะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ั‹ ะผะพะณัƒั‚ ัƒะฟั€ะฐะฒะปัั‚ัŒ ะบะฐะถะดะพะน ะณั€ัƒะฟะฟะพะน ั‡ะตั€ะตะท ยซะœะพะธ ะณั€ัƒะฟะฟั‹ยป.", + "help": "โ„น๏ธ ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะธ ะฟะพะผะพั‰ัŒ", + "home": "๐Ÿ  ะ“ะปะฐะฒะฝะพะต ะผะตะฝัŽ", + "info_intro": "โ„น๏ธ ะšะฐะบ ะฟะพะปัŒะทะพะฒะฐั‚ัŒัั ะฅั€ะฐะฝะธั‚ะตะปะตะผ ะจะฐะฑะฑะฐั‚ะฐ\n\nะ’ะฐัˆะต ั€ะฐัะฟะธัะฐะฝะธะต ะฝะฐ ะฒะฐัˆะตะผ ัะทั‹ะบะต ะธ ะดะปั ะฒะฐัˆะตะณะพ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธั.\nะ’ั‹ะฑะตั€ะธั‚ะต ั‚ะตะผัƒ ั ะฟะพััะฝะตะฝะธัะผะธ ะธ ะฟะพะปะตะทะฝั‹ะผะธ ะบะพะผะฐะฝะดะฐะผะธ. ะ”ะปั ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ ะตัั‚ัŒ ะธะฝั„ะพั€ะผะฐั†ะธั ะพะฑ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะผ ะทะฐะบั€ั‹ั‚ะธะธ, ะฝะฐะฟะพะผะธะฝะฐะฝะธัั… ะธ ะพั„ะพั€ะผะปะตะฝะธะธ ัะพะพะฑั‰ะตะฝะธะน.", + "info_personal_title": "โš™๏ธ ะœะพะธ ะฝะฐัั‚ั€ะพะนะบะธ", + "info_personal_text": "/preferences - ัะทั‹ะบ, ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต, ะบะฐะปะตะฝะดะฐั€ัŒ ะ˜ะทั€ะฐะธะปั ะธะปะธ ะดะธะฐัะฟะพั€ั‹, ะฟั€ะฐะฒะธะปะฐ ะทะฐะถะธะณะฐะฝะธั ัะฒะตั‡ะตะน ะธ ะฐะฒะดะฐะปั‹.\n/lang - ะฒั‹ะฑะพั€ ะพะดะฝะพะณะพ ะธะท ัˆะตัั‚ะธ ัะทั‹ะบะพะฒ.\nะ’ั€ะตะผั ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต ั€ะฐััั‡ะธั‚ั‹ะฒะฐะตั‚ัั ะฟะพ ะฒะฐัˆะธะผ ะฝะฐัั‚ั€ะพะนะบะฐะผ. ะ›ะธั‡ะฝั‹ะต ะธะทะผะตะฝะตะฝะธั ะฝะต ะผะตะฝััŽั‚ ะฝะฐัั‚ั€ะพะนะบะธ ะณั€ัƒะฟะฟ.\n/start - ะฒะตั€ะฝัƒั‚ัŒัั ะฒ ะณะปะฐะฒะฝะพะต ะผะตะฝัŽ.", + "info_calendar_title": "๐Ÿ•ฏ ะ’ั€ะตะผั ะธ ะตะฒั€ะตะนัะบะธะน ะบะฐะปะตะฝะดะฐั€ัŒ", + "info_calendar_text": "ะ’ะฐัˆะต ะฒั€ะตะผั\n/shabbat - ะฑะปะธะถะฐะนัˆะธะน ะจะฐะฑะฑะฐั‚, ะฝะตะฟั€ะตั€ั‹ะฒะฝั‹ะต ะฟะตั€ะธะพะดั‹ ะ™ะพะผ-ะขะพะฒะฐ, ะฝะตะดะตะปัŒะฝะฐั ะณะปะฐะฒะฐ, ะœะตะฒะฐั€ั…ะธะผ ะธ ะฑะปะธะถะฐะนัˆะธะต ะดะฐั‚ั‹ ะฒ ะพะดะฝะพะผ ัะพะพะฑั‰ะตะฝะธะธ.\n/zmanim - ะฒั€ะตะผั ะดะฝั ะดะปั ะฒะฐัˆะตะณะพ ะณะพั€ะพะดะฐ; ะดะฐั‚ะฐ ะฒั‹ะฑะธั€ะฐะตั‚ัั ะบะฝะพะฟะบะฐะผะธ.\n/daf - ะ”ะฐั„ ะ™ะพะผะธ. /omer - ัะตะทะพะฝะฝั‹ะน ัั‡ั‘ั‚ ะžะผะตั€ะฐ.\n\nะกะปะตะดัƒัŽั‰ะธะต ะฟะพะดั€ัะด ัะฒัั‚ั‹ะต ะดะฝะธ\nะŸะพะบะฐะทะฐะฝั‹ ะฒัะต ะทะฐะถะธะณะฐะฝะธั ัะฒะตั‡ะตะน ะธ ะพะบะพะฝั‡ะฐั‚ะตะปัŒะฝั‹ะน ะฒั‹ั…ะพะด. ะะฐั‡ะฐะปะพ ะธ ะบะพะฝะตั† ะฟั€ะฐะทะดะฝะธะบะฐ ะฟะพะบะฐะทั‹ะฒะฐัŽั‚ัั ั‚ะพะปัŒะบะพ ะดะปั ะ™ะพะผ-ะขะพะฒะฐ ะฟะพ ะบะปะฐััะธั„ะธะบะฐั†ะธะธ Hebcal. ะฅะฐะฝัƒะบะฐ, ะฅะพะปัŒ ั…ะฐ-ะœะพัะด ะธ ะดั€ัƒะณะธะต ะดะฐั‚ั‹ ัะฐะผะธ ะฟะพ ัะตะฑะต ะฝะต ะ™ะพะผ-ะขะพะฒ.\n\nะœะตัั‚ะพ ะธ ั€ะฐัั‡ั‘ั‚\nะ›ะธั‡ะฝั‹ะต ะฝะฐัั‚ั€ะพะนะบะธ ะพั‚ะฝะพััั‚ัั ะบ ะปะธั‡ะฝะพะผัƒ ั‡ะฐั‚ัƒ; ัƒ ะบะฐะถะดะพะน ะณั€ัƒะฟะฟั‹ ัะฒะพะธ. ะ”ะปั ะ˜ะตั€ัƒัะฐะปะธะผะฐ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ัะฒะตั‡ะธ ะทะฐ 40 ะผะธะฝัƒั‚ ะดะพ ะทะฐะบะฐั‚ะฐ, ัั‚ะพ ะผะพะถะฝะพ ะธะทะผะตะฝะธั‚ัŒ. ะ’ั€ะตะผั ัƒะบะฐะทะฐะฝะพ ะฒ ะฒั‹ะฑั€ะฐะฝะฝะพะผ ั‡ะฐัะพะฒะพะผ ะฟะพััะต IANA ั ัƒั‡ั‘ั‚ะพะผ ะฟะตั€ะตั…ะพะดะพะฒ ะฝะฐ ะปะตั‚ะฝะตะต ะฒั€ะตะผั.\n\nะะตะดะพัั‚ัƒะฟะฝะพะต ะฒั€ะตะผั ะฝะต ัƒะณะฐะดั‹ะฒะฐะตั‚ัั. ะšะฝะพะฟะบะฐ ยซะŸะพะดะตะปะธั‚ัŒััยป ะพั‚ะฟั€ะฐะฒะปัะตั‚ ั€ะตะทัƒะปัŒั‚ะฐั‚ ะฒ ะดั€ัƒะณะพะน ั‡ะฐั‚.\n\nะšะฐะปะตะฝะดะฐั€ัŒ ะธ ะฒั€ะตะผั: Hebcal.com. ะ’ ัะพะพะฑั‰ะตะฝะธะธ ะฟะพะบะฐะทะฐะฝ ะฒั‹ะฑั€ะฐะฝะฝั‹ะน ะฒะฐะผะธ ัะฟะพัะพะฑ ั€ะฐัั‡ั‘ั‚ะฐ ะพะบะพะฝั‡ะฐะฝะธั: ะณั€ะฐะดัƒัั‹ ะธะปะธ ะผะธะฝัƒั‚ั‹ ะฟะพัะปะต ะทะฐะบะฐั‚ะฐ.", + "info_groups_title": "๐Ÿ‘ฅ ะฃะฟั€ะฐะฒะปะตะฝะธะต ะณั€ัƒะฟะฟะฐะผะธ", + "info_groups_text": "ะŸะพะดะบะปัŽั‡ะตะฝะธะต ะธ ะฟะฐะฝะตะปัŒ\nะ”ะพะฑะฐะฒัŒั‚ะต ะฑะพั‚ะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะผ ะธ ะพั‚ะฟั€ะฐะฒัŒั‚ะต ะฒ ะณั€ัƒะฟะฟะต /settings. ะŸะฐะฝะตะปัŒ ั‚ะฐะบะถะต ะดะพัั‚ัƒะฟะฝะฐ ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต ั‡ะตั€ะตะท ยซะœะพะธ ะณั€ัƒะฟะฟั‹ยป. ะœะตะฝัŽ ะบะพะผะฐะฝะด ะณั€ัƒะฟะฟั‹ ัะพะดะตั€ะถะธั‚ ั‚ะพะปัŒะบะพ ะฝะฐัั‚ั€ะพะนะบะธ.\n\nะงั‚ะพ ะฝะฐัั‚ั€ะฐะธะฒะฐะตั‚ัั?\nะœะตัั‚ะพ, ัะทั‹ะบ, ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ, ะฒั€ะตะผั ะดะฝั, ะ”ะฐั„ ะ™ะพะผะธ, ั‚ั„ะธะปะธะฝ, ะžะผะตั€, ะพั„ะพั€ะผะปะตะฝะธะต ัะพะพะฑั‰ะตะฝะธะน ะธ ะฟั€ะฐะฒะฐ. ะฃ ะบะฐะถะดะพะน ะณั€ัƒะฟะฟั‹ ัะฒะพะธ ะฝะฐัั‚ั€ะพะนะบะธ.\n\nะ ะตะถะธะผ\nะ’ั‹ะบะปัŽั‡ะตะฝะพ, ั‚ะพะปัŒะบะพ ัะพะพะฑั‰ะตะฝะธั, ั‚ะพะปัŒะบะพ ะทะฐะบั€ั‹ั‚ะธะต ะธะปะธ ัะพะพะฑั‰ะตะฝะธั ะธ ะทะฐะบั€ั‹ั‚ะธะต. ะ’ ั€ะฐะทะดะตะปะต ะจะฐะฑะฑะฐั‚ะฐ ะฒั‹ะฑะตั€ะธั‚ะต ะดะฝะธ, ะผะตั‚ะพะดั‹ ะธ ะฒั€ะตะผั ะพะฑัŠัะฒะปะตะฝะธั.\n\nะ”ะปั ัะพั…ั€ะฐะฝะตะฝะธั ะฝัƒะถะฝั‹ ะดะตะนัั‚ะฒัƒัŽั‰ะธะต ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะธั‚ะพะณ ะฟะตั€ะตะด ะฒะบะปัŽั‡ะตะฝะธะตะผ; ะปะธั‡ะฝั‹ะน ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฝะธั‡ะตะณะพ ะฝะต ะพั‚ะฟั€ะฐะฒะปัะตั‚ ะฒ ะณั€ัƒะฟะฟัƒ.", + "info_notifications_title": "๐Ÿ”” ะะฐะฟะพะผะธะฝะฐะฝะธั ะธ ัะพะพะฑั‰ะตะฝะธั", + "info_notifications_text": "ะšะฐะถะดะพะต ะฝะฐะฟะพะผะธะฝะฐะฝะธะต ะพั‚ะดะตะปัŒะฝะพ\nะ’ ะฟะฐะฝะตะปะธ ะฒั‹ะฑะตั€ะธั‚ะต ะฒั€ะตะผั ะดะฝั, ะ”ะฐั„ ะ™ะพะผะธ, ั‚ั„ะธะปะธะฝ ะธะปะธ ะžะผะตั€, ะฒะบะปัŽั‡ะธั‚ะต ะธ ะทะฐะดะฐะนั‚ะต ะผะตัั‚ะฝะพะต ะฒั€ะตะผั. ะšะฝะพะฟะบะธ ะฒะตะดัƒั‚ ะฟะพ ัˆะฐะณะฐะผ ะฑะตะท ะบะปัŽั‡ะตะน ะฝะฐัั‚ั€ะพะตะบ ะธ ั‚ะพะบะตะฝะพะฒ.\n\nะขั„ะธะปะธะฝ\nะ’ั‹ะฑะตั€ะธั‚ะต ะดะฝะธ, ั„ะธะบัะธั€ะพะฒะฐะฝะฝะพะต ะฒั€ะตะผั, ะธะฝั‚ะตั€ะฒะฐะปั‹ ะธะปะธ ะผะธะฝัƒั‚ั‹ ะดะพ ะทะฐะบะฐั‚ะฐ. ะะฐัั‚ั€ะพะนั‚ะต ั‚ะธั…ะธะต ั‡ะฐัั‹, ะดะฝะตะฒะฝะพะน ะปะธะผะธั‚ ะธ ัƒะดะฐะปะตะฝะธะต ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ะฝะฐะฟะพะผะธะฝะฐะฝะธั. ะฃั‡ะธั‚ั‹ะฒะฐัŽั‚ัั ะจะฐะฑะฑะฐั‚, ะ™ะพะผ-ะขะพะฒ ะธ ะฝะฐัั‚ั€ะพะนะบะธ ะฅะพะปัŒ ั…ะฐ-ะœะพัะดะฐ ะธ ะ”ะตะฒัั‚ะพะณะพ ะฐะฒะฐ.\n\nะฏ ะฝะฐะดะตะป ั‚ั„ะธะปะธะฝ\nะšะฝะพะฟะบะฐ ัั‡ะธั‚ะฐะตั‚ ัƒะฝะธะบะฐะปัŒะฝั‹ั… ะฟะพะปัŒะทะพะฒะฐั‚ะตะปะตะน; ะฟะพะฒั‚ะพั€ะฝะพะต ะฝะฐะถะฐั‚ะธะต ะพั‚ะผะตะฝัะตั‚ ะฒั‹ะฑะพั€. ะะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ ะผะพะถะตั‚ ะธะทะผะตะฝะธั‚ัŒ ะฝะฐะทะฒะฐะฝะธะต ะธะปะธ ะพั‚ะบะปัŽั‡ะธั‚ัŒ ะบะฝะพะฟะบัƒ.\n\nะžะฑัŠัะฒะปะตะฝะธะต ะฒั€ะตะผะตะฝะธ\nะ’ ั€ะฐะทะดะตะปะต ะจะฐะฑะฑะฐั‚ะฐ ะฒั‹ะฑะตั€ะธั‚ะต ะฒั€ะตะผั ะฟัƒะฑะปะธะบะฐั†ะธะธ ะธ ะพั„ะพั€ะผะปะตะฝะธะต. ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะพัั‚ะฐั‘ั‚ัั ะปะธั‡ะฝั‹ะผ; ั‚ะตัั‚ ะฒ ะณั€ัƒะฟะฟะต ั‚ั€ะตะฑัƒะตั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั.", + "info_messages_title": "โœ‰๏ธ ะžั„ะพั€ะผะปะตะฝะธะต ะธ ั€ะฐะทั€ะตัˆะตะฝะธั", + "info_messages_text": "ะกะพะพะฑั‰ะตะฝะธั ะธ ะพั„ะพั€ะผะปะตะฝะธะต\nะ’ั‹ะฑะตั€ะธั‚ะต ะทะฐะบั€ั‹ั‚ะธะต, ะพั‚ะบั€ั‹ั‚ะธะต, ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ, ะฒั€ะตะผั ะดะฝั, ะ”ะฐั„ ะ™ะพะผะธ, ั‚ั„ะธะปะธะฝ ะธะปะธ ะžะผะตั€. ะขะตะบัั‚, ั„ะพั‚ะพ/ะผะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ ั€ะตะดะฐะบั‚ะธั€ัƒัŽั‚ัั ะพั‚ะดะตะปัŒะฝะพ.\n\nะŸั€ะพัั‚ะพะต ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต\nะŸั€ะธัˆะปะธั‚ะต ะถะตะปะฐะตะผะพะต ัะพะพะฑั‰ะตะฝะธะต. ะ‘ะพั‚ ัะพั…ั€ะฐะฝัะตั‚ ั‚ะตะบัั‚ ะธ ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต Telegram, ะฒะบะปัŽั‡ะฐั ะถะธั€ะฝั‹ะน ัˆั€ะธั„ั‚, ััั‹ะปะบะธ ะธ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ัะผะพะดะทะธ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ ะธ ัะพั…ั€ะฐะฝะธั‚ะต.\n\nะšะฝะพะฟะบะธ\nะ”ะพะฑะฐะฒัŒั‚ะต ััั‹ะปะบัƒ, ัั‚ะธะปัŒ, ัƒะฑะตั€ะธั‚ะต ะบะฝะพะฟะบะธ ะธะปะธ ะฒะพััั‚ะฐะฝะพะฒะธั‚ะต ัั‚ะฐะฝะดะฐั€ั‚ะฝั‹ะต. ะฃ ะฒั€ะตะผะตะฝะธ ะธ ะ”ะฐั„ ะ™ะพะผะธ ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ะตัั‚ัŒ ยซะŸะพะดะตะปะธั‚ัŒััยป. ะ˜ะทะผะตะฝะตะฝะธะต ั‚ะพะปัŒะบะพ ะบะฝะพะฟะพะบ ัะพั…ั€ะฐะฝัะตั‚ ะฒั‹ั‡ะธัะปะตะฝะฝะพะต ัะพะดะตั€ะถะธะผะพะต.\n\nะ•ัะปะธ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ัะผะพะดะทะธ ะฝะตะดะพัั‚ัƒะฟะฝั‹, ะธัะฟะพะปัŒะทัƒัŽั‚ัั ะพะฑั‹ั‡ะฝั‹ะต. ะขะตัั‚ ะฒ ะณั€ัƒะฟะฟะต ั‚ั€ะตะฑัƒะตั‚ ัะฒะฝะพะณะพ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั.", + "info_privacy_title": "๐Ÿ›ก ะšะพะฝั„ะธะดะตะฝั†ะธะฐะปัŒะฝะพัั‚ัŒ ะธ ะฑะตะทะพะฟะฐัะฝะพัั‚ัŒ", + "info_privacy_text": "ะšะพะฝั‚ั€ะพะปัŒ ะฝะฐัั‚ั€ะพะตะบ\nะŸะพะธัะบ ะณะพั€ะพะดะฐ ะธ ะณะตะพะฟะพะทะธั†ะธั ะฟะพะบะฐะทั‹ะฒะฐัŽั‚ ะฒั‹ะฑะพั€ ะธ ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€; ะธะทะผะตะฝะตะฝะธั ัะพั…ั€ะฐะฝััŽั‚ัั ะฟะพัะปะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั. ะ›ะธั‡ะฝั‹ะต ะฟั€ะตะดะฟะพั‡ั‚ะตะฝะธั ะฝะต ะผะตะฝััŽั‚ ะณั€ัƒะฟะฟั‹. ะŸะตั€ะตะด ะดะตะนัั‚ะฒะธัะผะธ ะฟั€ะพะฒะตั€ััŽั‚ัั ั‚ะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ.\n\nะ”ะฐะฝะฝั‹ะต\nะัƒะถะฝั‹ะต ัะตั€ะฒะธััƒ ะธะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ั‹, ะฝะฐัั‚ั€ะพะนะบะธ, ะฟั€ะฐะฒะฐ ะธ ะทะฐะฟะธัะธ ะพะฟะตั€ะฐั†ะธะน. ะšะฝะพะฟะบะฐ ั‚ั„ะธะปะธะฝ ัะพั…ั€ะฐะฝัะตั‚ ะฒั‹ะฑะพั€ ะดะปั ัƒะฝะธะบะฐะปัŒะฝะพะณะพ ะฟะพะดัั‡ั‘ั‚ะฐ; ะฟะพะฒั‚ะพั€ะฝะพะต ะฝะฐะถะฐั‚ะธะต ะตะณะพ ะพั‚ะผะตะฝัะตั‚.\n\nะŸั€ะพะฒะตั€ะบะฐ ะธ ะฟั€ะพะทั€ะฐั‡ะฝะพัั‚ัŒ\nะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะปะธั‡ะฝั‹ะน. ะขะตัั‚ ะฒ ะณั€ัƒะฟะฟะต ั‚ั€ะตะฑัƒะตั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั. ะะต ะฟั€ะธัั‹ะปะฐะนั‚ะต ะฟะฐั€ะพะปะธ ะธะปะธ ั‚ะพะบะตะฝั‹.\n/source ะพะฑัŠะตะดะธะฝัะตั‚ ะพั„ะธั†ะธะฐะปัŒะฝั‹ะน ั€ะตะฟะพะทะธั‚ะพั€ะธะน, ั‚ะตะบัƒั‰ัƒัŽ ะฒะตั€ัะธัŽ, ะปะธั†ะตะฝะทะธัŽ ะธ ะบะพะด ัะฑะพั€ะบะธ.", + "info_operators_title": "๐Ÿ›  ะะดะผะธะฝะธัั‚ั€ะธั€ะพะฒะฐะฝะธะต ะฑะพั‚ะฐ", + "info_operators_text": "/admin - ะฟะพะดะฟะธัั‡ะธะบะธ, ัั‚ะฐั‚ะธัั‚ะธะบะฐ ะธ ั€ะฐััั‹ะปะบะธ.\n/setcommands - ะผะตะฝัŽ ะบะพะผะฐะฝะด ะฟะพ ัะทั‹ะบัƒ ะธ ั‚ะธะฟัƒ ั‡ะฐั‚ะฐ; ัะฝะฐั‡ะฐะปะฐ ะฟั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะตะดะฒะฐั€ะธั‚ะตะปัŒะฝั‹ะน ะฟั€ะพัะผะพั‚ั€.\n/testzmanim - ะฟั€ะพะฒะตั€ะบะฐ ั€ะฐัั‡ั‘ั‚ะพะฒ ะฒั€ะตะผะตะฝะธ ะธ ั€ะฐัะฟะธัะฐะฝะธั.\n/closegroups ะธ /opengroups - ั€ัƒั‡ะฝั‹ะต ะดะตะนัั‚ะฒะธั ะดะปั ั€ะฐะทั€ะตัˆั‘ะฝะฝั‹ั… ะณั€ัƒะฟะฟ.\n/restart - ะบะพั€ั€ะตะบั‚ะฝั‹ะน ะฟะตั€ะตะทะฐะฟัƒัะบ.\nะญั‚ะธ ั„ัƒะฝะบั†ะธะธ ะดะพัั‚ัƒะฟะฝั‹ ั‚ะพะปัŒะบะพ ะพะฟะตั€ะฐั‚ะพั€ะฐะผ ะฑะพั‚ะฐ. ะŸั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ ะณั€ัƒะฟะฟั‹ ะฝะต ะดะฐัŽั‚ ะดะพัั‚ัƒะฟ ะบ ัƒะฟั€ะฐะฒะปะตะฝะธัŽ ะฑะพั‚ะพะผ.", + "callback_panel_unavailable": "ะะต ัƒะดะฐะปะพััŒ ะพะฑะฝะพะฒะธั‚ัŒ ัั‚ะพ ะผะตะฝัŽ. ะžั‚ะบั€ะพะนั‚ะต ะตะณะพ ะทะฐะฝะพะฒะพ ะบะพะผะฐะฝะดะพะน /start ะธะปะธ /settings.", + "provider_unavailable": "ะŸั€ะพะฒะตั€ะตะฝะฝั‹ะต ะดะฐะฝะฝั‹ะต ะฒั€ะตะผะตะฝะธ ะฒั€ะตะผะตะฝะฝะพ ะฝะตะดะพัั‚ัƒะฟะฝั‹. ะŸะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ ั‡ะตั€ะตะท ะฝะตัะบะพะปัŒะบะพ ะผะธะฝัƒั‚.", + "operator_subscriber_stats_full": "๐Ÿ“Š ะŸะพะดะฟะธัั‡ะธะบะธ ะฑะพั‚ะฐ ะจะฐะฑะฑะฐั‚ะฐ\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n๐Ÿ‘ค ะŸะพะปัŒะทะพะฒะฐั‚ะตะปะธ: {{users}}\n๐Ÿ‘ฅ ะ“ั€ัƒะฟะฟั‹: {{chats}}\n๐Ÿ‘ฅ ะกัƒะฟะตั€ะณั€ัƒะฟะฟั‹: {{supergroups}}\n๐Ÿ“ข ะšะฐะฝะฐะปั‹: {{channels}}\n๐Ÿค– ะ‘ะพั‚ั‹: {{bots}}\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\nะ’ัะตะณะพ: {{total}}", + "operator_group_stats": "๐Ÿ”’ ะ ะฐะฑะพั‚ะฐ ะณั€ัƒะฟะฟ\nะ˜ะทะฒะตัั‚ะฝั‹ะต ะณั€ัƒะฟะฟั‹: {{known}}\nะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝั‹: {{registered}}\nะ—ะฐะบั€ั‹ั‚ะธะต ะฒะบะปัŽั‡ะตะฝะพ: {{locking}}\nะกะตะนั‡ะฐั ะทะฐะบั€ั‹ั‚ั‹ ะฑะพั‚ะพะผ: {{locked}}\nะžะถะธะดะฐัŽั‚ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั: {{recovery}}", + "times_section_schedule": "ะ’ั€ะตะผั ะฝะฐั‡ะฐะปะฐ ะธ ะพะบะพะฝั‡ะฐะฝะธั", + "times_section_calendar": "ะกะพะฑั‹ั‚ะธั ะบะฐะปะตะฝะดะฐั€ั", + "times_stale": "โ„น๏ธ ะŸะพะบะฐะทะฐะฝั‹ ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะดะฐะฝะฝั‹ะต ะฟะพัะปะตะดะฝะตะน ัƒัะฟะตัˆะฝะพะน ะฟั€ะพะฒะตั€ะบะธ; ะธัั‚ะพั‡ะฝะธะบ ัะตะนั‡ะฐั ะฝะตะดะพัั‚ัƒะฟะตะฝ.", + "times_day_name_0": "ะ’ะพัะบั€ะตัะตะฝัŒะต", + "times_day_name_1": "ะŸะพะฝะตะดะตะปัŒะฝะธะบ", + "times_day_name_2": "ะ’ั‚ะพั€ะฝะธะบ", + "times_day_name_3": "ะกั€ะตะดะฐ", + "times_day_name_4": "ะงะตั‚ะฒะตั€ะณ", + "times_day_name_5": "ะŸัั‚ะฝะธั†ะฐ", + "times_day_name_6": "ะกัƒะฑะฑะพั‚ะฐ", + "times_heading": "๐Ÿ•ฏ ะ’ั€ะตะผั ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ", + "times_location": "๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}", + "times_dates": "๐Ÿ“… ะกะฒัั‚ั‹ะต ะดะฝะธ: {{dates}}", + "times_candles": "๐Ÿ•ฏ ะ—ะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน - {{date}} ยท {{time}}", + "times_havdalah": "๐ŸŒ™ ะžะบะพะฝั‡ะฐะฝะธะต / ะะฒะดะฐะปะฐ: {{time}}", + "times_close": "๐Ÿ”’ ะ—ะฐะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟั‹: {{time}}", + "times_open": "๐Ÿ”“ ะžั‚ะบั€ั‹ั‚ะธะต ะณั€ัƒะฟะฟั‹: {{time}}", + "times_sequence": "โ„น๏ธ ะกะฒัั‚ั‹ะต ะดะฝะธ ะธะดัƒั‚ ะฟะพะดั€ัะด: ะณั€ัƒะฟะฟะฐ ะพัั‚ะฐั‘ั‚ัั ะทะฐะบั€ั‹ั‚ะพะน ะดะพ ะพะบะพะฝั‡ะฐั‚ะตะปัŒะฝะพะณะพ ะพั‚ะบั€ั‹ั‚ะธั.", + "times_parasha": "๐Ÿ•ฏ ะะตะดะตะปัŒะฝะฐั ะณะปะฐะฒะฐ: {{title}}", + "times_mevarchim": "๐ŸŒ™ ะจะฐะฑะฑะฐั‚ ะผะตะฒะฐั€ั…ะธะผ: {{title}}", + "times_holiday": "โœก๏ธ ะกะพะฑั‹ั‚ะธะต ะบะฐะปะตะฝะดะฐั€ั: {{title}}", + "times_hebrew_date": "๐Ÿ“… {{date}} ยท {{hdate}}", + "times_information": "โ„น๏ธ {{memo}}", + "times_methods": "ะ—ะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน: ะทะฐ {{minutes}} ะผะธะฝัƒั‚ ะดะพ ะทะฐะบะฐั‚ะฐ\nะžะบะพะฝั‡ะฐะฝะธะต: {{method}} {{value}}", + "times_source": "ะ˜ัั‚ะพั‡ะฝะธะบ: Hebcal ยท ะžะฑะฝะพะฒะปะตะฝะพ: {{time}}", + "times_data_missing": "ะงะฐัั‚ัŒ ะดะฐะฝะฝั‹ั… ะฒั€ะตะผะตะฝะธ ัะตะนั‡ะฐั ะฝะตะดะพัั‚ัƒะฟะฝะฐ.", + "location_menu_intro": "๐Ÿ“ ะœะตัั‚ะพะฟะพะปะพะถะตะฝะธะต ะดะปั {{title}}\nะขะตะบัƒั‰ะตะต ะผะตัั‚ะพ: {{location}}\nะงะฐัะพะฒะพะน ะฟะพัั: {{timezone}}\nะ’ั‹ะฑะตั€ะธั‚ะต ัะฟะพัะพะฑ ะฟะพะธัะบะฐ. ะŸะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ ะฒั‹ ะฟั€ะพะฒะตั€ะธั‚ะต ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะดะฐะฝะฝั‹ะต.", + "location_input_city_title": "๐Ÿ”Ž ะŸะพะธัะบ ะณะพั€ะพะดะฐ", + "location_input_city_prompt": "ะ’ะฒะตะดะธั‚ะต ะฝะฐะทะฒะฐะฝะธะต ะณะพั€ะพะดะฐ. ะœะพะถะฝะพ ะธัะบะฐั‚ัŒ ะฝะฐ ั€ัƒััะบะพะผ ะธ ะดั€ัƒะณะธั… ะฟะพะดะดะตั€ะถะธะฒะฐะตะผั‹ั… ัะทั‹ะบะฐั…. ะ’ั‹ะฑะตั€ะธั‚ะต ั€ะตะทัƒะปัŒั‚ะฐั‚ ะธะท ัะฟะธัะบะฐ.", + "location_input_timezone_title": "๐ŸŒ ะงะฐัะพะฒะพะน ะฟะพัั", + "location_input_timezone_prompt": "ะ’ะฒะตะดะธั‚ะต ะฝะฐะทะฒะฐะฝะธะต ะณะพั€ะพะดะฐ ะธะปะธ ั‡ะฐัะพะฒะพะน ะฟะพัั, ะฝะฐะฟั€ะธะผะตั€ Jerusalem ะธะปะธ Asia/Jerusalem. ะ˜ะทะผะตะฝะตะฝะธะต ะฟะพััะฐ ะฝะต ะผะตะฝัะตั‚ ะบะพะพั€ะดะธะฝะฐั‚ั‹.", + "location_input_geoname_title": "๐Ÿ”ข ID GeoNames", + "location_input_geoname_prompt": "ะ’ะฒะตะดะธั‚ะต ั‡ะธัะปะพะฒะพะน ะธะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ ะผะตัั‚ะฐ ะฒ GeoNames, ะฝะฐะฟั€ะธะผะตั€ 281184 ะดะปั ะ˜ะตั€ัƒัะฐะปะธะผะฐ.", + "location_input_coordinates_title": "๐Ÿงญ ะšะพะพั€ะดะธะฝะฐั‚ั‹", + "location_input_coordinates_prompt": "ะ’ะฒะตะดะธั‚ะต ัˆะธั€ะพั‚ัƒ ะธ ะดะพะปะณะพั‚ัƒ, ะฝะฐะฟั€ะธะผะตั€ 31.76904, 35.21633. ะœะพะถะฝะพ ะดะพะฑะฐะฒะธั‚ัŒ ั‡ะฐัะพะฒะพะน ะฟะพัั, ะฝะฐะฟั€ะธะผะตั€ Asia/Jerusalem.", + "location_input_gps_title": "๐Ÿ“ฑ ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ะณะตะพะฟะพะทะธั†ะธัŽ", + "location_input_gps_prompt": "ะžั‚ะฒะตั‚ัŒั‚ะต ะฝะฐ ัั‚ะพ ัะพะพะฑั‰ะตะฝะธะต ะณะตะพะฟะพะทะธั†ะธะตะน Telegram. ะ—ะฐั‚ะตะผ ะฟั€ะพะฒะตั€ัŒั‚ะต ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะผะตัั‚ะพ ะธ ั‡ะฐัะพะฒะพะน ะฟะพัั.", + "location_input_invalid": "ะะต ัƒะดะฐะปะพััŒ ะพะฟั€ะตะดะตะปะธั‚ัŒ ะผะตัั‚ะพ. ะ’ะฒะตะดะธั‚ะต ะณะพั€ะพะด, ID GeoNames, ะบะพะพั€ะดะธะฝะฐั‚ั‹ ะธะปะธ ั‡ะฐัะพะฒะพะน ะฟะพัั ะฒ ัะพะพั‚ะฒะตั‚ัั‚ะฒะธะธ ั ะฒั‹ะฑั€ะฐะฝะฝั‹ะผ ัะฟะพัะพะฑะพะผ.", + "location_inline_hint": "ะœะพะถะฝะพ ะธัะบะฐั‚ัŒ ะธ ะฒ ะฟะพะปะต ัะพะพะฑั‰ะตะฝะธั: @{{bot}}, ะทะฐั‚ะตะผ ะฝะฐะทะฒะฐะฝะธะต ะณะพั€ะพะดะฐ.", + "location_input_cancelled": "ะ’ั‹ะฑะพั€ ะผะตัั‚ะฐ ะพั‚ะผะตะฝั‘ะฝ. ะะฐัั‚ั€ะพะนะบะธ ะฝะต ะธะทะผะตะฝะธะปะธััŒ.", + "daf_open_page": "๐Ÿ”— ะžั‚ะบั€ั‹ั‚ัŒ ัั‚ั€ะฐะฝะธั†ัƒ", + "timezone_inline_hint": "ะœะพะถะฝะพ ะธัะบะฐั‚ัŒ ะธ ะฒ ะฟะพะปะต ัะพะพะฑั‰ะตะฝะธั: @{{bot}} tz Jerusalem ะธะปะธ @{{bot}} Asia/Jerusalem. ะ”ะปั ะฒั‹ะฑะพั€ะฐ ะฒ ั‚ะตะบัƒั‰ะธั… ะฝะฐัั‚ั€ะพะนะบะฐั… ะธัะฟะพะปัŒะทัƒะนั‚ะต ะบะฝะพะฟะบัƒ ะฟะพะธัะบะฐ ะทะดะตััŒ.", + "donate_other": "ะ”ั€ัƒะณะฐั ััƒะผะผะฐ", + "donate_custom_prompt": "โญ ะ’ั‹ะฑะตั€ะธั‚ะต ััƒะผะผัƒ ะฟะพะดะดะตั€ะถะบะธ\n\nะžั‚ะฟั€ะฐะฒัŒั‚ะต ั†ะตะปะพะต ั‡ะธัะปะพ ะพั‚ 1 ะดะพ 10 000 ะทะฒั‘ะทะด.\nะ’ั‹ ะฟะพะปัƒั‡ะธั‚ะต ััั‹ะปะบัƒ ะฝะฐ ะพะฟะปะฐั‚ัƒ ะธ ัะผะพะถะตั‚ะต ะฟะพะดั‚ะฒะตั€ะดะธั‚ัŒ ะฟะตั€ะตะฒะพะด ะฒ Telegram.", + "donate_custom_invalid": "ะ’ะฒะตะดะธั‚ะต ั†ะตะปะพะต ั‡ะธัะปะพ ะพั‚ 1 ะดะพ 10 000 ะทะฒั‘ะทะด ะฑะตะท ะทะฝะฐะบะพะฒ ะธ ะดั€ะพะฑะตะน.", + "donate_custom_ready": "โญ ะงั‚ะพะฑั‹ ะพั‚ะฟั€ะฐะฒะธั‚ัŒ {{amount}} ะทะฒั‘ะทะด, ะพั‚ะบั€ะพะนั‚ะต ััั‹ะปะบัƒ ะฝะฐ ะพะฟะปะฐั‚ัƒ ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะฒ Telegram.", + "donate_custom_cancel": "โ†ฉ๏ธ ะะฐะทะฐะด ะบ ััƒะผะผะฐะผ", + "times_upcoming": "๐Ÿ•ฏ ะ‘ะปะธะถะฐะนัˆะธะต ะจะฐะฑะฑะฐั‚ั‹ ะธ ะฟั€ะฐะทะดะฝะธะบะธ", + "times_event_title": "{{title}} ยท {{dates}}", + "times_page": "ะกั‚ั€ะฐะฝะธั†ะฐ {{page}} ะธะท {{pages}}", + "times_period_exit": "๐ŸŒ™ ะžะบะพะฝั‡ะฐะฝะธะต ะฟะตั€ะธะพะดะฐ: {{time}}", + "times_no_events": "ะ’ ะฟั€ะพะฒะตั€ะตะฝะฝะพะผ ะดะธะฐะฟะฐะทะพะฝะต ะดะฐั‚ ะฑะพะปัŒัˆะต ะฝะตั‚ ะจะฐะฑะฑะฐั‚ะพะฒ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ.", + "times_lighting_after": "ะ’ ัั‚ะพั‚ ะฒะตั‡ะตั€ ัะฒะตั‡ะธ ะทะฐะถะธะณะฐัŽั‚ ะฟะพัะปะต ะพะบะพะฝั‡ะฐะฝะธั ะฟั€ะตะดั‹ะดัƒั‰ะตะณะพ ัะฒัั‚ะพะณะพ ะดะฝั ะพั‚ ะพะณะฝั, ะทะฐะถะถั‘ะฝะฝะพะณะพ ะดะพ ะฝะฐั‡ะฐะปะฐ ะฟั€ะฐะทะดะฝะธะบะฐ.", + "times_event_link": "ะ’ั€ะตะผั: {{title}}", + "omer_nightfall_label": "ะ’ั‹ั…ะพะด ะทะฒั‘ะทะด", + "wizard_title": "โš™๏ธ {{module}} - {{title}}", + "wizard_choose_field": "ะ’ั‹ะฑะตั€ะธั‚ะต ะฝะฐัั‚ั€ะพะนะบัƒ. ะ ัะดะพะผ ะฟะพะบะฐะทะฐะฝะพ ั‚ะตะบัƒั‰ะตะต ะทะฝะฐั‡ะตะฝะธะต.", + "wizard_custom": "โœ๏ธ ะ”ั€ัƒะณะพะต ะทะฝะฐั‡ะตะฝะธะต", + "wizard_input": "โš™๏ธ {{field}}\n๐Ÿ‘ฅ {{title}}\n\nะกะตะนั‡ะฐั: {{current}}\n\n{{hint}}", + "wizard_invalid": "ะ—ะฝะฐั‡ะตะฝะธะต ะฝะต ะฟะพะดั…ะพะดะธั‚. ะกะปะตะดัƒะนั‚ะต ะฟั€ะธะผะตั€ัƒ ะธะปะธ ะฒะตั€ะฝะธั‚ะตััŒ ะบ ัะฟะธัะบัƒ ะฝะฐัั‚ั€ะพะตะบ.", + "wizard_done": "โœ… ะŸั€ะพะฒะตั€ะธั‚ัŒ ะฒั‹ะฑะพั€", + "wizard_more": "โš™๏ธ ะ’ัะต ะฝะฐัั‚ั€ะพะนะบะธ", + "wizard_choose_option": "ะ’ั‹ะฑะตั€ะธั‚ะต ะฒะฐั€ะธะฐะฝั‚ ะธ ะฟั€ะพะฒะตั€ัŒั‚ะต ะธะทะผะตะฝะตะฝะธะต ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", + "wizard_template": "ะะฐัั‚ั€ะพะนั‚ะต ั‚ะตะบัั‚, ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต, ะผะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ ะฒ ั€ะตะดะฐะบั‚ะพั€ะต ัะพะพะฑั‰ะตะฝะธะน. ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€ ะพัั‚ะฐั‘ั‚ัั ะฒ ะปะธั‡ะฝะพะผ ั‡ะฐั‚ะต.", + "wizard_hint_time": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะผะตัั‚ะฝะพะต ะฒั€ะตะผั, ะฝะฐะฟั€ะธะผะตั€ 09:30.", + "wizard_hint_times": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะพะดะฝะพ ะธะปะธ ะฝะตัะบะพะปัŒะบะพ ะทะฝะฐั‡ะตะฝะธะน ะฒั€ะตะผะตะฝะธ ั‡ะตั€ะตะท ะทะฐะฟัั‚ัƒัŽ, ะฝะฐะฟั€ะธะผะตั€ 09:00, 13:30.", + "wizard_hint_integer": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั†ะตะปะพะต ั‡ะธัะปะพ. ะœั‹ ะฟั€ะพะฒะตั€ะธะผ ะดะพะฟัƒัั‚ะธะผั‹ะน ะดะธะฐะฟะฐะทะพะฝ.", + "wizard_hint_float": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั‡ะธัะปะพ: ะฝะฐะฟั€ะธะผะตั€ 8.5 ะดะปั ะณั€ะฐะดัƒัะพะฒ ะธะปะธ 50 ะดะปั ะผะธะฝัƒั‚.", + "wizard_hint_minutes_list": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะผะธะฝัƒั‚ั‹ ั‡ะตั€ะตะท ะทะฐะฟัั‚ัƒัŽ, ะฝะฐะฟั€ะธะผะตั€ 60, 30, 15.", + "wizard_hint_text": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั‚ะตะบัั‚ ะดะปั ัะพั…ั€ะฐะฝะตะฝะธั.", + "wizard_hint_days": "ะ’ั‹ะฑะตั€ะธั‚ะต ะดะฝะธ ะฝะตะดะตะปะธ ะธ ะฝะฐะถะผะธั‚ะต ยซะŸั€ะพะฒะตั€ะธั‚ัŒ ะฒั‹ะฑะพั€ยป.", + "template_edit_guided_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะฝะพะฒะพะต ัะพะพะฑั‰ะตะฝะธะต ะทะดะตััŒ. ะœะพะถะฝะพ ะดะพะฑะฐะฒะธั‚ัŒ ะผะตะดะธะฐ ะธ ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต Telegram. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", + "template_buttons_guided_prompt": "๐Ÿ”— ะšะฝะพะฟะบะธ ัะพะพะฑั‰ะตะฝะธั\n\nะ”ะปั ะฟั€ะพัั‚ะพะน ััั‹ะปะบะธ ะฒั‹ะฑะตั€ะธั‚ะต ยซะ”ะพะฑะฐะฒะธั‚ัŒ ะบะฝะพะฟะบัƒ-ััั‹ะปะบัƒยป. ะœั‹ ะพั‚ะดะตะปัŒะฝะพ ัะฟั€ะพัะธะผ ะฝะฐะดะฟะธััŒ ะธ ะฐะดั€ะตั.\n\nะœะพะถะฝะพ ะพั‚ะฟั€ะฐะฒะธั‚ัŒ ะฝะตัะบะพะปัŒะบะพ ะบะฝะพะฟะพะบ ะฟะพ ะฟั€ะธะผะตั€ัƒ:\nะ˜ะฝั„ะพั€ะผะฐั†ะธั - https://example.org - style:bg_primary\nะŸะพะดะดะตั€ะถะบะฐ - https://example.org/support\n\nะะพะฒะฐั ัั‚ั€ะพะบะฐ ัะพะทะดะฐั‘ั‚ ะฝะพะฒั‹ะน ั€ัะด ะบะฝะพะฟะพะบ. ะšะฝะพะฟะบะธ ะฒ ะพะดะฝะพะผ ั€ัะดัƒ ั€ะฐะทะดะตะปััŽั‚ัั &&. ะŸะพะดะดะตั€ะถะธะฒะฐะตั‚ัั JSON. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€ ะฟะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ.", + "operator_audience_authorize": "โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ะฐัƒะดะธั‚ะพั€ะธัŽ", + "command_adminstats": "๐Ÿ“Š ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ัƒะฟั€ะฐะฒะปะตะฝะธั", + "command_subscribers": "๐Ÿ‘ฅ ะŸะพะดะฟะธัั‡ะธะบะธ ะธ ะฟะพะธัะบ", + "command_broadcast": "๐Ÿ“ฃ ะ ะฐััั‹ะปะบะฐ ัะพะพะฑั‰ะตะฝะธั", + "command_managers": "๐Ÿ‘ฅ ะะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ั‹ ะธ ะฟั€ะฐะฒะฐ", + "command_systemlinks": "โš™๏ธ ะกะธัั‚ะตะผะฝั‹ะต ััั‹ะปะบะธ", + "command_trackinglinks": "โ„น๏ธ ะกัั‹ะปะบะธ ะพั‚ัะปะตะถะธะฒะฐะฝะธั", + "times_upcoming_calendar": "๐Ÿ•ฏ ะ‘ะปะธะถะฐะนัˆะธะต ะฟั€ะฐะทะดะฝะธะบะธ ะธ ะฟะฐะผัั‚ะฝั‹ะต ะดะฝะธ", + "times_sequence_calendar": "ะะตะฟั€ะตั€ั‹ะฒะฝั‹ะน ะฟะตั€ะธะพะด ะจะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ: ัƒะบะฐะทะฐะฝะพ ะบะฐะถะดะพะต ะทะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน ะธ ะพะบะพะฝั‡ะฐะฝะธะต ะฒัะตะณะพ ะฟะตั€ะธะพะดะฐ.", + "template_default_buttons": "โ†ฉ๏ธ ะ’ะตั€ะฝัƒั‚ัŒ ัั‚ะฐะฝะดะฐั€ั‚ะฝั‹ะต ะบะฝะพะฟะบะธ", + "tefillin_ack": "ะฏ ะฝะฐะดะตะป ั‚ั„ะธะปะธะฝ", + "field_ack_enabled": "ะšะฝะพะฟะบะฐ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ั‚ั„ะธะปะธะฝ", + "field_ack_label": "ะขะตะบัั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ั‚ั„ะธะปะธะฝ", + "field_tefillin_ack_enabled": "ะšะฝะพะฟะบะฐ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ั‚ั„ะธะปะธะฝ", + "field_tefillin_ack_label": "ะขะตะบัั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ั‚ั„ะธะปะธะฝ", + "template_add_link": "โž• ะ”ะพะฑะฐะฒะธั‚ัŒ ะบะฝะพะฟะบัƒ-ััั‹ะปะบัƒ", + "template_link_text_prompt": "๐Ÿ”— ะะพะฒะฐั ะบะฝะพะฟะบะฐ\n\nะžั‚ะฟั€ะฐะฒัŒั‚ะต ะบะพั€ะพั‚ะบัƒัŽ ะฝะฐะดะฟะธััŒ, ะฝะฐะฟั€ะธะผะตั€ ยซะŸะพะดั€ะพะฑะฝะตะตยป.", + "template_link_url_prompt": "๐Ÿ”— ะšัƒะดะฐ ะฒะตะดั‘ั‚ ะบะฝะพะฟะบะฐ?\n\nะžั‚ะฟั€ะฐะฒัŒั‚ะต ะฟะพะปะฝั‹ะน ะฐะดั€ะตั ั https://, ะฝะฐะฟั€ะธะผะตั€ https://example.org.\nะŸะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ ะฒั‹ ัƒะฒะธะดะธั‚ะต ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€.", + "template_save_review": "โœ… ะกะพั…ั€ะฐะฝะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธะต", + "template_ack_preview": "ะญั‚ะพ ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€. ะะฐะถะฐั‚ะธะต ะทะดะตััŒ ะฝะต ะผะตะฝัะตั‚ ัั‡ั‘ั‚ั‡ะธะบ.", + "template_restore_text": "๐Ÿ“ ะ’ะพััั‚ะฐะฝะพะฒะธั‚ัŒ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธะน ั‚ะตะบัั‚", + "template_remove_caption": "๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ะฟะพะดะฟะธััŒ ะบ ะผะตะดะธะฐ", + "template_text_restored": "ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธะน ั‚ะตะบัั‚ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝ. ะœะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ ัะพั…ั€ะฐะฝะตะฝั‹.", + "template_caption_removed": "ะŸะพะดะฟะธััŒ ัƒะดะฐะปะตะฝะฐ. ะœะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ ัะพั…ั€ะฐะฝะตะฝั‹.", + "settings_mode_intro": "โš™๏ธ ะ ะตะถะธะผ ั€ะฐะฑะพั‚ั‹ ะดะปั {{title}}\n\nะ’ั‹ะบะปัŽั‡ะตะฝะพ - ะฑะตะท ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธั… ัะพะพะฑั‰ะตะฝะธะน ะธ ะทะฐะบั€ั‹ั‚ะธั ะฒ ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ.\nะขะพะปัŒะบะพ ัะพะพะฑั‰ะตะฝะธั - ะฟัƒะฑะปะธะบะฐั†ะธะธ ะฑะตะท ะธะทะผะตะฝะตะฝะธั ะฟั€ะฐะฒ ะณั€ัƒะฟะฟั‹.\nะขะพะปัŒะบะพ ะทะฐะบั€ั‹ั‚ะธะต - ะทะฐะบั€ั‹ั‚ะธะต ะธ ะพั‚ะบั€ั‹ั‚ะธะต ะฟะพ ะฒั€ะตะผะตะฝะธ ะฑะตะท ะฟั€ะฐะทะดะฝะธั‡ะฝั‹ั… ัะพะพะฑั‰ะตะฝะธะน.\nะกะพะพะฑั‰ะตะฝะธั ะธ ะทะฐะบั€ั‹ั‚ะธะต - ะฟัƒะฑะปะธะบะฐั†ะธะธ ะธ ัƒะฟั€ะฐะฒะปะตะฝะธะต ะดะพัั‚ัƒะฟะพะผ.\n\nะŸั€ะธะผะตะฝัะตั‚ัั ะบ ะฒะบะปัŽั‡ั‘ะฝะฝั‹ะผ ะจะฐะฑะฑะฐั‚ะฐะผ ะธ ะดะฝัะผ ะ™ะพะผ-ะขะพะฒ.", + "settings_mode_button": "ะ ะตะถะธะผ: {{mode}}", + "settings_shabbat_timing": "๐Ÿ•ฏ๏ธ ะะฐั‡ะฐะปะพ, ะบะพะฝะตั† ะธ ะทะฐะบั€ั‹ั‚ะธะต", + "settings_shabbat_messages": "โœ๏ธ ะกะพะพะฑั‰ะตะฝะธั ะธ ะพั„ะพั€ะผะปะตะฝะธะต", + "settings_shabbat_timing_intro": "ะ’ั‹ะฑะตั€ะธั‚ะต ะพะฑั‹ั‡ะฐะน ะทะฐะถะธะณะฐะฝะธั ัะฒะตั‡ะตะน, ะผะตั‚ะพะด ะพะบะพะฝั‡ะฐะฝะธั ะธ ะธะฝั‚ะตั€ะฒะฐะปั‹ ะทะฐะบั€ั‹ั‚ะธั/ะพั‚ะบั€ั‹ั‚ะธั. ะ’ั€ะตะผั ัƒั‡ะธั‚ั‹ะฒะฐะตั‚ ะผะตัั‚ะพะฟะพะปะพะถะตะฝะธะต ะณั€ัƒะฟะฟั‹. ะŸั€ะธ ะฟะพัะปะตะดะพะฒะฐั‚ะตะปัŒะฝั‹ั… ะจะฐะฑะฑะฐั‚ะต ะธ ะ™ะพะผ-ะขะพะฒ ะณั€ัƒะฟะฟะฐ ะทะฐะบั€ั‹ั‚ะฐ ะดะพ ะบะพะฝั†ะฐ ะฒัะตะณะพ ะฟะตั€ะธะพะดะฐ.", + "settings_shabbat_messages_intro": "ะ’ั‹ะฑะตั€ะธั‚ะต ัะพะพะฑั‰ะตะฝะธั ะพ ะทะฐะบั€ั‹ั‚ะธะธ/ะพั‚ะบั€ั‹ั‚ะธะธ, ั‚ะธั…ะธะต ั‡ะฐัั‹ ะธ ั‚ะตะผัƒ ะณั€ัƒะฟะฟั‹. ะขะตะบัั‚, ะพั„ะพั€ะผะปะตะฝะธะต, ะผะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ ะบะฐะถะดะพะณะพ ัะพะพะฑั‰ะตะฝะธั ะฝะฐัั‚ั€ะฐะธะฒะฐัŽั‚ัั ะพั‚ะดะตะปัŒะฝะพ.", + "settings_shabbat_summary": "โœก๏ธ ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ - {{title}}\n๐Ÿ“ {{location}}\n๐ŸŒ {{timezone}}\n\nะ ะตะถะธะผ: {{mode}}\nะจะฐะฑะฑะฐั‚: {{shabbat}} ยท ะ™ะพะผ-ะขะพะฒ: {{holidays}}\n\n๐Ÿ•ฏ๏ธ ะะฐั‡ะฐะปะพ ะธ ะบะพะฝะตั†\nะ—ะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน: ะทะฐ {{candles}} ะผะธะฝ. ะดะพ ะทะฐะบะฐั‚ะฐ\nะžะบะพะฝั‡ะฐะฝะธะต: {{havdalah}}\nะ—ะฐะบั€ั‹ั‚ะธะต: ะทะฐ {{close}} ะผะธะฝ. ะดะพ ัะฒะตั‡ะตะน\nะžั‚ะบั€ั‹ั‚ะธะต: ั‡ะตั€ะตะท {{open}} ะผะธะฝ. ะฟะพัะปะต ะพะบะพะฝั‡ะฐะฝะธั\n\nะ’ั‹ะฑะตั€ะธั‚ะต ั€ะฐะทะดะตะป. ะŸะพัะปะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั ะธะทะผะตะฝะตะฝะธั ะฒั‹ ะฒะตั€ะฝั‘ั‚ะตััŒ ะฒ ั‚ะพั‚ ะถะต ั€ะฐะทะดะตะป.", + "template_current_summary": "ะขะตะบัƒั‰ะตะต ะพั„ะพั€ะผะปะตะฝะธะต\nะขะตะบัั‚: {{text}}\nะœะตะดะธะฐ: {{media}}\nะšะฝะพะฟะบะธ: {{buttons}}\n\nะ˜ะทะผะตะฝะธั‚ะต ัะพะดะตั€ะถะธะผะพะต ะธะปะธ ะบะฝะพะฟะบะธ, ะฟั€ะพะฒะตั€ัŒั‚ะต ะธ ัะพั…ั€ะฐะฝะธั‚ะต. ะขะตัั‚ะพะฒะฐั ะพั‚ะฟั€ะฐะฒะบะฐ ะฒ ะณั€ัƒะฟะฟัƒ ั‚ั€ะตะฑัƒะตั‚ ะพั‚ะดะตะปัŒะฝะพะณะพ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั.", + "template_text_default": "ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะพะต ัะพะพะฑั‰ะตะฝะธะต", + "template_text_custom": "ะกะฒะพะน ั‚ะตะบัั‚", + "template_buttons_inherit": "ะกั‚ะฐะฝะดะฐั€ั‚ะฝั‹ะต ะบะฝะพะฟะบะธ ะฑะพั‚ะฐ", + "template_buttons_custom": "ะกะฒะพะธ ะบะฝะพะฟะบะธ", + "template_buttons_none": "ะ‘ะตะท ะบะฝะพะฟะพะบ", + "template_panel_hint": "ะ’ั‹ะฑะตั€ะธั‚ะต ั‚ะธะฟ ัะพะพะฑั‰ะตะฝะธั. ะ”ะปั ะบะฐะถะดะพะณะพ ะดะพัั‚ัƒะฟะฝั‹ ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต, ะผะตะดะธะฐ ะธ ะบะฝะพะฟะบะธ. ะ˜ะทะผะตะฝะตะฝะธั ะดะตะนัั‚ะฒัƒัŽั‚ ั‚ะพะปัŒะบะพ ะดะปั ัั‚ะพะน ะณั€ัƒะฟะฟั‹.", + "times_period_enter": "๐Ÿ•ฏ ะะฐั‡ะฐะปะพ / ะทะฐะถะธะณะฐะฝะธะต ัะฒะตั‡ะตะน: {{time}}", + "times_upcoming_shabbat": "๐Ÿ•ฏ ะ‘ะปะธะถะฐะนัˆะธะต ััƒะฑะฑะพั‚ั‹", + "times_yomtov_unavailable": "ะŸั€ะพะฒะตั€ะตะฝะฝั‹ะต ะฒั€ะตะผะตะฝะฐ ะฝะฐั‡ะฐะปะฐ ะธ ะพะบะพะฝั‡ะฐะฝะธั ัั‚ะพะณะพ ะ™ะพะผ-ะขะพะฒะฐ ะฟะพะบะฐ ะฝะตะดะพัั‚ัƒะฟะฝั‹.", + "info_permissions_title": "๐Ÿ›ก ะŸั€ะฐะฒะฐ, ะทะฐะบั€ั‹ั‚ะธะต ะธ ะพั‚ะบั€ั‹ั‚ะธะต", + "info_permissions_text": "ะšะฐะบะธะต ะณั€ัƒะฟะฟั‹ ะฟะพะดะดะตั€ะถะธะฒะฐัŽั‚ัั?\nTelegram ะฟะพะทะฒะพะปัะตั‚ ะฑะพั‚ะฐะผ ะผะตะฝัั‚ัŒ ะพะฑั‰ะธะต ะฟั€ะฐะฒะฐ ะฒ ะพะฑั‹ั‡ะฝั‹ั… ะณั€ัƒะฟะฟะฐั… ะธ ััƒะฟะตั€ะณั€ัƒะฟะฟะฐั…. ะ‘ะพั‚ ะดะพะปะถะตะฝ ะฑั‹ั‚ัŒ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะผ ั ะฟั€ะฐะฒะพะผ ะพะณั€ะฐะฝะธั‡ะธะฒะฐั‚ัŒ ัƒั‡ะฐัั‚ะฝะธะบะพะฒ. ะžะณั€ะฐะฝะธั‡ะตะฝะธั ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ ะดะตะนัั‚ะฒัƒัŽั‚ ะฝะฐ ะพะฑั‹ั‡ะฝั‹ั… ัƒั‡ะฐัั‚ะฝะธะบะพะฒ; ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ั‹ ะผะพะณัƒั‚ ะฟะธัะฐั‚ัŒ.\n\nะŸะตั€ะตะด ะฒะบะปัŽั‡ะตะฝะธะตะผ\nะ’ั‹ะฑะตั€ะธั‚ะต ะณะพั€ะพะด, ั‡ะฐัะพะฒะพะน ะฟะพัั ะธ ะบะฐะปะตะฝะดะฐั€ัŒ ะ˜ะทั€ะฐะธะปั ะธะปะธ ะดะธะฐัะฟะพั€ั‹. ะ’ ั€ะฐะทะดะตะปะต ยซะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธยป ะฒั‹ะฑะตั€ะธั‚ะต ะทะฐะบั€ั‹ั‚ะธะต ะธะปะธ ัะพะพะฑั‰ะตะฝะธั ะธ ะทะฐะบั€ั‹ั‚ะธะต. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฒั€ะตะผั ัะฒะตั‡ะตะน, ัะผะตั‰ะตะฝะธั ะทะฐะบั€ั‹ั‚ะธั ะธ ะพั‚ะบั€ั‹ั‚ะธั, ะฐ ั‚ะฐะบะถะต ะฟั€ะฐะฒะฐ ะฑะพั‚ะฐ.\n\nะŸั€ะธ ะพั‚ะบั€ั‹ั‚ะธะธ\nะ‘ะพั‚ ัะพั…ั€ะฐะฝัะตั‚ ะฟั€ะฐะฒะฐ ะดะพ ะทะฐะบั€ั‹ั‚ะธั ะธ ะฒะพััั‚ะฐะฝะฐะฒะปะธะฒะฐะตั‚ ะธั… ะฟะพัะปะต. ะกะปะตะดัƒัŽั‰ะธะต ะฟะพะดั€ัะด ะจะฐะฑะฑะฐั‚ ะธ ะ™ะพะผ-ะขะพะฒ ะพัั‚ะฐัŽั‚ัั ะทะฐะบั€ั‹ั‚ั‹ะผะธ ะดะพ ะฟะพัะปะตะดะฝะตะณะพ ะฝะฐัั‚ั€ะพะตะฝะฝะพะณะพ ะฒั‹ั…ะพะดะฐ. ะ˜ะทะผะตะฝะตะฝะธั ะฟั€ะฐะฒ ะฒะฝะต ะฑะพั‚ะฐ ะธะปะธ ะฟะพั‚ะตั€ั ะดะพัั‚ัƒะฟะฐ ะผะพะณัƒั‚ ะฟะพั‚ั€ะตะฑะพะฒะฐั‚ัŒ ะฟั€ะพะฒะตั€ะบะธ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ.\n\nะฃะดะฐะปะตะฝะธะต ัะพะพะฑั‰ะตะฝะธะน\nะัƒะถะฝะพ ะฟั€ะฐะฒะพ ัƒะดะฐะปะตะฝะธั. ะกะพะพะฑั‰ะตะฝะธั ะดั€ัƒะณะธั… ะฑะพั‚ะพะฒ ัƒะดะฐะปััŽั‚ัั ะพั‚ะดะตะปัŒะฝะพะน ะพะฟั†ะธะตะน ะธ ั‚ะพะปัŒะบะพ ะตัะปะธ Telegram ะฟะตั€ะตะดะฐั‘ั‚ ะธั… ัั‚ะพะผัƒ ะฑะพั‚ัƒ.", + "template_buttons_simple_prompt": "ะ”ะพะฑะฐะฒัŒั‚ะต ะบะฝะพะฟะบัƒ ััั‹ะปะบะธ: ัะฝะฐั‡ะฐะปะฐ ัƒะบะฐะถะธั‚ะต ะฟะพะดะฟะธััŒ, ะทะฐั‚ะตะผ ะฐะดั€ะตั ั https://. ะŸะตั€ะตะด ัะพั…ั€ะฐะฝะตะฝะธะตะผ ะฟะพัะฒะธั‚ัั ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€.\n\nะ”ั€ัƒะณะธะต ะดะตะนัั‚ะฒะธั ะธ ะฝะตัะบะพะปัŒะบะพ ัั‚ั€ะพะบ ะดะพัั‚ัƒะฟะฝั‹ ะฒ ะดะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ั… ะฟะฐั€ะฐะผะตั‚ั€ะฐั….", + "template_advanced_buttons": "โš™๏ธ ะ”ะพะฟะพะปะฝะธั‚ะตะปัŒะฝั‹ะต ะฟะฐั€ะฐะผะตั‚ั€ั‹", + "operator_manual_details": "ะŸะพะดั€ะพะฑะฝะพัั‚ะธ ะฟะพ ะณั€ัƒะฟะฟะฐะผ", + "operator_manual_pending": "ะžะถะธะดะฐัŽั‚: {{count}}", + "operator_manual_more": "ะ•ั‰ั‘ {{count}} ะณั€ัƒะฟะฟ", + "permission_error_admin": "ะ”ะฐะนั‚ะต ะฑะพั‚ัƒ ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ ะฝะฐ ะพะณั€ะฐะฝะธั‡ะตะฝะธะต ัƒั‡ะฐัั‚ะฝะธะบะพะฒ.", + "permission_error_access": "ะ‘ะพั‚ ะฝะต ัะพัั‚ะพะธั‚ ะฒ ะณั€ัƒะฟะฟะต ะธะปะธ ะฝะต ะธะผะตะตั‚ ะดะพัั‚ัƒะฟะฐ.", + "permission_error_flags": "ะ’ั‹ะฑั€ะฐะฝะฝั‹ะต ะพะณั€ะฐะฝะธั‡ะตะฝะธั ะฝะตะดะพะฟัƒัั‚ะธะผั‹. ะกะฑั€ะพััŒั‚ะต ะธั… ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั… ัˆะฐะฑะฑะฐั‚ะฐ ะธ ะฟั€ะฐะทะดะฝะธะบะพะฒ.", + "permission_error_snapshot": "ะ˜ัั…ะพะดะฝั‹ะต ะฟั€ะฐะฒะฐ ะฝะตะปัŒะทั ะฟั€ะพะฒะตั€ะธั‚ัŒ. ะŸะตั€ะตะด ะพั‚ะบั€ั‹ั‚ะธะตะผ ะฝัƒะถะฝะฐ ะฟั€ะพะฒะตั€ะบะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ.", + "permission_error_conflict": "ะขะตะบัƒั‰ะธะต ั€ะฐะทั€ะตัˆะตะฝะธั ะพั‚ะปะธั‡ะฐัŽั‚ัั ะพั‚ ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ั…. ะะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธะต ะธะทะผะตะฝะตะฝะธั ะพัั‚ะฐะฝะพะฒะปะตะฝั‹ ะดะพ ะฟั€ะพะฒะตั€ะบะธ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะผ.", + "permission_error_provider": "ะกะตั€ะฒะธั ะบะฐะปะตะฝะดะฐั€ั ะฝะตะดะพัั‚ัƒะฟะตะฝ.", + "permission_error_unknown": "ะ”ะตะนัั‚ะฒะธะต ะฝะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะพ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะถัƒั€ะฝะฐะป ะดะธะฐะณะฝะพัั‚ะธะบะธ ะณั€ัƒะฟะฟั‹.", + "permission_status_wait": "ะžะถะธะดะฐะฝะธะต ะฟะพะฒั‚ะพั€ะฐ ะฟะพัะปะต ะพะณั€ะฐะฝะธั‡ะตะฝะธั Telegram", + "permission_status_busy": "ะ”ั€ัƒะณะพะต ะดะตะนัั‚ะฒะธะต ะดะปั ะณั€ัƒะฟะฟั‹ ะตั‰ั‘ ะฒั‹ะฟะพะปะฝัะตั‚ัั", + "permission_status_prelocked": "ะ“ั€ัƒะฟะฟะฐ ะทะฐะบั€ั‹ั‚ะฐ ะฒะฝะต ะฑะพั‚ะฐ ะธ ะฝะต ะฑัƒะดะตั‚ ะพั‚ะบั€ั‹ั‚ะฐ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ", + "permission_status_idle": "ะะตั‚ ัƒัั‚ะฐะฝะพะฒะปะตะฝะฝะพะน ะฑะพั‚ะพะผ ะฑะปะพะบะธั€ะพะฒะบะธ ะดะปั ัะฝัั‚ะธั", + "permission_status_unchanged": "ะ“ั€ัƒะฟะฟะฐ ัƒะถะต ะฒ ะทะฐะฟั€ะพัˆะตะฝะฝะพะผ ัะพัั‚ะพัะฝะธะธ", + "restart_requested": "โ™ป๏ธ ะะฐั‡ะธะฝะฐัŽ ะบะพั€ั€ะตะบั‚ะฝั‹ะน ะฟะตั€ะตะทะฐะฟัƒัะบ. ะ‘ะพั‚ ะฒะตั€ะฝั‘ั‚ัั ะฟะพัะปะต ะฟะพะฒั‚ะพั€ะฝะพะณะพ ะทะฐะฟัƒัะบะฐ.", + "permission_conflict_fields": "ะžั‚ะปะธั‡ะธั: {{fields}}.", + "permission_until_date": "ะกั€ะพะบ ะดะตะนัั‚ะฒะธั ั€ะฐะทั€ะตัˆะตะฝะธะน", + "permission_unknown": "ะะตะธะทะฒะตัั‚ะฝะพะต ั€ะฐะทั€ะตัˆะตะฝะธะต", + "times_shabbat_title": "ะจะฐะฑะฑะฐั‚", + "permission_recovery": "๐Ÿ›  ะ ะตัˆะธั‚ัŒ ะฟั€ะพะฑะปะตะผัƒ ะฟั€ะฐะฒ", + "recovery_title": "๐Ÿ›  ะ ะตัˆะตะฝะธะต ะฟั€ะพะฑะปะตะผั‹ ะฟั€ะฐะฒ\n๐Ÿ‘ฅ {{title}}\n{{group}}", + "recovery_intro": "ะ‘ะพั‚ ัะพั…ั€ะฐะฝัะตั‚ ะพะฑั‹ั‡ะฝั‹ะต ะฟั€ะฐะฒะฐ ะฟะตั€ะตะด ะฑะปะพะบะธั€ะพะฒะบะพะน ะธ ะฒะพะทะฒั€ะฐั‰ะฐะตั‚ ะธั… ะฟั€ะธ ะพั‚ะบั€ั‹ั‚ะธะธ. ะ—ะดะตััŒ ะผะพะถะฝะพ ะฟั€ะพะฒะตั€ะธั‚ัŒ ะฝะตะทะฐะฒะตั€ัˆั‘ะฝะฝัƒัŽ ะฟะพะฟั‹ั‚ะบัƒ ะธ ะฒั‹ะฑั€ะฐั‚ัŒ ะดะฐะปัŒะฝะตะนัˆะตะต ะดะตะนัั‚ะฒะธะต.", + "recovery_compare": "ะกั€ะฐะฒะฝะตะฝะธะต ั ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะผ ัะฝะธะผะบะพะผ ะฟั€ะฐะฒ", + "recovery_difference": "โ€ข {{permission}}: ัะตะนั‡ะฐั {{current}} ยท ัะพั…ั€ะฐะฝะตะฝะพ {{saved}}", + "recovery_more": "ะ•ั‰ั‘ ั€ะฐะทะปะธั‡ะธะน: {{count}}. ะ”ะตะนัั‚ะฒะธะต ะทะฐั‚ั€ะฐะณะธะฒะฐะตั‚ ะฒัะต ะฟั€ะฐะฒะฐ, ะฒะบะปัŽั‡ะฐั ะฝะต ะฟะพะบะฐะทะฐะฝะฝั‹ะต ะทะดะตััŒ.", + "recovery_allowed": "ั€ะฐะทั€ะตัˆะตะฝะพ", + "recovery_blocked": "ะทะฐะฟั€ะตั‰ะตะฝะพ", + "recovery_no_differences": "ะ ะฐะทะปะธั‡ะธะน ั ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะผะธ ะธัั…ะพะดะฝั‹ะผะธ ะฟั€ะฐะฒะฐะผะธ ะฝะตั‚.", + "recovery_adopt": "โœ… ะŸั€ะธะฝัั‚ัŒ ั‚ะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ", + "recovery_restore": "โ†ฉ๏ธ ะ’ะตั€ะฝัƒั‚ัŒ ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฟั€ะฐะฒะฐ", + "recovery_preview_adopt": "ะŸั€ะธะฝัั‚ัŒ ั‚ะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ\nะขะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ ะณั€ัƒะฟะฟั‹ ะฑัƒะดัƒั‚ ัั‡ะธั‚ะฐั‚ัŒัั ะพะฑั‹ั‡ะฝั‹ะผ ัะพัั‚ะพัะฝะธะตะผ. ะกะฐะผะธ ะฟั€ะฐะฒะฐ ะฝะต ะธะทะผะตะฝัั‚ัั. ะกั‚ะฐั€ะฐั ะฟะพะฟั‹ั‚ะบะฐ ะฑะปะพะบะธั€ะพะฒะบะธ ะทะฐะฒะตั€ัˆะธั‚ัั; ะฟะตั€ะตะด ัะปะตะดัƒัŽั‰ะตะน ะฑะปะพะบะธั€ะพะฒะบะพะน ะฑะพั‚ ัะพั…ั€ะฐะฝะธั‚ ะฐะบั‚ัƒะฐะปัŒะฝั‹ะต ะฟั€ะฐะฒะฐ.", + "recovery_preview_restore": "ะ’ะตั€ะฝัƒั‚ัŒ ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฟั€ะฐะฒะฐ\nะ‘ะพั‚ ะทะฐะฟั€ะพัะธั‚ ะฒัะต ะฟั€ะฐะฒะฐ, ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะดะพ ัะฑะพั, ะพั‚ะผะตะฝะธะฒ ะฟะพัะปะตะดัƒัŽั‰ะธะต ะธะทะผะตะฝะตะฝะธั. ะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ัั‡ะธั‚ะฐะตั‚ัั ัƒัะฟะตัˆะฝั‹ะผ ั‚ะพะปัŒะบะพ ะฟะพัะปะต ะฟั€ะพะฒะตั€ะบะธ ั‡ะตั€ะตะท Telegram.", + "recovery_confirm_adopt": "โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ั‚ะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ", + "recovery_confirm_restore": "โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต", + "recovery_preview_notice": "ะŸั€ะพะฒะตั€ัŒั‚ะต ะฝะฐะทะฒะฐะฝะธะต ะณั€ัƒะฟะฟั‹ ะธ ะฒั‹ะฑั€ะฐะฝะฝะพะต ะดะตะนัั‚ะฒะธะต. ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะดะตะนัั‚ะฒัƒะตั‚ ะฟัั‚ัŒ ะผะธะฝัƒั‚. ะŸั€ะฐะฒะฐ ะธ ัะพัั‚ะพัะฝะธะต ะฟั€ะพะฒะตั€ััŽั‚ัั ะฟะพะฒั‚ะพั€ะฝะพ; ะฒะพ ะฒั€ะตะผั ะฐะบั‚ะธะฒะฝะพะน ะฑะปะพะบะธั€ะพะฒะบะธ, ะจะฐะฑะฑะฐั‚ะฐ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบะฐ ั€ัƒั‡ะฝะพะต ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะฝะตะดะพัั‚ัƒะฟะฝะพ.", + "recovery_result_adopted": "โœ… ะขะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ ะฟั€ะธะฝัั‚ั‹ ะบะฐะบ ะพะฑั‹ั‡ะฝั‹ะต. ะกั‚ะฐั€ะฐั ะฟะพะฟั‹ั‚ะบะฐ ะทะฐะฒะตั€ัˆะตะฝะฐ.", + "recovery_result_restored": "โœ… ะกะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฟั€ะฐะฒะฐ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝั‹ ะธ ะฟั€ะพะฒะตั€ะตะฝั‹. ะกั‚ะฐั€ะฐั ะฟะพะฟั‹ั‚ะบะฐ ะทะฐะฒะตั€ัˆะตะฝะฐ.", + "recovery_block_active_lock": "๐Ÿ”’ ะกะตะนั‡ะฐั ะฑะตะทะพะฟะฐัะฝะพะต ะฒั€ะตะผั ะดะปั ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะฝะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะพ: ะฒะพะทะผะพะถะฝั‹ ะฐะบั‚ะธะฒะฝะฐั ะฑะปะพะบะธั€ะพะฒะบะฐ, ะจะฐะฑะฑะฐั‚ ะธะปะธ ะฟั€ะฐะทะดะฝะธะบ, ะปะธะฑะพ ะฝะตะดะพัั‚ัƒะฟะฝั‹ ะดะฐะฝะฝั‹ะต ะบะฐะปะตะฝะดะฐั€ั. ะœะพะถะฝะพ ะฟั€ะพัะผะพั‚ั€ะตั‚ัŒ ะธ ะพะฑะฝะพะฒะธั‚ัŒ ัะพัั‚ะพัะฝะธะต; ั€ัƒั‡ะฝั‹ะต ะธะทะผะตะฝะตะฝะธั ะดะพัั‚ัƒะฟะฝั‹ ั‚ะพะปัŒะบะพ ะฟะพัะปะต ะฟั€ะพะฒะตั€ะบะธ ะพั‚ััƒั‚ัั‚ะฒะธั ะทะฐั‰ะธั‰ั‘ะฝะฝะพะณะพ ะฟะตั€ะธะพะดะฐ.", + "recovery_block_no_problem": "โœ… ะŸั€ะพะฑะปะตะผ ะฟั€ะฐะฒ, ั‚ั€ะตะฑัƒัŽั‰ะธั… ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั, ะฝะตั‚. ะŸะตั€ะตะด ัะปะตะดัƒัŽั‰ะตะน ะฑะปะพะบะธั€ะพะฒะบะพะน ะฑะพั‚ ัะพั…ั€ะฐะฝะธั‚ ะฐะบั‚ัƒะฐะปัŒะฝั‹ะต ะฟั€ะฐะฒะฐ ะณั€ัƒะฟะฟั‹.", + "recovery_block_missing_snapshot": "โš ๏ธ ะะตั‚ ะฟั€ะพะฒะตั€ะตะฝะฝะพะณะพ ะธัั…ะพะดะฝะพะณะพ ัะฝะธะผะบะฐ ะดะปั ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั. ะ•ัะปะธ ะดะพัั‚ัƒะฟะฝะพ ะฟั€ะธะฝัั‚ะธะต ั‚ะตะบัƒั‰ะธั… ะฟั€ะฐะฒ, ัะฝะฐั‡ะฐะปะฐ ัƒะฑะตะดะธั‚ะตััŒ, ั‡ั‚ะพ ะธะผะตะฝะฝะพ ะพะฝะธ ะดะพะปะถะฝั‹ ะฑั‹ั‚ัŒ ะพะฑั‹ั‡ะฝั‹ะผะธ.", + "recovery_block_locked_current": "๐Ÿ”’ ะŸะพั…ะพะถะต, ะณั€ัƒะฟะฟะฐ ัะตะนั‡ะฐั ะทะฐะฑะปะพะบะธั€ะพะฒะฐะฝะฐ. ะะตะปัŒะทั ะฟั€ะธะฝัั‚ัŒ ัั‚ะพ ะทะฐ ะพะฑั‹ั‡ะฝะพะต ัะพัั‚ะพัะฝะธะต. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฒะพะทะผะพะถะฝะพัั‚ัŒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั ะธะปะธ ะพะฑั€ะฐั‚ะธั‚ะตััŒ ะบ ะพะฟะตั€ะฐั‚ะพั€ัƒ ะฑะพั‚ะฐ.", + "recovery_block_unknown": "โš ๏ธ ะกะตะนั‡ะฐั ะฝะตะปัŒะทั ะฟะพะดั‚ะฒะตั€ะดะธั‚ัŒ ะฑะตะทะพะฟะฐัะฝะพัั‚ัŒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั. ะžะฑะฝะพะฒะธั‚ะต ัะพัั‚ะพัะฝะธะต; ะตัะปะธ ะฟั€ะพะฑะปะตะผะฐ ะฟะพะฒั‚ะพั€ัะตั‚ัั, ะพะฑั€ะฐั‚ะธั‚ะตััŒ ะบ ะพะฟะตั€ะฐั‚ะพั€ัƒ ะฑะพั‚ะฐ.", + "recovery_result_stale": "โŒ› ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะธัั‚ะตะบะปะพ ะธะปะธ ัะพัั‚ะพัะฝะธะต ะธะทะผะตะฝะธะปะพััŒ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฐะบั‚ัƒะฐะปัŒะฝะพะต ัะพัั‚ะพัะฝะธะต ะธ ัะพะทะดะฐะนั‚ะต ะฝะพะฒะพะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต.", + "recovery_result_forbidden": "ะัƒะถะฝั‹ ะดะตะนัั‚ะฒัƒัŽั‰ะธะต ะฟั€ะฐะฒะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ ัั‚ะพะน ะณั€ัƒะฟะฟั‹.", + "recovery_result_busy": "โณ ะ’ ะณั€ัƒะฟะฟะต ะฒั‹ะฟะพะปะฝัะตั‚ัั ะดั€ัƒะณะฐั ะพะฟะตั€ะฐั†ะธั. ะžะฑะฝะพะฒะธั‚ะต ั‡ะตั€ะตะท ะฝะตะบะพั‚ะพั€ะพะต ะฒั€ะตะผั.", + "recovery_result_conflict": "โš ๏ธ Telegram ะฒะตั€ะฝัƒะป ะฝะตะพะถะธะดะฐะฝะฝะพะต ัะพัั‚ะพัะฝะธะต. ะ˜ัั…ะพะดะฝั‹ะต ะดะฐะฝะฝั‹ะต ัะพั…ั€ะฐะฝะตะฝั‹; ะฟั€ะพะฒะตั€ัŒั‚ะต ัั€ะฐะฒะฝะตะฝะธะต ะฟะตั€ะตะด ะฝะพะฒะพะน ะฟะพะฟั‹ั‚ะบะพะน.", + "recovery_result_error": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะฒะตั€ัˆะธั‚ัŒ ะธะปะธ ะฟั€ะพะฒะตั€ะธั‚ัŒ ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต. ะกะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะดะฐะฝะฝั‹ะต ะฝะต ัƒะดะฐะปะตะฝั‹. ะžะฑะฝะพะฒะธั‚ะต ัะพัั‚ะพัะฝะธะต ะฟะตั€ะตะด ะฝะพะฒะพะน ะฟะพะฟั‹ั‚ะบะพะน.", + "recovery_result_retry_wait": "โณ Telegram ะฟะพะฟั€ะพัะธะป ะฟะพะดะพะถะดะฐั‚ัŒ. ะะต ะฝะฐะถะธะผะฐะนั‚ะต ะผะฝะพะณะพะบั€ะฐั‚ะฝะพ; ะพะฑะฝะพะฒะธั‚ะต ะฟะพะทะถะต.", + "recovery_refresh": "๐Ÿ”„ ะžะฑะฝะพะฒะธั‚ัŒ ัะพัั‚ะพัะฝะธะต", + "recovery_cancelled": "ะ’ั‹ะฑะพั€ ะพั‚ะผะตะฝั‘ะฝ. ะ ัƒั‡ะฝะพะต ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะฝะต ะฒั‹ะฟะพะปะฝัะปะพััŒ.", + "recovery_state_problem": "โš ๏ธ ะ•ัั‚ัŒ ะฝะตะทะฐะฒะตั€ัˆั‘ะฝะฝะฐั ะฟะพะฟั‹ั‚ะบะฐ ะฑะปะพะบะธั€ะพะฒะบะธ. ะŸั€ะพะฒะตั€ัŒั‚ะต ั€ะฐะทะปะธั‡ะธั ะธ ะฒั‹ะฑะตั€ะธั‚ะต ะฝัƒะถะฝะพะต ัะพัั‚ะพัะฝะธะต. ะŸะตั€ะตะด ะฒั‹ะฟะพะปะฝะตะฝะธะตะผ ะบะฐะถะดะพะณะพ ะดะตะนัั‚ะฒะธั ะฟะพัะฒะธั‚ัั ะพั‚ะดะตะปัŒะฝะพะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต.", + "diagnostics_title": "๐Ÿงช ะ”ะธะฐะณะฝะพัั‚ะธะบะฐ ะฒั€ะตะผะตะฝะธ ะธ ั€ะฐะทั€ะตัˆะตะฝะธะน", + "diagnostics_read_only": "ะขะพะปัŒะบะพ ะฟั€ะพัะผะพั‚ั€. ะกะฝะธะผะพะบ ะดะตะนัั‚ะฒะธั‚ะตะปะตะฝ 15 ะผะธะฝัƒั‚; ะพะฑะฝะพะฒะธั‚ะต ะดะปั ะฐะบั‚ัƒะฐะปัŒะฝั‹ั… ะดะฐะฝะฝั‹ั….", + "diagnostics_context": "ะšะพะฝั‚ะตะบัั‚", + "diagnostics_build": "ะ’ะตั€ัะธั", + "diagnostics_location": "ะœะตัั‚ะพ ะธ ั‡ะฐัะพะฒะพะน ะฟะพัั", + "diagnostics_methods": "ะœะธะฝัƒั‚ั‹ ะดะพ ัะฒะตั‡ะตะน / ะผะตั‚ะพะด ะพะบะพะฝั‡ะฐะฝะธั ะธ ะทะฝะฐั‡ะตะฝะธะต", + "diagnostics_lock_now": "ะกะตะนั‡ะฐั ั‚ั€ะตะฑัƒะตั‚ัั ะทะฐะบั€ั‹ั‚ะธะต", + "diagnostics_quiet_now": "ะจะฐะฑะฑะฐั‚, ะฟั€ะฐะทะดะฝะธะบ ะธะปะธ ะฟะตั€ะธะพะด ะทะฐะบั€ั‹ั‚ะธั", + "diagnostics_stale": "ะฃัั‚ะฐั€ะตะฒัˆะธะต ะดะฐะฝะฝั‹ะต ะฟะพัั‚ะฐะฒั‰ะธะบะฐ", + "diagnostics_evaluated_at": "ะ’ั€ะตะผั ั€ะฐัั‡ั‘ั‚ะฐ", + "diagnostics_fetched_at": "ะ’ั€ะตะผั ะฟะพะปัƒั‡ะตะฝะธั ะดะฐะฝะฝั‹ั…", + "diagnostics_period": "ะŸะตั€ะธะพะด ะทะฐะบั€ั‹ั‚ะธั ะธ ะพั‚ะบั€ั‹ั‚ะธั", + "diagnostics_provider_error": "ะžัˆะธะฑะบะฐ ะฟะพัั‚ะฐะฒั‰ะธะบะฐ ะบะฐะปะตะฝะดะฐั€ั", + "diagnostics_queue": "ะžั‡ะตั€ะตะดัŒ ะทะฐะดะฐั‡", + "diagnostics_group": "ะ“ั€ัƒะฟะฟะฐ", + "diagnostics_limited": "ะŸะพะบะฐะทะฐะฝะพ ะณั€ัƒะฟะฟ / ะฒัะตะณะพ. ะ”ั€ัƒะณะฐั ะณั€ัƒะฟะฟะฐ: /testzmanim ะธ ะตั‘ ID", + "diagnostics_lock_state": "ะกะพั…ั€ะฐะฝั‘ะฝะฝะพะต ัะพัั‚ะพัะฝะธะต ะทะฐะบั€ั‹ั‚ะธั", + "diagnostics_original": "ะกะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะพะฑั‹ั‡ะฝั‹ะต ะฟั€ะฐะฒะฐ: ะฐะบั‚ะธะฒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹", + "diagnostics_requested": "ะ—ะฐะฟั€ะพัˆะตะฝะฝั‹ะต ะฟั€ะฐะฒะฐ: ะฐะบั‚ะธะฒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹", + "diagnostics_current": "ะขะตะบัƒั‰ะธะต ะฟั€ะฐะฒะฐ Telegram: ะฐะบั‚ะธะฒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹", + "diagnostics_compare_original": "ะ ะฐะทะปะธั‡ะธั ั ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะผะธ ะพะฑั‹ั‡ะฝั‹ะผะธ ะฟั€ะฐะฒะฐะผะธ", + "diagnostics_compare_requested": "ะ ะฐะทะปะธั‡ะธั ั ะทะฐะฟั€ะพัˆะตะฝะฝั‹ะผะธ ะฟั€ะฐะฒะฐะผะธ", + "diagnostics_all": "ะ’ัะต", + "diagnostics_problems": "โš ๏ธ ะŸั€ะพะฑะปะตะผั‹ ะธ ะฟั€ะตะดัƒะฟั€ะตะถะดะตะฝะธั", + "diagnostics_summary": "ะกะฒะพะดะบะฐ", + "diagnostics_calendar": "ะšะฐะปะตะฝะดะฐั€ัŒ", + "diagnostics_permissions": "ะ ะฐะทั€ะตัˆะตะฝะธั", + "diagnostics_groups": "๐Ÿ‘ฅ ะ“ั€ัƒะฟะฟั‹", + "diagnostics_busy": "ะžะฑั€ะฐะฑะพั‚ะบะฐ ะธะดั‘ั‚ ะธะปะธ ะฝะตะดะฐะฒะฝะพ ะทะฐะฒะตั€ัˆะตะฝะฐ. ะžะฑะฝะพะฒะปะตะฝะธะต ั€ะฐะท ะฒ 30 ัะตะบัƒะฝะด, ัะบัะฟะพั€ั‚ ั€ะฐะท ะฒ ะผะธะฝัƒั‚ัƒ.", + "diagnostics_unavailable": "ะ”ะธะฐะณะฝะพัั‚ะธะบะฐ ะฝะตะดะพัั‚ัƒะฟะฝะฐ, ัƒัั‚ะฐั€ะตะปะฐ ะธะปะธ ะฝะตั‚ ะดะพัั‚ัƒะฟะฐ. ะŸะพะฒั‚ะพั€ะธั‚ะต /testzmanim ะปะธั‡ะฝะพ ั‡ะตั€ะตะท 30 ัะตะบัƒะฝะด.", + "diagnostics_export_caption": "๐Ÿ“„ ะ”ะธะฐะณะฝะพัั‚ะธะบะฐ ะฒั€ะตะผะตะฝะธ ะธ ั€ะฐะทั€ะตัˆะตะฝะธะน. ะกะฝะธะผะพะบ ั‚ะพะปัŒะบะพ ะดะปั ั‡ั‚ะตะฝะธั.", + "diagnostics_export_sent": "ะคะฐะนะป JSON ะพั‚ะฟั€ะฐะฒะปะตะฝ.", + "diagnostics_previous": "โ—€๏ธ ะะฐะทะฐะด", + "diagnostics_next": "ะ”ะฐะปะตะต โ–ถ๏ธ", + "diagnostics_search": "๐Ÿ”Ž ะŸะพะธัะบ", + "diagnostics_filters": "๐Ÿ”ฝ ะคะธะปัŒั‚ั€", + "diagnostics_refresh": "๐Ÿ”„ ะžะฑะฝะพะฒะธั‚ัŒ", + "diagnostics_clear": "ะกะฑั€ะพัะธั‚ัŒ ะฟะพะธัะบ ะธ ั„ะธะปัŒั‚ั€ั‹", + "diagnostics_export": "๐Ÿ“„ ะญะบัะฟะพั€ั‚ JSON", + "diagnostics_back": "ะš ัƒะฟั€ะฐะฒะปะตะฝะธัŽ", + "diagnostics_cancel": "ะžั‚ะผะตะฝะฐ", + "diagnostics_empty": "ะะตั‚ ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ ะดะปั ัั‚ะพะณะพ ะฟะพะธัะบะฐ ะธ ั„ะธะปัŒั‚ั€ะฐ.", + "diagnostics_page_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะฝะพะผะตั€ ัั‚ั€ะฐะฝะธั†ั‹ ะพั‚ 1 ะดะพ {{pages}}. ะขะตะบัƒั‰ะฐั ะฟะฐะฝะตะปัŒ ะพะฑะฝะพะฒะธั‚ัั.", + "diagnostics_search_prompt": "ะžั‚ะฟั€ะฐะฒัŒั‚ะต ัะปะพะฒะพ, ะดะฐั‚ัƒ ะธะปะธ ID ะณั€ัƒะฟะฟั‹ ะดะปั ะฟะพะธัะบะฐ ะฒ ัะฝะธะผะบะต (ะดะพ 80 ัะธะผะฒะพะปะพะฒ).", + "diagnostics_invalid_page": "ะะตะฒะตั€ะฝั‹ะน ะฝะพะผะตั€ ัั‚ั€ะฐะฝะธั†ั‹. ะ’ั‹ะฑะตั€ะธั‚ะต ั‡ะธัะปะพ ะธะท ัƒะบะฐะทะฐะฝะฝะพะณะพ ะดะธะฐะฟะฐะทะพะฝะฐ.", + "diagnostics_is_work_prohibited_now": "ะ ะฐะฑะพั‚ะฐ ัะตะนั‡ะฐั ะทะฐะฟั€ะตั‰ะตะฝะฐ", + "diagnostics_is_shabbat": "ะกะตะนั‡ะฐั ะจะฐะฑะฑะฐั‚", + "diagnostics_is_yom_tov": "ะกะตะนั‡ะฐั ะ™ะพะผ-ะขะพะฒ", + "diagnostics_next_close_at": "ะกะปะตะดัƒัŽั‰ะตะต ะทะฐะบั€ั‹ั‚ะธะต", + "diagnostics_next_open_at": "ะกะปะตะดัƒัŽั‰ะตะต ะพั‚ะบั€ั‹ั‚ะธะต", + "diagnostics_baseline": "ะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะฝั‹ะต ะพะฑั‹ั‡ะฝั‹ะต ะฟั€ะฐะฒะฐ: ะฐะบั‚ะธะฒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹", + "diagnostics_compare_previous": "ะ ะฐะทะปะธั‡ะธั ั ะฟั€ะพะผะตะถัƒั‚ะพั‡ะฝั‹ะผะธ ะฟั€ะฐะฒะฐะผะธ", + "diagnostics_compare_baseline": "ะ ะฐะทะปะธั‡ะธั ั ะฑะฐะทะพะน ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธั", + "diagnostics_concurrent_change": "ะกะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฟั€ะฐะฒะฐ ะธะทะผะตะฝะธะปะธััŒ ะฒะพ ะฒั€ะตะผั ะฟั€ะพะฒะตั€ะบะธ. ะžะฑะฝะพะฒะธั‚ะต ะฟะตั€ะตะด ัั€ะฐะฒะฝะตะฝะธะตะผ.", + "diagnostics_operation": "ะŸะพัะปะตะดะฝัั ะพะฟะตั€ะฐั†ะธั: ะดะตะนัั‚ะฒะธะต / ัั‚ะฐะฟ / ั€ะตะทัƒะปัŒั‚ะฐั‚", + "diagnostics_previous_rights": "ะŸั€ะตะดั‹ะดัƒั‰ะธะต ะฟั€ะพะผะตะถัƒั‚ะพั‡ะฝั‹ะต ะฟั€ะฐะฒะฐ: ะฐะบั‚ะธะฒะฝั‹ะต ะทะฐะฟั€ะตั‚ั‹", + "diagnostics_invalid_search": "ะกะปะธัˆะบะพะผ ะดะปะธะฝะฝั‹ะน ะทะฐะฟั€ะพั. ะ’ะฒะตะดะธั‚ะต ะดะพ 80 ัะธะผะฒะพะปะพะฒ.", + "recovery_block_unverified_snapshot": "โš ๏ธ ะกะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฟั€ะฐะฒะฐ ะฟะพะปัƒั‡ะตะฝั‹ ะฒ ะฟั€ะตะดั‹ะดัƒั‰ะตะน ะฒะตั€ัะธะธ ะธ ะฝะต ะฟั€ะพะฒะตั€ะตะฝั‹ ะฝะพะฒั‹ะผ ัะฟะพัะพะฑะพะผ. ะžะฝะธ ะผะพะณัƒั‚ ะพั‚ะปะธั‡ะฐั‚ัŒัั ะพั‚ ะฟั€ะตะถะฝะธั… ะฝะฐัั‚ั€ะพะตะบ ะณั€ัƒะฟะฟั‹, ะฟะพัั‚ะพะผัƒ ะธั… ะฒะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะพั‚ะบะปัŽั‡ะตะฝะพ.\nะ’ะฝะต ะจะฐะฑะฑะฐั‚ะฐ, ะ™ะพะผ-ะขะพะฒะฐ ะธ ะฐะบั‚ะธะฒะฝะพะน ะฑะปะพะบะธั€ะพะฒะบะธ: ะทะฐะดะฐะนั‚ะต ะฝัƒะถะฝั‹ะต ะฟั€ะฐะฒะฐ ะณั€ัƒะฟะฟั‹ ะฒ Telegram, ะฝะฐะถะผะธั‚ะต ะทะดะตััŒ ยซะžะฑะฝะพะฒะธั‚ัŒยป, ะฟั€ะพะฒะตั€ัŒั‚ะต ะธั… ะธ ะฟะพะดั‚ะฒะตั€ะดะธั‚ะต ะฟั€ะธะฝัั‚ะธะต ั‚ะตะบัƒั‰ะธั… ะฟั€ะฐะฒ. ะกั‚ะฐั€ั‹ะน ัะฝะธะผะพะบ ะพัั‚ะฐะฝะตั‚ัั ะดะปั ะดะธะฐะณะฝะพัั‚ะธะบะธ.", + "permission_error_unverified": "ะกะฝะธะผะพะบ ะธะท ะฟั€ะตะดั‹ะดัƒั‰ะตะน ะฒะตั€ัะธะธ ั‚ั€ะตะฑัƒะตั‚ ะฟั€ะพะฒะตั€ะบะธ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะผ. ะžั‚ะบั€ะพะนั‚ะต ยซะ ะตัˆะธั‚ัŒ ะฟั€ะพะฑะปะตะผัƒ ั ะฟั€ะฐะฒะฐะผะธยป ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั… ะณั€ัƒะฟะฟั‹; ะฝะต ะฒะพััั‚ะฐะฝะฐะฒะปะธะฒะฐะนั‚ะต ะฟั€ะตะถะฝะตะต ัะพัั‚ะพัะฝะธะต ะธะท ัั‚ะพะณะพ ัะฝะธะผะบะฐ.", + "diagnostics_snapshot_verification": "ะŸั€ะพะฒะตั€ะบะฐ ัะฝะธะผะบะฐ ะฟั€ะฐะฒ", + "diagnostics_snapshot_verified": "ะกะพั…ั€ะฐะฝั‘ะฝ ะฟะพัะปะต ะฟั€ะพะฒะตั€ะตะฝะฝะพะณะพ ั‡ั‚ะตะฝะธั", + "diagnostics_snapshot_unverified": "ะกะฝะธะผะพะบ ะธะท ะฟั€ะตะดั‹ะดัƒั‰ะตะน ะฒะตั€ัะธะธ - ั‚ั€ะตะฑัƒะตั‚ัั ะฟั€ะพะฒะตั€ะบะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ", + "permission_error_readback": "โš ๏ธ ะ—ะฐะฟั€ะพั ะธะทะผะตะฝะตะฝะธั ะฟั€ะฐะฒ ะพั‚ะฟั€ะฐะฒะปะตะฝ, ะฝะพ ั€ะตะทัƒะปัŒั‚ะฐั‚ ะฝะต ะฟะพะดั‚ะฒะตั€ะถะดั‘ะฝ. ะŸั€ะฐะฒะฐ ะผะพะณะปะธ ัƒะถะต ะธะทะผะตะฝะธั‚ัŒัั. ะŸั€ะพะฒะตั€ัŒั‚ะต ะธั… ะฒ Telegram ะธ ะฒ ั€ะฐะทะดะตะปะต ั€ะตัˆะตะฝะธั ะฟั€ะพะฑะปะตะผ ั ะฟั€ะฐะฒะฐะผะธ ะฟะตั€ะตะด ะฟะพะฒั‚ะพั€ะฝะพะน ะฟะพะฟั‹ั‚ะบะพะน." +} diff --git a/resources/php/99-shabbat-runtime.ini b/resources/php/99-shabbat-runtime.ini new file mode 100644 index 0000000..45d0d1b --- /dev/null +++ b/resources/php/99-shabbat-runtime.ini @@ -0,0 +1,4 @@ +; SPDX-License-Identifier: AGPL-3.0-only +; Applies to both the application process and MadelineProto IPC subprocesses. +; MadelineProto builds TL traces from native exception frames. +zend.exception_ignore_args = On diff --git a/resources/templates/omer-counts-localized.json b/resources/templates/omer-counts-localized.json new file mode 100644 index 0000000..2a1a53d --- /dev/null +++ b/resources/templates/omer-counts-localized.json @@ -0,0 +1,449 @@ +{ + "source": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-03&end=2026-05-21&o=on&i=on&lg=he", + "attribution": "Hebcal.com; explanatory translations by WizardLoop Shabbat", + "license": "CC-BY-4.0", + "note": "Static explanatory translations of the verified 49-day calendar count; Hebrew ritual wording remains in omer-counts.json. No runtime translation or AI generation.", + "counts": { + "1": { + "day": 1, + "weeks": 0, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 1 jour.", + "es": "Hoy es 1 dรญa del ร“mer.", + "pt-br": "Hoje รฉ 1 dia do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 1 ะดะตะฝัŒ ะžะผะตั€ะฐ." + }, + "2": { + "day": 2, + "weeks": 0, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 2 jours.", + "es": "Hoy son 2 dรญas del ร“mer.", + "pt-br": "Hoje sรฃo 2 dias do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 2 ะดะฝั ะžะผะตั€ะฐ." + }, + "3": { + "day": 3, + "weeks": 0, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 3 jours.", + "es": "Hoy son 3 dรญas del ร“mer.", + "pt-br": "Hoje sรฃo 3 dias do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 3 ะดะฝั ะžะผะตั€ะฐ." + }, + "4": { + "day": 4, + "weeks": 0, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 4 jours.", + "es": "Hoy son 4 dรญas del ร“mer.", + "pt-br": "Hoje sรฃo 4 dias do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 4 ะดะฝั ะžะผะตั€ะฐ." + }, + "5": { + "day": 5, + "weeks": 0, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 5 jours.", + "es": "Hoy son 5 dรญas del ร“mer.", + "pt-br": "Hoje sรฃo 5 dias do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 5 ะดะฝะตะน ะžะผะตั€ะฐ." + }, + "6": { + "day": 6, + "weeks": 0, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 6 jours.", + "es": "Hoy son 6 dรญas del ร“mer.", + "pt-br": "Hoje sรฃo 6 dias do ร”mer.", + "ru": "ะกะตะณะพะดะฝั 6 ะดะฝะตะน ะžะผะตั€ะฐ." + }, + "7": { + "day": 7, + "weeks": 1, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 7 jours, soit 1 semaine.", + "es": "Hoy son 7 dรญas del ร“mer, equivalentes a 1 semana.", + "pt-br": "Hoje sรฃo 7 dias do ร”mer, correspondentes a 1 semana.", + "ru": "ะกะตะณะพะดะฝั 7 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั." + }, + "8": { + "day": 8, + "weeks": 1, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 8 jours, soit 1 semaine et 1 jour.", + "es": "Hoy son 8 dรญas del ร“mer, equivalentes a 1 semana y 1 dรญa.", + "pt-br": "Hoje sรฃo 8 dias do ร”mer, correspondentes a 1 semana e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 8 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 1 ะดะตะฝัŒ." + }, + "9": { + "day": 9, + "weeks": 1, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 9 jours, soit 1 semaine et 2 jours.", + "es": "Hoy son 9 dรญas del ร“mer, equivalentes a 1 semana y 2 dรญas.", + "pt-br": "Hoje sรฃo 9 dias do ร”mer, correspondentes a 1 semana e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 9 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 2 ะดะฝั." + }, + "10": { + "day": 10, + "weeks": 1, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 10 jours, soit 1 semaine et 3 jours.", + "es": "Hoy son 10 dรญas del ร“mer, equivalentes a 1 semana y 3 dรญas.", + "pt-br": "Hoje sรฃo 10 dias do ร”mer, correspondentes a 1 semana e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 10 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 3 ะดะฝั." + }, + "11": { + "day": 11, + "weeks": 1, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 11 jours, soit 1 semaine et 4 jours.", + "es": "Hoy son 11 dรญas del ร“mer, equivalentes a 1 semana y 4 dรญas.", + "pt-br": "Hoje sรฃo 11 dias do ร”mer, correspondentes a 1 semana e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 11 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 4 ะดะฝั." + }, + "12": { + "day": 12, + "weeks": 1, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 12 jours, soit 1 semaine et 5 jours.", + "es": "Hoy son 12 dรญas del ร“mer, equivalentes a 1 semana y 5 dรญas.", + "pt-br": "Hoje sรฃo 12 dias do ร”mer, correspondentes a 1 semana e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 12 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 5 ะดะฝะตะน." + }, + "13": { + "day": 13, + "weeks": 1, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 13 jours, soit 1 semaine et 6 jours.", + "es": "Hoy son 13 dรญas del ร“mer, equivalentes a 1 semana y 6 dรญas.", + "pt-br": "Hoje sรฃo 13 dias do ร”mer, correspondentes a 1 semana e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 13 ะดะฝะตะน ะžะผะตั€ะฐ: 1 ะฝะตะดะตะปั ะธ 6 ะดะฝะตะน." + }, + "14": { + "day": 14, + "weeks": 2, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 14 jours, soit 2 semaines.", + "es": "Hoy son 14 dรญas del ร“mer, equivalentes a 2 semanas.", + "pt-br": "Hoje sรฃo 14 dias do ร”mer, correspondentes a 2 semanas.", + "ru": "ะกะตะณะพะดะฝั 14 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ." + }, + "15": { + "day": 15, + "weeks": 2, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 15 jours, soit 2 semaines et 1 jour.", + "es": "Hoy son 15 dรญas del ร“mer, equivalentes a 2 semanas y 1 dรญa.", + "pt-br": "Hoje sรฃo 15 dias do ร”mer, correspondentes a 2 semanas e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 15 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 1 ะดะตะฝัŒ." + }, + "16": { + "day": 16, + "weeks": 2, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 16 jours, soit 2 semaines et 2 jours.", + "es": "Hoy son 16 dรญas del ร“mer, equivalentes a 2 semanas y 2 dรญas.", + "pt-br": "Hoje sรฃo 16 dias do ร”mer, correspondentes a 2 semanas e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 16 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 2 ะดะฝั." + }, + "17": { + "day": 17, + "weeks": 2, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 17 jours, soit 2 semaines et 3 jours.", + "es": "Hoy son 17 dรญas del ร“mer, equivalentes a 2 semanas y 3 dรญas.", + "pt-br": "Hoje sรฃo 17 dias do ร”mer, correspondentes a 2 semanas e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 17 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 3 ะดะฝั." + }, + "18": { + "day": 18, + "weeks": 2, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 18 jours, soit 2 semaines et 4 jours.", + "es": "Hoy son 18 dรญas del ร“mer, equivalentes a 2 semanas y 4 dรญas.", + "pt-br": "Hoje sรฃo 18 dias do ร”mer, correspondentes a 2 semanas e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 18 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 4 ะดะฝั." + }, + "19": { + "day": 19, + "weeks": 2, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 19 jours, soit 2 semaines et 5 jours.", + "es": "Hoy son 19 dรญas del ร“mer, equivalentes a 2 semanas y 5 dรญas.", + "pt-br": "Hoje sรฃo 19 dias do ร”mer, correspondentes a 2 semanas e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 19 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 5 ะดะฝะตะน." + }, + "20": { + "day": 20, + "weeks": 2, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 20 jours, soit 2 semaines et 6 jours.", + "es": "Hoy son 20 dรญas del ร“mer, equivalentes a 2 semanas y 6 dรญas.", + "pt-br": "Hoje sรฃo 20 dias do ร”mer, correspondentes a 2 semanas e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 20 ะดะฝะตะน ะžะผะตั€ะฐ: 2 ะฝะตะดะตะปะธ ะธ 6 ะดะฝะตะน." + }, + "21": { + "day": 21, + "weeks": 3, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 21 jours, soit 3 semaines.", + "es": "Hoy son 21 dรญas del ร“mer, equivalentes a 3 semanas.", + "pt-br": "Hoje sรฃo 21 dias do ร”mer, correspondentes a 3 semanas.", + "ru": "ะกะตะณะพะดะฝั 21 ะดะตะฝัŒ ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ." + }, + "22": { + "day": 22, + "weeks": 3, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 22 jours, soit 3 semaines et 1 jour.", + "es": "Hoy son 22 dรญas del ร“mer, equivalentes a 3 semanas y 1 dรญa.", + "pt-br": "Hoje sรฃo 22 dias do ร”mer, correspondentes a 3 semanas e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 22 ะดะฝั ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 1 ะดะตะฝัŒ." + }, + "23": { + "day": 23, + "weeks": 3, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 23 jours, soit 3 semaines et 2 jours.", + "es": "Hoy son 23 dรญas del ร“mer, equivalentes a 3 semanas y 2 dรญas.", + "pt-br": "Hoje sรฃo 23 dias do ร”mer, correspondentes a 3 semanas e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 23 ะดะฝั ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 2 ะดะฝั." + }, + "24": { + "day": 24, + "weeks": 3, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 24 jours, soit 3 semaines et 3 jours.", + "es": "Hoy son 24 dรญas del ร“mer, equivalentes a 3 semanas y 3 dรญas.", + "pt-br": "Hoje sรฃo 24 dias do ร”mer, correspondentes a 3 semanas e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 24 ะดะฝั ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 3 ะดะฝั." + }, + "25": { + "day": 25, + "weeks": 3, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 25 jours, soit 3 semaines et 4 jours.", + "es": "Hoy son 25 dรญas del ร“mer, equivalentes a 3 semanas y 4 dรญas.", + "pt-br": "Hoje sรฃo 25 dias do ร”mer, correspondentes a 3 semanas e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 25 ะดะฝะตะน ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 4 ะดะฝั." + }, + "26": { + "day": 26, + "weeks": 3, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 26 jours, soit 3 semaines et 5 jours.", + "es": "Hoy son 26 dรญas del ร“mer, equivalentes a 3 semanas y 5 dรญas.", + "pt-br": "Hoje sรฃo 26 dias do ร”mer, correspondentes a 3 semanas e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 26 ะดะฝะตะน ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 5 ะดะฝะตะน." + }, + "27": { + "day": 27, + "weeks": 3, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 27 jours, soit 3 semaines et 6 jours.", + "es": "Hoy son 27 dรญas del ร“mer, equivalentes a 3 semanas y 6 dรญas.", + "pt-br": "Hoje sรฃo 27 dias do ร”mer, correspondentes a 3 semanas e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 27 ะดะฝะตะน ะžะผะตั€ะฐ: 3 ะฝะตะดะตะปะธ ะธ 6 ะดะฝะตะน." + }, + "28": { + "day": 28, + "weeks": 4, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 28 jours, soit 4 semaines.", + "es": "Hoy son 28 dรญas del ร“mer, equivalentes a 4 semanas.", + "pt-br": "Hoje sรฃo 28 dias do ร”mer, correspondentes a 4 semanas.", + "ru": "ะกะตะณะพะดะฝั 28 ะดะฝะตะน ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ." + }, + "29": { + "day": 29, + "weeks": 4, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 29 jours, soit 4 semaines et 1 jour.", + "es": "Hoy son 29 dรญas del ร“mer, equivalentes a 4 semanas y 1 dรญa.", + "pt-br": "Hoje sรฃo 29 dias do ร”mer, correspondentes a 4 semanas e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 29 ะดะฝะตะน ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 1 ะดะตะฝัŒ." + }, + "30": { + "day": 30, + "weeks": 4, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 30 jours, soit 4 semaines et 2 jours.", + "es": "Hoy son 30 dรญas del ร“mer, equivalentes a 4 semanas y 2 dรญas.", + "pt-br": "Hoje sรฃo 30 dias do ร”mer, correspondentes a 4 semanas e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 30 ะดะฝะตะน ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 2 ะดะฝั." + }, + "31": { + "day": 31, + "weeks": 4, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 31 jours, soit 4 semaines et 3 jours.", + "es": "Hoy son 31 dรญas del ร“mer, equivalentes a 4 semanas y 3 dรญas.", + "pt-br": "Hoje sรฃo 31 dias do ร”mer, correspondentes a 4 semanas e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 31 ะดะตะฝัŒ ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 3 ะดะฝั." + }, + "32": { + "day": 32, + "weeks": 4, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 32 jours, soit 4 semaines et 4 jours.", + "es": "Hoy son 32 dรญas del ร“mer, equivalentes a 4 semanas y 4 dรญas.", + "pt-br": "Hoje sรฃo 32 dias do ร”mer, correspondentes a 4 semanas e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 32 ะดะฝั ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 4 ะดะฝั." + }, + "33": { + "day": 33, + "weeks": 4, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 33 jours, soit 4 semaines et 5 jours.", + "es": "Hoy son 33 dรญas del ร“mer, equivalentes a 4 semanas y 5 dรญas.", + "pt-br": "Hoje sรฃo 33 dias do ร”mer, correspondentes a 4 semanas e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 33 ะดะฝั ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 5 ะดะฝะตะน." + }, + "34": { + "day": 34, + "weeks": 4, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 34 jours, soit 4 semaines et 6 jours.", + "es": "Hoy son 34 dรญas del ร“mer, equivalentes a 4 semanas y 6 dรญas.", + "pt-br": "Hoje sรฃo 34 dias do ร”mer, correspondentes a 4 semanas e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 34 ะดะฝั ะžะผะตั€ะฐ: 4 ะฝะตะดะตะปะธ ะธ 6 ะดะฝะตะน." + }, + "35": { + "day": 35, + "weeks": 5, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 35 jours, soit 5 semaines.", + "es": "Hoy son 35 dรญas del ร“mer, equivalentes a 5 semanas.", + "pt-br": "Hoje sรฃo 35 dias do ร”mer, correspondentes a 5 semanas.", + "ru": "ะกะตะณะพะดะฝั 35 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ." + }, + "36": { + "day": 36, + "weeks": 5, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 36 jours, soit 5 semaines et 1 jour.", + "es": "Hoy son 36 dรญas del ร“mer, equivalentes a 5 semanas y 1 dรญa.", + "pt-br": "Hoje sรฃo 36 dias do ร”mer, correspondentes a 5 semanas e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 36 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 1 ะดะตะฝัŒ." + }, + "37": { + "day": 37, + "weeks": 5, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 37 jours, soit 5 semaines et 2 jours.", + "es": "Hoy son 37 dรญas del ร“mer, equivalentes a 5 semanas y 2 dรญas.", + "pt-br": "Hoje sรฃo 37 dias do ร”mer, correspondentes a 5 semanas e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 37 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 2 ะดะฝั." + }, + "38": { + "day": 38, + "weeks": 5, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 38 jours, soit 5 semaines et 3 jours.", + "es": "Hoy son 38 dรญas del ร“mer, equivalentes a 5 semanas y 3 dรญas.", + "pt-br": "Hoje sรฃo 38 dias do ร”mer, correspondentes a 5 semanas e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 38 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 3 ะดะฝั." + }, + "39": { + "day": 39, + "weeks": 5, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 39 jours, soit 5 semaines et 4 jours.", + "es": "Hoy son 39 dรญas del ร“mer, equivalentes a 5 semanas y 4 dรญas.", + "pt-br": "Hoje sรฃo 39 dias do ร”mer, correspondentes a 5 semanas e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 39 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 4 ะดะฝั." + }, + "40": { + "day": 40, + "weeks": 5, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 40 jours, soit 5 semaines et 5 jours.", + "es": "Hoy son 40 dรญas del ร“mer, equivalentes a 5 semanas y 5 dรญas.", + "pt-br": "Hoje sรฃo 40 dias do ร”mer, correspondentes a 5 semanas e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 40 ะดะฝะตะน ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 5 ะดะฝะตะน." + }, + "41": { + "day": 41, + "weeks": 5, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 41 jours, soit 5 semaines et 6 jours.", + "es": "Hoy son 41 dรญas del ร“mer, equivalentes a 5 semanas y 6 dรญas.", + "pt-br": "Hoje sรฃo 41 dias do ร”mer, correspondentes a 5 semanas e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 41 ะดะตะฝัŒ ะžะผะตั€ะฐ: 5 ะฝะตะดะตะปัŒ ะธ 6 ะดะฝะตะน." + }, + "42": { + "day": 42, + "weeks": 6, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 42 jours, soit 6 semaines.", + "es": "Hoy son 42 dรญas del ร“mer, equivalentes a 6 semanas.", + "pt-br": "Hoje sรฃo 42 dias do ร”mer, correspondentes a 6 semanas.", + "ru": "ะกะตะณะพะดะฝั 42 ะดะฝั ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ." + }, + "43": { + "day": 43, + "weeks": 6, + "days": 1, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 43 jours, soit 6 semaines et 1 jour.", + "es": "Hoy son 43 dรญas del ร“mer, equivalentes a 6 semanas y 1 dรญa.", + "pt-br": "Hoje sรฃo 43 dias do ร”mer, correspondentes a 6 semanas e 1 dia.", + "ru": "ะกะตะณะพะดะฝั 43 ะดะฝั ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 1 ะดะตะฝัŒ." + }, + "44": { + "day": 44, + "weeks": 6, + "days": 2, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 44 jours, soit 6 semaines et 2 jours.", + "es": "Hoy son 44 dรญas del ร“mer, equivalentes a 6 semanas y 2 dรญas.", + "pt-br": "Hoje sรฃo 44 dias do ร”mer, correspondentes a 6 semanas e 2 dias.", + "ru": "ะกะตะณะพะดะฝั 44 ะดะฝั ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 2 ะดะฝั." + }, + "45": { + "day": 45, + "weeks": 6, + "days": 3, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 45 jours, soit 6 semaines et 3 jours.", + "es": "Hoy son 45 dรญas del ร“mer, equivalentes a 6 semanas y 3 dรญas.", + "pt-br": "Hoje sรฃo 45 dias do ร”mer, correspondentes a 6 semanas e 3 dias.", + "ru": "ะกะตะณะพะดะฝั 45 ะดะฝะตะน ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 3 ะดะฝั." + }, + "46": { + "day": 46, + "weeks": 6, + "days": 4, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 46 jours, soit 6 semaines et 4 jours.", + "es": "Hoy son 46 dรญas del ร“mer, equivalentes a 6 semanas y 4 dรญas.", + "pt-br": "Hoje sรฃo 46 dias do ร”mer, correspondentes a 6 semanas e 4 dias.", + "ru": "ะกะตะณะพะดะฝั 46 ะดะฝะตะน ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 4 ะดะฝั." + }, + "47": { + "day": 47, + "weeks": 6, + "days": 5, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 47 jours, soit 6 semaines et 5 jours.", + "es": "Hoy son 47 dรญas del ร“mer, equivalentes a 6 semanas y 5 dรญas.", + "pt-br": "Hoje sรฃo 47 dias do ร”mer, correspondentes a 6 semanas e 5 dias.", + "ru": "ะกะตะณะพะดะฝั 47 ะดะฝะตะน ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 5 ะดะฝะตะน." + }, + "48": { + "day": 48, + "weeks": 6, + "days": 6, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 48 jours, soit 6 semaines et 6 jours.", + "es": "Hoy son 48 dรญas del ร“mer, equivalentes a 6 semanas y 6 dรญas.", + "pt-br": "Hoje sรฃo 48 dias do ร”mer, correspondentes a 6 semanas e 6 dias.", + "ru": "ะกะตะณะพะดะฝั 48 ะดะฝะตะน ะžะผะตั€ะฐ: 6 ะฝะตะดะตะปัŒ ะธ 6 ะดะฝะตะน." + }, + "49": { + "day": 49, + "weeks": 7, + "days": 0, + "fr": "Aujourdโ€™hui, le compte de lโ€™Omer est de 49 jours, soit 7 semaines.", + "es": "Hoy son 49 dรญas del ร“mer, equivalentes a 7 semanas.", + "pt-br": "Hoje sรฃo 49 dias do ร”mer, correspondentes a 7 semanas.", + "ru": "ะกะตะณะพะดะฝั 49 ะดะฝะตะน ะžะผะตั€ะฐ: 7 ะฝะตะดะตะปัŒ." + } + } +} diff --git a/resources/templates/omer-counts.json b/resources/templates/omer-counts.json new file mode 100644 index 0000000..c1a7a07 --- /dev/null +++ b/resources/templates/omer-counts.json @@ -0,0 +1,204 @@ +{ + "source": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-03&end=2026-05-21&o=on&i=on&lg=he", + "attribution": "Hebcal.com", + "counts": { + "32": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 32 days, which are 4 weeks and 4 days of the Omer" + }, + "11": { + "he": "ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 11 days, which are 1 week and 4 days of the Omer" + }, + "15": { + "he": "ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 15 days, which are 2 weeks and 1 day of the Omer" + }, + "41": { + "he": "ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 41 days, which are 5 weeks and 6 days of the Omer" + }, + "24": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 24 days, which are 3 weeks and 3 days of the Omer" + }, + "37": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 37 days, which are 5 weeks and 2 days of the Omer" + }, + "45": { + "he": "ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 45 days, which are 6 weeks and 3 days of the Omer" + }, + "48": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 48 days, which are 6 weeks and 6 days of the Omer" + }, + "14": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 14 days, which are 2 weeks of the Omer" + }, + "28": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 28 days, which are 4 weeks of the Omer" + }, + "10": { + "he": "ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 10 days, which are 1 week and 3 days of the Omer" + }, + "27": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 27 days, which are 3 weeks and 6 days of the Omer" + }, + "9": { + "he": "ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 9 days, which are 1 week and 2 days of the Omer" + }, + "40": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 40 days, which are 5 weeks and 5 days of the Omer" + }, + "26": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 26 days, which are 3 weeks and 5 days of the Omer" + }, + "12": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 12 days, which are 1 week and 5 days of the Omer" + }, + "21": { + "he": "ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 21 days, which are 3 weeks of the Omer" + }, + "20": { + "he": "ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 20 days, which are 2 weeks and 6 days of the Omer" + }, + "4": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 4 days of the Omer" + }, + "22": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 22 days, which are 3 weeks and 1 day of the Omer" + }, + "49": { + "he": "ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 49 days, which are 7 weeks of the Omer" + }, + "36": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 36 days, which are 5 weeks and 1 day of the Omer" + }, + "2": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 2 days of the Omer" + }, + "3": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 3 days of the Omer" + }, + "44": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 44 days, which are 6 weeks and 2 days of the Omer" + }, + "34": { + "he": "ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 34 days, which are 4 weeks and 6 days of the Omer" + }, + "31": { + "he": "ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 31 days, which are 4 weeks and 3 days of the Omer" + }, + "7": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 7 days, which are 1 week of the Omer" + }, + "16": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 16 days, which are 2 weeks and 2 days of the Omer" + }, + "13": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 13 days, which are 1 week and 6 days of the Omer" + }, + "23": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 23 days, which are 3 weeks and 2 days of the Omer" + }, + "47": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 47 days, which are 6 weeks and 5 days of the Omer" + }, + "33": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 33 days, which are 4 weeks and 5 days of the Omer" + }, + "43": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 43 days, which are 6 weeks and 1 day of the Omer" + }, + "18": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 18 days, which are 2 weeks and 4 days of the Omer" + }, + "17": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 17 days, which are 2 weeks and 3 days of the Omer" + }, + "38": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 38 days, which are 5 weeks and 3 days of the Omer" + }, + "1": { + "he": "ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 1 day of the Omer" + }, + "19": { + "he": "ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 19 days, which are 2 weeks and 5 days of the Omer" + }, + "42": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 42 days, which are 6 weeks of the Omer" + }, + "6": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 6 days of the Omer" + }, + "39": { + "he": "ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 39 days, which are 5 weeks and 4 days of the Omer" + }, + "29": { + "he": "ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 29 days, which are 4 weeks and 1 day of the Omer" + }, + "25": { + "he": "ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 25 days, which are 3 weeks and 4 days of the Omer" + }, + "5": { + "he": "ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 5 days of the Omer" + }, + "30": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 30 days, which are 4 weeks and 2 days of the Omer" + }, + "8": { + "he": "ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 8 days, which are 1 week and 1 day of the Omer" + }, + "46": { + "he": "ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 46 days, which are 6 weeks and 4 days of the Omer" + }, + "35": { + "he": "ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ", + "en": "Today is 35 days, which are 5 weeks of the Omer" + } + }, + "license": "CC-BY-4.0", + "retrieved": "2026-10-13" +} diff --git a/resources/templates/omer-liturgy.json b/resources/templates/omer-liturgy.json new file mode 100644 index 0000000..2d813bf --- /dev/null +++ b/resources/templates/omer-liturgy.json @@ -0,0 +1,88 @@ +{ + "license": "CC-BY-SA-4.0", + "attribution": "Hebrew Wikisource contributors; traditional Hebrew liturgy and biblical texts", + "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.", + "sources": [ + { + "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 + } + ], + "blessing": "ื‘ืจื•ืš ืืชื” ื”ืณ ืืœื•ื”ื™ื ื• ืžืœืš ื”ืขื•ืœื, ืืฉืจ ืงื™ื“ืฉื ื• ื‘ืžืฆื•ื•ืชื™ื• ื•ืฆื™ื•ื•ื ื• ืขืœ ืกืคื™ืจืช ื”ืขื•ืžืจ.", + "preparation_ashkenaz": "ื”ื ื ื™ ืžื•ื›ืŸ ื•ืžื–ื•ืžืŸ ืœืงื™ื™ื ืžืฆื•ื•ืช ืขืฉื” ืฉืœ ืกืคื™ืจืช ื”ืขื•ืžืจ, ื›ืžื• ืฉื›ืชื•ื‘ ื‘ืชื•ืจื”: ื•ืกืคืจืชื ืœื›ื ืžืžื—ืจืช ื”ืฉื‘ืช ืžื™ื•ื ื”ื‘ื™ืื›ื ืืช ืขื•ืžืจ ื”ืชื ื•ืคื”, ืฉื‘ืข ืฉื‘ืชื•ืช ืชืžื™ืžื•ืช ืชื”ื™ื™ื ื”.", + "preparation_sephard": "ืœืฉื ื™ื—ื•ื“ ืงื•ื“ืฉื ื‘ืจื™ืš ื”ื•ื ื•ืฉื›ื™ื ืชื™ื”, ื‘ื“ื—ื™ืœื• ื•ืจื—ื™ืžื•, ืœื™ื—ื“ ืฉื ื™ืดื” ื‘ื•ืดื” ื‘ื™ื—ื•ื“ื ืฉืœื™ื ื‘ืฉื ื›ืœ ื™ืฉืจืืœ.\nื”ื ื ื™ ืžื•ื›ืŸ ื•ืžื–ื•ืžืŸ ืœืงื™ื™ื ืžืฆื•ื•ืช ืขืฉื” ืฉืœ ืกืคื™ืจืช ื”ืขื•ืžืจ, ื›ืžื• ืฉื›ืชื•ื‘ ื‘ืชื•ืจื”: ื•ืกืคืจืชื ืœื›ื ืžืžื—ืจืช ื”ืฉื‘ืช ืžื™ื•ื ื”ื‘ื™ืื›ื ืืช ืขื•ืžืจ ื”ืชื ื•ืคื”, ืฉื‘ืข ืฉื‘ืชื•ืช ืชืžื™ืžื•ืช ืชื”ื™ื™ื ื”. ืขื“ ืžืžื—ืจืช ื”ืฉื‘ืช ื”ืฉื‘ื™ืขื™ืช ืชืกืคืจื• ื—ืžื™ืฉื™ื ื™ื•ื, ื•ื”ืงืจื‘ืชื ืžื ื—ื” ื—ื“ืฉื” ืœื”ืณ.\nื•ื™ื”ื™ ื ื•ืขื ืื“ื•ื ื™ ืืœื•ื”ื™ื ื• ืขืœื™ื ื•, ื•ืžืขืฉื” ื™ื“ื™ื ื• ื›ื•ื ื ื” ืขืœื™ื ื•, ื•ืžืขืฉื” ื™ื“ื™ื ื• ื›ื•ื ื ื”ื•.", + "harachaman": "ื”ืจื—ืžืŸ ื”ื•ื ื™ื—ื–ื™ืจ ืœื ื• ืขื‘ื•ื“ืช ื‘ื™ืช ื”ืžืงื“ืฉ ืœืžืงื•ืžื” ื‘ืžื”ืจื” ื‘ื™ืžื™ื ื•. ืืžืŸ ืกืœื”.", + "psalm67": "ืœืžื ืฆื— ื‘ื ื’ื™ื ื•ืช ืžื–ืžื•ืจ ืฉื™ืจ.\nืืœื•ื”ื™ื ื™ื—ื ื ื• ื•ื™ื‘ืจื›ื ื•, ื™ืืจ ืคื ื™ื• ืื™ืชื ื• ืกืœื”.\nืœื“ืขืช ื‘ืืจืฅ ื“ืจื›ืš, ื‘ื›ืœ ื’ื•ื™ื™ื ื™ืฉื•ืขืชืš.\nื™ื•ื“ื•ืš ืขืžื™ื ืืœื•ื”ื™ื, ื™ื•ื“ื•ืš ืขืžื™ื ื›ื•ืœื.\nื™ืฉืžื—ื• ื•ื™ืจื ื ื• ืœืื•ืžื™ื, ื›ื™ ืชืฉืคื•ื˜ ืขืžื™ื ืžื™ืฉื•ืจ, ื•ืœืื•ืžื™ื ื‘ืืจืฅ ืชื ื—ื ืกืœื”.\nื™ื•ื“ื•ืš ืขืžื™ื ืืœื•ื”ื™ื, ื™ื•ื“ื•ืš ืขืžื™ื ื›ื•ืœื.\nืืจืฅ ื ืชื ื” ื™ื‘ื•ืœื”, ื™ื‘ืจื›ื ื• ืืœื•ื”ื™ื ืืœื•ื”ื™ื ื•.\nื™ื‘ืจื›ื ื• ืืœื•ื”ื™ื, ื•ื™ื™ืจืื• ืื•ืชื• ื›ืœ ืืคืกื™ ืืจืฅ.", + "ana_bekoach": "ืื ื ื‘ื›ื•ื— ื’ื“ื•ืœืช ื™ืžื™ื ืš, ืชืชื™ืจ ืฆืจื•ืจื”.\nืงื‘ืœ ืจื™ื ืช ืขืžืš, ืฉื’ื‘ื ื• ื˜ื”ืจื ื• ื ื•ืจื.\nื ื ื’ื™ื‘ื•ืจ ื“ื•ืจืฉื™ ื™ื—ื•ื“ืš, ื›ื‘ื‘ืช ืฉื•ืžืจื.\nื‘ืจื›ื ื˜ื”ืจื ืจื—ืžื, ืฆื“ืงืชืš ืชืžื™ื“ ื’ื•ืžืœื.\nื—ืกื™ืŸ ืงื“ื•ืฉ ื‘ืจื•ื‘ ื˜ื•ื‘ืš, ื ื”ืœ ืขื“ืชืš.\nื™ื—ื™ื“ ื’ืื” ืœืขืžืš ืคื ื”, ื–ื•ื›ืจื™ ืงื“ื•ืฉืชืš.\nืฉื•ื•ืขืชื ื• ืงื‘ืœ ื•ืฉืžืข ืฆืขืงืชื ื•, ื™ื•ื“ืข ืชืขืœื•ืžื•ืช.\nื‘ืจื•ืš ืฉื ื›ื‘ื•ื“ ืžืœื›ื•ืชื• ืœืขื•ืœื ื•ืขื“.", + "prayer_start": "ืจื™ื‘ื•ื ื• ืฉืœ ืขื•ืœื, ืืชื” ืฆื™ื•ื•ื™ืชื ื• ืขืœ ื™ื“ื™ ืžืฉื” ืขื‘ื“ืš ืœืกืคื•ืจ ืกืคื™ืจืช ื”ืขื•ืžืจ, ื›ื“ื™ ืœื˜ื”ืจื ื• ืžืงืœื™ืคื•ืชื™ื ื• ื•ืžื˜ื•ืžืื•ืชื™ื ื•. ื›ืžื• ืฉื›ืชื‘ืช ื‘ืชื•ืจืชืš: ื•ืกืคืจืชื ืœื›ื ืžืžื—ืจืช ื”ืฉื‘ืช ืžื™ื•ื ื”ื‘ื™ืื›ื ืืช ืขื•ืžืจ ื”ืชื ื•ืคื”, ืฉื‘ืข ืฉื‘ืชื•ืช ืชืžื™ืžื•ืช ืชื”ื™ื™ื ื”. ืขื“ ืžืžื—ืจืช ื”ืฉื‘ืช ื”ืฉื‘ื™ืขื™ืช ืชืกืคืจื• ื—ืžื™ืฉื™ื ื™ื•ื. ื›ื“ื™ ืฉื™ื™ื˜ื”ืจื• ื ืคืฉื•ืช ืขืžืš ื™ืฉืจืืœ ืžื–ื•ื”ืžืชื.\nื•ื‘ื›ืŸ ื™ื”ื™ ืจืฆื•ืŸ ืžืœืคื ื™ืš ื”ืณ ืืœื•ื”ื™ื ื• ื•ืืœื•ื”ื™ ืื‘ื•ืชื™ื ื•, ืฉื‘ื–ื›ื•ืช ืกืคื™ืจืช ื”ืขื•ืžืจ ืฉืกืคืจืชื™ ื”ื™ื•ื, ื™ืชื•ืงืŸ ืžื” ืฉืคื’ืžืชื™ ื‘ืกืคื™ืจื”", + "prayer_end_ashkenaz": "ื•ืื˜ื”ืจ ื•ืืชืงื“ืฉ ื‘ืงื“ื•ืฉื” ืฉืœ ืžืขืœื”. ืืžืŸ ืกืœื”.", + "prayer_end_sephard": "ื•ืื˜ื”ืจ ื•ืืชืงื“ืฉ ื‘ืงื“ื•ืฉื” ืฉืœ ืžืขืœื”, ื•ืขืœ ื™ื“ื™ ื–ื” ื™ื•ืฉืคืข ืฉืคืข ืจื‘ ื‘ื›ืœ ื”ืขื•ืœืžื•ืช ื•ืœืชืงืŸ ืืช ื ืคืฉื•ืชื™ื ื• ื•ืจื•ื—ื•ืชื™ื ื• ื•ื ืฉืžื•ืชื™ื ื• ืžื›ืœ ืกื™ื’ ื•ืคื’ื, ื•ืœื˜ื”ืจื ื• ื•ืœืงื“ืฉื ื• ื‘ืงื“ื•ืฉืชืš ื”ืขืœื™ื•ื ื”. ืืžืŸ ืกืœื”.", + "daily_sefira": { + "1": "ื—ืกื“ ืฉื‘ื—ืกื“", + "2": "ื’ื‘ื•ืจื” ืฉื‘ื—ืกื“", + "3": "ืชืคืืจืช ืฉื‘ื—ืกื“", + "4": "ื ืฆื— ืฉื‘ื—ืกื“", + "5": "ื”ื•ื“ ืฉื‘ื—ืกื“", + "6": "ื™ืกื•ื“ ืฉื‘ื—ืกื“", + "7": "ืžืœื›ื•ืช ืฉื‘ื—ืกื“", + "8": "ื—ืกื“ ืฉื‘ื’ื‘ื•ืจื”", + "9": "ื’ื‘ื•ืจื” ืฉื‘ื’ื‘ื•ืจื”", + "10": "ืชืคืืจืช ืฉื‘ื’ื‘ื•ืจื”", + "11": "ื ืฆื— ืฉื‘ื’ื‘ื•ืจื”", + "12": "ื”ื•ื“ ืฉื‘ื’ื‘ื•ืจื”", + "13": "ื™ืกื•ื“ ืฉื‘ื’ื‘ื•ืจื”", + "14": "ืžืœื›ื•ืช ืฉื‘ื’ื‘ื•ืจื”", + "15": "ื—ืกื“ ืฉื‘ืชืคืืจืช", + "16": "ื’ื‘ื•ืจื” ืฉื‘ืชืคืืจืช", + "17": "ืชืคืืจืช ืฉื‘ืชืคืืจืช", + "18": "ื ืฆื— ืฉื‘ืชืคืืจืช", + "19": "ื”ื•ื“ ืฉื‘ืชืคืืจืช", + "20": "ื™ืกื•ื“ ืฉื‘ืชืคืืจืช", + "21": "ืžืœื›ื•ืช ืฉื‘ืชืคืืจืช", + "22": "ื—ืกื“ ืฉื‘ื ืฆื—", + "23": "ื’ื‘ื•ืจื” ืฉื‘ื ืฆื—", + "24": "ืชืคืืจืช ืฉื‘ื ืฆื—", + "25": "ื ืฆื— ืฉื‘ื ืฆื—", + "26": "ื”ื•ื“ ืฉื‘ื ืฆื—", + "27": "ื™ืกื•ื“ ืฉื‘ื ืฆื—", + "28": "ืžืœื›ื•ืช ืฉื‘ื ืฆื—", + "29": "ื—ืกื“ ืฉื‘ื”ื•ื“", + "30": "ื’ื‘ื•ืจื” ืฉื‘ื”ื•ื“", + "31": "ืชืคืืจืช ืฉื‘ื”ื•ื“", + "32": "ื ืฆื— ืฉื‘ื”ื•ื“", + "33": "ื”ื•ื“ ืฉื‘ื”ื•ื“", + "34": "ื™ืกื•ื“ ืฉื‘ื”ื•ื“", + "35": "ืžืœื›ื•ืช ืฉื‘ื”ื•ื“", + "36": "ื—ืกื“ ืฉื‘ื™ืกื•ื“", + "37": "ื’ื‘ื•ืจื” ืฉื‘ื™ืกื•ื“", + "38": "ืชืคืืจืช ืฉื‘ื™ืกื•ื“", + "39": "ื ืฆื— ืฉื‘ื™ืกื•ื“", + "40": "ื”ื•ื“ ืฉื‘ื™ืกื•ื“", + "41": "ื™ืกื•ื“ ืฉื‘ื™ืกื•ื“", + "42": "ืžืœื›ื•ืช ืฉื‘ื™ืกื•ื“", + "43": "ื—ืกื“ ืฉื‘ืžืœื›ื•ืช", + "44": "ื’ื‘ื•ืจื” ืฉื‘ืžืœื›ื•ืช", + "45": "ืชืคืืจืช ืฉื‘ืžืœื›ื•ืช", + "46": "ื ืฆื— ืฉื‘ืžืœื›ื•ืช", + "47": "ื”ื•ื“ ืฉื‘ืžืœื›ื•ืช", + "48": "ื™ืกื•ื“ ืฉื‘ืžืœื›ื•ืช", + "49": "ืžืœื›ื•ืช ืฉื‘ืžืœื›ื•ืช" + } +} diff --git a/scripts/apply-patches.php b/scripts/apply-patches.php new file mode 100644 index 0000000..cfef89f --- /dev/null +++ b/scripts/apply-patches.php @@ -0,0 +1,21 @@ + $status) { + echo $library . ': ' . $status . "\n"; + } + } catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . "\n"); + exit(1); + } +} diff --git a/scripts/check-docs.php b/scripts/check-docs.php new file mode 100644 index 0000000..7b30a13 --- /dev/null +++ b/scripts/check-docs.php @@ -0,0 +1,37 @@ +isFile() && $file->getExtension() === 'md') { + $files[] = $file->getPathname(); + } + } +} +foreach ($files as $file) { + preg_match_all('/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/u', (string) file_get_contents($file), $matches); + foreach ($matches[1] as $link) { + if (preg_match('~^(?:[a-z][a-z0-9+.-]*:|#|/)~i', $link)) { + continue; + } + $path = rawurldecode(explode('#', $link, 2)[0]); + if ($path !== '' && !file_exists(dirname($file) . '/' . $path)) { + $errors[] = basename($file) . ': missing local link ' . $link; + } + } +} +foreach ($errors as $error) { + fwrite(STDERR, $error . "\n"); +} +echo 'Documentation links: ' . count($files) . ' documents, ' . count($errors) . " errors.\n"; +exit($errors === [] ? 0 : 1); diff --git a/scripts/check-locales.php b/scripts/check-locales.php new file mode 100644 index 0000000..f389575 --- /dev/null +++ b/scripts/check-locales.php @@ -0,0 +1,100 @@ + $value) { + $name = $prefix . $key; + if (is_array($value)) { + $out += $flatten($value, $name . '.'); + } else { + $out[$name] = $value; + } + } + return $out; +}; +foreach ($required as $code) { + $file = $directory . '/' . $code . '.json'; + if (!is_file($file)) { + $errors[] = $code . ': required locale file is missing'; + continue; + } + try { + $value = json_decode((string) file_get_contents($file), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($value) || $value === []) { + throw new RuntimeException('Locale must be a nonempty dictionary.'); + } + $locales[$code] = $flatten($value); + } catch (Throwable $error) { + $errors[] = $code . ': invalid locale JSON: ' . $error->getMessage(); + } +} +$base = $locales['he'] ?? []; +$baseKeys = array_keys($base); +sort($baseKeys); +foreach ($locales as $code => $locale) { + $keys = array_keys($locale); + sort($keys); + if ($keys !== $baseKeys) { + $missing = array_diff($baseKeys, $keys); + $extra = array_diff($keys, $baseKeys); + $errors[] = $code . ': missing keys ' . implode(',', $missing) . '; extra keys ' . implode(',', $extra); + } + foreach ($base as $key => $source) { + $translated = $locale[$key] ?? null; + if (!is_string($source) || !is_string($translated) || trim($source) === '' || trim($translated) === '') { + $errors[] = $code . '/' . $key . ': missing, empty or non-string translation'; + continue; + } + $pattern = '/(?:\{\{?([a-zA-Z_][a-zA-Z_0-9]*)\}\}?|%(?:\d+\$)?[sd])/u'; + preg_match_all($pattern, $source, $a); + preg_match_all($pattern, $translated, $b); + sort($a[0]); + sort($b[0]); + if ($a[0] !== $b[0]) { + $errors[] = $code . '/' . $key . ': placeholder mismatch'; + } + if (!mb_check_encoding($translated, 'UTF-8')) { + $errors[] = $code . '/' . $key . ': invalid UTF-8'; + } + } +} +// Validate the actual public language metadata, without booting MadelineProto. +require_once $root . '/src/UI/Lang.php'; +if (!defined(Shabbat\UI\Lang::class . '::CODES') || Shabbat\UI\Lang::CODES !== $required) { + $errors[] = 'Lang::CODES must list exactly the six required internal locale codes.'; +} +if ($errors === []) { + try { + $language = new Shabbat\UI\Lang($directory); + if ($language->languages() !== $required) { + $errors[] = 'Lang::languages does not match the locale registry.'; + } + foreach ($required as $code) { + $telegramCode = $language->telegramLanguageCode($code); + if ($telegramCode !== ($code === 'pt-br' ? 'pt' : $code)) { + $errors[] = $code . ': Telegram language code must use the approved two-letter mapping.'; + } + } + $keys = $language->keys(); + sort($keys); + if ($keys !== $baseKeys) { + $errors[] = 'Lang::keys differs from the checked locale dictionary.'; + } + } catch (Throwable $error) { + $errors[] = 'Language service validation failed: ' . $error->getMessage(); + } +} +foreach ($errors as $error) { + fwrite(STDERR, $error . "\n"); +} +echo 'Locales: ' . count($locales) . '/' . count($required) . ' languages, ' + . count($baseKeys) . ' keys, ' . count($errors) . " errors.\n"; +exit($errors === [] ? 0 : 1); diff --git a/scripts/dependencies.php b/scripts/dependencies.php new file mode 100644 index 0000000..6af6661 --- /dev/null +++ b/scripts/dependencies.php @@ -0,0 +1,123 @@ + 1, 'lock_sha256' => hash_file('sha256', $root . '/composer.lock'), 'packages' => [], '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'], +]]; +foreach (['omer-counts.json' => 'CC-BY-4.0', 'omer-counts-localized.json' => 'CC-BY-4.0', 'omer-liturgy.json' => 'CC-BY-SA-4.0'] as $filename => $license) { + $relative = 'resources/templates/' . $filename; + $absolute = $root . '/' . $relative; + if (!is_file($absolute)) { + throw new RuntimeException('Missing required bundled data resource: ' . $relative); + } + $metadata = json_decode((string) file_get_contents($absolute), true, flags: JSON_THROW_ON_ERROR); + if (($metadata['license'] ?? $license) !== $license) { + throw new RuntimeException('Bundled resource license differs from its reviewed grant: ' . $relative); + } + $licenseFile = 'LICENSES/' . $license . '.txt'; + $licenseHash = match ($license) { + 'CC-BY-4.0' => '9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411', + 'CC-BY-SA-4.0' => '28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5', + }; + if (!is_file($root . '/' . $licenseFile) || !hash_equals($licenseHash, (string) hash_file('sha256', $root . '/' . $licenseFile))) { + throw new RuntimeException('Bundled data license text is missing or differs from its reviewed publisher text.'); + } + $report['services_and_assets'][] = [ + 'name' => $filename, + 'kind' => 'bundled data resource', + 'file' => $relative, + 'sha256' => hash_file('sha256', $absolute), + 'spdx' => $license, + 'license_files' => [['file' => $licenseFile, 'sha256' => $licenseHash]], + 'attribution' => $metadata['attribution'] ?? '', + 'source' => $metadata['source'] ?? $metadata['sources'] ?? [], + 'retrieved_at' => $metadata['retrieved_at'] ?? null, + 'notes' => $metadata['notes'] ?? $metadata['note'] ?? '', + 'status' => 'Bundled metadata recorded; preserve data license and attribution separately from the application AGPL.', + ]; +} +$report['local_patches'] = []; +foreach (['madelineproto', 'madelineproto-geopoint', 'madelineproto-formatted-date', 'madelineproto-fresh-peer', 'broadcastmanager'] as $library) { + $relative = 'patches/' . $library . '/manifest.json'; + if (!is_file($root . '/' . $relative)) { + throw new RuntimeException('Missing required local patch manifest: ' . $relative); + } + $manifest = json_decode((string) file_get_contents($root . '/' . $relative), true, flags: JSON_THROW_ON_ERROR); + $report['local_patches'][] = [ + 'manifest' => $relative, + 'sha256' => hash_file('sha256', $root . '/' . $relative), + 'package' => $manifest['package'] ?? 'danog/madelineproto', + 'target' => $manifest['target'] ?? 'src/Loop/Update/UpdateLoop.php', + 'approved' => $manifest['approved'], + 'notes' => 'Locally modified library source and its immutable baseline/diff ship in the corresponding-source archive; preserve upstream notices.', + ]; +} +$sqlite = (string) (new PDO('sqlite::memory:'))->query('SELECT sqlite_version()')->fetchColumn(); +$report['external_runtime'] = [['name' => 'SQLite', 'php_loaded_version' => $sqlite, 'license' => 'public-domain', 'terms' => 'https://sqlite.org/copyright.html', 'wal_fix_verified' => version_compare($sqlite, '3.51.3', '>=') || in_array($sqlite, ['3.44.6', '3.50.7'], true), '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']]; +foreach (['packages' => 'runtime', 'packages-dev' => 'dev'] as $section => $scope) { + foreach ($lock[$section] ?? [] as $package) { + $name = $package['name']; + $directory = $root . '/vendor/' . $name; + $licenses = []; + if (is_dir($directory)) { + foreach (new DirectoryIterator($directory) as $file) { + if ($file->isFile() && preg_match('/^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/i', $file->getFilename())) { + $licenses[] = ['file' => 'vendor/' . $name . '/' . $file->getFilename(), 'sha256' => hash_file('sha256', $file->getPathname())]; + } + } + } + $metadata = $package['license'] ?? []; + $findings = []; + $text = ''; + foreach ($licenses as $license) { + $text .= file_get_contents($root . '/' . $license['file']); + } + $verified = false; + foreach ($metadata as $spdx) { + $verified = $verified || match ($spdx) { + 'MIT' => str_contains($text, 'Permission is hereby granted'), + 'BSD-3-Clause', 'BSD-2-Clause' => str_contains($text, 'Redistribution and use'), + 'Apache-2.0' => str_contains($text, 'Apache License'), + 'AGPL-3.0-only', 'AGPL-3.0-or-later' => str_contains($text, 'GNU AFFERO GENERAL PUBLIC LICENSE') || str_contains($text, 'GNU Affero General Public License'), + 'LGPL-2.1-only', 'LGPL-2.1-or-later', 'LGPL-3.0-only' => str_contains($text, 'GNU LESSER GENERAL PUBLIC LICENSE'), + 'ISC' => str_contains($text, 'Permission to use, copy, modify'), + 'Unlicense' => str_contains($text, 'public domain'), + default => false, + }; + } + if (!$verified) { + $findings[] = 'NOT VERIFIED: declared SPDX not confirmed against a distributed root license text; release review required.'; + } + if ($name === 'danog/madelineproto') { + $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.'; + } + $source = $package['source'] ?? $package['dist'] ?? []; + $report['packages'][] = [ + 'name' => $name, 'version' => $package['version'], 'direct' => isset($composer['require'][$name]) || isset($composer['require-dev'][$name]), + 'scope' => $scope, 'spdx' => $metadata, 'source_url' => $source['url'] ?? '', 'source_commit' => $source['reference'] ?? '', + 'source_permalink' => isset($source['url'], $source['reference']) ? preg_replace('/\.git$/', '', $source['url']) . '/tree/' . $source['reference'] : '', + 'license_files' => $licenses, 'attribution' => 'Preserve distributed license and copyright notices; additional source obligations depend on the declared license.', + 'status' => $verified ? 'LICENSE_TEXT_PRESENT' : 'NOT VERIFIED', 'findings' => $findings, + ]; + } +} +if (!is_dir($root . '/docs')) { + mkdir($root . '/docs', 0755, true); +} +$json = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n"; +if (in_array('--check', $argv, true)) { + $saved = $root . '/docs/dependencies.json'; + if (!is_file($saved) || file_get_contents($saved) !== $json) { + fwrite(STDERR, "Dependency report differs; regenerate with composer dependencies.\n"); + exit(1); + } +} else { + file_put_contents($root . '/docs/dependencies.json', $json); +} +echo 'Dependency inventory: ' . count($report['packages']) . " locked packages. License text checks are evidence, not a legal certification.\n"; diff --git a/scripts/package.php b/scripts/package.php new file mode 100644 index 0000000..6a9f51d --- /dev/null +++ b/scripts/package.php @@ -0,0 +1,149 @@ + '7a2aa6d828415a02c060a2e9377e5402fa6039f4e3392d0c1a308221962b357d', + 'vendor/amphp/http-server/tools/tls/localhost.key.pem' => '1e11dc00c61adae8180d95a8e1774d2910cab3459c4744b2f6567f061e094eb0', +]; +$omittedExampleKeys = array_keys($approvedExampleKeys); +$exampleSource = [ + 'package' => 'amphp/http-server', + 'version' => 'v3.4.7', + 'commit' => 'aa96ca38c5f822a827236543d5417d0c2a782ef0', + 'permalink' => 'https://github.com/amphp/http-server/tree/aa96ca38c5f822a827236543d5417d0c2a782ef0', +]; +if (Composer\InstalledVersions::getReference($exampleSource['package']) !== $exampleSource['commit']) { + throw new RuntimeException('Review the immutable source of development TLS example-key omissions for this dependency version.'); +} +$omissionEvidence = []; +foreach ($approvedExampleKeys as $path => $sha256) { + if (is_file($root . '/' . $path) && hash_file('sha256', $root . '/' . $path) !== $sha256) { + throw new RuntimeException('Unknown content at a reviewed development example-key path: ' . $path); + } + $omissionEvidence[] = ['file' => $path, 'sha256' => $sha256, 'source' => $exampleSource, 'reason' => 'Public upstream development TLS example key; not required to build or run the bot.']; +} +$forbidden = static function (string $path) use ($omittedExampleKeys): bool { + if (in_array($path, $omittedExampleKeys, true)) { + return true; + } + foreach (explode('/', $path) as $segment) { + if ( + $segment === '.git' || $segment === '.env' || ($segment === 'data' && !str_starts_with($path, 'tests/Fixtures/legacy/data/')) || $segment === 'runtime' + || $segment === 'logs' || $segment === 'node_modules' || str_ends_with($segment, '.madeline') + || preg_match('/\.(?:sqlite|db)(?:-wal|-shm)?$/i', $segment) + ) { + return true; + } + } + return str_contains($path, 'bot.shabbat') || (str_contains($path, '.env.') && !str_ends_with($path, '.env.example')); +}; +$scan = static function (string $contents, string $path): void { + if ( + preg_match('/^-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----$/m', $contents) + || preg_match('/(?:BOT_TOKEN|API_HASH)\s*=\s*["\x27]?[0-9a-f]{32,}/i', $contents) + || preg_match('/\b[0-9]{6,12}:[A-Za-z0-9_-]{30,}\b/', $contents) + ) { + throw new RuntimeException('Potential private credential in archive input: ' . $path); + } +}; +foreach ($files as $path => $absolute) { + if ($forbidden($path)) { + throw new RuntimeException('Forbidden runtime file in archive inputs: ' . $path); + } + $scan((string) file_get_contents($absolute), $path); +} +ksort($files); +$sourceFiles = $files; +foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/vendor', FilesystemIterator::SKIP_DOTS)) as $file) { + if (!$file->isFile() || $file->isLink()) { + continue; + } + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); + if (!$forbidden($relative) && !str_contains($relative, '/composer/tmp-') && !str_ends_with($relative, '/write-check.txt')) { + $sourceFiles[$relative] = $file->getPathname(); + } +} +ksort($sourceFiles); +$hashes = []; +foreach ($sourceFiles as $path => $absolute) { + $hashes[$path] = hash_file('sha256', $absolute); +} +$manifest = ['version' => $version, 'commit' => Shabbat\Runtime\Version::commit($root), 'application_source_sha256' => hash('sha256', json_encode(array_intersect_key($hashes, $files), JSON_THROW_ON_ERROR)), 'review_status' => 'development snapshot; publication requires explicit owner approval', 'source_files' => $hashes, 'omitted_public_example_keys' => $omissionEvidence, 'excludes' => ['production data', 'secrets', 'sessions', '.git'], 'patches' => ['madelineproto' => json_decode((string) file_get_contents($root . '/patches/madelineproto/manifest.json'), true, flags: JSON_THROW_ON_ERROR), 'madelineproto-geopoint' => json_decode((string) file_get_contents($root . '/patches/madelineproto-geopoint/manifest.json'), true, flags: JSON_THROW_ON_ERROR), 'madelineproto-formatted-date' => json_decode((string) file_get_contents($root . '/patches/madelineproto-formatted-date/manifest.json'), true, flags: JSON_THROW_ON_ERROR), 'madelineproto-fresh-peer' => json_decode((string) file_get_contents($root . '/patches/madelineproto-fresh-peer/manifest.json'), true, flags: JSON_THROW_ON_ERROR), 'broadcastmanager' => json_decode((string) file_get_contents($root . '/patches/broadcastmanager/manifest.json'), true, flags: JSON_THROW_ON_ERROR)]]; +if (!is_dir($root . '/dist')) { + mkdir($root . '/dist', 0700); +} +$metadata = json_encode(['commit' => $manifest['commit'], 'application_source_sha256' => $manifest['application_source_sha256'], 'local_changes' => true], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); +$archive = static function (string $name, array $entries, string $kind) use ($root, $manifest, $metadata, $forbidden, $scan): string { + $hasDevelopmentTools = isset($entries['vendor/phpunit/phpunit/phpunit'], $entries['vendor/phpstan/phpstan/phpstan'], $entries['vendor/squizlabs/php_codesniffer/bin/phpcs']); + if ($kind === 'server-test' && !$hasDevelopmentTools) { + throw new RuntimeException('The server-test archive requires the locked installed offline verification tools.'); + } + $finalPath = $root . '/dist/' . $name; + $path = $finalPath . '.tmp-' . bin2hex(random_bytes(6)); + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to open release archive.'); + } + foreach ($entries as $relative => $absolute) { + if (!$zip->addFile($absolute, $relative)) { + throw new RuntimeException('Unable to add archive input: ' . $relative); + } + } + $archiveManifest = $manifest; + $archiveManifest['artifact_kind'] = $kind; + $archiveManifest['includes_installed_dependencies'] = $kind !== 'application'; + $archiveManifest['includes_development_tools'] = $hasDevelopmentTools; + $archiveManifest['setup_guide'] = 'docs/SERVER_TEST_DEPLOYMENT.md'; + $zip->addFromString('release-manifest.json', json_encode($archiveManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + $zip->addFromString('build-info.json', $metadata); + $zip->close(); + $check = new ZipArchive(); + if ($check->open($path) !== true) { + throw new RuntimeException('Generated archive could not be reopened.'); + } + for ($index = 0; $index < $check->numFiles; $index++) { + $entry = $check->getNameIndex($index); + if (!is_string($entry) || $forbidden($entry)) { + throw new RuntimeException('Forbidden entry in final archive.'); + } + $contents = $check->getFromIndex($index); + if (!is_string($contents)) { + throw new RuntimeException('Unreadable archive entry.'); + } + $scan($contents, $entry); + if (isset($entries[$entry]) && (hash('sha256', $contents) !== $manifest['source_files'][$entry] || hash_file('sha256', $entries[$entry]) !== $manifest['source_files'][$entry])) { + throw new RuntimeException('Archive entry checksum mismatch: ' . $entry); + } + } + $check->close(); + if (!rename($path, $finalPath)) { + throw new RuntimeException('Unable to publish verified local archive atomically.'); + } + return $finalPath; +}; +$application = $archive('shabbat-' . $version . '.zip', $files, 'application'); +$source = $archive('shabbat-' . $version . '-corresponding-source.zip', $sourceFiles, 'corresponding-source'); +$server = $archive('shabbat-' . $version . '-server-test.zip', $sourceFiles, 'server-test'); +$artifacts = [$application, $source, $server]; +$checksums = ''; +foreach ($artifacts as $artifact) { + $checksums .= hash_file('sha256', $artifact) . ' ' . basename($artifact) . "\n"; +} +file_put_contents($root . '/dist/SHA256SUMS', $checksums); +$manifest['artifacts'] = array_map(static fn (string $artifact): array => ['file' => basename($artifact), 'sha256' => hash_file('sha256', $artifact)], $artifacts); +file_put_contents($root . '/dist/release-manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); +echo 'Created and scanned ' . implode(', ', array_map('basename', $artifacts)) . ".\n"; diff --git a/scripts/quality.php b/scripts/quality.php new file mode 100644 index 0000000..0fe328d --- /dev/null +++ b/scripts/quality.php @@ -0,0 +1,32 @@ +isFile() || $file->getExtension() !== 'php') { + continue; + } + $source = file_get_contents($file->getPathname()); + try { + token_get_all((string) $source, TOKEN_PARSE); + } catch (ParseError $error) { + fwrite(STDERR, $file->getPathname() . ': ' . $error->getMessage() . "\n"); + $errors++; + } + if (str_starts_with((string) $source, "\xEF\xBB\xBF") || !str_starts_with((string) $source, 'getPathname() . ': PHP source must start with an opening tag without BOM.' . "\n"); + $errors++; + } + $checked++; + } +} +echo "PHP syntax and source encoding: {$checked} files, {$errors} errors.\n"; +exit($errors > 0 ? 1 : 0); diff --git a/scripts/verify-patches.php b/scripts/verify-patches.php new file mode 100644 index 0000000..ce9ab71 --- /dev/null +++ b/scripts/verify-patches.php @@ -0,0 +1,14 @@ + $status) { + echo $library . ' verified: ' . $status . "\n"; + } +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . "\n"); + exit(1); +} diff --git a/src/Calendar/CalendarDiagnostics.php b/src/Calendar/CalendarDiagnostics.php new file mode 100644 index 0000000..4288cb7 --- /dev/null +++ b/src/Calendar/CalendarDiagnostics.php @@ -0,0 +1,235 @@ +http = new SharedJsonClient($http ?? new AmpJsonClient(), $clock); + } + + /** @param list $arguments */ + public static function parseArguments(array $arguments): array + { + $options = []; + for ($index = 0; $index < count($arguments); $index++) { + if (!preg_match('/^--(date|latitude|longitude|timezone|israel|language)(?:=(.*))?$/D', $arguments[$index], $match)) { + throw new \InvalidArgumentException('Invalid diagnostic option.'); + } + $name = $match[1]; + $value = $match[2] ?? ($arguments[++$index] ?? null); + if (isset($options[$name]) || !is_string($value) || $value === '' || str_starts_with($value, '--')) { + throw new \InvalidArgumentException('Invalid diagnostic option.'); + } + $options[$name] = $value; + } + self::settings($options); + if (isset($options['date'])) { + HebcalProvider::date($options['date']); + } + return $options; + } + + /** These operational defaults are checked against the actual migrated group defaults. */ + public static function settings(array $options = []): array + { + if (array_diff(array_keys($options), ['date', 'latitude', 'longitude', 'timezone', 'israel', 'language']) !== []) { + throw new \InvalidArgumentException('Invalid diagnostic option.'); + } + foreach ($options as $value) { + if (!is_string($value) || $value === '') { + throw new \InvalidArgumentException('Invalid diagnostic option.'); + } + } + $settings = [ + 'location_name' => 'Jerusalem', 'country' => 'IL', 'geoname_id' => 281184, + 'latitude' => 31.76904, 'longitude' => 35.21633, 'timezone_id' => 'Asia/Jerusalem', + 'location_source' => 'default', 'israel' => true, 'language' => 'he', 'candle_minutes' => null, + 'havdalah_method' => 'degrees', 'havdalah_value' => 8.5, + 'close_before_candles_minutes' => 0, 'open_after_havdalah_minutes' => 0, + 'mode' => 'lock', 'shabbat_enabled' => true, 'holidays_enabled' => true, + 'omer_nightfall_method' => 'degrees', 'omer_nightfall_value' => 8.5, + 'omer_nusach' => 'ashkenaz', 'omer_day_completion' => false, + ]; + if (isset($options['latitude']) !== isset($options['longitude'])) { + throw new \InvalidArgumentException('Both coordinates are required.'); + } + if (isset($options['latitude'])) { + foreach (['latitude' => 90, 'longitude' => 180] as $field => $bound) { + if (!is_numeric($options[$field]) || !is_finite((float) $options[$field]) || abs((float) $options[$field]) > $bound) { + throw new \InvalidArgumentException('Invalid diagnostic coordinates.'); + } + $settings[$field] = (float) $options[$field]; + } + if ($settings['latitude'] !== 31.76904 || $settings['longitude'] !== 35.21633) { + $settings['geoname_id'] = null; + $settings['location_name'] = 'Custom coordinates'; + $settings['country'] = ''; + $settings['location_source'] = 'coordinates'; + } + } + if (isset($options['timezone'])) { + if (!in_array($options['timezone'], \DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC), true)) { + throw new \InvalidArgumentException('Invalid diagnostic IANA timezone.'); + } + $settings['timezone_id'] = $options['timezone']; + } + if (isset($options['israel'])) { + $settings['israel'] = match ($options['israel']) { + 'on', 'true', '1' => true, + 'off', 'false', '0' => false, + default => throw new \InvalidArgumentException('Invalid diagnostic Israel scheme.'), + }; + } + if (isset($options['language'])) { + HebcalProvider::providerLanguage($options['language']); + $settings['language'] = $options['language']; + } + return $settings; + } + + public function run(array $options = []): array + { + $settings = self::settings($options); + $zone = new \DateTimeZone($settings['timezone_id']); + if (isset($options['date'])) { + HebcalProvider::date($options['date']); + // An explicit date means local noon, making historical/future checks reproducible. + $instant = new \DateTimeImmutable($options['date'] . ' 12:00:00', $zone); + } else { + $instant = $this->clock->now()->setTimezone($zone); + } + $date = $instant->format('Y-m-d'); + $start = $instant->modify('first day of this month')->modify('-8 days')->format('Y-m-d'); + $end = $instant->modify('last day of this month')->modify('+40 days')->format('Y-m-d'); + $provider = new HebcalProvider($this->http); + $service = new CalendarService($provider, $this->clock); + $stages = []; + $stages['calendar'] = $this->stage('provider', static function () use ($provider, $start, $end, $settings): array { + $data = $provider->calendar($start, $end, $settings['israel'], $settings['language']); + return ['event_count' => count($data['items']), 'range' => $data['range']]; + }); + $stages['solar'] = $this->stage('provider', static function () use ($provider, $start, $end, $settings): array { + $days = $provider->solar($settings, $start, $end); + return ['day_count' => count($days)]; + }); + if ($stages['calendar']['status'] === 'ok' && $stages['solar']['status'] === 'ok') { + $stages['shabbat_holidays'] = $this->stage('calculation', function () use ($service, $settings, $instant, $zone): array { + $state = $service->snapshot($settings, $instant); + return [ + 'event_count' => count($state['events']), 'is_shabbat' => $state['is_shabbat'], + 'is_yom_tov' => $state['is_yom_tov'], 'quiet_now' => $state['quiet_now'], + 'next_close_at' => $this->localTime($state['next_close_at'], $zone), + 'next_open_at' => $this->localTime($state['next_open_at'], $zone), + ]; + }); + $stages['zmanim'] = $this->stage('calculation', function () use ($service, $settings, $date, $zone): array { + $data = $service->zmanim($settings, $date); + return [ + 'date' => $data['date'], 'available_time_count' => count(array_filter($data['times'], static fn($value): bool => $value !== null)), + 'sunrise' => $this->localTime($data['times']['sunrise'] ?? null, $zone), + 'sunset' => $this->localTime($data['times']['sunset'] ?? null, $zone), + 'nightfall' => $this->localTime(CalendarEngine::nightfall($data['times'], $settings), $zone), + ]; + }); + $stages['omer'] = $this->stage('calculation', static function () use ($service, $settings, $instant): array { + $data = $service->omer($settings, $instant); + return [ + 'state' => $data['season'] ? 'active_season' : 'valid_inactive', + 'season' => $data['season'], 'day' => $data['day'], 'phase' => $data['phase'], + 'eligible' => $data['eligible'], 'date' => $data['date'], + ]; + }); + } else { + foreach (['shabbat_holidays', 'zmanim', 'omer'] as $name) { + $stages[$name] = ['status' => 'blocked', 'reason' => 'provider_stage_failed']; + } + } + // Daf has no dependency on solar data. A distinct one-day query also diagnoses this reported asymmetry. + $stages['daf'] = $this->stage('calculation', static function () use ($service, $settings, $date): array { + $data = $service->daf($settings, $date); + return ['date' => $data['date'], 'page' => $data['page']]; + }); + return [ + 'diagnostic' => 'calendar-public-v1', 'read_only' => true, 'credentials_required' => false, + 'persisted_data_used' => false, 'source' => 'Hebcal', + 'input' => [ + 'date' => $date, 'instant' => $instant->format(DATE_ATOM), + 'latitude' => $settings['latitude'], 'longitude' => $settings['longitude'], + 'timezone_id' => $settings['timezone_id'], 'israel' => $settings['israel'], + 'language' => $settings['language'], 'candle_minutes' => CalendarEngine::candleMinutes($settings), + ], + 'window' => ['start' => $start, 'end' => $end], 'stages' => $stages, + 'ok' => count(array_filter($stages, static fn(array $stage): bool => $stage['status'] !== 'ok')) === 0, + ]; + } + + private function stage(string $kind, \Closure $check): array + { + try { + return ['status' => 'ok'] + $check(); + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + return ['status' => 'failed', 'kind' => $kind, 'reason' => self::reason($error, $kind)]; + } + } + + private static function reason(\Throwable $error, string $kind): string + { + if ($error instanceof TimeoutException || $error instanceof \Amp\TimeoutException) { + return 'provider_transport_timeout'; + } + if ($error instanceof HttpException) { + return 'provider_transport_failed'; + } + if ($error instanceof ProviderException) { + // Never emit exception text, response bodies, query URLs or traces. Only this fixed vocabulary escapes. + return match ($error->getMessage()) { + 'Malformed calendar response.' => 'calendar_schema_invalid', + 'Calendar response does not cover the requested date range.', 'Calendar coverage is stale.' => 'calendar_range_incomplete', + 'Malformed calendar event.', 'Invalid provider civil date.', 'Out-of-range calendar event.', + 'Malformed calendar holiday classification.', 'Malformed Omer count metadata.' => 'calendar_event_invalid', + 'Solar response does not cover the requested date range.' => 'solar_range_incomplete', + 'Solar provider timezone mismatch.' => 'solar_timezone_mismatch', + 'Malformed solar time series.' => 'solar_schema_invalid', + 'Missing sunset time series.' => 'solar_required_series_missing', + 'Incomplete solar date coverage.' => 'solar_daily_coverage_incomplete', + 'Invalid solar timestamp.' => 'solar_timestamp_invalid', + 'Solar date/timezone mismatch.' => 'solar_timestamp_timezone_mismatch', + 'Solar midnight is outside its preceding sunset/sunrise night.', 'Nightfall precedes sunset.', + 'Dawn follows sunset.' => 'solar_time_order_invalid', + 'Required sunset/nightfall unavailable. Automatic permissions remain unchanged.', + 'Intermediate festival candle-lighting nightfall is unavailable.', + 'Omer sunset/nightfall/dawn is unavailable.' => 'calculation_required_times_unavailable', + 'Invalid provider JSON.', 'Provider did not return an object.' => 'provider_json_invalid', + 'Daf Yomi is unavailable for this date.' => 'daf_unavailable', + 'Untrusted study link.', 'Malformed Daf Yomi tractate/page.' => 'daf_invalid', + default => preg_match('/^Provider HTTP status [1-5][0-9]{2}$/D', $error->getMessage()) ? 'provider_http_error' : 'provider_validation_failed', + }; + } + return $kind === 'provider' ? 'provider_internal_error' : 'calculation_internal_error'; + } + + private function localTime(?int $timestamp, \DateTimeZone $zone): ?string + { + return $timestamp === null ? null : (new \DateTimeImmutable('@' . $timestamp))->setTimezone($zone)->format(DATE_ATOM); + } +} diff --git a/src/Calendar/CalendarEngine.php b/src/Calendar/CalendarEngine.php new file mode 100644 index 0000000..250841a --- /dev/null +++ b/src/Calendar/CalendarEngine.php @@ -0,0 +1,174 @@ +40, 294801, 293067=>30, default=>18 + }; + } + public static function nightfall(array $sun, array $settings, string $prefix = 'havdalah'): ?int + { + $method = $settings[$prefix . '_method'] ?? 'degrees'; + $value = (float)($settings[$prefix . '_value'] ?? 8.5); + if ($method === 'degrees') { + $key = match ($value) { + 8.5=>'tzeit85deg', 7.083=>'tzeit7083deg', default=>throw new \InvalidArgumentException('Unsupported nightfall angle.') + }; + return $sun[$key] ?? null; + } + if ($method !== 'minutes' || $value < 1 || $value > 180 || floor($value) !== $value) { + throw new \InvalidArgumentException('Invalid nightfall method/minutes.'); + } + return isset($sun['sunset']) ? $sun['sunset'] + (int)$value * 60 : null; + } + public function evaluate(array $settings, array $calendar, array $solar, \DateTimeImmutable $now): array + { + $zone = new \DateTimeZone($settings['timezone_id']); + $local = $now->setTimezone($zone); + $dateKey = $local->format('Y-m-d'); + if (($calendar['range']['start'] ?? '9999') > $local->modify('-1 day')->format('Y-m-d') || ($calendar['range']['end'] ?? '0000') < $local->modify('+1 day')->format('Y-m-d')) { + throw new ProviderException('Calendar coverage is stale.'); + } + $byDay = []; + foreach ($calendar['items'] as $item) { + $byDay[substr($item['date'], 0, 10)][] = $item; + } + $intervals = []; + foreach ($solar as $day => $sun) { + $dayDate = new \DateTimeImmutable($day, $zone); + $saturday = $dayDate->format('N') === '6'; + $holidays = array_values(array_filter($byDay[$day] ?? [], fn(array $item): bool => ($item['yomtov'] ?? false) === true)); + if (!$saturday && $holidays === []) { + continue; + } + $prev = $dayDate->modify('-1 day')->format('Y-m-d'); + if (!isset($solar[$prev])) { + continue; + } + $sunset = $solar[$prev]['sunset'] ?? null; + $havdalah = self::nightfall($sun, $settings); + if ($sunset === null || $havdalah === null) { + throw new ProviderException('Required sunset/nightfall unavailable. Automatic permissions remain unchanged.'); + } + $candles = $sunset - self::candleMinutes($settings) * 60; + $startAt = $candles; + $previousSaturday = $dayDate->modify('-1 day')->format('N') === '6'; + $previousFestival = array_filter($byDay[$prev] ?? [], static fn(array $item): bool=>($item['yomtov'] ?? false) === true) !== []; + if (!$saturday && ($previousSaturday || $previousFestival)) { + // Lighting for a second festival day or after Shabbat waits for the previous day's nightfall. + $candles = self::nightfall($solar[$prev], $settings); + if ($candles === null) { + throw new ProviderException('Intermediate festival candle-lighting nightfall is unavailable.'); + } + $startAt = $sunset; + } + $enabled = ($saturday && (bool)($settings['shabbat_enabled'] ?? true)) || ($holidays !== [] && (bool)($settings['holidays_enabled'] ?? true)); + $intervals[] = [ + 'event_key' => $day, 'start_at' => $startAt,'end_at' => $havdalah,'candles_at' => $candles,'havdalah_at' => $havdalah, + 'close_at' => $candles - (int)($settings['close_before_candles_minutes'] ?? 0) * 60, + 'open_at' => $havdalah + (int)($settings['open_after_havdalah_minutes'] ?? 0) * 60, + 'is_shabbat' => $saturday,'is_yom_tov' => $holidays !== [], 'enabled' => $enabled, + 'title' => $holidays !== [] ? implode(' / ', array_column($holidays, ($settings['language'] ?? 'he') === 'he' ? 'hebrew' : 'title')) : (($settings['language'] ?? 'he') === 'he' ? 'ืฉื‘ืช' : 'Shabbat'), + 'days' => [$day], 'intermediate' => [], + ]; + } + usort($intervals, fn(array $a, array $b): int=>$a['start_at'] <=> $b['start_at']); + $all = $this->merge($intervals); + $active = null; + $next = null; + $stamp = $now->getTimestamp(); + foreach ($all as $event) { + if ($event['start_at'] <= $stamp && $event['end_at'] > $stamp) { + $active = $event; + } elseif ($event['start_at'] > $stamp && $next === null) { + $next = $event; + } + } + $operational = $this->merge(array_values(array_filter($intervals, fn(array $e): bool=>$e['enabled']))); + $lockEvent = null; + $nextLock = null; + foreach ($operational as $event) { + if ($event['close_at'] <= $stamp && $event['open_at'] > $stamp) { + $lockEvent = $event; + } elseif ($event['close_at'] > $stamp && $nextLock === null) { + $nextLock = $event; + } + } + // Sequence metadata stays merged, but current-day flags come from individual active segments. + $activeShabbat = false; + $activeYomTov = false; + foreach ($intervals as $segment) { + if ($segment['start_at'] <= $stamp && $segment['end_at'] > $stamp) { + $activeShabbat = $activeShabbat || $segment['is_shabbat']; + $activeYomTov = $activeYomTov || $segment['is_yom_tov']; + } + } + $metadataDate = $dateKey; + if (isset($solar[$dateKey]['sunset']) && $stamp >= $solar[$dateKey]['sunset']) { + $metadataDate = $local->modify('+1 day')->format('Y-m-d'); + } + $localItems = $byDay[$metadataDate] ?? []; + $chol = false; + $tisha = false; + foreach ($localItems as $item) { + // Structured original event names supplied by Hebcal distinguish intermediate festival/fast days. + $original = (string)($item['title_orig'] ?? $item['title'] ?? ''); + $chol = $chol || str_contains($original, "(CH'M)") || str_contains($original, "(CH''M)"); + // Fast dates supplied by Hebcal include observed/postponed days. Do not infer 9 Av yourself. + $tisha = $tisha || (str_starts_with($original, "Tish'a B'Av") && !str_starts_with($original, "Erev")); + } + // A current civil day's fast continues through twilight until the chosen nightfall. + foreach ($byDay[$dateKey] ?? [] as $item) { + if (str_starts_with((string)($item['title_orig'] ?? $item['title'] ?? ''), "Tish'a B'Av")) { + $fastEnd = self::nightfall($solar[$dateKey] ?? [], $settings); + $tisha = $tisha || ($fastEnd !== null && $stamp < $fastEnd); + } + } + return [ + 'current_event' => $active, 'next_event' => $next,'lock_event' => $lockEvent, + 'is_shabbat' => $activeShabbat,'is_yom_tov' => $activeYomTov, + 'is_chol_hamoed' => $chol,'is_tisha_bav' => $tisha,'is_work_prohibited_now' => $active !== null, + 'should_group_be_locked' => $lockEvent !== null && in_array($settings['mode'] ?? 'off', ['lock','lock_messages'], true), + 'quiet_now' => $lockEvent !== null || $active !== null, + 'next_close_at' => $nextLock['close_at'] ?? null,'next_open_at' => $lockEvent['open_at'] ?? $nextLock['open_at'] ?? null, + 'current_dawn_at' => $solar[$dateKey]['alotHaShachar'] ?? null,'current_sunset_at' => $solar[$dateKey]['sunset'] ?? null, + 'current_nightfall_at' => self::nightfall($solar[$dateKey] ?? [], $settings), + 'next_dawn_at' => $solar[$local->modify('+1 day')->format('Y-m-d')]['alotHaShachar'] ?? null, + 'events' => $all,'operational_events' => $operational,'coverage' => $calendar['range'], + ]; + } + private function merge(array $intervals): array + { + $merged = []; + foreach ($intervals as $event) { + $last = count($merged) - 1; + // Adjacent prohibited civil days form one continuous sequence even when candles are at nightfall. + if ($last >= 0 && (end($merged[$last]['days']) === (new \DateTimeImmutable($event['days'][0], new \DateTimeZone('UTC')))->modify('-1 day')->format('Y-m-d') || $merged[$last]['open_at'] >= $event['close_at'])) { + $merged[$last]['intermediate'][] = $event; + $merged[$last]['days'] = array_merge($merged[$last]['days'], $event['days']); + $merged[$last]['end_at'] = max($merged[$last]['end_at'], $event['end_at']); + $merged[$last]['havdalah_at'] = $event['havdalah_at']; + $merged[$last]['open_at'] = max($merged[$last]['open_at'], $event['open_at']); + $merged[$last]['is_shabbat'] = $merged[$last]['is_shabbat'] || $event['is_shabbat']; + $merged[$last]['is_yom_tov'] = $merged[$last]['is_yom_tov'] || $event['is_yom_tov']; + $merged[$last]['event_key'] = implode('+', $merged[$last]['days']); + $merged[$last]['title'] .= ' / ' . $event['title']; + } else { + $merged[] = $event; + } + } + return $merged; + } +} diff --git a/src/Calendar/CalendarService.php b/src/Calendar/CalendarService.php new file mode 100644 index 0000000..c41ab47 --- /dev/null +++ b/src/Calendar/CalendarService.php @@ -0,0 +1,146 @@ +engine ??= new CalendarEngine(); + } + public function snapshot(array $settings, ?\DateTimeImmutable $now = null): array + { + $now ??= $this->clock->now(); + $data = $this->data($settings, $now); + return $this->engine->evaluate($settings, $data['calendar'], $data['solar'], $now) + [ + 'evaluated_at' => $now->getTimestamp(), 'local_date' => $now->setTimezone(new \DateTimeZone($settings['timezone_id']))->format('Y-m-d'), + 'calendar_items' => $data['calendar']['items'], + 'fetched_at' => $data['fetched_at'],'stale' => $data['stale'],'source' => 'Hebcal','calculation_version' => 'shabbat-calendar-2']; + } + /** Evaluate after provider I/O; explicit snapshot timestamps remain available for planning. */ + public function snapshotNow(array $settings): array + { + $data = $this->data($settings, $this->clock->now()); + $now = $this->clock->now(); + return $this->engine->evaluate($settings, $data['calendar'], $data['solar'], $now) + [ + 'evaluated_at' => $now->getTimestamp(), + 'local_date' => $now->setTimezone(new \DateTimeZone($settings['timezone_id']))->format('Y-m-d'), + 'calendar_items' => $data['calendar']['items'], + 'fetched_at' => $data['fetched_at'], 'stale' => $data['stale'], 'source' => 'Hebcal', + 'calculation_version' => 'shabbat-calendar-2', + ]; + } + public function zmanim(array $settings, string $date): array + { + HebcalProvider::date($date); + $local = new \DateTimeImmutable($date, new \DateTimeZone($settings['timezone_id'])); + $data = $this->data($settings, $local); + $hdate = null; + foreach ($data['calendar']['items'] as $item) { + if (($item['category'] ?? '') === 'hebdate' && $item['date'] === $date) { + $hdate = $item['hebrew'] ?? $item['hdate'] ?? null; + break; + } + } + return ['date' => $date,'hdate' => $hdate,'location' => $settings['location_name'],'timezone_id' => $settings['timezone_id'], + 'times' => $data['solar'][$date] ?? [],'next_day_times' => $data['solar'][$local->modify('+1 day')->format('Y-m-d')] ?? [], + 'source' => 'Hebcal','fetched_at' => $data['fetched_at'],'stale' => $data['stale'], + 'methods' => ['candle_minutes' => CalendarEngine::candleMinutes($settings),'havdalah_method' => $settings['havdalah_method'],'havdalah_value' => $settings['havdalah_value']]]; + } + public function daf(array $settings, string $date): array + { + HebcalProvider::date($date); + $data = $this->provider->calendar($date, $date, (bool)$settings['israel'], $settings['language'] ?? 'he'); + foreach ($data['items'] as $item) { + if ($item['category'] !== 'dafyomi' || $item['date'] !== $date) { + continue; + } + $url = $item['link'] ?? ''; + if (!is_string($url) || parse_url($url, PHP_URL_SCHEME) !== 'https' || !in_array(parse_url($url, PHP_URL_HOST), ['www.sefaria.org','sefaria.org','www.hebcal.com','hebcal.com'], true)) { + throw new ProviderException('Untrusted study link.'); + } + $title = $item['title_orig'] ?? $item['title'] ?? ''; + if (!is_string($title) || !preg_match('/^(.+?)\s+([0-9]{1,3})$/uD', $title, $parts) || (int)$parts[2] < 2) { + throw new ProviderException('Malformed Daf Yomi tractate/page.'); + } + return $item + ['tractate' => $parts[1],'page' => (int)$parts[2],'source' => 'Hebcal','fetched_at' => $data['__shabbat_cache']['fetched_at'] ?? $this->clock->now()->getTimestamp()]; + } + throw new ProviderException('Daf Yomi is unavailable for this date.'); + } + public function omer(array $settings, ?\DateTimeImmutable $now = null): array + { + $now ??= $this->clock->now(); + $local = $now->setTimezone(new \DateTimeZone($settings['timezone_id'])); + $data = $this->data($settings, $now); + $day = $local->format('Y-m-d'); + $sun = $data['solar'][$day] ?? []; + $sunset = $sun['sunset'] ?? null; + $dawn = $sun['alotHaShachar'] ?? null; + $stamp = $now->getTimestamp(); + $night = CalendarEngine::nightfall($sun, $settings, 'omer_nightfall'); + if ($sunset === null || $dawn === null || $night === null) { + throw new ProviderException('Omer sunset/nightfall/dawn is unavailable.'); + } + $evening = $stamp >= $sunset; + $beforeDawn = $stamp < $dawn; + $studyDate = $evening ? $local->modify('+1 day')->format('Y-m-d') : $day; + $count = null; + $item = null; + foreach ($data['calendar']['items'] as $candidate) { + if ($candidate['category'] === 'omer' && $candidate['date'] === $studyDate && preg_match('/^Omer ([0-9]+)$/D', $candidate['title_orig'] ?? '', $match)) { + $count = (int)$match[1]; + $item = $candidate; + if ($count < 1 || $count > 49) { + throw new ProviderException('Provider Omer count is outside 1..49.'); + } + break; + } + } + $previousDate = $local->modify('-1 day')->format('Y-m-d'); + $previousNight = CalendarEngine::nightfall($data['solar'][$previousDate] ?? [], $settings, 'omer_nightfall'); + $applicableNight = $evening ? $night : $previousNight; + $phase = $evening ? ($stamp < $night ? 'twilight' : 'night') : ($beforeDawn ? 'night' : 'day'); + $nightEligible = $count !== null && $applicableNight !== null && $phase === 'night' && $stamp >= $applicableNight; + $state = $this->snapshot($settings, $now); + $quiet = $state['quiet_now']; + $completion = $count !== null && $phase === 'day' && (bool)($settings['omer_day_completion'] ?? false) && !$quiet; + $nextDawn = $data['solar'][$local->modify('+1 day')->format('Y-m-d')]['alotHaShachar'] ?? null; + return ['season' => $count !== null,'day' => $count,'date' => $studyDate,'hdate' => $item['hdate'] ?? null, + 'count_text' => $count !== null ? OmerText::count($count, $settings['omer_nusach'] ?? 'ashkenaz', $settings['language'] ?? 'he') : null, + 'nightfall' => $applicableNight,'next_nightfall' => $night,'sunset' => $sunset,'dawn' => $dawn, + 'night_date' => $evening ? $day : $previousDate,'phase' => $phase,'eligible' => $nightEligible && !$quiet,'day_completion' => $completion, + 'expires_at' => $evening ? $nextDawn : $dawn,'source' => 'Hebcal; traditional counting text','quiet_now' => $quiet, + 'fetched_at' => $data['fetched_at'],'stale' => $data['stale']]; + } + private function data(array $settings, \DateTimeImmutable $now): array + { + $local = $now->setTimezone(new \DateTimeZone($settings['timezone_id'])); + $key = hash('sha256', json_encode([$settings['latitude'],$settings['longitude'],$settings['timezone_id'],(bool)$settings['israel'],$settings['language'] ?? 'he',$local->format('Y-m')], JSON_THROW_ON_ERROR)); + $start = $local->modify('first day of this month')->modify('-8 days')->format('Y-m-d'); + $end = $local->modify('last day of this month')->modify('+40 days')->format('Y-m-d'); + if ( + isset($this->cache[$key]) && $this->cache[$key]['fetched_at'] + 21600 > $this->clock->now()->getTimestamp() + && $this->cache[$key]['calendar']['range']['start'] <= $start && $this->cache[$key]['calendar']['range']['end'] >= $end + ) { + return $this->cache[$key]; + } + $calendar = $this->provider->calendar($start, $end, (bool)$settings['israel'], $settings['language'] ?? 'he'); + $solar = $this->provider->solar($settings, $start, $end); + $meta = $this->provider->lastSolarMetadata(); + $calendarMeta = $calendar['__shabbat_cache'] ?? []; + $fetched = min($meta['fetched_at'] ?? $this->clock->now()->getTimestamp(), $calendarMeta['fetched_at'] ?? $this->clock->now()->getTimestamp()); + $stale = (bool)($meta['stale'] ?? false) || (bool)($calendarMeta['stale'] ?? false); + if (count($this->cache) >= 64) { + array_shift($this->cache); + } + return $this->cache[$key] = ['calendar' => $calendar,'solar' => $solar,'fetched_at' => $fetched,'stale' => $stale]; + } +} diff --git a/src/Calendar/HebcalProvider.php b/src/Calendar/HebcalProvider.php new file mode 100644 index 0000000..56d5674 --- /dev/null +++ b/src/Calendar/HebcalProvider.php @@ -0,0 +1,217 @@ +lastSolarMetadata; + } + public function calendar(string $start, string $end, bool $israel, string $language = 'he'): array + { + self::range($start, $end); + $providerLanguage = self::providerLanguage($language); + $data = $this->http->get('https://www.hebcal.com/hebcal', [ + 'v' => 1,'cfg' => 'json','start' => $start,'end' => $end,'i' => $israel ? 'on' : 'off', + 'maj' => 'on','min' => 'on','mf' => 'on','s' => 'on','mvch' => 'on','o' => 'on','F' => 'on','d' => 'on','hdp' => 1,'lg' => $providerLanguage, + ]); + if ( + !is_array($data['range'] ?? null) || !is_string($data['range']['start'] ?? null) || !is_string($data['range']['end'] ?? null) + || !is_array($data['items'] ?? null) || !array_is_list($data['items']) + ) { + throw new ProviderException('Malformed calendar response.'); + } + self::providerDate($data['range']['start']); + self::providerDate($data['range']['end']); + if ($data['range']['start'] > $start || $data['range']['end'] < $end) { + throw new ProviderException('Calendar response does not cover the requested date range.'); + } + foreach ($data['items'] as $item) { + if (!is_array($item) || !is_string($item['date'] ?? null) || !is_string($item['category'] ?? null)) { + throw new ProviderException('Malformed calendar event.'); + } + self::providerDate($item['date']); + if ($item['date'] < $start || $item['date'] > $end) { + throw new ProviderException('Out-of-range calendar event.'); + } + if (isset($item['yomtov']) && !is_bool($item['yomtov'])) { + throw new ProviderException('Malformed calendar holiday classification.'); + } + if ($item['category'] === 'omer' && isset($item['omer']['count']['he']) && !is_string($item['omer']['count']['he'])) { + throw new ProviderException('Malformed Omer count metadata.'); + } + } + return $data + ['requested_language' => $language,'provider_language' => $providerLanguage]; + } + /** Official Hebrew/French/Spanish/Russian aliases; Portuguese was checked against the live public API. */ + public static function providerLanguage(string $language): string + { + return match ($language) { + 'he','ru','fr','es'=>$language,'en'=>'s','pt-br'=>'pt',default=>throw new \InvalidArgumentException('Unsupported calendar language.') + }; + } + public function solar(array $settings, string $start, string $end): array + { + self::range($start, $end); + $zone = new \DateTimeZone($settings['timezone_id']); + foreach (['latitude' => 90,'longitude' => 180] as $name => $bound) { + if (!isset($settings[$name]) || !is_numeric($settings[$name]) || !is_finite((float)$settings[$name]) || abs((float)$settings[$name]) > $bound) { + throw new \InvalidArgumentException('Invalid solar coordinates.'); + } + } + $data = $this->http->get('https://www.hebcal.com/zmanim', [ + 'cfg' => 'json','latitude' => $settings['latitude'],'longitude' => $settings['longitude'], + 'tzid' => $settings['timezone_id'],'start' => $start,'end' => $end, + ]); + if (($data['date']['start'] ?? null) !== $start || ($data['date']['end'] ?? null) !== $end || !is_array($data['times'] ?? null) || $data['times'] === []) { + throw new ProviderException('Solar response does not cover the requested date range.'); + } + if (isset($data['location']['tzid']) && $data['location']['tzid'] !== $settings['timezone_id']) { + throw new ProviderException('Solar provider timezone mismatch.'); + } + $dailyTimes = array_intersect_key($data['times'], array_flip(self::DAILY_SOLAR_FIELDS)); + foreach ($dailyTimes as $values) { + if (!is_array($values)) { + throw new ProviderException('Malformed solar time series.'); + } + } + if (!array_key_exists('sunset', $dailyTimes)) { + throw new ProviderException('Missing sunset time series.'); + } + $days = []; + for ($date = new \DateTimeImmutable($start, new \DateTimeZone('UTC')); $date->format('Y-m-d') <= $end; $date = $date->modify('+1 day')) { + $key = $date->format('Y-m-d'); + $days[$key] = []; + foreach ($dailyTimes as $name => $values) { + if (!array_key_exists($key, $values)) { + throw new ProviderException('Incomplete solar date coverage.'); + } + $value = $values[$key]; + if ($value === null) { + $days[$key][$name] = null; + continue; + } + if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/D', $value)) { + throw new ProviderException('Invalid solar timestamp.'); + } + $instant = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $value); + $errors = \DateTimeImmutable::getLastErrors(); + if ( + $instant === false || ($errors !== false && ($errors['warning_count'] !== 0 || $errors['error_count'] !== 0)) + || $instant->setTimezone($zone)->format('Y-m-d\TH:i:sP') !== $value || !self::matchesSolarDay($name, $key, $instant, $zone) + ) { + throw new ProviderException('Solar date/timezone mismatch.'); + } + $days[$key][$name] = $instant->getTimestamp(); + } + if (isset($days[$key]['chatzotNight'])) { + $midnight = $days[$key]['chatzotNight']; + $sunrise = $days[$key]['sunrise'] ?? null; + $previousSunset = $days[$date->modify('-1 day')->format('Y-m-d')]['sunset'] ?? null; + if (($sunrise !== null && $midnight >= $sunrise) || ($previousSunset !== null && $midnight <= $previousSunset)) { + throw new ProviderException('Solar midnight is outside its preceding sunset/sunrise night.'); + } + } + if (isset($days[$key]['sunset'])) { + foreach (['tzeit85deg','tzeit7083deg'] as $night) { + if (isset($days[$key][$night]) && $days[$key][$night] <= $days[$key]['sunset']) { + throw new ProviderException('Nightfall precedes sunset.'); + } + } + if (isset($days[$key]['alotHaShachar']) && $days[$key]['alotHaShachar'] >= $days[$key]['sunset']) { + throw new ProviderException('Dawn follows sunset.'); + } + } + } + $this->lastSolarMetadata = $data['__shabbat_cache'] ?? []; + return $days; + } + private static function matchesSolarDay(string $name, string $day, \DateTimeImmutable $instant, \DateTimeZone $zone): bool + { + if ($name !== 'chatzotNight') { + return $instant->format('Y-m-d') === $day; + } + // Hebcal assigns the preceding solar night to this day's sunrise. In winter, + // its midpoint can be on the previous civil date. Preserve the exact instant, + // allowing only this preceding night; all operational daytime fields stay strict. + $noon = new \DateTimeImmutable($day . ' 12:00:00', $zone); + return $instant >= $noon->modify('-1 day') && $instant < $noon; + } + private static function range(string $start, string $end): void + { + self::date($start); + self::date($end); + if ($start > $end || (new \DateTimeImmutable($start))->diff(new \DateTimeImmutable($end))->days > 179) { + throw new \InvalidArgumentException('Provider date range must be ordered and at most 180 inclusive days.'); + } + } + private static function providerDate(string $value): void + { + try { + self::date($value); + } catch (\InvalidArgumentException $error) { + throw new ProviderException('Invalid provider civil date.', 0, $error); + } + } + public static function date(string $value): void + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, new \DateTimeZone('UTC')); + if ($date === false || $date->format('Y-m-d') !== $value) { + throw new \InvalidArgumentException('Invalid Gregorian date.'); + } + } +} diff --git a/src/Content/GroupMessageButtons.php b/src/Content/GroupMessageButtons.php new file mode 100644 index 0000000..5cdb9f5 --- /dev/null +++ b/src/Content/GroupMessageButtons.php @@ -0,0 +1,28 @@ + 'shabbat', + 'zmanim', 'daf' => $kind, + default => null, + }; + if ($module === null) { + return []; + } + return [[Presentation::button([ + 'text' => (new Lang())->text($language, 'share'), + 'switch_inline_query' => $module === 'shabbat' ? 'shabat' : $module, + ], key: $module)]]; + } +} diff --git a/src/Content/OmerText.php b/src/Content/OmerText.php new file mode 100644 index 0000000..e3f12b9 --- /dev/null +++ b/src/Content/OmerText.php @@ -0,0 +1,117 @@ +'ashkenazi','sephard'=>'sephardi',default=>$nusach + }; + if ($day < 1 || $day > 49) { + throw new \InvalidArgumentException('Omer day must be 1..49.'); + } + if (!in_array($nusach, ['sephardi','ashkenazi'], true) || !in_array($language, Lang::CODES, true)) { + throw new \InvalidArgumentException('Unsupported counting text.'); + } + self::load(); + $ritual = self::$counts[(string)$day]['he']; + if ($nusach === 'ashkenazi') { + $ritual = str_replace('ืœึธืขึฝื•ึนืžึถืจ', 'ื‘ึธึผืขึฝื•ึนืžึถืจ', $ritual); + } + if ($language === 'he') { + return $ritual; + } + $summary = $language === 'en' ? self::$counts[(string)$day]['en'] : self::$localized[(string)$day][$language]; + self::$lang ??= new Lang(); + return $ritual . "\n" . self::$lang->text($language, 'omer_translation_note') . "\n" . $summary; + } + public static function render(int $day, string $nusach = 'sephardi', bool $full = false, bool $dayCompletion = false, string $language = 'he'): string + { + $count = self::count($day, $nusach, $language); + self::$lang ??= new Lang(); + if ($dayCompletion) { + return $count . "\n" . self::$lang->text($language, 'omer_day_completion_label'); + } + if (!$full) { + return $count; + } + $ritual = self::liturgy(); + $variant = in_array($nusach, ['ashkenaz','ashkenazi'], true) ? 'ashkenaz' : 'sephard'; + return implode("\n\n", [ + self::$lang->text($language, 'omer_blessing_eligibility'),$ritual['preparation_' . $variant], + $ritual['blessing'],$count,$ritual['harachaman'],$ritual['psalm67'],$ritual['ana_bekoach'], + $ritual['prayer_start'] . ' ' . $ritual['daily_sefira'][(string)$day] . ', ' . $ritual['prayer_end_' . $variant], + 'Hebrew Wikisource contributors ยท CC-BY-SA-4.0' . "\n" + . 'https://he.wikisource.org/w/index.php?oldid=' . ($variant === 'ashkenaz' ? '3017054' : '2929975') . "\n" + . 'https://creativecommons.org/licenses/by-sa/4.0/', + ]); + } + private static function liturgy(): array + { + if (self::$liturgy !== null) { + return self::$liturgy; + } + $source = file_get_contents(dirname(__DIR__, 2) . '/resources/templates/omer-liturgy.json'); + if ($source === false) { + throw new \RuntimeException('Bundled Omer liturgy is missing.'); + } + $data = json_decode($source, true, 64, JSON_THROW_ON_ERROR); + foreach (['preparation_ashkenaz','preparation_sephard','blessing','harachaman','psalm67','ana_bekoach','prayer_start','prayer_end_ashkenaz','prayer_end_sephard'] as $key) { + if (!is_string($data[$key] ?? null) || $data[$key] === '') { + throw new \RuntimeException('Incomplete verified Omer liturgy.'); + } + } + for ($day = 1; $day <= 49; $day++) { + if (!is_string($data['daily_sefira'][(string)$day] ?? null) || $data['daily_sefira'][(string)$day] === '') { + throw new \RuntimeException('Incomplete verified Omer day additions.'); + } + } + return self::$liturgy = $data; + } + private static function load(): void + { + if (self::$counts !== null) { + return; + } + $root = dirname(__DIR__, 2) . '/resources/templates/'; + $basic = file_get_contents($root . 'omer-counts.json'); + $translated = file_get_contents($root . 'omer-counts-localized.json'); + if ($basic === false || $translated === false) { + throw new \RuntimeException('Bundled Omer text resources are missing.'); + } + $counts = json_decode($basic, true, 64, JSON_THROW_ON_ERROR)['counts'] ?? null; + $localized = json_decode($translated, true, 64, JSON_THROW_ON_ERROR)['counts'] ?? null; + if (!is_array($counts) || !is_array($localized)) { + throw new \RuntimeException('Invalid Omer text resources.'); + } + for ($day = 1; $day <= 49; $day++) { + foreach (['he','en'] as $code) { + if (!is_string($counts[(string)$day][$code] ?? null) || $counts[(string)$day][$code] === '') { + throw new \RuntimeException('Incomplete Omer count table.'); + } + } + foreach (['ru','fr','es','pt-br'] as $code) { + if (!is_string($localized[(string)$day][$code] ?? null) || $localized[(string)$day][$code] === '') { + throw new \RuntimeException('Incomplete localized Omer count table.'); + } + } + if (($localized[(string)$day]['day'] ?? null) !== $day || ($localized[(string)$day]['weeks'] ?? null) !== intdiv($day, 7) || ($localized[(string)$day]['days'] ?? null) !== $day % 7) { + throw new \RuntimeException('Localized Omer week/day structure is inconsistent.'); + } + } + self::$counts = $counts; + self::$localized = $localized; + } +} diff --git a/src/Content/ShabbatTimesFormatter.php b/src/Content/ShabbatTimesFormatter.php new file mode 100644 index 0000000..8d143df --- /dev/null +++ b/src/Content/ShabbatTimesFormatter.php @@ -0,0 +1,320 @@ +lang ??= new Lang(); + } + + public function format(array $settings, array $snapshot, ?array $event = null): array + { + return $this->period($settings, $snapshot, $event); + } + + /** Metadata may be shortened to keep verified Shabbat and Yom Tov boundaries visible. */ + private function period(array $settings, array $snapshot, ?array $event, int $metadataLimit = 1200): array + { + $language = Lang::canonical($settings['language'] ?? 'he'); + $t = fn(string $key, array $values = []): string => $this->lang->text($language, $key, $values); + $zone = new \DateTimeZone($settings['timezone_id']); + $time = static fn(mixed $stamp): string => is_int($stamp) ? (new \DateTimeImmutable('@' . $stamp))->setTimezone($zone)->format('Y-m-d H:i') : $t('unavailable'); + $event ??= $snapshot['current_event'] ?? $snapshot['next_event'] ?? null; + $rows = ['' . $t('times_heading') . '', $t('times_location', ['location' => self::locationName($settings), 'timezone' => $zone->getName()])]; + if ($event === null || isset($snapshot['error'])) { + $rows[] = $t('times_data_missing'); + return $this->payload($rows); + } + $days = array_values(array_filter($event['days'] ?? [], static fn(mixed $day): bool => is_string($day) && preg_match('/^\d{4}-\d{2}-\d{2}$/D', $day) === 1)); + if ($days === [] && is_string($event['event_key'] ?? null)) { + $days = [$event['event_key']]; + } + $rows[] = ''; + $title = ($event['is_shabbat'] ?? false) === true && ($event['is_yom_tov'] ?? false) !== true + ? $t('times_shabbat_title') : ($event['title'] ?? null); + if (is_string($title) && $title !== '') { + $rows[] = '' . htmlspecialchars(mb_substr($title, 0, 180), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } + $rows[] = $t('times_dates', ['dates' => implode(' / ', $days) ?: $t('unavailable')]); + if (count($days) > 1) { + $rows[] = $t(in_array($settings['mode'] ?? 'off', ['lock','lock_messages'], true) ? 'times_sequence' : 'times_sequence_calendar'); + } + $metadata = []; + $calendarItems = $snapshot['calendar_items'] ?? []; + foreach ($days as $day) { + $hdate = null; + foreach ($calendarItems as $item) { + if (($item['date'] ?? '') !== $day) { + continue; + } + if ($hdate === null && is_string($item['hdate'] ?? null)) { + $hdate = $item['hdate']; + } + if (($item['category'] ?? '') === 'hebdate' && $language === 'he' && is_string($item['hebrew'] ?? null)) { + $hdate = $item['hebrew']; + } + $category = $item['category'] ?? ''; + $key = match ($category) { + 'parashat' => 'times_parasha', 'mevarchim' => 'times_mevarchim', 'holiday','roshchodesh','molad' => 'times_holiday', + default => null, + }; + if ($key === null) { + continue; + } + $title = $language === 'he' ? ($item['hebrew'] ?? $item['title'] ?? '') : ($item['title'] ?? ''); + if (!is_string($title) || $title === '') { + continue; + } + $line = $t($key, ['title' => mb_substr($title, 0, 180)]); + if (!in_array($line, $metadata, true)) { + $metadata[] = $line; + } + } + $rows[] = $t('times_hebrew_date', ['date' => $day, 'hdate' => $hdate === null ? $t('unavailable') : mb_substr($hdate, 0, 80)]); + } + $metadataUnits = 0; + foreach (array_slice($metadata, 0, 12) as $line) { + $units = self::units(html_entity_decode(strip_tags($line), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($metadataUnits + $units > $metadataLimit) { + break; + } + $rows[] = $line; + $metadataUnits += $units; + } + $rows[] = ''; + $rows[] = '' . $t('times_section_schedule') . ''; + $segments = [$event, ...($event['intermediate'] ?? [])]; + foreach ($segments as $index => $segment) { + $day = is_int($segment['candles_at'] ?? null) + ? (new \DateTimeImmutable('@' . $segment['candles_at']))->setTimezone($zone)->format('Y-m-d') + : ($segment['days'][0] ?? ($segment['event_key'] ?? '')); + $dayLabel = preg_match('/^\d{4}-\d{2}-\d{2}$/D', $day) ? $t('times_day_name_' . (new \DateTimeImmutable($day, $zone))->format('w')) : $day; + $rows[] = $t('times_candles', ['date' => $dayLabel, 'time' => $time($segment['candles_at'] ?? null)]); + if ($index > 0 && ($segment['is_yom_tov'] ?? false) && !($segment['is_shabbat'] ?? false)) { + $rows[] = '' . $t('times_lighting_after') . ''; + } + } + $rows[] = $t('times_havdalah', ['time' => $time($event['havdalah_at'] ?? null)]); + if (in_array($settings['mode'] ?? 'off', ['lock','lock_messages'], true)) { + foreach (['close_at' => 'candles_at', 'open_at' => 'havdalah_at'] as $groupTime => $calendarTime) { + if (is_int($event[$groupTime] ?? null) && $event[$groupTime] !== ($event[$calendarTime] ?? null)) { + $rows[] = $t($groupTime === 'close_at' ? 'times_close' : 'times_open', ['time' => $time($event[$groupTime])]); + } + } + } + $rows[] = $t('times_methods', ['minutes' => CalendarEngine::candleMinutes($settings), 'method' => $t('method_' . $settings['havdalah_method']), 'value' => $settings['havdalah_value']]); + if (array_filter($segments, static fn(array $segment): bool => !is_int($segment['candles_at'] ?? null)) !== [] || !is_int($event['havdalah_at'] ?? null)) { + $rows[] = $t('times_data_missing'); + } + if (($snapshot['stale'] ?? false) === true) { + $rows[] = ''; + $rows[] = $t('times_stale'); + } + return $this->payload($rows); + } + + /** + * A single current/upcoming overview. Legacy page offsets are ignored, so + * already-delivered buttons refresh this view instead of exposing old dates. + */ + public function overview(array $settings, array $snapshot, int $offset = 0): array + { + $language = Lang::canonical($settings['language'] ?? 'he'); + $t = fn(string $key, array $values = []): string => $this->lang->text($language, $key, $values); + $reference = $snapshot['current_event'] ?? $snapshot['next_event'] ?? null; + $evaluatedAt = is_int($snapshot['evaluated_at'] ?? null) ? $snapshot['evaluated_at'] : null; + $allEvents = $snapshot['events'] ?? ($reference === null ? [] : [$reference]); + $events = array_values(array_filter($allEvents, static fn(array $event): bool => + self::periodHasCompleteCoverage($event, $snapshot) + && ($evaluatedAt !== null + ? ($event['havdalah_at'] ?? $event['end_at'] ?? 0) > $evaluatedAt + : ($event['start_at'] ?? $event['candles_at'] ?? 0) >= ($reference['start_at'] ?? $reference['candles_at'] ?? 0)))); + usort($events, static fn(array $a, array $b): int => ($a['start_at'] ?? $a['candles_at'] ?? 0) <=> ($b['start_at'] ?? $b['candles_at'] ?? 0)); + $anchor = null; + foreach ($events as $event) { + if (($event['is_shabbat'] ?? false) === true) { + $anchor = $event; + break; + } + } + // Old explicit snapshots without a period list retain deterministic rendering. + if ($anchor === null && !array_key_exists('events', $snapshot)) { + $anchor = $reference; + } + $primarySnapshot = array_replace($snapshot, ['current_event' => $anchor, 'next_event' => $anchor]); + $payload = $this->format($settings, $primarySnapshot, $anchor); + if ($anchor === null || isset($snapshot['error'])) { + return $payload + ['offset' => 0, 'total' => 0]; + } + $zone = new \DateTimeZone($settings['timezone_id']); + $time = static fn(int $stamp): string => (new \DateTimeImmutable('@' . $stamp))->setTimezone($zone)->format('Y-m-d H:i'); + // Evaluation time, never cache-fetch time or the process clock, defines "upcoming". + $firstDate = $evaluatedAt !== null + ? (new \DateTimeImmutable('@' . $evaluatedAt))->setTimezone($zone)->format('Y-m-d') + : ($snapshot['local_date'] ?? $reference['days'][0] ?? $anchor['days'][0] ?? ''); + $rows = [$payload['text']]; + $calendarBlocks = []; + $protectedFestival = false; + $shownPeriods = [(string)($anchor['event_key'] ?? '') => true]; + $holidays = array_values(array_filter($snapshot['calendar_items'] ?? [], static fn(array $item): bool => + in_array($item['category'] ?? '', ['holiday','roshchodesh'], true) + && (($item['date'] ?? '') >= $firstDate + || (($item['yomtov'] ?? false) === true && in_array($item['date'] ?? '', $snapshot['current_event']['days'] ?? [], true))) + && !in_array($item['date'] ?? '', $anchor['days'] ?? [], true))); + usort($holidays, static fn(array $a, array $b): int => strcmp($a['date'], $b['date'])); + $seen = []; + foreach ($holidays as $item) { + $title = $language === 'he' ? ($item['hebrew'] ?? $item['title'] ?? '') : ($item['title'] ?? ''); + if (!is_string($title) || $title === '') { + continue; + } + $identity = $item['date'] . ':' . $title; + if (isset($seen[$identity])) { + continue; + } + $period = null; + // Only strict provider classification gives a holiday verified boundaries. + if (($item['yomtov'] ?? false) === true) { + foreach ($allEvents as $candidate) { + if (($candidate['is_yom_tov'] ?? false) === true && in_array($item['date'], $candidate['days'] ?? [], true)) { + $period = $candidate; + break; + } + } + if ($period !== null && $evaluatedAt !== null && ($period['havdalah_at'] ?? $period['end_at'] ?? 0) <= $evaluatedAt) { + continue; + } + if ($period !== null && !self::periodHasCompleteCoverage($period, $snapshot)) { + $period = null; + } + } + $seen[$identity] = true; + $itemRows = [$t('times_event_title', ['title' => mb_substr($title, 0, 120), 'dates' => $item['date']])]; + $required = false; + if (($item['yomtov'] ?? false) === true) { + if ($period === null) { + $itemRows[] = '' . $t('times_yomtov_unavailable') . ''; + } elseif (!isset($shownPeriods[$period['event_key']])) { + array_push($itemRows, ...$this->periodRows($period, $t, $time)); + $required = !$protectedFestival; + $protectedFestival = true; + $shownPeriods[$period['event_key']] = true; + } + } + $calendarBlocks[] = ['rows' => $itemRows, 'required' => $required]; + if (count($seen) >= 5) { + break; + } + } + if ($calendarBlocks !== []) { + $heading = '' . $t('times_upcoming_calendar') . ''; + $flatten = static fn(array $blocks): array => array_merge(...array_column($blocks, 'rows')); + if (!$this->appendSection($rows, $heading, $flatten($calendarBlocks))) { + // Preserve the nearest Shabbat and nearest/current verified Yom Tov. + // Optional metadata and farther observances yield before their boundaries. + $rows = [$this->period($settings, $primarySnapshot, $anchor, metadataLimit: 0)['text']]; + while (!$this->appendSection($rows, $heading, $flatten($calendarBlocks))) { + $removable = array_keys(array_filter($calendarBlocks, static fn(array $block): bool => !$block['required'])); + if ($removable === []) { + throw new \LengthException('Verified calendar boundaries exceed the safe message length.'); + } + unset($calendarBlocks[$removable[count($removable) - 1]]); + if ($calendarBlocks === []) { + break; + } + } + } + } + $payload['text'] = implode("\n", $rows); + return $payload + ['offset' => 0, 'total' => count($events)]; + } + + /** Use a known bilingual label only for the matching curated Jerusalem location. */ + private static function locationName(array $settings): string + { + $name = (string)($settings['location_name'] ?? ''); + $jerusalem = Place::common()['jerusalem']; + if ( + (int)($settings['geoname_id'] ?? 0) === $jerusalem['geoname_id'] + && ($settings['timezone_id'] ?? '') === $jerusalem['timezone_id'] + && (float)($settings['latitude'] ?? 0) === $jerusalem['latitude'] + && (float)($settings['longitude'] ?? 0) === $jerusalem['longitude'] + && in_array($name, ['Jerusalem', 'ื™ืจื•ืฉืœื™ื', $jerusalem['location_name']], true) + ) { + return $jerusalem['location_name']; + } + return $name; + } + + /** A final exit needs evidence for the following date, which may continue the festival. */ + private static function periodHasCompleteCoverage(array $event, array $snapshot): bool + { + $coverageEnd = $snapshot['coverage']['end'] ?? null; + $days = $event['days'] ?? []; + $lastDay = $days[count($days) - 1] ?? null; + return !is_string($coverageEnd) || (is_string($lastDay) && $lastDay < $coverageEnd); + } + + /** Only a complete continuous period is rendered; intermediate exits are never implied. */ + private function periodRows(array $event, callable $t, callable $time): array + { + $rows = []; + foreach ([$event, ...($event['intermediate'] ?? [])] as $index => $segment) { + if (is_int($segment['candles_at'] ?? null)) { + $rows[] = $t('times_period_enter', ['time' => $time($segment['candles_at'])]); + if ($index > 0 && ($segment['is_yom_tov'] ?? false) && !($segment['is_shabbat'] ?? false)) { + $rows[] = '' . $t('times_lighting_after') . ''; + } + } + } + if (is_int($event['havdalah_at'] ?? null)) { + $rows[] = $t('times_period_exit', ['time' => $time($event['havdalah_at'])]); + } + return $rows; + } + + /** Preserve the full primary period if optional calendar metadata is unusually long. */ + private function appendSection(array &$rows, string $heading, array $section): bool + { + $candidate = implode("\n", [...$rows, '', $heading, ...$section]); + if (self::units(html_entity_decode(strip_tags($candidate), ENT_QUOTES | ENT_HTML5, 'UTF-8')) <= 3900) { + array_push($rows, '', $heading, ...$section); + return true; + } + return false; + } + + private static function units(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } + private function payload(array $rows): array + { + $text = implode("\n", $rows); + $plain = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (strlen(mb_convert_encoding($plain, 'UTF-16LE', 'UTF-8')) > 8000) { + $short = ''; + $units = 0; + foreach (mb_str_split($plain) as $character) { + $length = strlen(mb_convert_encoding($character, 'UTF-16LE', 'UTF-8')) / 2; + if ($units + $length > 3998) { + break; + } + $short .= $character; + $units += $length; + } + $text = htmlspecialchars($short . 'โ€ฆ', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + return ['text' => $text,'media' => null,'buttons' => []]; + } +} diff --git a/src/Content/TemplateEditor.php b/src/Content/TemplateEditor.php new file mode 100644 index 0000000..f1c3c14 --- /dev/null +++ b/src/Content/TemplateEditor.php @@ -0,0 +1,434 @@ +lang = new Lang(); + } + public function callback(array $query): bool + { + $data = $query['data']; + $user = $query['user_id']; + if (preg_match('/^tpl:ack_preview:[A-Za-z0-9_-]{24}$/D', $data)) { + $this->app->gateway->answerCallback((string)($query['query_id'] ?? ''), $this->t($user, 'template_ack_preview')); + return true; + } + if (!str_starts_with($data, 'v3:templates:') && !str_starts_with($data, 'tpl:')) { + return false; + } + try { + $ui = \Shabbat\UI\CallbackUiGateway::fromQuery($this->app->gateway, $query); + if (!$query['private']) { + throw new \RuntimeException($this->t($user, 'not_private')); + } + if (!preg_match('/^tpl:(?:save|discard):/', $data)) { + $this->app->flows->revokeAction($user, 'template_input'); + $this->app->flows->revokeAction($user, 'template_review'); + } + if (str_starts_with($data, 'v3:templates:')) { + [$group, $requestedKind] = array_pad(explode(':', substr($data, 13), 2), 2, null); + if ($requestedKind !== null && !in_array($requestedKind, self::KINDS, true)) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $g = $this->app->settings->getGroup($group); + if ($g === null || !$this->app->gateway->isGroupAdmin($user, $group)) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $rows = []; + $choices = []; + foreach ($requestedKind === null ? self::KINDS : [$requestedKind] as $kind) { + $token = $this->app->flows->create($user, 'group', $group, $g['revision'], 'template', 600, ['kind' => $kind,'template_revision' => $this->app->templates->get($group, $kind)['revision'] ?? 0]); + if ($requestedKind !== null) { + return $this->callback(array_replace($query, ['data' => 'tpl:menu:' . $token])); + } + $choices[] = ['text' => $this->t($user, 'template_kind_' . $kind),'data' => 'tpl:menu:' . $token]; + } + $rows = array_chunk($choices, 2); + $panel = $this->app->flows->create($user, 'group', $group, $g['revision'], 'panel'); + $rows[] = [['text' => $this->t($user, 'back'),'data' => 'sb:group:' . $panel]]; + $ui->sendPrivate($user, $this->t($user, 'template_panel', ['title' => $g['title']]) . "\n\n" . $this->t($user, 'template_panel_hint'), $this->markup($rows)); + $ui->answerCallback((string)($query['query_id'] ?? ''), ''); + return true; + } + if (!preg_match('/^tpl:([a-z_]+):([A-Za-z0-9_-]{24})$/D', $data, $parts)) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $action = $parts[1]; + $token = $parts[2]; + $flowAction = in_array($action, ['save','discard'], true) ? 'template_review' : 'template'; + $flow = $this->app->flows->peek($token, $user, $flowAction); + if ($flow === null || !$this->app->gateway->isGroupAdmin($user, $flow['group_id'])) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $g = $this->app->settings->getGroup($flow['group_id']); + if ($g['revision'] !== $flow['revision']) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $kind = $flow['payload']['kind']; + $template = $this->app->templates->get($flow['group_id'], $kind); + if ($action === 'menu') { + $this->showMenu($ui, $user, $g, $kind, $token); + } elseif (in_array($action, ['edit','buttons','add_link','advanced_buttons'], true)) { + $mode = match ($action) { + 'add_link' => 'link_text', 'advanced_buttons' => 'buttons', default => $action + }; + $input = $this->app->flows->create($user, 'group', $g['group_id'], $g['revision'], 'template_input', 600, ['mode' => $mode, 'panel_id' => $ui->messageId()]); + $child = $this->app->flows->create($user, 'group', $g['group_id'], $g['revision'], 'template', 600, [ + 'kind' => $kind, 'template_revision' => $template['revision'] ?? 0, 'source_template_input_token' => $input, + ]); + $this->app->flows->updatePayload($input, $user, 'template_input', ['mode' => $mode, 'panel_id' => $ui->messageId(), 'template_token' => $child]); + $rows = []; + if ($action === 'buttons') { + $rows[] = [['text' => $this->t($user, 'template_add_link'), 'data' => 'tpl:add_link:' . $token, 'color' => 'bg_primary']]; + $rows[] = [['text' => $this->t($user, 'template_advanced_buttons'), 'data' => 'tpl:advanced_buttons:' . $token]]; + } + $rows[] = [['text' => $this->t($user, 'back'), 'data' => 'tpl:menu:' . $token]]; + $key = match ($action) { + 'edit' => 'template_edit_guided_prompt', 'add_link' => 'template_link_text_prompt', 'buttons' => 'template_buttons_simple_prompt', + default => 'template_buttons_guided_prompt', + }; + $ui->sendPrivate($user, $this->t($user, $key), $this->markup($rows)); + } elseif ($action === 'save') { + $pending = $flow['payload']['pending_content'] ?? throw new \RuntimeException('Template review expired.'); + (new TemplateRenderer())->validateStoredPayload($pending); + $this->app->flows->confirmAndApply( + $token, + $user, + 'template_review', + fn($group, $actor) => $this->app->gateway->isGroupAdmin($actor, $group), + [], + fn($pdo) => $this->app->templates->saveInTransaction($pdo, $g['group_id'], $kind, $pending, $user, $flow['payload']['template_revision']) + ); + $this->app->flows->revokeAction($user, 'template_input'); + $this->app->flows->revokeAction($user, 'template_review'); + $this->freshMenu($ui, $user, $g['group_id'], $kind, 'template_saved'); + } elseif ($action === 'discard') { + $this->app->flows->revoke($token, $user, 'template_review'); + $this->app->flows->revokeAction($user, 'template_input'); + $this->freshMenu($ui, $user, $g['group_id'], $kind, 'template_cancelled'); + } elseif ($action === 'preview') { + $payload = $this->app->payload($g, $kind, $this->app->clock->now()); + unset($payload['topic_id']); + $payload = $this->withInertAcknowledgement($payload, $g, $kind, $token); + $markup = \Shabbat\Runtime\MadelineGateway::normalizeMarkup(['inline_keyboard' => $payload['buttons'] ?? []]); + $markup = $markup ?: ['_' => 'replyInlineMarkup', 'rows' => []]; + $markup['rows'][] = $this->markup([[['text' => $this->t($user, 'back'), 'data' => 'tpl:menu:' . $token]]])['rows'][0]; + $payload['reply_markup'] = $markup; + $ui->preview($payload); + } elseif ($action === 'reset') { + $this->app->flows->confirmAndApply($token, $user, 'template', fn($group, $actor)=>$this->app->gateway->isGroupAdmin($actor, $group), [], fn($pdo)=>$this->app->templates->saveInTransaction($pdo, $g['group_id'], $kind, ['text' => '','entities' => [],'buttons' => [],'media' => null,'buttons_mode' => 'inherit'], $user, $flow['payload']['template_revision'])); + $this->freshMenu($ui, $user, $g['group_id'], $kind, 'template_cleared'); + } elseif (in_array($action, ['remove_text','remove_media','remove_buttons','default_buttons'], true)) { + $content = $template ?? ['text' => '','entities' => [],'media' => null,'buttons' => []]; + if ($action === 'remove_text') { + $content['text'] = ''; + $content['entities'] = []; + } + if ($action === 'remove_media') { + $content['media'] = null; + } + if ($action === 'remove_buttons') { + $content['buttons'] = []; + $content['buttons_mode'] = 'none'; + } + if ($action === 'default_buttons') { + $content['buttons'] = []; + $content['buttons_mode'] = 'inherit'; + } + $this->app->flows->confirmAndApply($token, $user, 'template', fn($group, $actor)=>$this->app->gateway->isGroupAdmin($actor, $group), [], fn($pdo)=>$this->app->templates->saveInTransaction($pdo, $g['group_id'], $kind, $content, $user, $flow['payload']['template_revision'])); + $notice = $action === 'remove_text' ? (($content['media'] ?? null) === null ? 'template_text_restored' : 'template_caption_removed') : 'template_saved'; + $this->freshMenu($ui, $user, $g['group_id'], $kind, $notice); + } elseif ($action === 'test') { + $confirm = $this->app->flows->create($user, 'group', $g['group_id'], $g['revision'], 'template', 600, ['kind' => $kind,'test' => true,'template_revision' => $template['revision'] ?? 0]); + $rows = [[['text' => $this->t($user, 'template_confirm_test'),'data' => 'tpl:sendtest:' . $confirm,'color' => 'bg_primary']],[['text' => $this->t($user, 'cancel'),'data' => 'tpl:cancel:' . $confirm,'color' => 'bg_danger']]]; + $ui->sendPrivate($user, $this->t($user, 'template_test_confirm', ['title' => $g['title']]), $this->markup($rows)); + } elseif ($action === 'cancel') { + $this->app->flows->revoke($token, $user, 'template'); + $this->freshMenu($ui, $user, $g['group_id'], $kind, 'template_cancelled'); + } elseif ($action === 'sendtest' && ($flow['payload']['test'] ?? false)) { + if (($template['revision'] ?? 0) !== $flow['payload']['template_revision']) { + throw new \RuntimeException($this->t($user, 'denied')); + } + $recent = $this->app->db->pdo()->prepare("SELECT created_at FROM audit_log WHERE group_id=? AND action='template.test' ORDER BY created_at DESC LIMIT 1"); + $recent->execute([$g['group_id']]); + if (($recent->fetchColumn() ?: 0) > $this->app->clock->now()->getTimestamp() - 300) { + throw new \RuntimeException($this->t($user, 'template_test_wait')); + } + $this->app->flows->confirm($token, $user, 'template', fn($group, $actor)=>$this->app->gateway->isGroupAdmin($actor, $group)); + $payload = $this->app->payload($g, $kind, $this->app->clock->now()); + $payload = $this->withInertAcknowledgement($payload, $g, $kind, $token); + $prefix = '[TEST / ื‘ื“ื™ืงื”] '; + $payload['text'] = $prefix . ($payload['text'] ?? ''); + if (array_key_exists('entities', $payload)) { + $offset = self::utf16Length($prefix); + foreach ($payload['entities'] as &$entity) { + $entity['offset'] += $offset; + } + unset($entity); + } + $this->app->gateway->send($g['group_id'], $payload, (string)random_int(1, PHP_INT_MAX)); + $this->app->db->pdo()->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([$user,$g['group_id'],'template.test','{}',$this->app->clock->now()->getTimestamp()]); + $this->freshMenu($ui, $user, $g['group_id'], $kind, 'template_saved'); + } else { + throw new \RuntimeException($this->t($user, 'denied')); + } + $ui->answerCallback((string)($query['query_id'] ?? ''), ''); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->app->gateway->answerCallback((string)($query['query_id'] ?? ''), $this->t($user, $error instanceof \Shabbat\UI\PanelEditException || $error instanceof \InvalidArgumentException ? 'callback_panel_unavailable' : 'denied')); + } + return true; + } + private function freshMenu(\Shabbat\UI\UiGateway $ui, string $user, string $groupId, string $kind, string $notice): void + { + $group = $this->app->settings->getGroup($groupId) ?? throw new \RuntimeException('The group no longer exists.'); + $token = $this->app->flows->create($user, 'group', $groupId, $group['revision'], 'template', 600, [ + 'kind' => $kind, 'template_revision' => $this->app->templates->get($groupId, $kind)['revision'] ?? 0, + ]); + $this->showMenu($ui, $user, $group, $kind, $token, $notice); + } + + private function showMenu(\Shabbat\UI\UiGateway $ui, string $user, array $group, string $kind, string $token, ?string $notice = null): void + { + $template = $this->app->templates->get($group['group_id'], $kind); + $rows = []; + foreach ([['edit', 'buttons'], ['preview', 'test'], ['remove_text', 'remove_media'], ['default_buttons', 'remove_buttons'], ['reset']] as $actions) { + $row = []; + foreach ($actions as $action) { + $key = $action === 'remove_text' ? (($template['media'] ?? null) === null ? 'template_restore_text' : 'template_remove_caption') : 'template_' . $action; + $row[] = ['text' => $this->t($user, $key), 'data' => 'tpl:' . $action . ':' . $token, + 'color' => ($action === 'reset' || in_array($action, ['remove_media', 'remove_buttons'], true)) ? 'bg_danger' + : (in_array($action, ['edit', 'buttons'], true) ? 'bg_primary' : null)]; + } + $rows[] = $row; + } + $panel = $this->app->flows->create($user, 'group', $group['group_id'], $group['revision'], 'panel'); + $screen = match ($kind) { + 'close', 'open' => 'shabbat_messages', 'times_alert' => 'announcement', default => $kind, + }; + $rows[] = [ + ['text' => $this->t($user, 'messages'), 'data' => 'v3:templates:' . $group['group_id']], + ['text' => $this->t($user, 'back'), 'data' => 'sb:v:' . $panel . ':' . $screen], + ]; + $text = $this->t($user, 'template_menu', ['title' => $group['title'], 'kind' => $this->t($user, 'template_kind_' . $kind)]) + . "\n\n" . $this->t($user, 'template_current_summary', [ + 'text' => $this->t($user, ($template['text'] ?? '') === '' ? (($template['media'] ?? null) === null ? 'template_text_default' : 'off') : 'template_text_custom'), + 'media' => $this->t($user, ($template['media'] ?? null) === null ? 'off' : 'on'), + 'buttons' => $this->t($user, 'template_buttons_' . ($template['buttons_mode'] ?? 'inherit')), + ]); + $ui->sendPrivate($user, ($notice === null ? '' : $this->t($user, $notice) . "\n\n") . $text, $this->markup($rows)); + } + + public function message(array $message): bool + { + if (!$message['private']) { + return false; + } + $user = $message['user_id']; + $legacy = preg_match('/^\/(template|buttons) ([A-Za-z0-9_-]{24})\s*\n(.*)$/sD', $message['text'], $m) === 1; + $input = $this->app->flows->latest($user, 'template_input'); + $target = $this->app->gateway; + if (!$legacy) { + if (str_starts_with(ltrim($message['text']), '/')) { + $this->app->flows->revokeAction($user, 'template_input'); + return false; + } + if ($input === null) { + return false; + } + $mode = $input['payload']['mode']; + $m = ['', in_array($mode, ['buttons','link_text','link_url'], true) ? $mode : 'template', $input['payload']['template_token'], $message['text']]; + $target = new \Shabbat\UI\CallbackUiGateway($this->app->gateway, $user, $input['payload']['panel_id']); + } + try { + $flow = $this->app->flows->peek($m[2], $user, 'template'); + if ($flow === null || !array_key_exists('template_revision', $flow['payload'])) { + throw new \RuntimeException('Template editing expired.'); + } + $g = $this->app->settings->getGroup($flow['group_id']); + if ($g === null) { + throw new \RuntimeException('The group no longer exists.'); + } + $current = $this->app->templates->get($g['group_id'], $flow['payload']['kind']) ?? ['text' => '', 'entities' => [], 'buttons' => [], 'media' => null, 'revision' => 0]; + if (!$this->app->gateway->isGroupAdmin($user, $g['group_id']) || $g['revision'] !== $flow['revision']) { + throw new \RuntimeException('Current group administrator permission is required.'); + } + if (!$legacy && !$this->activeCapture($user, $flow, $m[2])) { + return true; + } + if (!$legacy) { + $this->app->flows->revokeAction($user, 'template_review'); + } + if ($m[1] === 'link_text') { + if (trim($m[3]) === '' || self::utf16Length($m[3]) > 128 || preg_match('/[\x00-\x1f\x7f]/u', $m[3])) { + throw new \InvalidArgumentException('A short one-line button label is required.'); + } + $payload = $input['payload']; + $payload['mode'] = 'link_url'; + $payload['link_label'] = $m[3]; + $this->app->flows->updatePayload($flow['payload']['source_template_input_token'], $user, 'template_input', $payload); + $target->sendPrivate($user, $this->t($user, 'template_link_url_prompt'), $this->markup([ + [['text' => $this->t($user, 'cancel'), 'data' => 'v3:templates:' . $g['group_id'] . ':' . $flow['payload']['kind']]], + ])); + return true; + } + if ($m[1] === 'link_url') { + $button = ['text' => $input['payload']['link_label'], 'url' => trim($m[3]), 'style' => 'primary']; + TemplateRenderer::validateButtons([[$button]]); + $existing = $this->app->groupMessageButtons($g, $flow['payload']['kind']); + $current['buttons'] = [...$existing, [$button]]; + $current['buttons_mode'] = 'custom'; + } elseif ($m[1] === 'buttons') { + try { + $current['buttons'] = json_decode($m[3], true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $current['buttons'] = $this->app->parseCustomButtons($m[3]); + } + TemplateRenderer::validateButtons($current['buttons']); + $current['buttons_mode'] = 'custom'; + } else { + $prefix = $legacy ? substr($message['text'], 0, strlen($message['text']) - strlen($m[3])) : ''; + $offset = self::utf16Length($prefix); + $length = self::utf16Length($m[3]); + $current['text'] = $m[3]; + $current['entities'] = []; + foreach ($message['entities'] ?? [] as $entity) { + if (!isset($entity['offset'], $entity['length']) || !is_int($entity['offset']) || !is_int($entity['length'])) { + throw new \InvalidArgumentException('Invalid Telegram entity offsets.'); + } + $start = max($offset, $entity['offset']); + $end = min($offset + $length, $entity['offset'] + $entity['length']); + if ($end > $start) { + $entity['offset'] = $start - $offset; + $entity['length'] = $end - $start; + $current['entities'][] = $entity; + } + } + if (array_key_exists('media', $message)) { + $current['media'] = $message['media']; + } + } + (new TemplateRenderer())->validateStoredPayload($current); + if (!$legacy) { + $this->stageReview($user, $flow, $m[2], $current, $target); + return true; + } + $this->app->flows->confirmAndApply( + $m[2], + $user, + 'template', + fn($group, $actor) => $this->app->gateway->isGroupAdmin($actor, $group), + [], + fn($pdo) => $this->app->templates->saveInTransaction($pdo, $g['group_id'], $flow['payload']['kind'], $current, $user, $flow['payload']['template_revision']) + ); + $this->app->flows->revokeAction($user, 'template_input'); + $target->sendPrivate($user, $this->t($user, 'template_saved'), $this->markup([ + [['text' => $this->t($user, 'back'), 'data' => 'v3:templates:' . $g['group_id'] . ':' . $flow['payload']['kind']]], + ])); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + if (!$legacy) { + $latest = $this->app->flows->latest($user, 'template_input'); + if ($latest === null || ($latest['payload']['template_token'] ?? null) !== $m[2]) { + // A cancelled or replaced prompt owns the panel now; never repaint it. + return true; + } + } + // Never echo arbitrary parser/transport exceptions into a user conversation. + $target->sendPrivate($user, $this->t($user, 'denied'), $this->markup([ + [['text' => $this->t($user, 'back'), 'data' => 'sb:groups']], + ])); + } + return true; + } + private function activeCapture(string $user, array $flow, string $capture): bool + { + $source = $flow['payload']['source_template_input_token'] ?? null; + $parent = is_string($source) ? $this->app->flows->peek($source, $user, 'template_input') : null; + return $parent !== null && $parent['scope'] === $flow['scope'] + && $parent['group_id'] === $flow['group_id'] && $parent['revision'] === $flow['revision'] + && ($parent['payload']['template_token'] ?? null) === $capture; + } + + private function stageReview(string $user, array $flow, string $capture, array $content, \Shabbat\UI\UiGateway $target): void + { + $review = $this->app->db->transaction(function () use ($user, $flow, $capture, $content): string { + if (!$this->activeCapture($user, $flow, $capture)) { + throw new \RuntimeException('The editing question expired or was replaced.'); + } + $this->app->flows->revokeAction($user, 'template_review'); + return $this->app->flows->create($user, 'group', $flow['group_id'], $flow['revision'], 'template_review', 600, [ + 'kind' => $flow['payload']['kind'], 'template_revision' => $flow['payload']['template_revision'], + 'source_template_input_token' => $flow['payload']['source_template_input_token'], 'pending_content' => $content, + ]); + }); + $group = $this->app->settings->getGroup($flow['group_id']) ?? throw new \RuntimeException('The group no longer exists.'); + $payload = $this->app->payload($group, $flow['payload']['kind'], $this->app->clock->now(), templateOverride: $content); + unset($payload['topic_id']); + $payload = $this->withInertAcknowledgement($payload, $group, $flow['payload']['kind'], $review); + $markup = \Shabbat\Runtime\MadelineGateway::normalizeMarkup(['inline_keyboard' => $payload['buttons'] ?? []]); + $markup = $markup ?: ['_' => 'replyInlineMarkup', 'rows' => []]; + $controls = $this->markup([ + [['text' => $this->t($user, 'template_save_review'), 'data' => 'tpl:save:' . $review, 'color' => 'bg_primary']], + [['text' => $this->t($user, 'cancel'), 'data' => 'tpl:discard:' . $review, 'color' => 'bg_danger']], + ]); + array_push($markup['rows'], ...$controls['rows']); + $payload['reply_markup'] = $markup; + if (!$this->activeCapture($user, $flow, $capture) || $this->app->flows->peek($review, $user, 'template_review') === null) { + return; + } + if (!$target instanceof \Shabbat\UI\CallbackUiGateway) { + throw new \RuntimeException('A private review panel is required.'); + } + $target->preview($payload); + } + + /** Preview and explicit TEST counters never create rounds, votes or tf callbacks. */ + private function withInertAcknowledgement(array $payload, array $group, string $kind, string $token): array + { + if ($kind !== 'tefillin' || !($group['tefillin_ack_enabled'] ?? true) || ($payload['buttons_mode'] ?? 'inherit') !== 'inherit') { + return $payload; + } + $payload['buttons'] ??= []; + $payload['buttons'][] = [['text' => ($group['tefillin_ack_label'] ?? \Shabbat\Scheduling\TefillinAckText::label($group['language'])) . ' (0)', + 'callback_data' => 'tpl:ack_preview:' . $token, + 'style' => ['_' => 'keyboardButtonStyle','bg_primary' => true,'icon' => \Shabbat\Scheduling\TefillinAcknowledgements::ICON]]]; + return $payload; + } + private static function utf16Length(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } + private function t(string $user, string $key, array $values = []): string + { + return $this->lang->text($this->app->settings->getUser($user)['language'], $key, $values); + } + private function markup(array $rows): array + { + $result = ['_' => 'replyInlineMarkup','rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $b) { + $style = ['_' => 'keyboardButtonStyle']; + if (($b['color'] ?? null) !== null) { + $style[$b['color']] = true; + } + $buttons[] = ['_' => 'keyboardButtonCallback','text' => $b['text'],'data' => $b['data'],'style' => $style]; + }$result['rows'][] = ['_' => 'keyboardButtonRow','buttons' => $buttons]; + } + return $result; + } +} diff --git a/src/Content/TemplateRenderer.php b/src/Content/TemplateRenderer.php new file mode 100644 index 0000000..77df020 --- /dev/null +++ b/src/Content/TemplateRenderer.php @@ -0,0 +1,202 @@ +renderPayload($payload, array_fill_keys(self::VARIABLES, 'value')); + if (self::utf16((string)($payload['text'] ?? '')) > 4096) { + throw new \InvalidArgumentException('Template text exceeds the UTF-16 message limit.'); + } + self::validateButtons($payload['buttons'] ?? []); + } + public function render(string $template, array $values): string + { + if (mb_strlen($template) > 4096) { + throw new \InvalidArgumentException('Template is too long.'); + } + preg_match_all('/\{\{\s*([a-z_]+)\s*\}\}/', $template, $matches); + foreach ($matches[1] as $name) { + if (!in_array($name, self::VARIABLES, true) || !array_key_exists($name, $values) || (!is_scalar($values[$name]) && $values[$name] !== null)) { + throw new \InvalidArgumentException('Unknown/unavailable template variable: ' . $name); + } + } + $result = preg_replace_callback('/\{\{\s*([a-z_]+)\s*\}\}/', static fn(array $m): string=>htmlspecialchars((string)$values[$m[1]], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $template); + if ($result === null || mb_strlen($result) > 4096) { + throw new \InvalidArgumentException('Rendered template exceeds Telegram limits.'); + } + return $result; + } + /** Expand plain text with MTProto UTF-16 entity offsets, or escaped HTML without entities. */ + public function renderPayload(array $payload, array $values): array + { + $text = $payload['text'] ?? ''; + if (!is_string($text) || !mb_check_encoding($text, 'UTF-8') || mb_strlen($text) > 4096) { + throw new \InvalidArgumentException('Invalid template UTF-8 text.'); + } + $entities = $payload['entities'] ?? []; + if (!is_array($entities)) { + throw new \InvalidArgumentException('Invalid template entities.'); + } + if (!array_key_exists('entities', $payload)) { + $payload['text'] = $this->render($text, $values); + return $payload; + } + preg_match_all('/\{\{\s*([a-z_]+)\s*\}\}/', $text, $matches, PREG_OFFSET_CAPTURE); + $replacements = []; + $expanded = ''; + $cursor = 0; + foreach ($matches[0] as $index => $match) { + [$placeholder,$byteStart] = $match; + $name = $matches[1][$index][0]; + if (!in_array($name, self::VARIABLES, true) || !array_key_exists($name, $values) || (!is_scalar($values[$name]) && $values[$name] !== null)) { + throw new \InvalidArgumentException('Unknown/unavailable template variable: ' . $name); + } + $value = (string)$values[$name]; + if (!mb_check_encoding($value, 'UTF-8')) { + throw new \InvalidArgumentException('Invalid template variable UTF-8.'); + } + $start = self::utf16(substr($text, 0, $byteStart)); + $oldLength = self::utf16($placeholder); + $replacements[] = ['start' => $start,'end' => $start + $oldLength,'length' => self::utf16($value)]; + $expanded .= substr($text, $cursor, $byteStart - $cursor) . $value; + $cursor = $byteStart + strlen($placeholder); + } + $expanded .= substr($text, $cursor); + if (self::utf16($expanded) > 4096) { + throw new \InvalidArgumentException('Rendered template exceeds Telegram limits.'); + } + $total = self::utf16($text); + $adjusted = []; + $boundaries = [0 => true]; + $position = 0; + foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $character) { + $position += self::utf16($character); + $boundaries[$position] = true; + } + foreach ($entities as $entity) { + if ( + !is_array($entity) || !is_int($entity['offset'] ?? null) || !is_int($entity['length'] ?? null) + || $entity['offset'] < 0 || $entity['length'] < 1 || $entity['offset'] + $entity['length'] > $total + || !isset($boundaries[$entity['offset']], $boundaries[$entity['offset'] + $entity['length']]) + ) { + throw new \InvalidArgumentException('Entity lies outside template text.'); + } + $start = self::mapPosition($entity['offset'], $replacements, false); + $end = self::mapPosition($entity['offset'] + $entity['length'], $replacements, true); + if ($end > $start) { + $entity['offset'] = $start; + $entity['length'] = $end - $start; + $adjusted[] = $entity; + } + } + $payload['text'] = $expanded; + $payload['entities'] = $adjusted; + return $payload; + } + private static function utf16(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } + private static function mapPosition(int $position, array $replacements, bool $end): int + { + $shift = 0; + foreach ($replacements as $replacement) { + if ($position < $replacement['start']) { + break; + } + if ($position === $replacement['start']) { + return $position + $shift; + } + if ($position < $replacement['end']) { + return $replacement['start'] + $shift + ($end ? $replacement['length'] : 0); + } + $shift += $replacement['length'] - ($replacement['end'] - $replacement['start']); + } + return $position + $shift; + } + public static function nativeStyle(mixed $style): array + { + if (is_string($style)) { + $flag = match ($style) { + 'primary'=>'bg_primary','success'=>'bg_success','danger'=>'bg_danger',default=>throw new \InvalidArgumentException('Unsupported button color.') + }; + return ['_' => 'keyboardButtonStyle',$flag => true]; + } + if (!is_array($style) || ($style['_'] ?? 'keyboardButtonStyle') !== 'keyboardButtonStyle') { + throw new \InvalidArgumentException('Invalid button style.'); + } + $active = 0; + foreach ($style as $key => $value) { + if ($key === '_') { + continue; + } + if (in_array($key, ['bg_primary','bg_success','bg_danger'], true)) { + if (!is_bool($value)) { + throw new \InvalidArgumentException('Button color flags must be booleans.'); + } + if ($value) { + $active++; + } + } elseif ($key === 'icon') { + $decimal = is_int($value) ? (string)$value : $value; + if (!is_string($decimal) || !preg_match('/^[1-9][0-9]{0,18}$/D', $decimal) || (strlen($decimal) === 19 && strcmp($decimal, '9223372036854775807') > 0)) { + throw new \InvalidArgumentException('Button icon must be a positive int64 identifier.'); + } + } else { + throw new \InvalidArgumentException('Unsupported button style field.'); + } + } + if ($active > 1) { + throw new \InvalidArgumentException('Choose one background color per button.'); + } + return ['_' => 'keyboardButtonStyle'] + $style; + } + public static function validateButtons(array $rows): void + { + if (!array_is_list($rows) || count($rows) > 20) { + throw new \InvalidArgumentException('Invalid keyboard rows.'); + } + $total = 0; + foreach ($rows as $row) { + if (!is_array($row) || !array_is_list($row) || count($row) > 8) { + throw new \InvalidArgumentException('Invalid keyboard row.'); + } + foreach ($row as $button) { + if (++$total > 100 || !is_array($button) || !is_string($button['text'] ?? null) || trim($button['text']) === '' || mb_strlen($button['text']) > 128) { + throw new \InvalidArgumentException('Invalid button.'); + } + $actions = 0; + foreach (['url','callback_data','data','copy_text','switch_inline_query','switch_inline_query_current_chat','query'] as $action) { + if (array_key_exists($action, $button)) { + $actions++; + } + } + if ($actions !== 1) { + throw new \InvalidArgumentException('A button needs exactly one supported action.'); + } + if ( + isset($button['url']) && (!is_string($button['url']) || !in_array(parse_url($button['url'], PHP_URL_SCHEME), ['https','tg'], true) + || preg_match('/[\r\n\x00]/', $button['url'])) + ) { + throw new \InvalidArgumentException('Invalid button URL.'); + } + foreach (['callback_data','data'] as $field) { + if (isset($button[$field]) && (!is_string($button[$field]) || strlen($button[$field]) < 1 || strlen($button[$field]) > 64)) { + throw new \InvalidArgumentException('Callback must be 1..64 bytes.'); + } + } + if (isset($button['style'])) { + self::nativeStyle($button['style']); + } + } + } + } +} diff --git a/src/Http/AmpJsonClient.php b/src/Http/AmpJsonClient.php new file mode 100644 index 0000000..8c1ec89 --- /dev/null +++ b/src/Http/AmpJsonClient.php @@ -0,0 +1,44 @@ +client = $client ?? HttpClientBuilder::buildDefault(); + } + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + if (parse_url($url, PHP_URL_SCHEME) !== 'https') { + throw new ProviderException('Providers require HTTPS.'); + } + $request = new Request($url . ($query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986))); + $request->setHeader('Accept', 'application/json'); + $request->setTransferTimeout(10); + $request->setInactivityTimeout(5); + $request->setBodySizeLimit(2097152); + $response = $this->client->request($request, $cancellation); + if ($response->getStatus() !== 200) { + throw new ProviderException('Provider HTTP status ' . $response->getStatus()); + } + try { + $data = json_decode($response->getBody()->buffer($cancellation), true, 64, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new ProviderException('Invalid provider JSON.', 0, $e); + } + if (!is_array($data)) { + throw new ProviderException('Provider did not return an object.'); + } + return $data; + } +} diff --git a/src/Http/JsonClient.php b/src/Http/JsonClient.php new file mode 100644 index 0000000..894b903 --- /dev/null +++ b/src/Http/JsonClient.php @@ -0,0 +1,11 @@ +throwIfRequested(); + $host = strtolower((string) parse_url($url, PHP_URL_HOST)); + $now = $this->clock->now()->getTimestamp(); + $state = $this->hosts[$host] ?? ['failures' => 0, 'until' => 0, 'last' => $now]; + if ($now < $state['last']) { + $state = ['failures' => 0, 'until' => 0, 'last' => $now]; + } + if ($state['until'] > $now) { + throw new ProviderException('Provider temporarily in cooldown after repeated failures.'); + } + try { + $result = $this->inner->get($url, $query, $cancellation); + unset($this->hosts[$host]); + return $result; + } catch (\Amp\CancelledException $error) { + // A deliberate caller/shutdown cancellation is not evidence of an outage. + if (!$error->getPrevious() instanceof \Amp\TimeoutException) { + throw $error; + } + $this->failed($host); + throw $error; + } catch (\Throwable $error) { + $this->failed($host); + throw $error; + } + } + + private function failed(string $host): void + { + $now = $this->clock->now()->getTimestamp(); + $previous = $this->hosts[$host] ?? null; + $failures = $previous === null || $now < $previous['last'] || $now - $previous['last'] > $this->cooldown + ? 1 : min($this->threshold, $previous['failures'] + 1); + if (count($this->hosts) >= 32 && !isset($this->hosts[$host])) { + unset($this->hosts[array_key_first($this->hosts)]); + } + $this->hosts[$host] = ['failures' => $failures, 'last' => $now, 'until' => $failures >= $this->threshold ? $now + $this->cooldown : 0]; + } +} diff --git a/src/Http/ProviderException.php b/src/Http/ProviderException.php new file mode 100644 index 0000000..509618a --- /dev/null +++ b/src/Http/ProviderException.php @@ -0,0 +1,10 @@ +throwIfRequested(); + ksort($query); + $key = hash('sha256', $url . json_encode($query, JSON_THROW_ON_ERROR)); + $now = $this->clock->now()->getTimestamp(); + $cached = $this->cache[$key] ?? null; + if ($cached !== null && $cached['created'] <= $now && $cached['expires'] > $now) { + return $cached['data']; + } + $failure = $this->failures[$key] ?? null; + if ($failure !== null && $failure['created'] <= $now && $failure['until'] > $now) { + throw new ProviderException('Provider request temporarily in cooldown.'); + } + if (!isset($this->pending[$key])) { + if (count($this->pending) >= min(32, $this->limit)) { + throw new ProviderException('Too many distinct provider requests are already in flight.'); + } + // A caller cancelling its wait does not cancel the request shared by other groups. + // Cache/failure recording belongs to the single producer, not every waiter. + $this->pending[$key] = async(function () use ($url, $query, $key): array { + try { + $data = $this->inner->get($url, $query); + if (count($this->cache) >= $this->limit && !isset($this->cache[$key])) { + unset($this->cache[array_key_first($this->cache)]); + } + $now = $this->clock->now()->getTimestamp(); + $this->cache[$key] = ['created' => $now, 'expires' => $now + $this->ttl, 'data' => $data]; + unset($this->failures[$key]); + return $data; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + if (count($this->failures) >= $this->limit && !isset($this->failures[$key])) { + unset($this->failures[array_key_first($this->failures)]); + } + $now = $this->clock->now()->getTimestamp(); + $this->failures[$key] = ['created' => $now, 'until' => $now + 15]; + throw $error; + } finally { + unset($this->pending[$key]); + } + }); + $this->pending[$key]->ignore(); + } + return $this->pending[$key]->await($cancellation); + } +} diff --git a/src/Http/SqliteJsonClient.php b/src/Http/SqliteJsonClient.php new file mode 100644 index 0000000..a969f17 --- /dev/null +++ b/src/Http/SqliteJsonClient.php @@ -0,0 +1,61 @@ +throwIfRequested(); + ksort($query); + $key = hash('sha256', 'json-v1:' . $url . json_encode($query, JSON_THROW_ON_ERROR)); + $get = $this->db->pdo()->prepare('SELECT * FROM calendar_cache WHERE cache_key=?'); + $get->execute([$key]); + $cached = $get->fetch(); + $now = $this->clock->now()->getTimestamp(); + if ($cached !== false && $cached['expires_at'] > $now) { + return $this->cached($cached, false); + } + try { + $data = $this->inner->get($url, $query, $cancellation); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + // Provider consumers still validate coverage/events. This fallback is visible, never "no event". + if ($cached !== false && parse_url($url, PHP_URL_HOST) === 'www.hebcal.com') { + return $this->cached($cached, true) + ['__shabbat_refresh_error' => 'Provider refresh failed; using previously fetched data after coverage validation.']; + } + throw $error; + } + $cancellation?->throwIfRequested(); + unset($data['__shabbat_cache'], $data['__shabbat_refresh_error']); + $this->db->pdo()->prepare('INSERT INTO calendar_cache(cache_key,provider,payload_json,fetched_at,expires_at,coverage_start,coverage_end) VALUES(?,?,?,?,?,?,?) ON CONFLICT(cache_key) DO UPDATE SET payload_json=excluded.payload_json,fetched_at=excluded.fetched_at,expires_at=excluded.expires_at,coverage_start=excluded.coverage_start,coverage_end=excluded.coverage_end') + ->execute([$key,parse_url($url, PHP_URL_HOST),json_encode($data, JSON_THROW_ON_ERROR),$now,$now + $this->ttl,$query['start'] ?? null,$query['end'] ?? null]); + return $data + ['__shabbat_cache' => ['fetched_at' => $now,'expires_at' => $now + $this->ttl,'stale' => false]]; + } + private function cached(array $row, bool $stale): array + { + try { + $data = json_decode($row['payload_json'], true, 64, JSON_THROW_ON_ERROR); + } catch (\JsonException $error) { + throw new ProviderException('Cached provider JSON is corrupt.', 0, $error); + } + if (!is_array($data)) { + throw new ProviderException('Cached provider response is malformed.'); + } + return $data + ['__shabbat_cache' => ['fetched_at' => (int)$row['fetched_at'],'expires_at' => (int)$row['expires_at'],'stale' => $stale]]; + } +} diff --git a/src/Location/GeoNames.php b/src/Location/GeoNames.php new file mode 100644 index 0000000..35c4c62 --- /dev/null +++ b/src/Location/GeoNames.php @@ -0,0 +1,138 @@ +username) !== '' && strtolower(trim($this->username)) !== 'demo'; + } + public static function normalize(string $query): string + { + if (!mb_check_encoding($query, 'UTF-8')) { + throw new LocationException('Invalid UTF-8 query'); + } + $query = mb_strtolower(trim(preg_replace('/\s+/u', ' ', $query) ?? ''), 'UTF-8'); + if (mb_strlen($query) < 2 || mb_strlen($query) > 120) { + throw new LocationException('City query must contain 2โ€“120 characters'); + } + return $query; + } + public function search(string $query, int $offset = 0, string $actor = 'public', ?Cancellation $cancellation = null, string $language = 'en'): array + { + $query = self::normalize($query); + if (!$this->configured()) { + throw new LocationException('GEONAMES_USERNAME is not configured'); + } + if ($offset < 0 || $offset > 1000) { + throw new LocationException('Invalid search page'); + } + $language = $this->providerLanguage($language); + $key = hash('sha256', $query . ':' . $offset . ':' . $language); + $now = $this->clock->now()->getTimestamp(); + if (($this->cache[$key]['expires'] ?? 0) > $now) { + return $this->cache[$key]['value']; + } + if (($this->lastRequest[$actor] ?? 0) > $now - 1) { + throw new LocationException('Please wait before another location search'); + } + if (count($this->lastRequest) >= 1024) { + foreach ($this->lastRequest as $keyActor => $last) { + if ($last < $now - 1) { + unset($this->lastRequest[$keyActor]); + } + } + if (count($this->lastRequest) >= 1024) { + throw new LocationException('Location provider is busy; please try again shortly'); + } + } + $this->lastRequest[$actor] = $now; + $body = $this->http->get($this->endpoint . '/searchJSON', ['q' => $query, 'username' => $this->username, 'maxRows' => 10, 'startRow' => $offset, 'style' => 'FULL', 'featureClass' => 'P', 'lang' => $language], $cancellation); + $this->assertSuccess($body); + if (!isset($body['geonames']) || !is_array($body['geonames'])) { + throw new LocationException('Malformed GeoNames search response'); + } + $items = []; + foreach ($body['geonames'] as $row) { + if (!is_array($row) || empty($row['geonameId']) || empty($row['name']) || !isset($row['lat'], $row['lng'])) { + throw new LocationException('Malformed GeoNames place'); + } + if (!is_numeric($row['lat']) || !is_numeric($row['lng']) || !is_finite((float) $row['lat']) || !is_finite((float) $row['lng']) || abs((float) $row['lat']) > 90 || abs((float) $row['lng']) > 180 || !preg_match('/^[1-9][0-9]*$/D', (string) $row['geonameId'])) { + throw new LocationException('Invalid GeoNames place coordinates or identifier'); + } + $items[] = ['geoname_id' => (int) $row['geonameId'], 'location_name' => (string) $row['name'], 'country' => (string) ($row['countryName'] ?? $row['countryCode'] ?? ''), 'admin_region' => (string) ($row['adminName1'] ?? ''), 'latitude' => (float) $row['lat'], 'longitude' => (float) $row['lng'], 'timezone_id' => $row['timezone']['timeZoneId'] ?? $row['timezone']['timezoneId'] ?? null, 'location_source' => 'geonames']; + } + $total = (int) ($body['totalResultsCount'] ?? count($items)); + $result = ['items' => $items, 'total' => $total, 'next_offset' => $offset + count($items) < min($total, 1010) ? $offset + count($items) : null, 'query_key' => $key]; + if (count($this->cache) >= 128) { + array_shift($this->cache); + } + $this->cache[$key] = ['expires' => $now + 3600, 'value' => $result]; + return $result; + } + /** Revalidate only the selected place and fill its IANA zone. */ + public function get(int $id, ?Cancellation $cancellation = null, string $language = 'en'): array + { + if ($id < 1 || !$this->configured()) { + throw new LocationException('GeoNames place cannot be verified'); + } + $body = $this->http->get($this->endpoint . '/getJSON', ['geonameId' => $id, 'username' => $this->username, 'style' => 'FULL', 'lang' => $this->providerLanguage($language)], $cancellation); + $this->assertSuccess($body); + if ((int) ($body['geonameId'] ?? 0) !== $id || !isset($body['lat'], $body['lng'], $body['name'])) { + throw new LocationException('GeoNames place ID mismatch'); + } + $zone = $body['timezone']['timeZoneId'] ?? $body['timezone']['timezoneId'] ?? null; + if (!$zone) { + $tz = $this->http->get($this->endpoint . '/timezoneJSON', ['lat' => $body['lat'], 'lng' => $body['lng'], 'username' => $this->username], $cancellation); + $this->assertSuccess($tz); + $zone = $tz['timezoneId'] ?? null; + } + return Place::validate(['geoname_id' => $id, 'location_name' => $body['name'], 'country' => $body['countryName'] ?? $body['countryCode'] ?? '', 'admin_region' => $body['adminName1'] ?? '', 'latitude' => $body['lat'], 'longitude' => $body['lng'], 'timezone_id' => $zone, 'location_source' => 'geonames']); + } + /** Only timezoneId is authoritative; provider offsets/sun times are not calculation inputs. */ + public function timezone(float $latitude, float $longitude, ?Cancellation $cancellation = null, string $language = 'en'): array + { + if (!$this->configured()) { + throw new LocationException('GEONAMES_USERNAME is not configured; choose an explicit IANA timezone instead'); + } + Place::validate(['latitude' => $latitude, 'longitude' => $longitude, 'timezone_id' => 'UTC', 'location_name' => 'Coordinates']); + $body = $this->http->get($this->endpoint . '/timezoneJSON', ['lat' => $latitude, 'lng' => $longitude, 'username' => $this->username, 'lang' => $this->providerLanguage($language)], $cancellation); + $this->assertSuccess($body); + $zone = $body['timezoneId'] ?? null; + if (!is_string($zone) || !in_array($zone, \DateTimeZone::listIdentifiers(), true)) { + throw new LocationException('GeoNames did not return a verified IANA timezone'); + } + return ['timezone_id' => $zone, 'country' => (string) ($body['countryName'] ?? $body['countryCode'] ?? '')]; + } + private function providerLanguage(string $language): string + { + $language = strtolower(str_replace('_', '-', trim($language))); + if ($language === 'pt-br') { + $language = 'pt'; + } + return in_array($language, ['he','en','ru','fr','es','pt'], true) ? $language : 'en'; + } + private function assertSuccess(array $body): void + { + if (isset($body['status'])) { + throw new LocationException('GeoNames provider error ' . (int) ($body['status']['value'] ?? 0) . ': ' . (string) ($body['status']['message'] ?? 'unknown')); + } + } +} diff --git a/src/Location/LocationException.php b/src/Location/LocationException.php new file mode 100644 index 0000000..3b0d207 --- /dev/null +++ b/src/Location/LocationException.php @@ -0,0 +1,10 @@ + [-90, 90], 'longitude' => [-180, 180]] as $key => [$min, $max]) { + if (!isset($place[$key]) || !is_numeric($place[$key]) || !is_finite((float) $place[$key]) || $place[$key] < $min || $place[$key] > $max) { + throw new LocationException('Invalid ' . $key); + } + $place[$key] = (float) $place[$key]; + } + if (!in_array($place['timezone_id'] ?? '', \DateTimeZone::listIdentifiers(), true)) { + throw new LocationException('Timezone must be an IANA identifier'); + } + $name = trim((string) ($place['location_name'] ?? '')); + if ($name === '' || mb_strlen($name) > 160) { + throw new LocationException('A location name of 1โ€“160 characters is required'); + } + $place['location_name'] = $name; + $place['country'] = (string) ($place['country'] ?? ''); + $place['geoname_id'] = isset($place['geoname_id']) ? (int) $place['geoname_id'] : null; + $place['location_source'] = (string) ($place['location_source'] ?? 'coordinates'); + return $place; + } + public static function common(): array + { + return [ + 'jerusalem' => ['location_name' => 'Jerusalem / ื™ืจื•ืฉืœื™ื', 'country' => 'Israel', 'geoname_id' => 281184, 'latitude' => 31.76904, 'longitude' => 35.21633, 'timezone_id' => 'Asia/Jerusalem', 'location_source' => 'curated'], + 'telaviv' => ['location_name' => 'Tel Aviv / ืชืœ ืื‘ื™ื‘', 'country' => 'Israel', 'geoname_id' => 293397, 'latitude' => 32.08088, 'longitude' => 34.78057, 'timezone_id' => 'Asia/Jerusalem', 'location_source' => 'curated'], + 'london' => ['location_name' => 'London / ืœื•ื ื“ื•ืŸ', 'country' => 'United Kingdom', 'geoname_id' => 2643743, 'latitude' => 51.50853, 'longitude' => -0.12574, 'timezone_id' => 'Europe/London', 'location_source' => 'curated'], + 'newyork' => ['location_name' => 'New York / ื ื™ื• ื™ื•ืจืง', 'country' => 'United States', 'geoname_id' => 5128581, 'latitude' => 40.71427, 'longitude' => -74.00597, 'timezone_id' => 'America/New_York', 'location_source' => 'curated'], + ]; + } +} diff --git a/src/Notifications/ReminderPlanner.php b/src/Notifications/ReminderPlanner.php new file mode 100644 index 0000000..2616b5a --- /dev/null +++ b/src/Notifications/ReminderPlanner.php @@ -0,0 +1,194 @@ +format('N'); + if ($weekday === 6 || !in_array($weekday, $this->weekdays($rule, $settings, 'tefillin'), true) || ($rule['pause_date'] ?? $settings['tefillin_suspended_date'] ?? null) === $date) { + return []; + } + if (($state['is_chol_hamoed'] ?? false) && !($rule['chol_hamoed'] ?? false)) { + return []; + } + if (($state['is_tisha_bav'] ?? false) && ($rule['tisha_bav'] ?? 'off') === 'off') { + return []; + } + $start = $sun[$rule['misheyakir_method'] ?? 'misheyakir'] ?? null; + $sunset = $sun['sunset'] ?? null; + if ($start === null || $sunset === null) { + return []; + } + if (($state['is_tisha_bav'] ?? false) && ($rule['tisha_bav'] ?? 'off') === 'mincha') { + if (!isset($sun['minchaGedola'])) { + return []; + } + $start = max($start, $sun['minchaGedola']); + } + $end = $sunset - max(1, (int)($rule['sunset_safety_minutes'] ?? 10)) * 60; + foreach ($state['events'] ?? [] as $event) { + if ($event['start_at'] >= $start && $event['start_at'] <= $end) { + $end = $event['start_at']; + } + } + foreach ($state['operational_events'] ?? [] as $event) { + if ($event['close_at'] >= $start && $event['close_at'] <= $end) { + $end = $event['close_at']; + } + } + if (isset($rule['window_start'])) { + $bound = LocalTime::resolve($date, $rule['window_start'], $settings['timezone_id']); + if ($bound === null) { + return []; + } $start = max($start, $bound); + } + if (isset($rule['window_end'])) { + $bound = LocalTime::resolve($date, $rule['window_end'], $settings['timezone_id']); + if ($bound === null) { + return []; + } $end = min($end, $bound); + } + if ($end <= $start) { + return []; + } + $times = []; + foreach ($rule['times'] ?? [] as $time) { + $stamp = LocalTime::resolve($date, $time, $settings['timezone_id']); + if ($stamp !== null) { + $times[] = $stamp; + } + } + $interval = (int)($rule['interval_minutes'] ?? 0); + if ($interval !== 0) { + if ($interval < 5 || $interval > 1440) { + throw new \InvalidArgumentException('Reminder interval must be 5..1440 minutes.'); + } + for ($at = $start; $at < $end; $at += $interval * 60) { + $times[] = $at; + } + } + foreach ($rule['before_sunset'] ?? [] as $minutes) { + if (!is_int($minutes) || $minutes < 1 || $minutes > 360) { + throw new \InvalidArgumentException('Invalid relative reminder.'); + } + $times[] = $sunset - $minutes * 60; + } + sort($times); + $result = []; + $last = null; + $minimum = max(5, (int)($rule['min_gap_minutes'] ?? 30)) * 60; + $maximum = min(24, max(1, (int)($rule['max_per_day'] ?? 3))); + foreach (array_unique($times) as $at) { + if ($at < $start || $at >= $end || ($last !== null && $at - $last < $minimum) || $this->quiet($at, $settings['timezone_id'], $rule)) { + continue; + } + $result[] = ['run_at' => $at,'expires_at' => $end,'kind' => 'tefillin','event_key' => 'tefillin:' . $date . ':' . $at]; + $last = $at; + if (count($result) >= $maximum) { + break; + } + } + return $result; + } + public function omer(array $settings, array $rule, string $date, array $sun, array $state): array + { + if (!($rule['enabled'] ?? false)) { + return []; + } + $night = CalendarEngine::nightfall($sun, [ + 'omer_nightfall_method' => $rule['nightfall_method'] ?? $rule['tzeit_method'] ?? $settings['omer_nightfall_method'] ?? 'degrees', + 'omer_nightfall_value' => $rule['nightfall_value'] ?? $rule['tzeit_value'] ?? $settings['omer_nightfall_value'] ?? 8.5, + ], 'omer_nightfall'); + if ($night === null) { + return []; + } + $nextDate = (new \DateTimeImmutable($date, new \DateTimeZone($settings['timezone_id'])))->modify('+1 day')->format('Y-m-d'); + $dawn = $sun['next_alotHaShachar'] ?? $state['next_dawn_at'] ?? null; + if ($dawn === null || $dawn <= $night) { + return []; + } + $schedule = $rule['schedule'] ?? $settings['omer_mode'] ?? 'nightfall'; + if ($schedule === 'fixed') { + $time = $rule['time'] ?? $settings['omer_time'] ?? '21:00'; + // Midnight belongs to the same counting night that began on the preceding civil date. + $requested = LocalTime::resolve($time < '12:00' ? $nextDate : $date, $time, $settings['timezone_id']); + } else { + $requested = $night + (int)($rule['after_nightfall_minutes'] ?? $rule['after_tzeit_minutes'] ?? $settings['omer_after_nightfall_minutes'] ?? 15) * 60; + } + if ($requested === null) { + return []; + } + if ($requested < $night && ($rule['early_policy'] ?? $settings['omer_early_policy'] ?? 'defer') === 'skip') { + return []; + } + $at = max($requested, $night); + $reason = $requested < $night ? 'delayed_until_nightfall' : null; + $quiet = []; + foreach ($state['events'] ?? [] as $event) { + $quiet[] = ['start' => $event['start_at'],'end' => $event['end_at']]; + } + foreach ($state['operational_events'] ?? [] as $event) { + $quiet[] = ['start' => $event['close_at'],'end' => $event['open_at']]; + } + usort($quiet, static fn(array $a, array $b): int=>$a['start'] <=> $b['start']); + foreach ($quiet as $event) { + if ($event['start'] <= $at && $event['end'] > $at) { + $at = $event['end'] + max(0, (int)($rule['after_open_minutes'] ?? $settings['omer_after_open_minutes'] ?? 5)) * 60; + $reason = 'delayed_until_group_open'; + } + } + if ($at >= $dawn || $this->quiet($at, $settings['timezone_id'], $rule)) { + return []; + } + return [['run_at' => $at,'expires_at' => min($dawn, $at + 7200),'kind' => 'omer','event_key' => 'omer-night:' . $date,'reason' => $reason]]; + } + public function daily(array $settings, array $rule, string $date, string $kind, array $state): array + { + if (!($rule['enabled'] ?? false)) { + return []; + } + $weekday = (int)(new \DateTimeImmutable($date, new \DateTimeZone($settings['timezone_id'])))->format('N'); + if (!in_array($weekday, $this->weekdays($rule, $settings, $kind), true)) { + return []; + } + $at = LocalTime::resolve($date, $rule['time'] ?? '08:00', $settings['timezone_id']); + if ($at === null || $this->quiet($at, $settings['timezone_id'], $rule)) { + return []; + } + foreach ($state['events'] ?? [] as $event) { + if ($event['start_at'] <= $at && $event['end_at'] > $at) { + return []; + } + } + foreach ($state['operational_events'] ?? [] as $event) { + if ($event['close_at'] <= $at && $event['open_at'] > $at) { + return []; + } + } + return [['run_at' => $at,'expires_at' => $at + 3600,'kind' => $kind,'event_key' => $kind . ':' . $date]]; + } + private function weekdays(array $rule, array $settings, string $module): array + { + if (isset($rule['weekdays'])) { + return $rule['weekdays']; + } // Existing planner API uses ISO N. + $days = $rule['days'] ?? $settings[$module . '_days'] ?? [0,1,2,3,4,5]; // Repository/UI stores PHP w. + return array_map(static fn(int $day): int=>$day === 0 ? 7 : $day, $days); + } + private function quiet(int $at, string $zone, array $rule): bool + { + return \Shabbat\Scheduling\QuietHours::contains($at, $zone, $rule['quiet_start'] ?? null, $rule['quiet_end'] ?? null); + } +} diff --git a/src/Runtime/AddedFilePatchManager.php b/src/Runtime/AddedFilePatchManager.php new file mode 100644 index 0000000..cb747a8 --- /dev/null +++ b/src/Runtime/AddedFilePatchManager.php @@ -0,0 +1,267 @@ +prepare(); + $state = $this->state($prepared); + if ($state === 'patched') { + return 'already-patched'; + } + if (!$apply) { + throw new RuntimeException('Required library patch set is absent. Run php scripts/apply-patches.php before startup.'); + } + $writer = SingleInstance::acquire($this->writerLockPath); + try { + if ($this->state($prepared) !== 'pristine') { + throw new RuntimeException('Library patch set changed while acquiring the installer lock.'); + } + $this->install($prepared); + if ($this->state($prepared) !== 'patched') { + throw new RuntimeException('Installed library patch set failed final verification.'); + } + return 'patched'; + } finally { + $writer->release(); + } + } + + private function prepare(): array + { + if (!is_array($this->entry['added_files']) || $this->entry['added_files'] === []) { + throw new RuntimeException('An added-file patch must declare its complete reviewed file set.'); + } + foreach ($this->entry['license_files'] ?? [] as $license) { + $this->artifact($license['file'], $license['sha256']); + } + $baseline = $this->artifact($this->entry['baseline'], $this->entry['original_sha256']); + $patch = $this->artifact($this->entry['patch'], $this->entry['patch_sha256']); + $sections = self::sections($patch); + if (!isset($sections[$this->target]) || $sections[$this->target]['old'] !== $this->target) { + throw new RuntimeException('Existing-file diff header differs from the approved target.'); + } + $prepared = [$this->target => ['original' => $baseline, + 'output' => PatchManager::applyDiff($baseline, $sections[$this->target]['diff'])]]; + self::output($prepared[$this->target]['output'], $this->entry['patched_sha256']); + if ( + isset($this->entry['reviewed_output']) + && $prepared[$this->target]['output'] !== $this->artifact($this->entry['reviewed_output'], $this->entry['patched_sha256']) + ) { + throw new RuntimeException('Existing-file diff differs from its reviewed output.'); + } + foreach ($this->entry['added_files'] as $file) { + if (($file['original_state'] ?? '') !== 'absent') { + throw new RuntimeException('Unreviewed original added-file state.'); + } + $target = self::relative($file['target']); + if (isset($prepared[$target]) || !isset($sections[$target]) || $sections[$target]['old'] !== null) { + throw new RuntimeException('Added-file diff header is missing, duplicated or not an addition.'); + } + $output = PatchManager::applyDiff('', $sections[$target]['diff']); + self::output($output, $file['patched_sha256']); + if ($output !== $this->artifact($file['reviewed_output'], $file['patched_sha256'])) { + throw new RuntimeException('Added-file diff differs from its reviewed output.'); + } + $prepared[$target] = ['original' => null, 'output' => $output]; + } + $actual = array_keys($sections); + $expected = array_keys($prepared); + sort($actual); + sort($expected); + if ($actual !== $expected) { + throw new RuntimeException('Unified diff contains an undeclared library target.'); + } + $this->prerequisites(); + return $prepared; + } + + private function prerequisites(): void + { + if (!isset($this->entry['prerequisites']) || !is_array($this->entry['prerequisites']) || $this->entry['prerequisites'] === []) { + throw new RuntimeException('Added-file patch requires exact reviewed source/schema prerequisites.'); + } + foreach ($this->entry['prerequisites'] as $prerequisite) { + if (isset($prerequisite['baseline'])) { + $this->artifact($prerequisite['baseline'], $prerequisite['sha256']); + } + $path = $this->path(self::relative($prerequisite['target'])); + if (!is_file($path) || !hash_equals($prerequisite['sha256'], hash('sha256', self::read($path)))) { + throw new RuntimeException('Library schema/source prerequisite differs from the approved source.'); + } + } + } + + private function state(array $prepared): string + { + $this->prerequisites(); + $states = []; + foreach ($prepared as $target => $file) { + $path = $this->path($target); + if (!file_exists($path)) { + if ($file['original'] !== null) { + throw new RuntimeException('Approved original library file is absent.'); + } + $states[] = 'pristine'; + continue; + } + if (!is_file($path)) { + throw new RuntimeException('Library target is not a regular source file.'); + } + $contents = self::read($path); + if ($contents === $file['output']) { + $states[] = 'patched'; + } elseif ($file['original'] !== null && $contents === $file['original']) { + $states[] = 'pristine'; + } else { + throw new RuntimeException('Unknown or modified library patch-set source; refusing to continue.'); + } + } + if (count(array_unique($states)) !== 1) { + throw new RuntimeException('Partial library patch set; refusing repair or silent continuation.'); + } + return $states[0]; + } + + private function install(array $prepared): void + { + $temporary = []; + try { + foreach ($prepared as $target => $file) { + $path = $this->path($target); + $temp = $path . '.shabbat-' . bin2hex(random_bytes(6)) . '.tmp'; + $temporary[$target] = $temp; + if (file_put_contents($temp, $file['output'], LOCK_EX) !== strlen($file['output'])) { + throw new RuntimeException('Unable to stage a complete library patch-set file.'); + } + } + // Install additions before dispatch. A crash leaves a detectable partial set. + foreach ($prepared as $target => $file) { + if ($file['original'] !== null) { + continue; + } + $path = $this->path($target); + if (!@link($temporary[$target], $path)) { + throw new RuntimeException('Unable to exclusively install the reviewed added library file.'); + } + unlink($temporary[$target]); + unset($temporary[$target]); + } + $path = $this->path($this->target); + if (!rename($temporary[$this->target], $path)) { + throw new RuntimeException('Unable to atomically install reviewed library dispatch.'); + } + unset($temporary[$this->target]); + } finally { + foreach ($temporary as $temp) { + if (is_file($temp)) { + unlink($temp); + } + } + } + } + + private function path(string $relative): string + { + $relative = self::relative($relative); + $path = $this->packageRoot . '/' . $relative; + $root = realpath($this->packageRoot); + $parent = realpath(dirname($path)); + if ( + $root === false || $parent === false || is_link($this->packageRoot) || is_link($path) + || ($parent !== $root && !str_starts_with($parent, $root . DIRECTORY_SEPARATOR)) + ) { + throw new RuntimeException('Library patch-set target escaped its regular package directory.'); + } + return $path; + } + + private function artifact(string $relative, string $sha256): string + { + $path = $this->patchDirectory . '/' . self::relative($relative); + if (is_link($path)) { + throw new RuntimeException('Reviewed patch artifacts must be regular contained files.'); + } + $contents = self::read($path); + if (!hash_equals($sha256, hash('sha256', $contents))) { + throw new RuntimeException('Reviewed patch artifact or baseline hash mismatch.'); + } + return $contents; + } + + private static function output(string $contents, string $sha256): void + { + if (!hash_equals($sha256, hash('sha256', $contents))) { + throw new RuntimeException('Unified diff output differs from its approved SHA256.'); + } + $tokens = token_get_all($contents, TOKEN_PARSE); + if ($tokens === [] || !is_array($tokens[0]) || $tokens[0][0] !== T_OPEN_TAG) { + throw new RuntimeException('Patch-set output must be a complete PHP source file.'); + } + } + + private static function sections(string $patch): array + { + $lines = explode("\n", $patch); + $starts = []; + foreach ($lines as $index => $line) { + if (str_starts_with($line, '--- ')) { + $starts[] = $index; + } + } + $sections = []; + foreach ($starts as $position => $start) { + $old = substr($lines[$start], 4); + $new = $lines[$start + 1] ?? ''; + if (!str_starts_with($new, '+++ b/') || ($old !== '/dev/null' && !str_starts_with($old, 'a/'))) { + throw new RuntimeException('Invalid reviewed unified-diff file headers.'); + } + $target = self::relative(substr($new, 6)); + $oldTarget = $old === '/dev/null' ? null : self::relative(substr($old, 2)); + if (($oldTarget !== null && $oldTarget !== $target) || isset($sections[$target])) { + throw new RuntimeException('Renamed or duplicate patch-set target.'); + } + $end = $starts[$position + 1] ?? count($lines); + $sections[$target] = ['old' => $oldTarget, 'diff' => implode("\n", array_slice($lines, $start, $end - $start))]; + } + return $sections; + } + + private static function relative(string $path): string + { + if ( + $path === '' || str_contains($path, "\0") || str_contains($path, '\\') || str_contains($path, ':') + || str_starts_with($path, '/') || str_contains($path, '//') || preg_match('~(?:^|/)\.\.?(?:/|$)~', $path) + ) { + throw new RuntimeException('Patch-set paths must be contained relative paths.'); + } + return $path; + } + + private static function read(string $path): string + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false) { + throw new RuntimeException('Missing or unreadable patch-set prerequisite: ' . $path); + } + return $contents; + } +} diff --git a/src/Runtime/AdminIntegration.php b/src/Runtime/AdminIntegration.php new file mode 100644 index 0000000..1d3c135 --- /dev/null +++ b/src/Runtime/AdminIntegration.php @@ -0,0 +1,621 @@ +v3App()); + } + + private function v4Admin(): AdminController + { + $app = $this->v3App(); + return new AdminController( + $app->db, + $app->clock, + $app->settings, + $app->adminAccess, + new TrackingLinkRepository($app->db, $app->clock, $app->adminAccess), + $app->gateway->botUsername(), + (string)$this->getSelf()['id'] + ); + } + + private function v4Subscribers(): SubscriberController + { + $app = $this->v3App(); + return new SubscriberController( + $app->settings, + $app->subscribers, + fn(string $user, string $capability, bool $mutate = false): bool => $mutate + ? $app->adminAccess->canMutate($user, $capability) : $app->adminAccess->canView($user, $capability) + ); + } + + private function v4Broadcast(): BroadcastController + { + $app = $this->v3App(); + return new BroadcastController( + $app->settings, + $this->v3OperatorDraft(), + $app->clock, + fn(string $user, string $capability = 'broadcast', bool $mutate = false): bool => $mutate + ? $app->adminAccess->canMutate($user, $capability) : $app->adminAccess->canView($user, $capability) + ); + } + + private function v4RememberPanel(string $user, int $messageId): void + { + if ($messageId < 1 || $messageId > 2147483647) { + throw new \InvalidArgumentException('Invalid operator panel.'); + } + $app = $this->v3App(); + $app->flows->revokeAction($user, 'operator_panel'); + $app->flows->create($user, 'user', null, $app->settings->getUser($user)['revision'], 'operator_panel', 3600, ['message_id' => $messageId]); + } + + private function v4InputFrame(string $user, array $frame): void + { + $app = $this->v3App(); + $panel = $app->flows->latest($user, 'operator_panel')['payload']['message_id'] ?? null; + if (is_int($panel) && $panel > 0) { + $app->gateway->editPrivate($user, $panel, $frame['text'], $frame['markup']); + } else { + $app->gateway->sendPrivate($user, $frame['text'], $frame['markup']); + } + } + + /** Navigation can clear only the authenticated private actor's pending input modes. */ + private function v4ClearCompetingInputs(string $user, string $keep = ''): void + { + $app = $this->v3App(); + if ($keep !== 'diagnostics') { + (new \Shabbat\Storage\DiagnosticsRepository($app->db, $app->clock))->cancelInput($user); + } + if ($keep !== 'admin') { + $this->v4Admin()->cancel($user); + } + if ($keep !== 'subscriber') { + $app->db->pdo()->prepare('UPDATE subscriber_directory_views SET prompt=NULL WHERE viewer_id=?')->execute([$user]); + } + if ($keep !== 'broadcast') { + $app->db->pdo()->prepare("DELETE FROM admin_state WHERE user_id=? AND state_key='grs1.txt'")->execute([$user]); + } + } + + private function v4ObservePeer(string $id, bool $private, bool $activity = false, bool $opening = false): void + { + $app = $this->v3App(); + try { + $info = $this->getInfo((int)$id); + $body = $info['User'] ?? $info['Chat'] ?? []; + $type = $info['type'] ?? 'unknown'; + if (!in_array($type, ['unknown', 'user', 'bot', 'chat', 'supergroup', 'channel'], true)) { + $type = 'unknown'; + } + if (($body['bot'] ?? false) === true) { + $type = 'bot'; + } + $metadata = ['type' => $type]; + foreach (['title', 'first_name', 'last_name', 'username', 'language_code'] as $name) { + if (is_string($body[$name] ?? null)) { + $metadata[$name] = $body[$name]; + } + } + foreach (['bot', 'deleted', 'premium', 'contact'] as $name) { + if (is_bool($body[$name] ?? null)) { + $metadata[$name] = $body[$name]; + } + } + if ($private) { + $app->subscribers->observePrivate($id, $metadata, $activity, $opening); + } else { + $app->subscribers->observe($id, $metadata, $activity, false); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + // Directory enrichment must never prevent calendar or permission operations. + if ($private) { + $app->subscribers->observePrivate($id, [], $activity, $opening); + } + } + } + + private function v4ObserveMessage(Message $message): void + { + if ($message instanceof PrivateMessage) { + $this->v4ObservePeer((string)$message->senderId, true, true, preg_match('/^\/start(?:@\w+)?(?:\s|$)/i', $message->message) === 1); + } else { + // A group sender has not granted the bot permission to send private messages. + $this->v4ObservePeer((string)$message->chatId, false, true); + } + } + + private function v4HandleMessage(Message $message, array $event): bool + { + $app = $this->v3App(); + $user = (string)$message->senderId; + if (!($message instanceof PrivateMessage)) { + return false; + } + $text = $message->message; + if (str_starts_with(ltrim($text), '/')) { + $this->v4ClearCompetingInputs($user); + } + + if ($this->v4Diagnostics()->input($event)) { + return true; + } + if ($app->donationUi?->message($event)) { + return true; + } + if (preg_match('/^\/start(?:@\w+)?\s+([A-Za-z0-9_-]{1,64})$/iD', trim($text), $parts)) { + $parameter = $parts[1]; + try { + $destination = (new TrackingLinkRepository($app->db, $app->clock, $app->adminAccess))->recordStart($parameter, $user, $message->id, true); + if ($destination !== null) { + $parameter = $destination; + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + // Analytics are best effort and never block an ordinary /start. + } + try { + $action = SystemLinks::resolve($parameter, $user, true, $app->adminAccess); + if ($action !== null) { + return $this->v4OpenDestination($message, $event, $action); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $app->gateway->sendPrivate($user, $this->v3Text($user, 'denied')); + return true; + } + } + if (preg_match('/^\/(adminstats|subscribers|broadcast|managers|systemlinks|trackinglinks)(?:@\w+)?$/iD', trim($text), $parts)) { + $action = match (strtolower($parts[1])) { + 'adminstats' => 'statistics', 'systemlinks' => 'system_links', 'trackinglinks' => 'tracking_links', + default => strtolower($parts[1]), + }; + try { + return $this->v4OpenDestination($message, $event, $action); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $app->gateway->sendPrivate($user, $this->v3Text($user, 'denied')); + return true; + } + } + if (!$app->adminAccess->isAdmin($user)) { + return false; + } + if (str_starts_with(ltrim($text), '/')) { + $this->v4Admin()->cancel($user); + if ($app->adminAccess->canView($user, 'subscribers')) { + $app->subscribers->saveView($user, ['prompt' => null]); + } + if ($app->adminAccess->canMutate($user, 'broadcast')) { + $this->v3OperatorDraft()->cancel($user); + } + return false; + } + try { + $forwarded = $message->fwdInfo->fromId ?? null; + $frame = $this->v4Admin()->capture($user, $text, is_int($forwarded) && $forwarded > 0 ? (string)$forwarded : null); + if ($frame !== null) { + $this->v4InputFrame($user, $frame); + return true; + } + if ($app->adminAccess->canView($user, 'subscribers')) { + $frame = $this->v4Subscribers()->input($user, $text); + if ($frame !== null) { + $this->v4InputFrame($user, $frame); + return true; + } + } + if ($app->adminAccess->canMutate($user, 'broadcast')) { + $frame = $this->v4Broadcast()->input($user, $text); + if ($frame !== null) { + $this->v4InputFrame($user, $frame); + return true; + } + $mode = $this->v3OperatorDraft()->mode($user); + if (in_array($mode, ['broadcast1', 'addBUTTONS'], true)) { + return $this->v3OperatorCapture($message, $mode); + } + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $this->v4InputFrame($user, ['text' => $this->v3Text($user, 'operator_draft_invalid'), 'markup' => [ + '_' => 'replyInlineMarkup', 'rows' => [['_' => 'keyboardButtonRow', 'buttons' => [ + ['_' => 'keyboardButtonCallback', 'text' => $this->v3Text($user, 'back'), 'data' => 'ื—ื–ืจื”ืžื ื”ืœ'], + ]]], + ]]); + return true; + } + return false; + } + + private function v4OpenDestination(Message $message, array $event, string $action): bool + { + $app = $this->v3App(); + $user = (string)$message->senderId; + if (in_array($action, ['admin', 'statistics', 'subscribers', 'broadcast', 'managers', 'system_links', 'tracking_links', 'commands'], true)) { + if (!$app->adminAccess->isAdmin($user)) { + throw new \RuntimeException('Bot manager access required.'); + } + $this->v4Admin()->cancel($user); + $frame = match ($action) { + 'admin' => $this->v3OperatorUi()->main($user), + 'statistics' => $this->v4Subscribers()->statistics($user), + 'subscribers' => $this->v4Subscribers()->listing($user), + 'broadcast' => $this->v4Broadcast()->main($user), + 'managers' => $this->v4Admin()->managers($user), + 'system_links' => $this->v4Admin()->systemLinks($user), + 'tracking_links' => $this->v4Admin()->trackers($user), + 'commands' => $this->v4CommandsHelp($user), + }; + $app->gateway->sendPrivate($user, $frame['text'], $frame['markup']); + return true; + } + if ($action === 'donate') { + $this->v3DonationMenu($user); + return true; + } + if (in_array($action, ['source', 'license', 'version'], true)) { + $app->gateway->sendPrivate($user, $this->v3Source($action, $user)); + return true; + } + $app->ui->handleMessage(array_replace($event, ['text' => '/' . $action])); + return true; + } + + private function v4CommandsHelp(string $user): array + { + $this->v3App()->adminAccess->require($user, 'commands'); + return ['text' => $this->v3Text($user, 'commands_setup_help') . "\n/setcommands all all preview\n/setcommands all all", + 'markup' => ['_' => 'replyInlineMarkup', 'rows' => [['_' => 'keyboardButtonRow', 'buttons' => [ + ['_' => 'keyboardButtonCallback', 'text' => $this->v3Text($user, 'back'), 'data' => 'ื—ื–ืจื”ืžื ื”ืœ'], + ]]]]]; + } + + private function v4HandleCallback(ButtonQuery $query, array $event): bool + { + $app = $this->v3App(); + $user = (string)$query->userId; + $data = $query->data; + $private = $query instanceof ChatButtonQuery && (string)$query->chatId === $user; + if ($private) { + $keep = match (true) { + str_starts_with($data, 'dz:') => 'diagnostics', + str_starts_with($data, 'bc:') => 'broadcast', + str_starts_with($data, 'sd:') => 'subscriber', + preg_match('/^(?:adm|sys|trk):/', $data) === 1 => 'admin', + default => '', + }; + $this->v4ClearCompetingInputs($user, $keep); + } + + if ($private && !str_starts_with($data, 'sb:')) { + $app->flows->revokeAction($user, 'settings_wizard'); + } + if ($private && !str_starts_with($data, 'tpl:') && !str_starts_with($data, 'v3:templates:')) { + $app->flows->revokeAction($user, 'template_input'); + } + if ($private && !in_array($data, ['v3:donate', 'v3:donate:custom', 'v3:donate:cancel'], true)) { + $app->donationUi?->cancel($user); + } + if (str_starts_with($data, 'dz:') && $this->v4Diagnostics()->callback($event)) { + return true; + } + if (str_starts_with($data, 'ps:')) { + if (!$private || !in_array($data, ['ps:overview', 'ps:modules'], true)) { + $app->gateway->answerCallback((string)$event['query_id'], $this->v3Text($user, 'denied')); + return true; + } + $preferences = $app->settings->getUser($user); + $frame = (new \Shabbat\UI\StatisticsController($app->db, $app->clock, new Lang()))->frame($preferences['language'], substr($data, 3), $preferences['timezone_id']); + $target = CallbackUiGateway::fromQuery($app->gateway, $event); + try { + $target->sendPrivate($user, $frame['text'], $frame['markup']); + } catch (\Shabbat\UI\PanelEditException) { + $target->answerCallback((string)$event['query_id'], $this->v3Text($user, 'callback_panel_unavailable')); + return true; + } + $target->answerCallback((string)$event['query_id'], ''); + return true; + } + if (str_starts_with($data, 'v3:donate')) { + try { + if ($app->donationUi === null || !$app->donationUi->callback($event)) { + throw new \RuntimeException('Donation service unavailable.'); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $query->answer($this->v3Text($user, 'callback_panel_unavailable'), true); + } + return true; + } + if (!preg_match('/^(?:adm|sys|trk|sd|bc|opx):/', $data)) { + if ($private && $app->adminAccess->isAdmin($user)) { + $this->v4Admin()->cancel($user); + if (str_starts_with($data, 'sb:') || str_starts_with($data, 'tpl:') || str_starts_with($data, 'v3:')) { + if ($app->adminAccess->canMutate($user, 'broadcast')) { + $this->v3OperatorDraft()->cancel($user); + } + } + if ($app->adminAccess->canView($user, 'subscribers')) { + $app->subscribers->saveView($user, ['prompt' => null]); + } + } + return false; + } + try { + if (!$private || !$app->adminAccess->isAdmin($user)) { + throw new \RuntimeException('A private manager panel is required.'); + } + $target = CallbackUiGateway::fromQuery($app->gateway, $event); + $this->v4RememberPanel($user, $query->messageId); + if (!preg_match('/^(?:adm|sys|trk):/', $data)) { + $this->v4Admin()->cancel($user); + } + if (!str_starts_with($data, 'sd:') && $app->adminAccess->canView($user, 'subscribers')) { + $app->subscribers->saveView($user, ['prompt' => null]); + } + if ($data === 'sys:commands') { + $frame = $this->v4CommandsHelp($user); + } elseif (str_starts_with($data, 'opx:')) { + $frame = $this->v4ConfirmAction($user, $data); + } elseif (str_starts_with($data, 'sd:')) { + if (preg_match('/^sd:view:(-?[1-9][0-9]{0,19}):/', $data, $match)) { + $app->adminAccess->require($user, 'subscribers'); + $this->v4ObservePeer($match[1], false); + } + $frame = $this->v4Subscribers()->callback($user, $data); + if (($frame['action'] ?? '') === 'authorize_peer') { + $frame = $this->v4ActionPrompt($user, $frame); + } + } elseif (str_starts_with($data, 'bc:')) { + $frame = $this->v4Broadcast()->callback($user, $data); + $action = $frame['action'] ?? null; + if ($action === 'confirm') { + $this->v3OperatorConfirmBroadcast($query); + $query->answer(''); + return true; + } + if ($action === 'preview') { + $this->v3OperatorPanel($query, 'preview'); + return true; + } + if ($action === 'jobs') { + $frame = $this->v4BroadcastJobs($user, (int)$frame['page']); + } elseif (in_array($action, ['cancel_job', 'cancel_self_destruct'], true)) { + $frame = $this->v4ActionPrompt($user, $frame); + } + } else { + $frame = $this->v4Admin()->callback($user, $data, true); + } + if ($frame === null || !isset($frame['text'], $frame['markup'])) { + throw new \InvalidArgumentException('Unknown manager action.'); + } + $target->sendPrivate($user, $frame['text'], $frame['markup']); + $target->answerCallback((string)$event['query_id'], ''); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $query->answer($this->v3Text($user, 'denied'), true); + } + return true; + } + + private function v4ActionPrompt(string $user, array $action): array + { + $app = $this->v3App(); + $app->adminAccess->require($user, 'broadcast', true); + $app->flows->revokeAction($user, 'operator_action'); + $token = $app->flows->create($user, 'user', null, $app->settings->getUser($user)['revision'], 'operator_action', 600, $action); + $label = match ($action['action']) { + 'authorize_peer' => $this->v3Text($user, 'operator_audience_authorize'), + 'cancel_job', 'cancel_self_destruct' => $this->v3Text($user, 'operator_cancel'), + 'delete_last' => $this->v3Text($user, 'operator_delete_last'), + 'delete_all' => $this->v3Text($user, 'operator_delete_all'), + 'unpin' => $this->v3Text($user, 'operator_unpin'), + default => throw new \InvalidArgumentException('Invalid manager action.'), + }; + return ['text' => '' . $this->v3Text($user, 'confirm') . '' . "\n" . $label . "\n" . htmlspecialchars((string)$action['id'], ENT_QUOTES, 'UTF-8') . '', + 'markup' => ['_' => 'replyInlineMarkup', 'rows' => [ + ['_' => 'keyboardButtonRow', 'buttons' => [[ + '_' => 'keyboardButtonCallback', 'text' => $this->v3Text($user, 'confirm'), + 'data' => 'opx:confirm:' . $token, 'style' => ['_' => 'keyboardButtonStyle', 'bg_primary' => true], + ]]], + ['_' => 'keyboardButtonRow', 'buttons' => [[ + '_' => 'keyboardButtonCallback', 'text' => $this->v3Text($user, 'cancel'), 'data' => 'opx:cancel:' . $token, + 'style' => ['_' => 'keyboardButtonStyle', 'bg_danger' => true], + ]]], + ]]]; + } + + private function v4ConfirmAction(string $user, string $data): array + { + $app = $this->v3App(); + $app->adminAccess->require($user, 'broadcast', true); + if (!preg_match('/^opx:(confirm|cancel):([A-Za-z0-9_-]{24})$/D', $data, $match)) { + throw new \InvalidArgumentException('Invalid confirmation.'); + } + $flow = $app->flows->peek($match[2], $user, 'operator_action'); + if ($flow === null) { + throw new \RuntimeException('Confirmation expired.'); + } + if ($match[1] === 'cancel') { + $app->flows->revoke($match[2], $user, 'operator_action'); + return $this->v4Broadcast()->main($user); + } + $action = $flow['payload']; + $app->flows->confirmAndApply( + $match[2], + $user, + 'operator_action', + static fn(): bool => true, + [], + function (\PDO $pdo) use ($user, $action, $app): void { + $app->adminAccess->require($user, 'broadcast', true); + if (($action['action'] ?? '') === 'authorize_peer') { + $app->subscribers->setBroadcastAuthorizedInTransaction($pdo, (string)$action['id'], (bool)$action['authorized']); + } + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([ + $user, null, 'operator.confirmed.' . $action['action'], Database::encode(['id' => $action['id']]), $app->clock->now()->getTimestamp(), + ]); + } + ); + $app->adminAccess->require($user, 'broadcast', true); + if ($action['action'] === 'authorize_peer') { + return $this->v4Subscribers()->profile($user, (string)$action['id']); + } + $manager = $this->v3OperatorManager($user); + if (in_array($action['action'], ['delete_last', 'delete_all', 'unpin'], true)) { + if (!$app->prioritizePermissions() || Runner::isStopping()) { + throw new \RuntimeException('Broadcast maintenance stopped during shutdown.'); + } + $app->adminAccess->require($user, 'broadcast', true); + $peers = $this->v3KnownPeers(); + match ($action['action']) { + 'delete_last' => $manager->deleteLastBroadcastForAll($peers, (int)$user, 20), + 'delete_all' => $manager->deleteAllBroadcastsForAll($peers, (int)$user, 20), + 'unpin' => $manager->unpinAllMessagesForAll($manager->filterPeers($peers, 'users')['targets'], (int)$user, 20), + }; + return $this->v4Broadcast()->main($user); + } + $ack = $action['action'] === 'cancel_job' + ? $manager->cancelScheduledBroadcast((string)$action['id']) + : $manager->cancelSelfDestructJob((string)$action['id']); + if (!$ack) { + throw new \RuntimeException('The job is no longer pending.'); + } + return $this->v4BroadcastJobs($user); + } + + private function v4BroadcastJobs(string $user, int $page = 0): array + { + $this->v3App()->adminAccess->require($user, 'broadcast'); + $manager = $this->v3OperatorManager($user); + $jobs = []; + foreach ($manager->listScheduledBroadcasts() as $job) { + if ($job['status'] === 'scheduled') { + $jobs[] = ['id' => $job['id'], 'kind' => 'scheduled', 'at' => (int)$job['scheduledAt'], 'targets' => (int)$job['totalUsers']]; + } + } + foreach ($manager->listSelfDestructJobs() as $job) { + if ($job['status'] === 'scheduled') { + $jobs[] = ['id' => $job['id'], 'kind' => 'self_destruct', 'at' => (int)$job['deleteAt'], 'targets' => (int)$job['totalPeers']]; + } + } + usort($jobs, static fn(array $a, array $b): int => $a['at'] <=> $b['at']); + return $this->v4Broadcast()->jobs($user, $jobs, $page); + } + + private function v4AudienceTargets(string $audience): array + { + foreach ($this->v3KnownPeers() as $peer) { + $this->v4ObservePeer((string)$peer, false); + } + return $this->v3App()->subscribers->targets($audience); + } + + private function v4TefillinCallback(ButtonQuery $query, array $event): void + { + $app = $this->v3App(); + try { + if ( + !$query instanceof ChatButtonQuery || $query->chatId >= 0 + || !preg_match('/^tf:([A-Za-z0-9_-]{24})$/D', $query->data, $match) + || Runner::isStopping() + ) { + throw new \InvalidArgumentException('A native group acknowledgement is required.'); + } + $user = (string)$query->userId; + if (!$app->gateway instanceof \Shabbat\Telegram\MessageMaintenanceGateway) { + throw new \RuntimeException('Sender verification is unavailable.'); + } + $isBot = $app->gateway->senderIsBot((string)$query->chatId, $user); + if ($isBot === null) { + throw new \RuntimeException('The callback sender cannot be verified.'); + } + $state = $app->tefillinAcknowledgements->toggle( + $user, + (string)$query->chatId, + $query->messageId, + $match[1], + (string)$event['query_id'], + $isBot, + $query->userId <= 0 + ); + $query->answer(\Shabbat\Scheduling\TefillinAckText::feedback($state['language'], $state['selected'], $state['count'])); + if ($app->gateway instanceof MadelineGateway && !Runner::isStopping()) { + $app->tefillinAcknowledgements->refresh( + (string)$query->chatId, + $query->messageId, + $app->gateway->editGroupMarkup(...) + ); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $query->answer($this->v3Text((string)$query->userId, 'denied'), true); + } + } + + private function v4FlushTefillinAcknowledgements(): void + { + $app = $this->v3App(); + if ($app->gateway instanceof MadelineGateway && !Runner::isStopping()) { + $app->tefillinAcknowledgements->flushPending(function (string $group, int $message, array $markup) use ($app): void { + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Acknowledgement editing stopped during shutdown.')); + } + $app->gateway->editGroupMarkup($group, $message, $markup); + }); + } + } + + private function v4RunBroadcastJobs(): void + { + $app = $this->v3App(); + $owner = $app->adminAccess->ownerIds()[0] ?? null; + if ($owner === null || Runner::isStopping()) { + return; + } + $manager = $this->v3OperatorManager($owner, true); + $app->broadcastJobs->runDue( + $manager->listScheduledBroadcasts(...), + $manager->cancelScheduledBroadcast(...), + $manager->runDueScheduledBroadcasts(...), + $manager->runDueSelfDestructJobs(...), + $app->prioritizePermissions(...), + Runner::isStopping(...), + $manager->listSelfDestructJobs(...), + ); + } +} diff --git a/src/Runtime/Application.php b/src/Runtime/Application.php new file mode 100644 index 0000000..ce23cc4 --- /dev/null +++ b/src/Runtime/Application.php @@ -0,0 +1,844 @@ +|null */ + private ?\Amp\DeferredFuture $tickCompletion = null; + private string $owner; + private ?\Closure $buttonParser; + public function __construct(public readonly \Shabbat\Telegram\TelegramGateway&\Shabbat\UI\UiGateway $gateway, public readonly Clock $clock = new SystemClock(), ?Database $database = null, ?CalendarService $calendar = null, ?GeoNames $geo = null, ?callable $buttonParser = null, ?callable $rpc = null, ?callable $authorizeOperator = null, ?callable $configuredOwners = null) + { + $this->buttonParser = $buttonParser === null ? null : \Closure::fromCallable($buttonParser); + $path = RuntimePaths::resolve(dirname(__DIR__, 2))->databasePath; + if ($database === null && !is_file($path)) { + throw new \RuntimeException('Create the application schema with bin/migrate.php before starting the bot.'); + } + $this->db = $database ?? Database::open($path); + $this->db->assertCurrentSchema(); + $this->adminAccess = new \Shabbat\Storage\AdminAccessRepository($this->db, $clock, $configuredOwners ?? static fn(): array => []); + $this->subscribers = new \Shabbat\Storage\SubscriberDirectory($this->db, $clock); + $this->broadcastJobs = new BroadcastJobs($this->db, $clock, $this->adminAccess); + $operatorAccess = $configuredOwners !== null ? $this->adminAccess->isAdmin(...) : ($authorizeOperator ?? static fn(string $user): bool => false); + $groupOperations = $configuredOwners !== null ? fn(string $user): bool => $this->adminAccess->canMutate($user, 'group_operations') : $operatorAccess; + \Shabbat\Storage\AdminStateFiles::configure($this->db, $clock, dirname(__DIR__, 2)); + $this->settings = new SettingsRepository($this->db, $clock); + $this->flows = new FlowTokenRepository($this->db, $clock); + $this->templates = new TemplateRepository($this->db, $clock); + $this->tefillinAcknowledgements = new \Shabbat\Scheduling\TefillinAcknowledgements($this->db, $this->settings, $clock, $gateway instanceof \Shabbat\Telegram\MessageMaintenanceGateway ? $gateway->selfUserId() : null); + // Additive registration only; rich settings/snapshots need the explicit maintenance importer. + if ($database === null) { + (new LegacyImporter($this->db, $clock))->importGroupListAutomatically(getenv('LEGACY_DATA_PATH') ?: dirname(__DIR__, 2)); + } + $http = new SharedJsonClient(new SqliteJsonClient(new \Shabbat\Http\ProviderCircuitClient(new AmpJsonClient(), $clock), $this->db, $clock), $clock); + $this->calendar = $calendar ?? new CalendarService(new HebcalProvider($http), $clock); + $this->locks = new LockCoordinator($this->db, $clock, $gateway, $this->freshPermissionDesire(...), $this->permissionDesireStillCurrent(...)); + $this->permissionRecovery = new \Shabbat\Scheduling\PermissionRecovery( + $this->db, + $clock, + $gateway, + $this->locks, + $gateway->isGroupAdmin(...), + $this->permissionRecoveryAllowed(...), + $this->permissionRecoveryStillAllowed(...) + ); + $this->ui = new BotController($this->settings, $this->flows, $this->calendar, $gateway, $geo ?? new GeoNames($http, $clock, getenv('GEONAMES_USERNAME') ?: '', getenv('GEONAMES_ENDPOINT') ?: 'https://secure.geonames.org'), $clock, authorizeOperator: $operatorAccess, permissionRecovery: $this->permissionRecovery); + $this->manualGroups = new ManualGroups($this->db, $this->settings, $this->locks, $clock, $groupOperations); + $this->donations = $rpc === null ? null : new Donations($this->db, $clock, $rpc); + $this->donationUi = $this->donations === null ? null : new \Shabbat\UI\DonationController($this->db, $this->settings, $this->flows, $clock, $this->donations, $gateway); + $this->jobs = new JobRepository($this->db, $clock); + $this->deletions = new \Shabbat\Storage\DeletionRepository($this->db, $clock); + $this->cleanup = new MessageCleanup($this->deletions, $this->settings, $gateway, $this->calendar, $clock, Runner::isStopping(...)); + $this->worker = new OutboxWorker($this->jobs, $this->settings, $gateway, $clock, $this->eligible(...), $this->preparePayload(...), $this->recordDelivered(...), $this->dispatchEligible(...)); + $this->planner = new ReminderPlanner(); + $this->owner = bin2hex(random_bytes(16)); + } + public function parseCustomButtons(string $input): array + { + if ($this->buttonParser === null) { + throw new \InvalidArgumentException('Use a JSON array of button rows in this installation.'); + } + $markup = ($this->buttonParser)($input); + $rows = []; + if (!is_array($markup) || ($markup['_'] ?? null) !== 'replyInlineMarkup') { + throw new \InvalidArgumentException('Invalid button text.'); + } + foreach ($markup['rows'] ?? [] as $row) { + $buttons = [];foreach ($row['buttons'] ?? [] as $button) { + $normalized = ['text' => $button['text']]; + if (isset($button['url'])) { + $normalized['url'] = $button['url']; + } elseif (isset($button['data'])) { + $normalized['callback_data'] = $button['data']; + } elseif (isset($button['copy_text'])) { + $normalized['copy_text'] = $button['copy_text']; + } else { + throw new \InvalidArgumentException('Unsupported custom button.'); + } + if (isset($button['style'])) { + $normalized['style'] = $button['style']; + }$buttons[] = $normalized; + }if ($buttons !== []) { + $rows[] = $buttons; + } + } + if ($rows === []) { + throw new \InvalidArgumentException('No valid buttons found.'); + }TemplateRenderer::validateButtons($rows); + return $rows; + } + /** Wait for in-flight permission work before preparing an operator broadcast. */ + public function prioritizePermissions(?\Amp\Cancellation $cancellation = null): bool + { + $cancellation?->throwIfRequested(); + while ($this->tickCompletion !== null) { + if (Runner::isStopping()) { + return false; + } + $this->tickCompletion->getFuture()->await($cancellation); + } + if (Runner::isStopping()) { + return false; + } + $cancellation?->throwIfRequested(); + $this->permissionTick(); + return !Runner::isStopping() && !$this->stopped; + } + + /** Compatibility entry point. Production runs the two independent cron callbacks. */ + public function tick(): void + { + $this->permissionTick(); + $this->notificationTick(); + } + + public function permissionTick(): void + { + if ($this->ticking || $this->isStopping()) { + return; + } + $this->ticking = true; + $started = hrtime(true); + $count = 0; + $completion = new \Amp\DeferredFuture(); + $completion->getFuture()->ignore(); + $this->tickCompletion = $completion; + try { + $cursor = null; + $page = []; + $offset = 0; + $exhausted = false; + $halted = new \Amp\DeferredCancellation(); + // Claim one peer synchronously. This shared cursor never yields, so each + // peer is claimed once while four independent workers keep their slots busy. + $claim = function () use (&$cursor, &$page, &$offset, &$exhausted, $halted, &$count): ?array { + if ($halted->isCancelled() || $this->isStopping()) { + return null; + } + if ($offset >= count($page)) { + if ($exhausted) { + return null; + } + $page = $this->settings->groupsAfter($cursor, 100); + $offset = 0; + $exhausted = count($page) < 100; + if ($page === []) { + return null; + } + } + $group = $page[$offset++]; + $cursor = $group['group_id']; + $count++; + return $group; + }; + $futures = []; + for ($worker = 0; $worker < 4; $worker++) { + $futures[] = \Amp\async(function () use ($claim, $halted): void { + try { + while (($group = $claim()) !== null) { + $this->reconcileGroup($group); + \Amp\delay(0); // Cooperate even when every cached operation completed synchronously. + } + } catch (\Throwable $error) { + $halted->cancel($error); + throw $error; + } + }); + } + // Join every worker even on cancellation. A blocked peer consumes only + // its own slot; other slots continue across page boundaries. + [$errors] = \Amp\Future\awaitAll($futures); + if ($errors !== []) { + throw reset($errors); + } + if ($this->isStopping()) { + return; + } + $this->permissionHeartbeat = $this->clock->now()->getTimestamp(); + $this->cycleMetrics['permissions'] = ['groups' => $count, 'duration_ms' => (int) ((hrtime(true) - $started) / 1000000)]; + $this->writeHeartbeat(); + } catch (\Throwable $error) { + $completion->error($error); + throw $error; + } finally { + $this->ticking = false; + $this->tickCompletion = null; + if (!$completion->isComplete()) { + $completion->complete(); + } + } + } + + private function reconcileGroup(array $group): void + { + if ($this->isStopping()) { + return; + } + try { + $now = $this->clock->now(); + $manual = in_array($group['manual_override_mode'], ['lock', 'open'], true) + && $group['manual_override_until'] > $now->getTimestamp(); + $state = $manual ? null : $this->calendar->snapshotNow($group); + $now = $this->clock->now(); + $event = $state['lock_event'] ?? $state['current_event'] ?? $state['next_event'] ?? null; + $eventKey = $manual ? 'manual:' . $group['revision'] . ':' . $group['manual_override_until'] : ($event['event_key'] ?? 'ordinary'); + $previousLock = $this->locks->state($group['group_id']); + $result = $this->locks->reconcile($group['group_id'], $eventKey, ManualGroups::desired($group, $state['should_group_be_locked'] ?? false, $now->getTimestamp())); + if ($result['changed']) { + $kind = $result['status'] === 'locked' ? 'close' : 'open'; + $stamp = $this->clock->now()->getTimestamp(); + // Rendering may fetch data. Never delay the next permission change for it. + $this->jobs->enqueue($group['group_id'], $kind, $kind . ':' . ($kind === 'open' ? ($previousLock['event_key'] ?? 'recovery') : ($event['event_key'] ?? $now->format('Y-m-d'))), $stamp, $stamp + 3600, $group['revision'], ['text' => ''], 1); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->recordError($group['group_id'], 'scheduler', $error); + } + } + + public function notificationTick(): void + { + if ($this->notifying || $this->isStopping()) { + return; + } + $this->notifying = true; + $started = hrtime(true); + $planned = 0; + $processed = 0; + try { + $groups = $this->settings->groupsAfter($this->planningCursor, 20); + if ($groups === [] && $this->planningCursor !== null) { + $this->planningCursor = null; + $groups = $this->settings->groupsAfter(null, 20); + } + foreach ($groups as $group) { + if ($this->isStopping() || ($planned > 0 && hrtime(true) - $started >= 2000000000)) { + break; + } + $this->planningCursor = $group['group_id']; + $planned++; + if (!$this->needsPlanning($group)) { + continue; + } + try { + $state = $this->calendar->snapshotNow($group); + $this->plan($group, $state, new \DateTimeImmutable('@' . $state['evaluated_at'])); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->recordError($group['group_id'], 'planning', $error); + } + } + $this->replayDeliveryBookkeeping(); + $deliveryStarted = hrtime(true); + for ($count = 0; $count < 20 && !$this->stopped && !Runner::isStopping(); $count++) { + if ($count > 0 && hrtime(true) - $deliveryStarted >= 2000000000) { + break; + } + if ($this->worker->runOnce($this->owner) === null) { + break; + } + $processed++; + } + for ($count = 0; $count < 20 && !$this->stopped && !Runner::isStopping(); $count++) { + if ($count > 0 && hrtime(true) - $deliveryStarted >= 3000000000) { + break; + } + if ($this->cleanup->runOnce($this->owner) === null) { + break; + } + } + $now = $this->clock->now()->getTimestamp(); + if ($now < $this->lastMaintenance || $now - $this->lastMaintenance >= 60) { + $this->jobs->maintain(90, 200); + $this->lastMaintenance = $now; + } + $this->notificationHeartbeat = $now; + $this->cycleMetrics['notifications'] = ['groups_planned' => $planned, 'jobs_processed' => $processed, 'duration_ms' => (int) ((hrtime(true) - $started) / 1000000)]; + $this->writeHeartbeat(); + } finally { + $this->notifying = false; + } + } + + private function needsPlanning(array $group): bool + { + return $group['zmanim_enabled'] || $group['daf_enabled'] || $group['tefillin_enabled'] + || $group['omer_enabled'] || $group['times_alert_enabled'] + || ($group['mode'] === 'messages' && ($group['close_message_enabled'] || $group['open_message_enabled'])); + } + + private function writeHeartbeat(): void + { + $heartbeat = RuntimePaths::resolve(dirname(__DIR__, 2))->heartbeatPath; + $data = ['timestamp' => $this->permissionHeartbeat, 'permission_timestamp' => $this->permissionHeartbeat, + 'notification_timestamp' => $this->notificationHeartbeat, 'cycles' => $this->cycleMetrics, + 'version' => Version::current(), 'commit' => Version::commit()]; + $temporary = $heartbeat . '.' . $this->owner . '.tmp'; + if (file_put_contents($temporary, json_encode($data, JSON_THROW_ON_ERROR), LOCK_EX) === false || !rename($temporary, $heartbeat)) { + if (is_file($temporary)) { + unlink($temporary); + } + throw new \RuntimeException('Cannot persist scheduler heartbeat.'); + } + } + private function plan(array $g, array $state, \DateTimeImmutable $now): void + { + $zone = new \DateTimeZone($g['timezone_id']); + $local = $now->setTimezone($zone); + foreach (($g['zmanim_enabled'] || $g['daf_enabled'] || $g['tefillin_enabled'] || $g['omer_enabled']) ? [-1,0,1] : [] as $offset) { + $date = $local->modify('+' . $offset . ' day')->format('Y-m-d'); + $sun = $this->calendar->zmanim($g, $date)['times']; + $midday = new \DateTimeImmutable($date . ' 12:00', $zone); + $dayState = $this->calendar->snapshot($g, $midday); + if ($offset >= 0) { + foreach (['zmanim','daf'] as $kind) { + $rule = ['enabled' => $g[$kind . '_enabled'],'time' => $g[$kind . '_time'],'weekdays' => $this->weekdays($g[$kind . '_days']),'quiet_start' => $g['quiet_start'],'quiet_end' => $g['quiet_end']]; + foreach ($this->planner->daily($g, $rule, $date, $kind, $dayState) as $job) { + $this->enqueue($g, $job); + } + } + $rule = ['enabled' => $g['tefillin_enabled'],'weekdays' => $this->weekdays($g['tefillin_days']),'times' => $g['tefillin_fixed_times'],'interval_minutes' => $g['tefillin_interval_minutes'] ?? 0,'window_start' => $g['tefillin_window_start'],'window_end' => $g['tefillin_window_end'],'before_sunset' => $g['tefillin_sunset_offsets'],'max_per_day' => $g['tefillin_max_per_day'],'min_gap_minutes' => $g['tefillin_min_spacing_minutes'],'quiet_start' => $g['quiet_start'],'quiet_end' => $g['quiet_end'],'pause_date' => $g['tefillin_suspended_date'],'chol_hamoed' => $g['tefillin_chol_hamoed'],'tisha_bav' => $g['tefillin_tisha_bav_policy'],'sunset_safety_minutes' => $g['tefillin_sunset_safety_minutes']]; + $tefillin = $this->planner->tefillin($g, $rule, $date, $sun, $dayState); + // At most one still-valid late reminder after a restart; never replay an entire missed series. + $past = array_values(array_filter($tefillin, fn(array $j): bool=>$j['run_at'] < $now->getTimestamp())); + $lastPast = $past === [] ? null : end($past)['event_key']; + foreach ($tefillin as $job) { + if ($job['run_at'] < $now->getTimestamp() && $job['event_key'] !== $lastPast) { + continue; + }$this->enqueue($g, $job); + } + } + $omerRule = ['enabled' => $g['omer_enabled'],'schedule' => $g['omer_mode'] === 'fixed' ? 'fixed' : 'relative','time' => $g['omer_time'],'after_tzeit_minutes' => $g['omer_after_nightfall_minutes'],'tzeit_method' => $g['omer_nightfall_method'],'tzeit_value' => $g['omer_nightfall_value'],'early_policy' => $g['omer_early_policy'] === 'skip' ? 'skip' : 'delay','after_open_minutes' => $g['omer_after_open_minutes'],'quiet_start' => $g['quiet_start'],'quiet_end' => $g['quiet_end']]; + foreach ($this->planner->omer($g, $omerRule, $date, $sun, $dayState) as $job) { + $this->enqueue($g, $job); + } + } + if ($g['omer_enabled'] && $g['omer_day_completion']) { + $o = $this->calendar->omer($g, $now); + $date = $local->format('Y-m-d'); + $at = LocalTime::resolve($date, '10:00', $g['timezone_id']); + if ($at !== null && $o['season']) { + $sun = $this->calendar->zmanim($g, $date)['times']; + if (isset($sun['sunset']) && $at < $sun['sunset']) { + $this->enqueue($g, ['kind' => 'omer_day','run_at' => $at,'expires_at' => $sun['sunset'],'event_key' => 'omer-day:' . $date]); + } + } + } + if ($g['times_alert_enabled']) { + foreach ($state['operational_events'] as $event) { + if ($event['close_at'] < $now->getTimestamp()) { + continue; + }$date = (new \DateTimeImmutable('@' . $event['close_at']))->setTimezone($zone)->format('Y-m-d'); + $at = ($g['times_alert_mode'] ?? 'local_time') === 'before_close' ? $event['close_at'] - (int)$g['times_alert_before_minutes'] * 60 : LocalTime::resolve($date, $g['times_alert_time'], $g['timezone_id']); + if ($at !== null && $at < $event['close_at']) { + $this->enqueue($g, ['kind' => 'times_alert','run_at' => $at,'expires_at' => $event['close_at'],'event_key' => 'times:' . $event['event_key']]); + }break; + } + } + // Messages-only mode still announces transitions; it does not claim permission changes. + if ($g['mode'] === 'messages') { + foreach ($state['operational_events'] as $event) { + foreach (['close' => 'close_at','open' => 'open_at'] as $kind => $field) { + $this->enqueue($g, ['kind' => $kind,'run_at' => $event[$field],'expires_at' => $event[$field] + 3600,'event_key' => $kind . ':' . $event['event_key']]); + } + } + } + } + private function enqueue(array $g, array $job): void + { + if ($job['expires_at'] <= $this->clock->now()->getTimestamp()) { + return; + } + $payload = ['text' => '']; + $this->jobs->enqueue($g['group_id'], $job['kind'], $job['event_key'], $job['run_at'], $job['expires_at'], $g['revision'], $payload, in_array($job['kind'], ['close', 'open'], true) ? 1 : ($job['kind'] === 'tefillin' ? 50 : 100)); + } + public function payload(array $g, string $kind, \DateTimeImmutable $instant, ?array $templateOverride = null): array + { + $zone = new \DateTimeZone($g['timezone_id']); + $date = $instant->setTimezone($zone)->format('Y-m-d'); + $lang = new Lang(); + $t = fn(string $key, array $values = []): string=>$lang->text($g['language'], $key, $values); + $text = match ($kind) { + 'close'=>$t('group_close_notice'),'open'=>$t('group_open_notice'),default=>'' + }; + if ($kind === 'zmanim') { + $text = $this->ui->renderView('0', $g, 'zmanim', $date); + } + if ($kind === 'daf') { + $d = $this->calendar->daf($g, $date); + $text = htmlspecialchars(($g['language'] === 'he' ? $d['hebrew'] : $d['title']) . ' - ' . $date, ENT_QUOTES, 'UTF-8') . "\n" . $d['link']; + } + if ($kind === 'tefillin') { + $sun = $this->calendar->zmanim($g, $date)['times']; + $sunset = isset($sun['sunset']) ? (new \DateTimeImmutable('@' . $sun['sunset']))->setTimezone($zone)->format('H:i') : '-'; + $text = $t('tefillin_reminder', ['location' => $g['location_name'],'sunset' => $sunset]); + } + if (in_array($kind, ['omer','omer_day'], true)) { + $o = $this->calendar->omer($g, $instant); + if ($o['season']) { + $nightfall = $o['nightfall'] === null ? '-' : (new \DateTimeImmutable('@' . $o['nightfall']))->setTimezone($zone)->format('d/m/Y H:i'); + $content = OmerText::render($o['day'], $g['omer_nusach'], $g['omer_format'] === 'full', $kind === 'omer_day', $g['language']) . "\n" . $t('omer_nightfall_label') . ': ' . $nightfall; + $text = $t('date_view', ['module' => $t('omer'),'date' => $o['date'],'location' => $g['location_name'],'timezone' => $g['timezone_id'],'hdate' => $o['hdate'] ?? '-','content' => $content]); + // The shared frame escapes dynamic values; the bundled traditional text contains no HTML markup. + } else { + $text = $t('omer_inactive'); + } + } + if ($kind === 'times_alert') { + $text = $this->formatShabbat($g, $this->calendar->snapshot($g, $instant)); + } + $templateKind = match ($kind) { + 'omer_day'=>'omer',default=>$kind + }; + $template = $kind === 'omer_day' ? null : ($templateOverride ?? $this->templates->get($g['group_id'], $templateKind)); + $payload = ['text' => $text,'topic_id' => $g['target_topic_id']]; + $payload['buttons_mode'] = $template['buttons_mode'] ?? 'inherit'; + if ($template !== null) { + // Editing only a keyboard preserves the generated, current calendar content. + if ($template['text'] !== '' || $template['media'] !== null) { + $payload = array_intersect_key($template, array_flip(['text','entities','media'])) + $payload; + } + if ($payload['buttons_mode'] === 'custom') { + $payload['buttons'] = $template['buttons']; + } + } + if ($payload['buttons_mode'] === 'inherit') { + $payload['buttons'] = \Shabbat\Content\GroupMessageButtons::defaults($kind, $g['language']); + } + $inline = $g[$kind . '_template'] ?? ''; + if ($inline !== '' && $template === null) { + $payload['text'] = $inline; + } + if (str_contains($payload['text'], '{{')) { + $z = $this->calendar->zmanim($g, $date); + $sun = $z['times']; + $state = $this->calendar->snapshot($g, $instant); + $event = $state['current_event'] ?? $state['next_event']; + $format = fn(?int $at): string=>$at === null ? '-' : (new \DateTimeImmutable('@' . $at))->setTimezone($zone)->format('H:i'); + $values = ['location' => $g['location_name'],'date' => $date,'hdate' => $z['hdate'] ?? '-','source' => 'Hebcal','daf' => $text,'omer' => $text,'sunset' => $format($sun['sunset'] ?? null),'remaining' => isset($sun['sunset']) ? (string)max(0, (int)ceil(($sun['sunset'] - $instant->getTimestamp()) / 60)) : '-','candles' => $format($event['candles_at'] ?? null),'havdalah' => $format($event['havdalah_at'] ?? null),'close' => $format($event['close_at'] ?? null),'open' => $format($event['open_at'] ?? null)]; + $payload = (new TemplateRenderer())->renderPayload($payload, $values); + } + return $payload; + } + /** Keyboard-only edits do not replace generated calendar text. */ + public function groupMessageButtons(array $group, string $kind): array + { + $template = $this->templates->get($group['group_id'], $kind); + return match ($template['buttons_mode'] ?? 'inherit') { + 'inherit' => \Shabbat\Content\GroupMessageButtons::defaults($kind, $group['language']), + 'custom' => $template['buttons'], + default => [], + }; + } + private function preparePayload(array $job, array $g, \DateTimeImmutable $now): array + { + // A delayed announcement still names its scheduled sequence, not the next unrelated holiday. + $instant = $job['module'] === 'times_alert' ? new \DateTimeImmutable('@' . $job['run_at']) : $now; + $payload = $this->payload($g, $job['module'], $instant); + return $this->tefillinAcknowledgements->preparePayload($job, $g, $instant, $payload); + } + public function incomingGroupMessage(string $group, int $messageId, int $sentAt, string $sender, bool $outgoing = false): bool + { + try { + $queued = $this->cleanup->receive($group, $messageId, $sentAt, $sender, $outgoing); + if ($queued) { + $this->cleanup->runOnce($this->owner); + } + return $queued; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->recordError($group, 'cleanup', $error); + return false; + } + } + private function recordDelivered(array $job, array $response): void + { + if (!$this->gateway instanceof \Shabbat\Telegram\MessageMaintenanceGateway) { + $this->jobs->markDeliveryProcessed($job['job_id']); + return; + } + $settings = $this->settings->getGroup($job['group_id']); + $ids = $job['module'] === 'tefillin' ? $this->gateway->extractSentMessageIds($response, $job['random_id'], $job['group_id']) : []; + if ($job['module'] === 'tefillin' && $ids === []) { + // Preserve the prior pointer when Telegram did not supply verifiable sent message IDs. + $this->jobs->markDeliveryProcessed($job['job_id']); + $this->recordError($job['group_id'], 'delivery_cleanup', new \RuntimeException('Sent message identity unavailable; previous reminder preserved.')); + return; + } + if ($job['module'] === 'tefillin') { + $this->tefillinAcknowledgements->registerDelivery($job['job_id'], $ids); + } + $this->deletions->recordSentJobDelivery($job['job_id'], $ids, (bool)($settings['tefillin_delete_previous'] ?? false)); + } + private function replayDeliveryBookkeeping(): void + { + foreach ($this->jobs->pendingDeliveryBookkeeping(20) as $job) { + if (Runner::isStopping()) { + return; + } + try { + $this->recordDelivered($job, $job['response']); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->recordError($job['group_id'], 'delivery_cleanup', $error); + } + } + } + /** Recovery never bypasses a religious/operational interval, including an operator-open override. */ + private function permissionRecoveryAllowed(string $group): bool + { + unset($this->recoveryWindows[$group]); + for ($pass = 0; $pass < 2; $pass++) { + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission recovery stopped during shutdown.')); + } + $settings = $this->settings->getGroup($group) ?? throw new \RuntimeException('Group disappeared before recovery.'); + $instant = $this->clock->now(); + if ($settings['manual_override_mode'] === 'lock' && $settings['manual_override_until'] > $instant->getTimestamp()) { + return false; + } + $state = $this->calendar->snapshotNow($settings); + $instant = new \DateTimeImmutable('@' . $state['evaluated_at']); + $latest = $this->settings->getGroup($group) ?? throw new \RuntimeException('Group disappeared during recovery.'); + if ($latest['revision'] !== $settings['revision'] || $this->clock->now()->getTimestamp() !== $instant->getTimestamp()) { + continue; + } + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission recovery stopped during shutdown.')); + } + if ($state['quiet_now'] !== false || $state['should_group_be_locked'] !== false) { + return false; + } + $stamp = $instant->getTimestamp(); + $coverageEnd = new \DateTimeImmutable($state['coverage']['end'] . ' 00:00:00', new \DateTimeZone($settings['timezone_id'])); + $until = min($stamp + 120, $coverageEnd->getTimestamp()); + foreach (['events' => 'start_at', 'operational_events' => 'close_at'] as $collection => $boundary) { + foreach ($state[$collection] as $event) { + if ($event[$boundary] > $stamp) { + $until = min($until, $event[$boundary]); + } + } + } + if ($until <= $stamp) { + return false; + } + if (count($this->recoveryWindows) >= 256) { + unset($this->recoveryWindows[array_key_first($this->recoveryWindows)]); + } + $this->recoveryWindows[$group] = ['settings' => hash('sha256', Database::encode($latest)), 'checked_at' => $stamp, 'until' => $until]; + return true; + } + throw new \RuntimeException('Recovery conditions changed; refresh the panel.'); + } + /** Synchronous fence after the final Telegram read; never fetches or suspends. */ + private function permissionRecoveryStillAllowed(string $group): bool + { + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission recovery stopped during shutdown.')); + } + $window = $this->recoveryWindows[$group] ?? null; + $now = $this->clock->now()->getTimestamp(); + if ($window === null || $now < $window['checked_at'] || $now >= $window['until']) { + return false; + } + $settings = $this->settings->getGroup($group); + return $settings !== null && hash_equals($window['settings'], hash('sha256', Database::encode($settings))) + && !($settings['manual_override_mode'] === 'lock' && $settings['manual_override_until'] > $now); + } + private function freshPermissionDesire(string $group): bool + { + unset($this->permissionWindows[$group]); + for ($pass = 0; $pass < 2; $pass++) { + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission mutation stopped during shutdown.')); + } + $settings = $this->settings->getGroup($group) ?? throw new \RuntimeException('Group disappeared before permission mutation.'); + $instant = $this->clock->now(); + // An active operator override must not wait for the calendar provider. + if ( + in_array($settings['manual_override_mode'], ['lock', 'open'], true) + && $settings['manual_override_until'] > $instant->getTimestamp() + ) { + $desired = $settings['active'] && $settings['manual_override_mode'] === 'lock'; + $this->rememberPermissionDesire($group, $settings, $desired, $instant->getTimestamp(), $settings['manual_override_until']); + return $desired; + } + $state = $this->calendar->snapshotNow($settings); + $instant = new \DateTimeImmutable('@' . $state['evaluated_at']); + $latest = $this->settings->getGroup($group) ?? throw new \RuntimeException('Group disappeared during permission calculation.'); + $current = $this->clock->now(); + if ($latest['revision'] !== $settings['revision'] || $current->getTimestamp() !== $instant->getTimestamp()) { + continue; + } + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission mutation stopped during shutdown.')); + } + $stamp = $current->getTimestamp(); + $coverageEnd = new \DateTimeImmutable($state['coverage']['end'] . ' 00:00:00', new \DateTimeZone($settings['timezone_id'])); + $until = $coverageEnd->getTimestamp(); + foreach ($state['operational_events'] as $event) { + foreach (['close_at','open_at'] as $boundary) { + if ($event[$boundary] > $stamp) { + $until = min($until, $event[$boundary]); + } + } + } + $desired = $latest['active'] && ManualGroups::desired($latest, $state['should_group_be_locked'], $stamp); + $this->rememberPermissionDesire($group, $latest, $desired, $stamp, $until); + return $desired; + } + throw new \RuntimeException('Permission desire changed during provider work; defer to a fresh cycle.'); + } + + private function rememberPermissionDesire(string $group, array $settings, bool $desired, int $checkedAt, int $until): void + { + if (count($this->permissionWindows) >= 256) { + unset($this->permissionWindows[array_key_first($this->permissionWindows)]); + } + $this->permissionWindows[$group] = ['settings' => hash('sha256', Database::encode($settings)), + 'desired' => $desired,'checked_at' => $checkedAt,'until' => min($checkedAt + 120, $until)]; + } + /** Synchronous local fence between the final Telegram read and the mutation. */ + private function permissionDesireStillCurrent(string $group, bool $expected): bool + { + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Permission mutation stopped during shutdown.')); + } + $window = $this->permissionWindows[$group] ?? null; + $now = $this->clock->now()->getTimestamp(); + if ($window === null || $now < $window['checked_at'] || $now >= $window['until'] || $window['desired'] !== $expected) { + return false; + } + $settings = $this->settings->getGroup($group); + return $settings !== null && hash_equals($window['settings'], hash('sha256', Database::encode($settings))); + } + + private function eligible(array $job, array $g, \DateTimeImmutable $now): bool + { + unset($this->dispatchWindows[$job['job_id']]); + if ($this->isStopping()) { + return false; + } + $state = $this->calendar->snapshotNow($g); + $stamp = $state['evaluated_at']; + $now = new \DateTimeImmutable('@' . $stamp); + $kind = $job['module']; + $transition = in_array($kind, ['close', 'open'], true); + if (!$transition) { + $lock = $this->locks->state($g['group_id']); + if ($lock !== null && $lock['snapshot_json'] !== null && !in_array($lock['status'], ['restored', 'prelocked'], true)) { + throw new \Shabbat\Scheduling\DeliveryDeferredException('Waiting for restoration of bot-owned permissions.', max($stamp + 60, (int) ($lock['retry_at'] ?? 0))); + } + if ($kind === 'omer' && $lock !== null && $lock['status'] === 'restored' && $lock['restored_at'] !== null) { + $afterOpen = (int) $lock['restored_at'] + $g['omer_after_open_minutes'] * 60; + if ($stamp < $afterOpen) { + throw new \Shabbat\Scheduling\DeliveryDeferredException('Waiting for configured Omer gap after actual restoration.', $afterOpen); + } + } + } + $manualClosed = ($g['manual_override_mode'] ?? 'none') === 'lock' && ($g['manual_override_until'] ?? 0) > $stamp; + $allowed = match ($kind) { + 'close' => $state['quiet_now'] || $manualClosed, + default => !$state['quiet_now'] && !$manualClosed, + }; + if (!$allowed) { + return false; + } + $zone = $g['timezone_id']; + $quietStart = $g['quiet_start'] ?? null; + $quietEnd = $g['quiet_end'] ?? null; + if (!$transition && \Shabbat\Scheduling\QuietHours::contains($stamp, $zone, $quietStart, $quietEnd)) { + $next = \Shabbat\Scheduling\QuietHours::nextBoundary($stamp, $zone, $quietStart, $quietEnd); + throw new \Shabbat\Scheduling\DeliveryDeferredException('Waiting for local quiet hours to end.', $next ?? $stamp + 3600); + } + $until = min((int) $job['expires_at'], $stamp + 120); + $local = $now->setTimezone(new \DateTimeZone($zone)); + $until = min($until, $local->modify('tomorrow')->setTime(0, 0)->getTimestamp()); + if (($g['manual_override_until'] ?? 0) > $stamp) { + $until = min($until, $g['manual_override_until']); + } + foreach (['events' => ['start_at', 'end_at'], 'operational_events' => ['close_at', 'open_at']] as $collection => $fields) { + foreach ($state[$collection] as $event) { + foreach ($fields as $field) { + if ($event[$field] > $stamp) { + $until = min($until, $event[$field]); + } + } + } + } + if (!$transition) { + $quietBoundary = \Shabbat\Scheduling\QuietHours::nextBoundary($stamp, $zone, $quietStart, $quietEnd); + $until = min($until, $quietBoundary ?? $until); + } + if ($kind === 'omer' || $kind === 'omer_day') { + $omer = $this->calendar->omer($g, $now); + if ($kind === 'omer_day') { + if (!$g['omer_day_completion'] || !$omer['day_completion']) { + return false; + } + $until = min($until, $omer['sunset']); + } else { + if (!$omer['eligible'] || $omer['expires_at'] === null) { + return false; + } + $until = min($until, $omer['expires_at']); + } + } + if ($kind === 'tefillin') { + $sun = $this->calendar->zmanim($g, $local->format('Y-m-d'))['times']; + $windowStart = LocalTime::resolve($local->format('Y-m-d'), $g['tefillin_window_start'], $zone); + $windowEnd = LocalTime::resolve($local->format('Y-m-d'), $g['tefillin_window_end'], $zone); + if ( + !isset($sun['sunset'], $sun['misheyakir']) || $windowStart === null || $windowEnd === null + || $stamp < max($sun['misheyakir'], $windowStart) + || !in_array((int) $local->format('w'), $g['tefillin_days'], true) + || $g['tefillin_suspended_date'] === $local->format('Y-m-d') + || ($state['is_chol_hamoed'] && !$g['tefillin_chol_hamoed']) + ) { + return false; + } + if ($state['is_tisha_bav'] && ($g['tefillin_tisha_bav_policy'] !== 'mincha' || !isset($sun['minchaGedola']) || $stamp < $sun['minchaGedola'])) { + return false; + } + $until = min($until, $sun['sunset'] - $g['tefillin_sunset_safety_minutes'] * 60, $windowEnd); + } + if (count($this->dispatchWindows) >= 512) { + unset($this->dispatchWindows[array_key_first($this->dispatchWindows)]); + } + $this->dispatchWindows[$job['job_id']] = ['settings' => hash('sha256', Database::encode($g)), 'checked_at' => $stamp, 'until' => $until]; + // Additional provider work above may have crossed a boundary. Do not trust the + // captured timestamp again, even before the gateway performs its own preflight. + return $this->dispatchEligible($job, $g, $this->clock->now()); + } + + /** Synchronous, repeatable evidence check immediately before the transport call. */ + private function dispatchEligible(array $job, array $settings, \DateTimeImmutable $now): bool + { + $window = $this->dispatchWindows[$job['job_id']] ?? null; + $stamp = $now->getTimestamp(); + if ($this->isStopping() || $window === null || $stamp < $window['checked_at'] || $stamp >= $window['until']) { + return false; + } + if (!hash_equals($window['settings'], hash('sha256', Database::encode($settings)))) { + return false; + } + if (!in_array($job['module'], ['close', 'open'], true)) { + if (\Shabbat\Scheduling\QuietHours::contains($stamp, $settings['timezone_id'], $settings['quiet_start'] ?? null, $settings['quiet_end'] ?? null)) { + return false; + } + $lock = $this->locks->state($settings['group_id']); + if ($lock !== null && $lock['snapshot_json'] !== null && !in_array($lock['status'], ['restored', 'prelocked'], true)) { + return false; + } if ($job['module'] === 'omer' && $lock !== null && $lock['status'] === 'restored' && $lock['restored_at'] !== null) { + $afterOpen = (int) $lock['restored_at'] + $settings['omer_after_open_minutes'] * 60; + if ($stamp < $afterOpen) { + throw new \Shabbat\Scheduling\DeliveryDeferredException('Waiting for updated actual restoration gap.', $afterOpen); + } + } + } + return true; + } + public function formatShabbat(array $g, array $s): string + { + return (new ShabbatTimesFormatter())->overview($g, $s)['text']; + } + public function formatZmanim(array $g, array $z): string + { + return $this->ui->renderView('0', $g, 'zmanim', $z['date']); + } + private function weekdays(array $days): array + { + return array_map(fn(int $day): int=>$day === 0 ? 7 : $day, $days); + } + private function recordError(string $group, string $action, \Throwable $error): void + { + $latest = $this->db->pdo()->prepare('SELECT created_at FROM audit_log WHERE group_id=? AND action=? ORDER BY created_at DESC LIMIT 1'); + $latest->execute([$group,$action . '.error']); + if (($latest->fetchColumn() ?: 0) > $this->clock->now()->getTimestamp() - 300) { + return; + } + $this->db->pdo()->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(NULL,?,?,?,?)')->execute([$group,$action . '.error',json_encode(['type' => get_class($error)], JSON_THROW_ON_ERROR),$this->clock->now()->getTimestamp()]); + } + /** @phpstan-impure Shutdown state can change while another Amp operation is suspended. */ + private function isStopping(): bool + { + return $this->stopped || Runner::isStopping(); + } + public function stop(): void + { + $this->stopped = true; + $this->broadcastJobs->stop(); + $this->worker->stop(); + $this->cleanup->stop(); + } +} diff --git a/src/Runtime/BotIntegration.php b/src/Runtime/BotIntegration.php new file mode 100644 index 0000000..ae8a1c7 --- /dev/null +++ b/src/Runtime/BotIntegration.php @@ -0,0 +1,702 @@ +stop(); + } + RuntimeClientPool::release($this); + } + #[\danog\MadelineProto\EventHandler\Filter\FilterCommand('restart')] + public function gracefulRestart(Incoming&PrivateMessage&\danog\MadelineProto\EventHandler\SimpleFilter\FromAdmin $message): void + { + if (!$this->v3App()->adminAccess->isOwner((string)$message->senderId) || Runner::isStopping()) { + return; + } + Runner::restart( + fn() => $message->reply($this->v3Text((string)$message->senderId, 'restart_requested'), cancellation: new \Amp\TimeoutCancellation(3)), + $this->restart(...) + ); + } + private function v3App(): Application + { + self::$applications ??= new \WeakMap(); + if (!isset(self::$applications[$this])) { + $app = new Application(new MadelineGateway($this, \Shabbat\UI\Presentation::enabledFromEnvironment()), buttonParser:$this->parseButtons(...), rpc:$this->v3Rpc(...), configuredOwners:$this->v3ConfiguredOwners(...)); + self::$applications[$this] = $app; + $app->adminAccess->importLegacyOnce($this->getAdminIds()); + } + return self::$applications[$this]; + } + private function v3ConfiguredOwners(): array + { + // SDK 8.7 resolves configured report-peer usernames and includes only user IDs. + // Its getAdminIds() is independent of our removable SQLite administrators. + return $this->getAdminIds(); + } + private function v3Query(CallbackQuery $query, ?string $data = null): array + { + return ['user_id' => (string)$query->userId,'data' => $data ?? ($query instanceof ButtonQuery ? $query->data : ''),'query_id' => (string)$query->queryId,'message_id' => $query instanceof ChatButtonQuery ? $query->messageId : 0,'chat_id' => $query instanceof ChatButtonQuery ? (string)$query->chatId : null,'inline_selection' => $query instanceof InlineButtonQuery,'private' => $query instanceof ChatButtonQuery && (string)$query->chatId === (string)$query->userId]; + } + private function v3Message(Message $message, ?string $text = null): void + { + $app = $this->v3App(); + $private = $message instanceof PrivateMessage; + $text ??= $message->message; + $user = (string)$message->senderId; + $group = (string)$message->chatId; + if (!$private && $user === $group) { + if (preg_match('/^\/settings(?:@\w+)?$/i', $text)) { + $locale = $app->settings->getGroup($group)['language'] ?? 'he'; + $lang = new Lang(); + $message->reply($lang->text($locale, 'permission_required'), replyMarkup:['inline_keyboard' => [[['text' => $lang->text($locale, 'command_settings'),'url' => 'https://t.me/' . $app->gateway->botUsername() . '?start=g_' . $group]]]]); + }return; + } + $refresh = null; + if (preg_match('/^\/settings(?:@\\w+)?(?:\\s+(-?[0-9]+))?$/i', $text, $selection)) { + $refresh = $private ? ($selection[1] ?? null) : $group; + } elseif ($private && preg_match('/^\/start(?:@\\w+)?\\s+g_(-?[0-9]+)$/i', $text, $selection)) { + $refresh = $selection[1]; + } + if ($refresh !== null) { + try { + GroupRegistration::refresh($app, $user, $refresh, $this->getInfo(...)); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + // The settings UI will show the existing permission/error path. + } + } + $entities = array_map(static fn($entity)=>is_array($entity) ? $entity : $entity->toMTProto(), $message->entities ?? []); + $event = ['user_id' => $user,'text' => $text,'private' => $private,'group_id' => $private ? null : $group,'message_id' => $message->id,'entities' => $entities,'media' => isset($message->media->botApiFileId) ? ['bot_api_file_id' => $message->media->botApiFileId] : null]; + $app->settings->getUser($user); + $this->v4ObserveMessage($message); + if ($this->v4HandleMessage($message, $event)) { + return; + } + if ((new TemplateEditor($app))->message($event)) { + return; + } + if (preg_match('/^\/(source|license|version)(?:@\w+)?$/i', $text, $m)) { + $app->gateway->send($group, ['text' => $this->v3Source($m[1], $user, $this->replyScopeLanguage($message))], (string)random_int(1, PHP_INT_MAX)); + return; + } + if (!$private && preg_match('/^\/(shabat|shabbat|zmanim|daf|omer)(?:@\w+)?(?:\s+(\d{4}-\d{2}-\d{2}))?$/i', $text, $m)) { + $g = $app->settings->getGroup($group); + if ($g === null) { + $message->reply($this->v3Text($user, 'permission_required') . "\n/add โ€” " . $this->v3Text($user, 'add')); + return; + } + $date = $m[2] ?? $app->clock->now()->setTimezone(new \DateTimeZone($g['timezone_id']))->format('Y-m-d'); + try { + $module = in_array(strtolower($m[1]), ['shabat','shabbat'], true) ? 'shabbat' : strtolower($m[1]); + $output = $app->ui->renderView($user, $g, $module, $date); + $buttonKind = $module === 'shabbat' ? 'times_alert' : $module; + $app->gateway->send($group, ['text' => $output, 'buttons' => $app->groupMessageButtons($g, $buttonKind)], (string)random_int(1, PHP_INT_MAX)); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $message->reply($this->v3Text($user, 'provider_unavailable')); + }return; + } + $app->ui->handleMessage($event); + } + private function v3Rpc(string $method, array $parameters): mixed + { + [$namespace,$name] = explode('.', $method, 2); + if (!in_array($method, ['payments.exportInvoice','messages.setBotPrecheckoutResults','bots.setBotCommands'], true)) { + throw new \InvalidArgumentException('Unsupported service RPC.'); + } + if (array_key_exists('query_id', $parameters)) { + $parameters['query_id'] = TelegramInt64::fromDecimal($parameters['query_id']); + } + return $this->{$namespace}->{$name}($parameters); + } + private function v3DonationMenu(string $user, ?\Shabbat\UI\UiGateway $ui = null): void + { + $app = $this->v3App(); + $app->flows->revokeAction($user, 'settings_wizard'); + $app->flows->revokeAction($user, 'template_input'); + $app->flows->revokeAction($user, 'location_input'); + if ($app->donationUi === null) { + throw new \RuntimeException('Payment service unavailable.'); + } + $app->donationUi->menu($user, $ui); + } + private function v3Text(string $user, string $key, array $values = []): string + { + return (new Lang())->text($this->v3App()->settings->getUser($user)['language'], $key, $values); + } + private function v3SetCommands(Message $message): void + { + $user = (string)$message->senderId; + if (!$this->v3App()->adminAccess->canMutate($user, 'commands')) { + $message->reply($this->v3Text($user, 'commands_forbidden')); + return; + } + $parts = preg_split('/\s+/', trim($message->message)); + $language = strtolower($parts[1] ?? 'all'); + $scope = strtolower($parts[2] ?? 'all'); + $preview = ($parts[3] ?? '') === 'preview'; + try { + $service = new CommandMenus( + new Lang(), + $this->v3Rpc(...), + fn(string $actor): bool => $this->v3App()->adminAccess->canMutate($actor, 'commands'), + function (string $target, string $command): bool { + $access = $this->v3App()->adminAccess; + if ($command === 'admin') { + return $access->isAdmin($target); + } + if ($command === 'restart') { + return $access->isOwner($target); + } + $capability = match ($command) { + 'adminstats', 'testzmanim' => 'statistics', + 'subscribers' => 'subscribers', 'broadcast' => 'broadcast', + 'managers' => 'manage_admins', 'systemlinks' => 'system_links', + 'trackinglinks' => 'tracking_links', 'setcommands' => 'commands', + 'closegroups', 'opengroups' => 'group_operations', + default => throw new \InvalidArgumentException('Unknown manager command.'), + }; + return in_array($command, ['setcommands', 'closegroups', 'opengroups'], true) + ? $access->canMutate($target, $capability) : $access->canView($target, $capability); + } + ); + $result = $service->apply($user, array_map('intval', $this->v3App()->adminAccess->adminIds()), $language, $scope, $preview); + $text = $this->v3Text($user, $preview ? 'commands_setup_preview' : 'commands_setup_result', $preview ? ['total' => $result['total']] : ['total' => $result['total'],'failed' => $result['failed'] + $result['skipped']]); + $message->reply($text); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\InvalidArgumentException) { + $message->reply($this->v3Text($user, 'commands_setup_help') . "\n/setcommands [all|he|en|ru|fr|es|pt-br] [all|private|operators|groups|group_admins] [preview]"); + } + } + private function v3ManualGroups(Message $message, bool $close): void + { + $app = $this->v3App(); + $user = (string)$message->senderId; + if (!$app->adminAccess->canMutate($user, 'group_operations')) { + $app->gateway->sendPrivate($user, $this->v3Text($user, 'denied')); + return; + } + $result = $app->manualGroups->apply($close, $user); + $preferences = $app->settings->getUser($user); + $message->reply(ManualGroupReport::format($result, $preferences['language'], $preferences['timezone_id']), parseMode: \danog\MadelineProto\ParseMode::HTML); + } + private function v3RecordPayment(array $update): void + { + $app = $this->v3App(); + $payment = $app->donations?->recordPayment($update); + if (($payment['status'] ?? '') === 'recorded') { + $app->gateway->sendPrivate((string)$payment['user_id'], (new Lang())->text($payment['language'], 'donate_thanks', ['amount' => $payment['amount']])); + foreach ($this->getAdminIds() as $admin) { + if (!is_int($admin) || $admin <= 0) { + continue; + } + try { + $actor = (string)$admin; + $app->gateway->sendPrivate($actor, $this->v3Text($actor, 'donate_received_admin', ['user_id' => $payment['user_id'],'amount' => $payment['amount']])); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + \danog\MadelineProto\Logger::log('Donation operator notice failed: ' . get_class($error), \danog\MadelineProto\Logger::ERROR); + } + } + } + } + + private function v3RawLocation(array $update, ?int $selfId = null, ?callable $replyLookup = null): bool + { + $raw = $update['message'] ?? []; + if (!is_array($raw) || ($raw['media']['_'] ?? null) !== 'messageMediaGeo') { + return false; + } + $selfId ??= $this->getSelf()['id'] ?? null; + if (!is_int($selfId)) { + return true; + } + $user = PrivatePeer::sender($raw, $selfId); + if ($user === null) { + return true; + } + $replyId = $raw['reply_to']['reply_to_msg_id'] ?? null; + $replyText = ''; + $fromBot = false; + if (is_int($replyId) && $replyId > 0) { + // The optional lookup is an offline protocol-test seam; production uses this bot's RPC. + $response = $replyLookup !== null ? $replyLookup($replyId) : $this->messages->getMessages(id:[['_' => 'inputMessageID','id' => $replyId]]); + $replies = is_array($response) && is_array($response['messages'] ?? null) ? $response['messages'] : []; + foreach ($replies as $reply) { + if (is_array($reply) && PrivatePeer::botReply($reply, $selfId, $user, $replyId)) { + $replyText = $reply['message']; + $fromBot = true; + break; + } + } + } + $geo = $raw['media']['geo'] ?? []; + $this->v3App()->ui->handleMessage(['user_id' => (string)$user,'text' => '','private' => true,'location' => ['latitude' => $geo['lat'] ?? null,'longitude' => $geo['long'] ?? null],'reply_text' => $replyText,'reply_message_id' => $replyId,'replied_bot' => $fromBot]); + return true; + } + + private function replyScopeLanguage(Message $message): string + { + $app = $this->v3App(); + if (!$message instanceof PrivateMessage) { + $group = $app->settings->getGroup((string)$message->chatId); + if ($group !== null) { + return $group['language']; + } + } + return $message->senderId > 0 ? $app->settings->getUser((string)$message->senderId)['language'] : 'he'; + } + private function v3StatsText(string $user, ?string $languageOverride = null): string + { + $app = $this->v3App(); + $language = $languageOverride ?? $app->settings->getUser($user)['language']; + return (new \Shabbat\UI\StatisticsController($app->db, $app->clock, new Lang()))->frame($language)['text']; + } + private function v3Statistics(Message $message): void + { + $app = $this->v3App(); + $language = $this->replyScopeLanguage($message); + $preferences = $message instanceof PrivateMessage + ? $app->settings->getUser((string)$message->senderId) + : $app->settings->getGroup((string)$message->chatId); + $frame = (new \Shabbat\UI\StatisticsController($app->db, $app->clock, new Lang()))->frame($language, 'overview', $preferences['timezone_id'] ?? 'Asia/Jerusalem'); + if ($message instanceof PrivateMessage) { + $app->gateway->sendPrivate((string)$message->senderId, $frame['text'], $frame['markup']); + } else { + $app->gateway->send((string)$message->chatId, ['text' => $frame['text']], (string)random_int(1, PHP_INT_MAX)); + } + } + + private function v3TestZmanim(Message $message): void + { + // Defense in depth: no diagnostic data can be emitted by a legacy group route. + if (!$message instanceof PrivateMessage) { + return; + } + $user = (string)$message->senderId; + try { + $args = preg_split('/\s+/', trim($message->message)); + if (count($args) > 2) { + throw new \InvalidArgumentException('Expected an optional group identifier.'); + } + $this->v4ClearCompetingInputs($user, 'diagnostics'); + $this->v4Diagnostics()->open($user, $args[1] ?? null); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Shabbat\Storage\DiagnosticThrottled) { + // The existing panel remains available; repeated commands do not add messages. + } catch (\Throwable) { + $this->v3App()->gateway->sendPrivate($user, $this->v3Text($user, 'diagnostics_unavailable')); + } + } + private function v3KnownPeers(): array + { + $rows = $this->v3App()->db->pdo()->query('SELECT user_id AS id FROM users UNION SELECT group_id AS id FROM groups WHERE active=1 UNION SELECT peer_id AS id FROM known_broadcast_peers WHERE NOT EXISTS (SELECT 1 FROM groups WHERE groups.group_id=known_broadcast_peers.peer_id AND groups.active=0)')->fetchAll(); + return array_map(static fn(array $row): int=>(int)$row['id'], $rows); + } + private function legacySettingsPanelIsCurrent(CallbackQuery $query): bool + { + // Old buttons have no user/group/revision token. Their features now have secure SQLite workflows. + $query->answer($this->v3Text((string)$query->userId, 'denied'), true); + return false; + } + private function legacySystemQueryAllowed(CallbackQuery $query): bool + { + if (!$query instanceof ChatButtonQuery || (string)$query->chatId !== (string)$query->userId || !$this->v3App()->adminAccess->isAdmin((string)$query->userId)) { + $query->answer($this->v3Text((string)$query->userId, 'commands_forbidden'), true); + return false; + } + return true; + } + private function v3Source(string $kind = 'source', string $user = '0', ?string $languageOverride = null): string + { + $url = Version::sourceUrl(); + $values = ['version' => Version::current(),'commit' => Version::commit(),'license' => 'AGPL-3.0-only','url' => $url !== '' ? $url : '-']; + // Legacy /version and /license remain aliases of the complete source card. + $key = 'source_info'; + $lang = new Lang(); + $language = $languageOverride ?? $this->v3App()->settings->getUser($user)['language']; + return $lang->text($language, $key, $values) . ($url === '' ? "\n" . $lang->text($language, 'source_unpublished') : ''); + } + + private function v3OperatorUi(): \Shabbat\UI\OperatorController + { + return new \Shabbat\UI\OperatorController($this->v3App()->settings, new Lang(), $this->v3App()->adminAccess->isAdmin(...), $this->v3App()->adminAccess); + } + private function v3OperatorDraft(): \Shabbat\UI\OperatorDraft + { + return new \Shabbat\UI\OperatorDraft($this->v3App(), fn(string $user, bool $mutate = false): bool=>$mutate ? $this->v3App()->adminAccess->canMutate($user, 'broadcast') : $this->v3App()->adminAccess->canView($user, 'broadcast')); + } + private function v3OperatorManager(string $user, bool $scheduled = false): \BroadcastTool\BroadcastManager + { + \BroadcastTool\BroadcastManager::setDataDir(getenv('DATA_DIR') ?: dirname(__DIR__, 2) . '/data'); + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Broadcast initialization stopped during shutdown.')); + } + $client = RuntimeClientPool::get($this, static fn(): \danog\MadelineProto\API => new \danog\MadelineProto\API( + getenv('SESSION_PATH') ?: dirname(__DIR__, 2) . '/bot.shabbat', + (new \danog\MadelineProto\Settings\Logger())->setLevel(\danog\MadelineProto\Logger::ERROR) + )); + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Broadcast initialization completed during shutdown.')); + } + if (!$client instanceof \danog\MadelineProto\API) { + throw new \LogicException('Invalid shared broadcast client.'); + } + $manager = new \BroadcastTool\BroadcastManager($client); + $app = $this->v3App(); + $manager->setRuntimeAdmission(static fn(callable $work): mixed => $app->broadcastJobs->runExclusive(static function () use ($app, $user, $work, $scheduled): mixed { + // Waiting for another operation can outlive an operator's authority. + $app->adminAccess->require($user, 'broadcast', true); + if ($scheduled && !$app->broadcastJobs->canContinue()) { + throw new \RuntimeException('Broadcast creator authorization changed while waiting for admission.'); + } + return $work(); + })); + $manager->setRuntimeGuard( + static fn(): bool => !Runner::isStopping() && (!$scheduled || $app->broadcastJobs->canContinue()) + && $app->adminAccess->canMutate($user, 'broadcast'), + $app->broadcastJobs->cancellation() + ); + $formatter = new BroadcastStatusFormatter(new Lang(), $this->v3App()->settings->getUser($user)['language']); + $manager->setStatusFormatter($formatter); + $manager->setStatusControlsFormatter($formatter->controls(...)); + if ($this->v3App()->gateway instanceof MadelineGateway) { + $manager->setMessageTransport($this->v3App()->gateway->broadcastCall(...)); + } + return $manager; + } + private function v3OperatorEdit(ChatButtonQuery $query, array $frame): void + { + $this->v4RememberPanel((string)$query->userId, $query->messageId); + \Shabbat\UI\CallbackUiGateway::fromQuery($this->v3App()->gateway, $this->v3Query($query))->sendPrivate((string)$query->userId, $frame['text'], $frame['markup']); + } + private function v3OperatorMain(Message $message): void + { + $app = $this->v3App(); + $user = (string)$message->senderId; + if (!$message instanceof PrivateMessage || !$app->adminAccess->isAdmin($user)) { + $app->gateway->sendPrivate($user, $this->v3Text($user, 'denied')); + return; + } + $this->v4Admin()->cancel($user); + if ($app->adminAccess->canMutate($user, 'broadcast')) { + $this->v3OperatorDraft()->cancel($user); + } + $frame = $this->v3OperatorUi()->main($user); + $app->gateway->sendPrivate($user, $frame['text'], $frame['markup']); + } + private function v3OperatorPanel(ChatButtonQuery $query, string $kind): void + { + $app = $this->v3App(); + $user = (string)$query->userId; + $target = \Shabbat\UI\CallbackUiGateway::fromQuery($app->gateway, $this->v3Query($query)); + $this->v4RememberPanel($user, $query->messageId); + if (in_array($kind, ['main', 'clear'], true)) { + if ($app->adminAccess->canMutate($user, 'broadcast')) { + $this->v3OperatorDraft()->cancel($user, $kind === 'clear'); + } + $this->v4Admin()->cancel($user); + $frame = $this->v3OperatorUi()->main($user); + } elseif ($kind === 'stats') { + $frame = $this->v4Subscribers()->statistics($user); + } elseif (in_array($kind, ['subscribers', 'next', 'previous', 'page'], true)) { + $app->adminAccess->require($user, 'subscribers'); + $view = $app->subscribers->view($user); + $page = match ($kind) { + 'subscribers' => 0, 'next' => $view['page'] + 1, + 'previous' => max(0, $view['page'] - 1), default => $view['page'], + }; + $frame = $this->v4Subscribers()->callback($user, 'sd:list:' . $page); + } elseif ($kind === 'broadcast') { + $frame = $this->v4Broadcast()->main($user); + } elseif ($kind === 'preview') { + $app->adminAccess->require($user, 'broadcast'); + $state = $this->v3OperatorDraft()->state($user); + if (!$state['exists']) { + $frame = $this->v4Broadcast()->draft($user); + } else { + $payload = $state['payload']; + $markup = MadelineGateway::normalizeMarkup(['inline_keyboard' => $payload['buttons'] ?? []]); + $markup = $markup ?: ['_' => 'replyInlineMarkup', 'rows' => []]; + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [[ + '_' => 'keyboardButtonCallback', 'text' => $this->v3Text($user, 'back'), 'data' => 'bc:draft', + ]]]; + $payload['reply_markup'] = $markup; + $target->preview($payload); + $query->answer(''); + return; + } + } elseif ($kind === 'progress') { + $app->adminAccess->require($user, 'broadcast'); + $frame = $this->v3OperatorUi()->progress($user, ['active' => false]); + $record = $app->db->pdo()->query("SELECT details_json FROM audit_log WHERE action='operator.broadcast.completed' ORDER BY created_at DESC LIMIT 1")->fetchColumn(); + if (is_string($record)) { + $details = json_decode($record, true, 64, JSON_THROW_ON_ERROR); + $progress = $this->v3OperatorManager($user)->progress((string)$details['id']); + if ($progress !== null) { + $frame = $this->v3OperatorUi()->progress($user, ['active' => !$progress['done'] && !$progress['cancel'], 'page' => (int)$progress['processed'], 'total' => (int)$progress['total']]); + } + } + } else { + $draft = $this->v3OperatorDraft(); + $mutate = in_array($kind, ['edit', 'buttons', 'pin', 'unpin'], true) || str_starts_with($kind, 'audience:'); + $app->adminAccess->require($user, 'broadcast', $mutate); + if ($kind === 'edit' || $kind === 'buttons') { + $frame = $this->v4Broadcast()->callback($user, $kind === 'edit' ? 'bc:content' : 'bc:buttons'); + } elseif ($kind === 'audience') { + $frame = $this->v4Broadcast()->callback($user, 'bc:audience'); + } else { + if (str_starts_with($kind, 'audience:')) { + $draft->setAudience($user, substr($kind, 9)); + } elseif ($kind === 'pin' || $kind === 'unpin') { + $draft->setPin($user, $kind === 'pin'); + } else { + $draft->cancel($user); + } + $frame = $this->v4Broadcast()->draft($user); + } + } + $this->v3OperatorEdit($query, $frame); + } + private function v3OperatorCapture(Message $message, string $mode): bool + { + $text = $message->message; + if (str_starts_with(ltrim($text), '/')) { + return false; + } + $app = $this->v3App(); + $user = (string)$message->senderId; + if (!$message instanceof PrivateMessage || !$app->adminAccess->canMutate($user, 'broadcast')) { + return false; + } + $draft = $this->v3OperatorDraft(); + if ($draft->mode($user) !== $mode) { + return false; + } + try { + if ($mode === 'broadcast1') { + $entities = array_map(static fn($entity) => is_array($entity) ? $entity : $entity->toMTProto(), $message->entities ?? []); + $media = isset($message->media->botApiFileId) ? (string)$message->media->botApiFileId : null; + if ($message->media !== null && $media === null) { + throw new \InvalidArgumentException('Unsupported message media.'); + } + $draft->capture($user, $text, $entities, $media); + } else { + $draft->buttons($user, $text); + } + $frame = $this->v4Broadcast()->draft($user); + $frame['text'] = $this->v3Text($user, 'operator_draft_saved') . "\n\n" . $frame['text']; + $this->v4InputFrame($user, $frame); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $this->v4InputFrame($user, ['text' => $this->v3Text($user, 'operator_draft_invalid'), 'markup' => $this->v4Broadcast()->draft($user)['markup']]); + } + return true; + } + private function v3OperatorProfile(ChatButtonQuery $query): void + { + $user = (string)$query->userId; + $this->v3App()->adminAccess->require($user, 'subscribers'); + $id = substr($query->data, 5); + if (!preg_match('/^-?[1-9][0-9]{0,19}$/D', $id) || !in_array((int)$id, $this->v3KnownPeers(), true)) { + $query->answer($this->v3Text($user, 'denied'), true); + return; + } + $this->v4ObservePeer($id, false); + $this->v3OperatorEdit($query, $this->v4Subscribers()->profile($user, $id)); + } + private function v3OperatorMaintenance(ChatButtonQuery $query, string $action): void + { + $user = (string)$query->userId; + $this->v3App()->adminAccess->require($user, 'broadcast', true); + if (!in_array($action, ['delete_last', 'delete_all', 'unpin'], true)) { + throw new \InvalidArgumentException('Unknown broadcast maintenance action.'); + } + $frame = $this->v4ActionPrompt($user, ['action' => $action, 'id' => 'broadcast']); + $this->v3OperatorEdit($query, $frame); + $query->answer(''); + } + private function v3OperatorConfirmBroadcast(ChatButtonQuery $query): void + { + $user = (string)$query->userId; + $draft = $this->v3OperatorDraft(); + $state = $draft->state($user); + if (!$state['exists']) { + $query->answer($this->v3Text($user, 'operator_no_draft'), true); + return; + } + $this->v3App()->adminAccess->require($user, 'broadcast', true); + $targets = $this->v4AudienceTargets($state['audience']); + if ($targets === []) { + $query->answer($this->v3Text($user, 'operator_empty_recipients'), true); + return; + } + $token = $draft->confirmation($user, $targets); + $this->v3OperatorEdit($query, $this->v4Broadcast()->confirmation($user, count($targets), 'op:send:' . $token)); + } + #[Handler] + public function operatorBroadcastCallbacks(ChatButtonQuery $query): void + { + if (!preg_match('/^(?:op:send:[A-Za-z0-9_-]{24}|bm:(?:pause|resume|cancel):[a-f0-9]{16})$/D', $query->data)) { + return; + } + if (!$this->legacySystemQueryAllowed($query)) { + return; + } + $user = (string)$query->userId; + if (!$this->v3App()->adminAccess->canMutate($user, 'broadcast')) { + $query->answer($this->v3Text($user, 'denied'), true); + return; + } + $manager = $this->v3OperatorManager($user); + try { + if (str_starts_with($query->data, 'bm:')) { + [, $action,$id] = explode(':', $query->data); + $progress = $manager->progress($id); + if ($progress === null || $progress['done']) { + throw new \RuntimeException('Inactive broadcast.'); + } + $manager->{$action}($id); + $progress = $manager->progress($id); + $this->v3OperatorEdit($query, $this->v3OperatorUi()->progress($user, ['active' => !$progress['done'] && !$progress['cancel'],'page' => (int)$progress['processed'],'total' => (int)$progress['total']])); + $query->answer(''); + return; + } + $token = substr($query->data, 8); + $snapshot = $this->v3OperatorDraft()->claim($user, $token); + $currentTargets = $this->v4AudienceTargets($snapshot['state']['audience']); + $targets = array_values(array_intersect($snapshot['targets'], $currentTargets)); + if ($targets === []) { + throw new \RuntimeException('Recipients are no longer registered.'); + } + $payload = $snapshot['state']['payload']; + $message = ['message' => $payload['text'],'entities' => $payload['entities']]; + if ($payload['buttons'] !== []) { + $message['reply_markup'] = MadelineGateway::normalizeMarkup(['inline_keyboard' => $payload['buttons']]); + } + if ($payload['media'] !== null) { + $file = $this->getFileInfo($payload['media']['bot_api_file_id']); + $media = $file['InputMedia'] ?? null; + if (!is_array($media) || !str_starts_with((string)($media['_'] ?? ''), 'inputMedia')) { + throw new \InvalidArgumentException('Reusable media is unavailable.'); + } + $message['media'] = $media; + } + if (!$this->v3App()->prioritizePermissions()) { + throw new \RuntimeException('Shutdown prevents broadcast preparation.'); + } + if (Runner::isStopping() || !$this->v3App()->adminAccess->canMutate((string)$query->userId, 'broadcast')) { + throw new \RuntimeException('Broadcast start is unavailable.'); + } + $query->answer(''); + $state = $snapshot['state']; + if (($state['operation'] ?? 'send') === 'edit') { + $html = \danog\MadelineProto\StrTools::entitiesToHtml($payload['text'], $payload['entities'], true); + $id = $manager->editLastBroadcastForAll($targets, $html, $query->userId, $message['reply_markup'] ?? ['_' => 'replyInlineMarkup', 'rows' => []], $message['media'] ?? null, 20, 'HTML'); + } elseif (($state['scheduled_at'] ?? null) !== null) { + if ($state['scheduled_at'] <= $this->v3App()->clock->now()->getTimestamp()) { + throw new \RuntimeException('The scheduled time elapsed; choose a new time.'); + } + $id = $manager->scheduleBroadcastForAll($targets, [$message], $state['scheduled_at'], $query->userId, (bool)$state['pin'], 20, $state['self_destruct_hours'] ?? null); + $this->v3App()->broadcastJobs->recordScheduled($user, $id); + } else { + $id = $manager->broadcastWithProgress($targets, [$message], $query->userId, (bool)$state['pin'], 20, $state['self_destruct_hours'] ?? null); + } + $this->v3App()->db->pdo()->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([$user,null,'operator.broadcast.completed',\Shabbat\Storage\Database::encode(['id' => $id,'progress' => $manager->progress($id)]),$this->v3App()->clock->now()->getTimestamp()]); + $frame = $this->v4Broadcast()->main($user); + $frame['text'] = $this->v3Text($user, 'operator_complete') . "\n\n" . $frame['text']; + $this->v3OperatorEdit($query, $frame); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $query->answer($this->v3Text($user, 'denied'), true); + } + } + + #[Handler] + public function groupMessageCleanup(\danog\MadelineProto\EventHandler\Message\GroupMessage & Incoming & IsNotEdited $message): void + { + // The native GroupMessage wrapper covers basic groups and supergroups, excluding service updates. + $this->v3App()->incomingGroupMessage((string)$message->chatId, $message->id, $message->date, (string)$message->senderId, $message->out); + } + #[Handler] + public function upgradedMessages(Message & Incoming & IsNotEdited $message): void + { + // Existing command-specific handlers retain their route. This handler owns newly introduced commands only. + if (($message instanceof PrivateMessage && !str_starts_with(ltrim($message->message), '/')) || preg_match('/^\/(?:language|lang|zmanim|daf|omer|groups|preferences|help|city|coordinates|location|timezone|configure|viewdate|source|license|version|template|buttons|adminstats|subscribers|broadcast|managers|systemlinks|trackinglinks)(?:@\w+)?(?:\s|$)/i', $message->message)) { + $this->v3Message($message); + } + } + #[Handler] + public function upgradedCallbacks(ButtonQuery $query): void + { + $data = $query->data; + if (!preg_match('/^(?:sb|tpl|v3|adm|sys|trk|sd|bc|opx|tf|ps|dz):/', $data)) { + return; + } + $event = $this->v3Query($query); + $app = $this->v3App(); + if (str_starts_with($data, 'tf:')) { + $this->v4TefillinCallback($query, $event); + return; + } + if ($query instanceof InlineButtonQuery) { + if (!str_starts_with($data, 'sb:lp:')) { + return; + }$event['private'] = true; + } // Selection previews only; final writes require a private ChatButtonQuery. + if ($this->v4HandleCallback($query, $event)) { + return; + } + if ($data === 'v3:source' || $data === 'v3:donate') { + try { + $ui = \Shabbat\UI\CallbackUiGateway::fromQuery($app->gateway, $event); + if ($data === 'v3:source') { + $markup = ['_' => 'replyInlineMarkup', 'rows' => [ + ['_' => 'keyboardButtonRow', 'buttons' => [['_' => 'keyboardButtonUrl', 'text' => $this->v3Text($event['user_id'], 'source'), 'url' => 'https://github.com/WizardLoop/shabbat', 'style' => ['_' => 'keyboardButtonStyle', 'bg_primary' => true]]]], + ['_' => 'keyboardButtonRow', 'buttons' => [['_' => 'keyboardButtonCallback', 'text' => $this->v3Text($event['user_id'], 'back'), 'data' => 'sb:start', 'style' => ['_' => 'keyboardButtonStyle']]]], + ]]; + $ui->sendPrivate($event['user_id'], $this->v3Source('source', $event['user_id']), $markup); + } else { + $this->v3DonationMenu($event['user_id'], $ui); + } + $ui->answerCallback($event['query_id'], ''); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $app->gateway->answerCallback($event['query_id'], $this->v3Text($event['user_id'], 'callback_panel_unavailable')); + } + } elseif (!(new TemplateEditor($app))->callback($event)) { + $app->ui->handleCallback($event); + } + } +} diff --git a/src/Runtime/BroadcastJobs.php b/src/Runtime/BroadcastJobs.php new file mode 100644 index 0000000..549224b --- /dev/null +++ b/src/Runtime/BroadcastJobs.php @@ -0,0 +1,279 @@ +> */ + private array $admissionQueue = []; + private int $nextAdmission = 0; + private ?int $admitted = null; + + /** One native operation at a time across scheduled and manual managers. */ + public function runExclusive(callable $operation): mixed + { + $cancellation = $this->cancellation(); + $cancellation->throwIfRequested(); + $ticket = ++$this->nextAdmission; + $ready = new \Amp\DeferredFuture(); + $this->admissionQueue[$ticket] = $ready; + $this->promoteAdmission(); + try { + $ready->getFuture()->await($cancellation); + $cancellation->throwIfRequested(); + return $operation(); + } finally { + unset($this->admissionQueue[$ticket]); + if ($this->admitted === $ticket) { + $this->admitted = null; + $this->promoteAdmission(); + } + } + } + private function promoteAdmission(): void + { + if ($this->admitted !== null || $this->stopped || $this->admissionQueue === []) { + return; + } + $ticket = array_key_first($this->admissionQueue); + $ready = $this->admissionQueue[$ticket]; + unset($this->admissionQueue[$ticket]); + $this->admitted = $ticket; + $ready->complete(); + } + public function cancellation(): \Amp\Cancellation + { + $this->shutdown ??= new \Amp\DeferredCancellation(); + if ($this->stopped) { + $this->shutdown->cancel(); + } + return $this->shutdown->getCancellation(); + } + public function stop(): void + { + $this->stopped = true; + $this->shutdown?->cancel(); + } + /** Local-only guard for each native worker; no provider or Telegram requests. */ + public function canContinue(): bool + { + if ($this->stopped) { + return false; + } + foreach ($this->activeActors as $actor) { + if (!$this->access->canMutate($actor, 'broadcast')) { + return false; + } + } + return true; + } + public function isRunning(): bool + { + return $this->running; + } + public function __construct(private Database $db, private Clock $clock, private AdminAccessRepository $access) + { + } + public function recordScheduled(string $actor, string $id): void + { + self::validateId($id); + $this->db->transaction(function (PDO $pdo) use ($actor, $id): void { + $this->access->require($actor, 'broadcast', true); + $existing = $this->actorForSchedule($id); + if ($existing !== null) { + if (!hash_equals($existing, $actor)) { + throw new \RuntimeException('Broadcast schedule already belongs to another actor.'); + } + return; + } + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,NULL,?,?,?)')->execute([$actor, 'operator.broadcast.scheduled', Database::encode(['id' => $id]), $this->clock->now()->getTimestamp()]); + }); + } + public function actorForSchedule(string $id): ?string + { + self::validateId($id); + $q = $this->db->pdo()->prepare("SELECT actor_id,details_json FROM audit_log WHERE action='operator.broadcast.scheduled' AND CASE WHEN json_valid(details_json) THEN json_extract(details_json,'$.id') END=? ORDER BY audit_id DESC"); + $q->execute([$id]); + $found = null; + foreach ($q->fetchAll() as $row) { + try { + $details = json_decode($row['details_json'], true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + continue; + } + if (!is_array($details) || ($details['id'] ?? null) !== $id) { + continue; + } + $actor = $row['actor_id']; + if (!is_string($actor) || !preg_match('/^[1-9][0-9]{0,18}$/D', $actor) || ($found !== null && !hash_equals($found, $actor))) { + return null; + } + $found = $actor; + } + return $found; + } + /** + * Callbacks invoke the pinned native manager; they may yield, so no DB transaction surrounds them. + * A failed cancellation blocks every native runner. The next tick rechecks all persisted state. + */ + public function runDue(callable $list, callable $cancel, callable $runSchedules, callable $runDestruction, callable $priority, callable $stopping, ?callable $listDestruction = null): array + { + if ($this->running) { + return ['ran' => false, 'cancelled' => [], 'reason' => 'running']; + } + $this->running = true; + $cancelled = []; + $ran = false; + try { + if ($this->shouldStop($stopping)) { + return ['ran' => false, 'cancelled' => [], 'reason' => 'shutdown']; + } + $scheduled = $this->scheduled($list()); + foreach ($scheduled as $job) { + if ($this->shouldStop($stopping)) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'shutdown']; + } + $actor = $this->actorForSchedule($job['id']); + if ($actor !== null && $this->access->canMutate($actor, 'broadcast')) { + continue; + } + if ($job['scheduledAt'] > $this->clock->now()->getTimestamp()) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'unattributed_future']; + } + if ($cancel($job['id']) !== true) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'cancellation_unconfirmed']; + } + $cancelled[] = $job['id']; + } + // Local preflight: an empty/future queue must not trigger full permission scans. + $scheduled = $this->scheduled($list()); + $sendDue = $this->hasDueSchedule($scheduled); + $deleteDue = $listDestruction === null || $this->hasDueDestruction($listDestruction()); + if (!$sendDue && !$deleteDue) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'idle']; + } + if (!$this->priorityReady($priority)) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'permission_priority']; + } + if ($this->shouldStop($stopping)) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'shutdown']; + } + // The priority pass can yield. Recheck snapshot and authority afterwards. + $scheduled = $this->scheduled($list()); + foreach ($scheduled as $job) { + $actor = $this->actorForSchedule($job['id']); + if ($actor === null || !$this->access->canMutate($actor, 'broadcast')) { + return ['ran' => false, 'cancelled' => $cancelled, 'reason' => 'authorization_changed']; + } + $this->activeActors[$actor] = $actor; + } + if ($this->hasDueSchedule($scheduled)) { + $runSchedules(); + $ran = true; + } + if ($this->shouldStop($stopping)) { + return ['ran' => $ran, 'cancelled' => $cancelled, 'reason' => 'shutdown']; + } + $deleteDue = $listDestruction === null || $this->hasDueDestruction($listDestruction()); + if ($deleteDue) { + if ($ran && !$this->priorityReady($priority)) { + return ['ran' => true, 'cancelled' => $cancelled, 'reason' => 'permission_priority']; + } + if ($this->shouldStop($stopping)) { + return ['ran' => $ran, 'cancelled' => $cancelled, 'reason' => 'shutdown']; + } + $runDestruction(); + $ran = true; + } + return ['ran' => $ran, 'cancelled' => $cancelled, 'reason' => 'ok']; + } finally { + $this->activeActors = []; + // Intentional cancellation propagates after native children have drained. + $this->running = false; + } + } + private function hasDueSchedule(array $jobs): bool + { + foreach ($jobs as $job) { + if ($job['scheduledAt'] <= $this->clock->now()->getTimestamp()) { + return true; + } + } + return false; + } + private function hasDueDestruction(mixed $items): bool + { + if (!is_array($items)) { + throw new \RuntimeException('Invalid native destruction list.'); + } + $due = false; + foreach ($items as $key => $item) { + if (!is_array($item) || !is_string($item['status'] ?? null)) { + throw new \RuntimeException('Invalid native destruction record.'); + } + if ($item['status'] !== 'scheduled') { + continue; + } + if ( + !is_string($item['id'] ?? null) || !preg_match('/^selfdestruct_[a-f0-9]{16}$/D', $item['id']) + || $key !== $item['id'] || !is_int($item['deleteAt'] ?? null) || $item['deleteAt'] < 1 + ) { + throw new \RuntimeException('Invalid native destruction identity or deadline.'); + } + $due = $due || $item['deleteAt'] <= $this->clock->now()->getTimestamp(); + } + return $due; + } + /** @phpstan-impure Shutdown can change while native callbacks await work. */ + private function shouldStop(callable $stopping): bool + { + return $this->stopped || $stopping() === true; + } + /** @phpstan-impure Pending permission work can change while a native callback awaits. */ + private function priorityReady(callable $priority): bool + { + return $priority() === true; + } + private function scheduled(mixed $items): array + { + if (!is_array($items)) { + throw new \RuntimeException('Invalid native broadcast schedule list.'); + } + $out = []; + foreach ($items as $key => $item) { + if (!is_array($item) || !is_string($item['status'] ?? null)) { + throw new \RuntimeException('Invalid native broadcast schedule record.'); + } + if ($item['status'] !== 'scheduled') { + continue; + } + if (!is_string($item['id'] ?? null) || !is_int($item['scheduledAt'] ?? null) || $item['scheduledAt'] < 1) { + throw new \RuntimeException('Invalid scheduled broadcast metadata.'); + } + self::validateId($item['id']); + if (!is_string($key) || !hash_equals($key, $item['id'])) { + throw new \RuntimeException('Native broadcast schedule identity mismatch.'); + } + $out[] = $item; + } + return $out; + } + private static function validateId(string $id): void + { + if (!preg_match('/^schedule_[a-f0-9]{16}$/D', $id)) { + throw new \InvalidArgumentException('Invalid native broadcast schedule ID.'); + } + } +} diff --git a/src/Runtime/BroadcastStatusFormatter.php b/src/Runtime/BroadcastStatusFormatter.php new file mode 100644 index 0000000..c673544 --- /dev/null +++ b/src/Runtime/BroadcastStatusFormatter.php @@ -0,0 +1,122 @@ + $this->text('operator_bc_starting', ['operation' => $this->operation($context['type'] ?? null)]), + 'progress' => $this->progress($context), + 'pause', 'resume', 'cancel' => $this->text('operator_' . $event), + 'last_data' => is_array($context['progress'] ?? null) + ? $this->progress($context['progress'] + ['final' => (bool) ($context['progress']['done'] ?? false)]) + : $this->text('operator_bc_legacy_summary'), + default => throw new InvalidArgumentException('Unknown broadcast presentation event.'), + }; + } + + /** Convert only upstream controls; preserve operation identifiers byte for byte. */ + public function controls(array $default, array $context): array + { + $id = $context['id'] ?? null; + if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $id)) { + throw new InvalidArgumentException('Invalid broadcast control context.'); + } + $rows = $default['inline_keyboard'] ?? null; + if (!is_array($rows) || count($rows) !== 1 || !is_array($rows[0]) || count($rows[0]) !== 2) { + throw new InvalidArgumentException('Unknown broadcast controls.'); + } + $buttons = []; + foreach ($rows[0] as $button) { + $data = $button['callback_data'] ?? null; + $text = $button['text'] ?? null; + if (!is_string($data) || !is_string($text) || !preg_match('/^bm:(pause|resume|cancel):' . preg_quote($id, '/') . '$/D', $data, $matches)) { + throw new InvalidArgumentException('Broadcast callback binding changed.'); + } + $flag = match ($matches[1]) { + 'resume' => 'bg_primary', + 'cancel' => 'bg_danger', + 'pause' => null, + }; + $native = ['_' => 'keyboardButtonCallback', 'text' => $text, 'data' => $data]; + if ($flag !== null) { + $native['style'] = ['_' => 'keyboardButtonStyle', $flag => true]; + } + $buttons[] = $native; + } + return ['_' => 'replyInlineMarkup', 'rows' => [['_' => 'keyboardButtonRow', 'buttons' => $buttons]]]; + } + + private function progress(array $context): string + { + $breakdown = is_array($context['breakdown'] ?? null) ? $context['breakdown'] : []; + $context = $context + $breakdown; + $values = []; + foreach (['processed', 'total', 'sent', 'edited', 'unchanged', 'deleted', 'unpin', 'failed', 'flood', 'pending'] as $key) { + $value = $context[$key] ?? 0; + if (!is_int($value) || $value < 0) { + throw new InvalidArgumentException('Invalid broadcast status counter.'); + } + $values[$key] = (string) $value; + } + $rate = $context['tps'] ?? 0; + if ((!is_int($rate) && !is_float($rate)) || !is_finite((float) $rate) || $rate < 0) { + throw new InvalidArgumentException('Invalid broadcast status rate.'); + } + $decimal = in_array($this->language, ['fr', 'es', 'ru', 'pt-br'], true) ? ',' : '.'; + $values['tps'] = number_format((float) $rate, 2, $decimal, ''); + $total = (int) $values['total']; + $percent = $total > 0 ? min(100, (int) round(((int) $values['processed'] / $total) * 100)) : 0; + $filled = (int) round($percent / 5); + $bar = str_repeat('#', $filled) . str_repeat('-', 20 - $filled) . ' ' . $percent . '%'; + $lines = ['' . $this->escape($this->operation($context['type'] ?? null)) . '', '' . $bar . '', $this->text('operator_bc_counts', $values)]; + if ($context['paused'] ?? false) { + $lines[] = '' . $this->text('operator_bc_paused') . ''; + } + if ($context['cancel'] ?? false) { + $lines[] = '' . $this->text('operator_bc_cancelled') . ''; + } elseif (($context['final'] ?? false) || ($context['done'] ?? false)) { + $lines[] = '' . $this->text('operator_bc_finished') . ''; + } + return implode("\n", $lines); + } + + private function operation(mixed $type): string + { + return $this->text(match ($type) { + 'edit' => 'operator_bc_edit', + 'deletelast' => 'operator_delete_last', + 'deleteall' => 'operator_delete_all', + 'unpin' => 'operator_unpin', + 'selfdestruct' => 'operator_bc_selfdestruct', + default => 'operator_progress', + }); + } + + private function text(string $key, array $values = []): string + { + return $this->lang->text($this->language, $key, $values); + } + + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/Runtime/BuildMetadata.php b/src/Runtime/BuildMetadata.php new file mode 100644 index 0000000..157ef8d --- /dev/null +++ b/src/Runtime/BuildMetadata.php @@ -0,0 +1,47 @@ +rpc = \Closure::fromCallable($rpc); + $this->authorize = \Closure::fromCallable($authorize); + $this->commandAllowed = $commandAllowed === null ? null : \Closure::fromCallable($commandAllowed); + } + public function plan(array $operatorIds, string $language = 'all', string $scope = 'all'): array + { + if ($language !== 'all' && !in_array($language, self::LANGUAGES, true)) { + throw new \InvalidArgumentException('Unsupported menu language.'); + } + if (!in_array($scope, ['all','private','operators','groups','group_admins'], true)) { + throw new \InvalidArgumentException('Unsupported command scope.'); + } + $operators = []; + foreach ($operatorIds as $id) { + $id = (string)$id; + if (!preg_match('/^[1-9][0-9]{0,18}$/D', $id) || strlen($id) > strlen((string)PHP_INT_MAX) || (strlen($id) === strlen((string)PHP_INT_MAX) && strcmp($id, (string)PHP_INT_MAX) > 0)) { + throw new \InvalidArgumentException('Operator menu peers must be real positive user IDs.'); + }$operators[$id] = $id; + } + $scopes = []; + if (in_array($scope, ['all','private'], true)) { + $scopes[] = ['scope' => ['_' => 'botCommandScopeUsers'],'ids' => self::PRIVATE_COMMANDS]; + } + if (in_array($scope, ['all','groups'], true)) { + $scopes[] = ['scope' => ['_' => 'botCommandScopeChats'],'ids' => self::GROUP_COMMANDS]; + } + if (in_array($scope, ['all','group_admins'], true)) { + $scopes[] = ['scope' => ['_' => 'botCommandScopeChatAdmins'],'ids' => self::GROUP_COMMANDS]; + } + if (in_array($scope, ['all','operators'], true)) { + foreach ($operators as $id) { + $scopes[] = ['scope' => ['_' => 'botCommandScopePeer','peer' => $id],'ids' => array_merge(self::PRIVATE_COMMANDS, self::OPERATOR_COMMANDS)]; + } + } + $languages = $language === 'all' ? self::LANGUAGES : [$language]; + $plans = []; + foreach ($scopes as $spec) { + foreach ($languages as $locale) { + $plans[] = $this->entry($spec, $locale, $locale === 'pt-br' ? 'pt' : $locale); + } + // Explicit Hebrew fallback for clients without a supported language. Updating one language does not rewrite this fallback. + if ($language === 'all') { + $plans[] = $this->entry($spec, 'he', ''); + } + } + return $plans; + } + private function entry(array $spec, string $locale, string $nativeCode): array + { + $commands = []; + foreach ($spec['ids'] as $id) { + if ( + $this->commandAllowed !== null && in_array($id, self::OPERATOR_COMMANDS, true) + && !($this->commandAllowed)((string)($spec['scope']['peer'] ?? ''), $id) + ) { + continue; + } + $description = html_entity_decode($this->lang->text($locale, 'command_' . ($id === 'language' ? 'lang' : $id)), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (!preg_match('/^[a-z0-9_]{1,32}$/D', $id) || mb_strlen($description) < 1 || mb_strlen($description) > 256) { + throw new \RuntimeException('Invalid command description: ' . $locale . '/' . $id); + } + $commands[] = ['_' => 'botCommand','command' => $id,'description' => $description]; + } + return ['locale' => $locale,'scope' => $spec['scope'],'lang_code' => $nativeCode,'commands' => $commands]; + } + /** @phpstan-impure Operator authorization may change while menu RPCs await Telegram. */ + private function isAuthorized(string $operator): bool + { + return ($this->authorize)($operator); + } + public function apply(string $operator, array $operatorIds, string $language = 'all', string $scope = 'all', bool $preview = false): array + { + if (!$this->isAuthorized($operator)) { + throw new \RuntimeException('Operator permission required.'); + } + $plans = $this->plan($operatorIds, $language, $scope); + if ($preview) { + return ['preview' => true,'total' => count($plans),'applied' => 0,'failed' => 0,'plans' => $plans]; + } + $applied = 0; + $failures = []; + $attempted = 0; + foreach ($plans as $plan) { + if (!$this->isAuthorized($operator)) { + break; + }$attempted++; + try { + $params = ['scope' => $plan['scope'],'lang_code' => $plan['lang_code'],'commands' => $plan['commands']]; + if (($this->rpc)('bots.setBotCommands', $params) !== true) { + throw new \RuntimeException('Telegram did not acknowledge the menu update.'); + }$applied++; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $failures[] = ['scope' => $plan['scope']['_'],'locale' => $plan['locale'],'error' => get_class($error)]; + } + } + return ['preview' => false,'total' => count($plans),'attempted' => $attempted,'applied' => $applied,'failed' => count($failures),'skipped' => count($plans) - $attempted,'failures' => $failures]; + } +} diff --git a/src/Runtime/DiagnosticReport.php b/src/Runtime/DiagnosticReport.php new file mode 100644 index 0000000..bc0183f --- /dev/null +++ b/src/Runtime/DiagnosticReport.php @@ -0,0 +1,184 @@ +app->adminAccess->require($user, 'statistics'); + $settings = $group === null ? $this->app->settings->getUser($user) : $this->app->settings->getGroup($group); + if ($settings === null) { + throw new \InvalidArgumentException('Unknown diagnostic group.'); + } + $records = []; + $add = static function (string $category, string $label, string $value, string $status = 'ok', array $data = []) use (&$records): void { + $records[] = ['category' => $category, 'label' => $label, 'value' => mb_substr($value, 0, 500), 'status' => $status, 'data' => $data]; + }; + $add('summary', 'build', Version::current() . ' / ' . Version::commit()); + $add('summary', 'location', $settings['location_name'] . ' / ' . $settings['timezone_id']); + $add('summary', 'methods', CalendarEngine::candleMinutes($settings) . ' / ' . $settings['havdalah_method'] . ' ' . $settings['havdalah_value'], 'ok', [ + 'candle_minutes' => CalendarEngine::candleMinutes($settings), 'havdalah_method' => $settings['havdalah_method'], 'havdalah_value' => $settings['havdalah_value'], + ]); + try { + $state = $this->app->calendar->snapshot($settings); + $state['lock_now'] = $state['should_group_be_locked']; + foreach (['lock_now', 'quiet_now', 'is_work_prohibited_now', 'is_shabbat', 'is_yom_tov', 'next_close_at', 'next_open_at', 'stale', 'evaluated_at', 'fetched_at'] as $key) { + if (array_key_exists($key, $state)) { + $value = $state[$key]; + $add('calendar', $key, is_bool($value) ? ($value ? 'โœ“' : 'โœ—') : ($value === null ? '-' : $this->date((int)$value, $settings['timezone_id'])), $key === 'stale' && $value ? 'warning' : 'ok', [$key => $value]); + } + } + foreach (array_slice($state['events'] ?? [], 0, 80) as $period) { + $projected = array_intersect_key($period, array_flip(['event_key', 'start_at', 'end_at', 'candles_at', 'havdalah_at', 'close_at', 'open_at', 'is_shabbat', 'is_yom_tov', 'enabled'])); + $add('calendar', 'period', ($period['title'] ?? '') . "\n" . $this->date((int)$period['close_at'], $settings['timezone_id']) . ' โ†’ ' . $this->date((int)$period['open_at'], $settings['timezone_id']), 'ok', $projected); + } + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $add('calendar', 'provider_error', self::errorClass($error), 'error'); + } + foreach ($this->app->db->pdo()->query('SELECT status,count(*) AS total FROM scheduled_jobs GROUP BY status')->fetchAll() as $row) { + $add('queue', 'queue', $row['status'] . ': ' . $row['total'], in_array($row['status'], ['failed', 'uncertain'], true) ? 'warning' : 'ok', ['status' => $row['status'], 'total' => (int)$row['total']]); + } + if ($group !== null) { + $this->permissions($group, $add); + } + $total = (int)$this->app->db->pdo()->query('SELECT count(*) FROM groups')->fetchColumn(); + $rows = $this->app->db->pdo()->query('SELECT g.group_id,g.title,s.status FROM groups g LEFT JOIN group_lock_state s ON s.group_id=g.group_id ORDER BY g.group_id LIMIT 200')->fetchAll(); + foreach ($rows as $row) { + $add('groups', 'group', $row['title'] . ' (' . $row['group_id'] . ') / ' . ($row['status'] ?? '-'), in_array($row['status'], ['conflict', 'error', 'recovery_required'], true) ? 'warning' : 'ok', ['group_id' => $row['group_id']]); + } + if ($total > count($rows)) { + $add('groups', 'limited', count($rows) . ' / ' . $total, 'warning'); + } + $this->app->adminAccess->require($user, 'statistics'); + return ['schema' => 'shabbat-diagnostics-1', 'created_at' => $this->app->clock->now()->getTimestamp(), + 'context' => $group ?? 'personal', 'timezone' => $settings['timezone_id'], 'read_only' => true, 'records' => $records]; + } + + private function permissions(string $group, callable $add): void + { + $q = $this->app->db->pdo()->prepare('SELECT group_id,prepared_at,status,snapshot_json,applied_json,previous_applied_json,recovery_baseline_json,recovery_baseline_sha256,snapshot_sha256,snapshot_provenance,snapshot_evidence_json,revision FROM group_lock_state WHERE group_id=?'); + $q->execute([$group]); + $saved = $q->fetch(); + $add('permissions', 'lock_state', $saved === false ? '-' : $saved['status'], $saved !== false && in_array($saved['status'], ['conflict', 'error', 'recovery_required'], true) ? 'warning' : 'ok'); + + $rights = []; + foreach (['original' => 'snapshot_json', 'requested' => 'applied_json', 'previous' => 'previous_applied_json', 'baseline' => 'recovery_baseline_json'] as $name => $column) { + try { + if ($saved !== false && is_string($saved[$column])) { + $hashColumn = match ($name) { + 'original' => 'snapshot_sha256', 'baseline' => 'recovery_baseline_sha256', default => null, + }; + if ($hashColumn !== null && (!is_string($saved[$hashColumn]) || !hash_equals($saved[$hashColumn], hash('sha256', $saved[$column])))) { + $add('permissions', $name === 'previous' ? 'previous_rights' : $name, 'checksum_mismatch', 'error', ['integrity' => 'checksum_mismatch']); + continue; + } + $rights[$name] = json_decode($saved[$column], true, 16, JSON_THROW_ON_ERROR); + Permissions::validate($rights[$name]); + } + } catch (\Throwable $error) { + unset($rights[$name]); + $add('permissions', $name === 'previous' ? 'previous_rights' : $name, self::errorClass($error), 'error'); + } + } + if ($saved !== false && $saved['snapshot_json'] !== null) { + $verified = isset($rights['original']) && \Shabbat\Scheduling\PermissionRecoveryProvenance::verified($saved); + $add('permissions', 'snapshot_verification', $saved['snapshot_provenance'], $verified ? 'ok' : 'warning', ['snapshot_provenance' => $verified ? 'verified_native_v1' : 'legacy_unverified']); + } + try { + $rights['current'] = $this->app->gateway->getPermissions($group); + Permissions::validate($rights['current']); + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + unset($rights['current']); + $add('permissions', 'current', self::errorClass($error), 'error'); + } + $q->execute([$group]); + $after = $q->fetch(); + if (($after['revision'] ?? null) !== ($saved['revision'] ?? null) || $after !== $saved) { + $add('permissions', 'concurrent_change', '-', 'warning'); + // A live read that crossed a local operation is useful, but not a coherent comparison. + unset($rights['original'], $rights['requested'], $rights['previous'], $rights['baseline']); + } + foreach ($rights as $name => $values) { + $projected = []; + foreach ([...Permissions::FLAGS, 'until_date'] as $flag) { + $projected[$flag] = $values[$flag] ?? ($flag === 'until_date' ? 0 : false); + } + $add('permissions', $name === 'previous' ? 'previous_rights' : $name, implode(', ', array_keys(array_filter($projected, static fn(mixed $value): bool => $value === true))) ?: '-', 'ok', ['rights' => $projected]); + } + $this->journal($group, $add); + foreach (['original', 'requested', 'previous', 'baseline'] as $name) { + if (!isset($rights['current'], $rights[$name])) { + continue; + } + $different = Permissions::differentFields($rights['current'], $rights[$name]); + $add('permissions', 'compare_' . $name, $different === [] ? '=' : implode(', ', $different), $different === [] ? 'ok' : 'warning', ['different_fields' => $different]); + } + } + + private function journal(string $group, callable $add): void + { + $q = $this->app->db->pdo()->prepare('SELECT operation_id,action,stage,outcome,before_json,requested_json,observed_json,original_json,original_sha256,created_at,updated_at FROM permission_operations WHERE group_id=? ORDER BY operation_id DESC LIMIT 1'); + $q->execute([$group]); + $operation = $q->fetch(); + if ($operation === false) { + return; + } + $projection = []; + foreach (['operation_id', 'created_at', 'updated_at'] as $field) { + $projection[$field] = (int)$operation[$field]; + } + foreach ( + [ + 'action' => ['lock', 'restore', 'administrator_adopt', 'administrator_restore'], + 'stage' => ['prepared', 'deferred', 'dispatched', 'verified', 'completed', 'recovered_readback'], + 'outcome' => ['not_dispatched', 'unknown', 'confirmed', 'conflict'], + ] as $field => $allowed + ) { + $projection[$field] = in_array($operation[$field], $allowed, true) ? $operation[$field] : 'unknown'; + } + foreach (['before', 'requested', 'observed', 'original'] as $kind) { + $json = $operation[$kind . '_json']; + if (!is_string($json)) { + $projection[$kind] = null; + continue; + } + try { + if ($kind === 'original' && (!is_string($operation['original_sha256']) || !hash_equals($operation['original_sha256'], hash('sha256', $json)))) { + throw new \RuntimeException('Unverified journal original.'); + } + $value = json_decode($json, true, 16, JSON_THROW_ON_ERROR); + Permissions::validate($value); + $projection[$kind] = array_intersect_key($value, array_flip([...Permissions::FLAGS, 'until_date'])); + } catch (\Throwable) { + $projection[$kind] = ['integrity' => 'invalid']; + } + } + $add('permissions', 'operation', $projection['action'] . ' / ' . $projection['stage'] . ' / ' . $projection['outcome'], $projection['outcome'] === 'confirmed' ? 'ok' : 'warning', $projection); + } + private function date(int $timestamp, string $timezone): string + { + return (new \DateTimeImmutable('@' . $timestamp))->setTimezone(new \DateTimeZone($timezone))->format('Y-m-d H:i'); + } + + private static function errorClass(\Throwable $error): string + { + return substr(strrchr('\\' . $error::class, '\\'), 1); + } +} diff --git a/src/Runtime/Donations.php b/src/Runtime/Donations.php new file mode 100644 index 0000000..743003e --- /dev/null +++ b/src/Runtime/Donations.php @@ -0,0 +1,274 @@ +rpc = Closure::fromCallable($rpc); + $this->lang = new Lang(); + } + /** Only invoice export; this never enables paid broadcasts or asks Telegram to debit a payer. */ + public function createOffers(string $user, string $language = 'he'): array + { + $offers = []; + $errors = []; + self::userId($user); + $this->language($language); + foreach (self::AMOUNTS as $amount) { + try { + $offers[] = $this->exportOffer($user, $amount, $language); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $errors[] = ['amount' => $amount, 'error' => $this->lang->text($language, 'donate_invoice_error')]; + } + } + $buttons = array_chunk(array_column($offers, 'button'), 3); + $buttons[] = [[ + '_' => 'keyboardButtonCallback', + 'text' => $this->lang->text($language, 'donate_other'), + 'data' => 'v3:donate:custom', + 'style' => ['_' => 'keyboardButtonStyle', 'bg_primary' => true], + ]]; + return [ + 'text' => $this->lang->text($language, 'donate_prompt'), + 'offers' => $offers, 'buttons' => $buttons, 'errors' => $errors, + 'reply_markup' => ['_' => 'replyInlineMarkup', 'rows' => array_map( + static fn(array $row): array => ['_' => 'keyboardButtonRow', 'buttons' => $row], + $buttons, + )], + ]; + } + + public static function customAmount(mixed $amount): int + { + if (is_string($amount) && preg_match('/^[1-9][0-9]{0,4}$/D', $amount)) { + $amount = (int)$amount; + } + if (!is_int($amount) || $amount < 1 || $amount > 10000) { + throw new \InvalidArgumentException('A donation requires a whole number from 1 to 10000 Stars.'); + } + return $amount; + } + + public function createCustomOffer(string $user, mixed $amount, string $language = 'he'): array + { + self::userId($user); + $this->language($language); + return $this->exportOffer($user, self::customAmount($amount), $language); + } + + private function language(string $language): void + { + if (!in_array($language, Lang::CODES, true)) { + throw new \InvalidArgumentException('Unsupported donation language.'); + } + } + + private function exportOffer(string $user, int $amount, string $language): array + { + $now = $this->clock->now()->getTimestamp(); + $this->db->pdo()->prepare('INSERT OR IGNORE INTO users(user_id,created_at,updated_at) VALUES(?,?,?)')->execute([$user, $now, $now]); + $nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); + $payload = 'shabbat:donate:' . $nonce; + $this->db->pdo()->prepare('INSERT INTO payment_intents(user_id,payload,nonce,amount,language,created_at,expires_at) VALUES(?,?,?,?,?,?,?)') + ->execute([$user, $payload, $nonce, $amount, $language, $now, $now + 3600]); + $id = (int)$this->db->pdo()->lastInsertId(); + try { + $invoice = [ + '_' => 'inputMediaInvoice', + 'title' => $this->lang->text($language, 'donate_title'), + 'description' => $this->lang->text($language, 'donate_description', ['amount' => $amount]), + 'invoice' => ['_' => 'invoice', 'currency' => 'XTR', 'prices' => [['_' => 'labeledPrice', 'label' => 'star', 'amount' => $amount]]], + 'payload' => $payload, 'provider_data' => ['_' => 'dataJSON', 'data' => '{}'], + ]; + $response = ($this->rpc)('payments.exportInvoice', ['invoice_media' => $invoice]); + $url = is_array($response) ? ($response['url'] ?? null) : null; + if ( + !is_string($url) || strlen($url) > 2048 || parse_url($url, PHP_URL_SCHEME) !== 'https' + || !in_array(parse_url($url, PHP_URL_HOST), ['t.me', 'telegram.me'], true) || parse_url($url, PHP_URL_USER) !== null + || parse_url($url, PHP_URL_PASS) !== null || preg_match('/[\x00\r\n]/', $url) + ) { + throw new \RuntimeException('Invoice export returned an invalid Telegram URL.'); + } + $this->db->pdo()->prepare("UPDATE payment_intents SET status='exported',invoice_url=? WHERE intent_id=? AND status='created'")->execute([$url, $id]); + return [ + 'intent_id' => $id, 'amount' => $amount, 'payload' => $payload, 'url' => $url, 'expires_at' => $now + 3600, + 'button' => ['_' => 'keyboardButtonUrl', 'text' => 'โญ๏ธ ' . $amount, 'url' => $url, 'style' => ['_' => 'keyboardButtonStyle']], + ]; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->db->pdo()->prepare("UPDATE payment_intents SET status='failed',last_error='invoice export failed' WHERE intent_id=?")->execute([$id]); + throw $error; + } + } + /** Read-only validation. Use answerPrecheckout() to reserve/approve an intent atomically. */ + public function validatePrecheckout(array $query): array + { + try { + $user = self::userId($query['user_id'] ?? null); + $payload = $query['payload'] ?? null; + $amount = $query['total_amount'] ?? null; + $currency = $query['currency'] ?? null; + $queryId = self::queryId($query['query_id'] ?? null); + if (!is_string($payload) || strlen($payload) > 128 || !is_int($amount) || $currency !== 'XTR') { + return $this->reject('invalid_invoice', $user); + } + $intent = $this->intent($payload); + if ($intent === null) { + return $this->reject(str_starts_with($payload, 'donate|') ? 'legacy_invoice_expired' : 'unknown_invoice', $user); + } + if ($intent['user_id'] !== $user || (int)$intent['amount'] !== $amount || $intent['currency'] !== $currency) { + return $this->reject('invoice_mismatch', $user); + } + if ((int)$intent['expires_at'] <= $this->clock->now()->getTimestamp()) { + return $this->reject('invoice_expired', $user); + } + if (!in_array($intent['status'], ['exported','precheckout_approved'], true)) { + return $this->reject('invoice_unavailable', $user); + } + if ($intent['precheckout_query_id'] !== null && $intent['precheckout_query_id'] !== $queryId) { + return $this->reject('invoice_already_reserved', $user); + } + return ['ok' => true,'query_id' => $queryId,'intent_id' => (int)$intent['intent_id'],'user_id' => $user,'amount' => $amount,'currency' => 'XTR','language' => $intent['language']]; + } catch (\InvalidArgumentException) { + return $this->reject('invalid_invoice'); + } + } + public function answerPrecheckout(array $query): array + { + $result = $this->validatePrecheckout($query); + $queryId = self::queryId($query['query_id'] ?? null); + if ($result['ok']) { + $reserved = $this->db->transaction(function (PDO $pdo) use ($result, $queryId): bool { + $now = $this->clock->now()->getTimestamp(); + $reserve = $pdo->prepare("UPDATE payment_intents SET status='precheckout_approved',precheckout_query_id=?,approved_at=? WHERE intent_id=? AND expires_at>? AND (status='exported' OR (status='precheckout_approved' AND precheckout_query_id=?))"); + $reserve->execute([$queryId,$now,$result['intent_id'],$now,$queryId]); + return $reserve->rowCount() === 1; + }); + if (!$reserved) { + $result = $this->reject('invoice_unavailable', $result['user_id']); + } + } + $parameters = ['query_id' => $queryId,'success' => $result['ok']]; + if (!$result['ok']) { + $parameters['error'] = $result['error']; + } + $ack = ($this->rpc)('messages.setBotPrecheckoutResults', $parameters); + if ($ack !== true) { + throw new \RuntimeException('Telegram did not acknowledge the precheckout answer.'); + } + return $result; + } + /** Accept only authenticated MTProto payment service updates, never ordinary text/callbacks. */ + public function recordPayment(array $update): array + { + $message = $update['message'] ?? $update; + $action = $message['action'] ?? $update; + if (isset($message['action']) && ($action['_'] ?? null) !== 'messageActionPaymentSentMe') { + return ['status' => 'rejected','reason' => 'not_payment_service']; + } + try { + $sender = $message['from_id'] ?? null; + if (isset($message['action'])) { + $user = self::userId(is_array($sender) ? (($sender['_'] ?? null) === 'peerUser' ? ($sender['user_id'] ?? null) : null) : $sender); + if (isset($update['user_id']) && self::userId($update['user_id']) !== $user) { + return ['status' => 'rejected','reason' => 'sender_mismatch']; + } + } else { + $user = self::userId($update['user_id'] ?? null); + } + } catch (\InvalidArgumentException) { + return ['status' => 'rejected','reason' => 'invalid_sender']; + } + $payload = $action['payload'] ?? null; + $amount = $action['total_amount'] ?? null; + $currency = $action['currency'] ?? null; + $charge = $action['charge']['id'] ?? $action['charge_uid'] ?? null; + if (!is_string($payload) || !is_int($amount) || $currency !== 'XTR' || !is_string($charge) || strlen($charge) < 1 || strlen($charge) > 256 || preg_match('/[\x00\r\n]/', $charge)) { + return ['status' => 'rejected','reason' => 'invalid_payment_fields']; + } + return $this->db->transaction(function (PDO $pdo) use ($user, $payload, $amount, $currency, $charge, $message): array { + $intent = $this->intent($payload); + if ($intent === null || $intent['user_id'] !== $user || (int)$intent['amount'] !== $amount || $intent['currency'] !== $currency) { + return ['status' => 'rejected','reason' => str_starts_with($payload, 'donate|') ? 'legacy_unverifiable' : 'intent_mismatch']; + } + $existing = $pdo->prepare('SELECT intent_id,user_id,amount,currency FROM donations WHERE charge_uid=?'); + $existing->execute([$charge]); + $donation = $existing->fetch(); + if ($donation !== false) { + return ['status' => (int)$donation['intent_id'] === (int)$intent['intent_id'] && $donation['user_id'] === $user && (int)$donation['amount'] === $amount && $donation['currency'] === $currency ? 'duplicate' : 'rejected']; + } + if ($intent['status'] !== 'precheckout_approved') { + return ['status' => 'rejected','reason' => 'payment_not_approved_or_already_recorded']; + } + $now = $this->clock->now()->getTimestamp(); + $serviceId = is_int($message['id'] ?? null) ? $message['id'] : null; + $pdo->prepare('INSERT INTO donations(intent_id,user_id,currency,amount,charge_uid,received_at,service_message_id) VALUES(?,?,?,?,?,?,?)') + ->execute([$intent['intent_id'],$user,$currency,$amount,$charge,$now,$serviceId]); + $pdo->prepare("UPDATE payment_intents SET status='paid',paid_at=? WHERE intent_id=?")->execute([$now,$intent['intent_id']]); + return ['status' => 'recorded','user_id' => $user,'amount' => $amount,'currency' => $currency,'language' => $intent['language'],'service_message_id' => $serviceId]; + }); + } + public function stats(): array + { + $row = $this->db->pdo()->query('SELECT COUNT(*) AS donations,COALESCE(SUM(amount),0) AS stars FROM donations')->fetch(); + return ['donations' => (int)$row['donations'],'stars' => (int)$row['stars']]; + } + private function intent(string $payload): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM payment_intents WHERE payload=?'); + $query->execute([$payload]); + $row = $query->fetch(); + return $row === false ? null : $row; + } + private function reject(string $reason, ?string $user = null): array + { + $language = 'he'; + if ($user !== null) { + $query = $this->db->pdo()->prepare('SELECT language FROM user_preferences WHERE user_id=?'); + $query->execute([$user]); + $language = $query->fetchColumn() ?: 'he'; + } + return ['ok' => false,'reason' => $reason,'error' => $this->lang->text($language, 'donate_checkout_error')]; + } + private static function userId(mixed $id): string + { + if (!is_int($id) && !is_string($id)) { + throw new \InvalidArgumentException('Invalid payment user.'); + } + $id = (string)$id; + if (!preg_match('/^[1-9][0-9]{0,19}$/D', $id)) { + throw new \InvalidArgumentException('Invalid payment user.'); + } + return $id; + } + private static function queryId(mixed $id): string + { + if (!is_int($id) && !is_string($id)) { + throw new \InvalidArgumentException('Invalid precheckout query.'); + } + $id = (string)$id; + if (!preg_match('/^-?[0-9]{1,19}$/D', $id)) { + throw new \InvalidArgumentException('Invalid precheckout query.'); + } + return $id; + } +} diff --git a/src/Runtime/EventHandlerTransport.php b/src/Runtime/EventHandlerTransport.php new file mode 100644 index 0000000..3e5e59e --- /dev/null +++ b/src/Runtime/EventHandlerTransport.php @@ -0,0 +1,44 @@ +handler->{$namespace}->{$name}($parameters); + } + + public function info(string $peer): array + { + return $this->handler->getInfo($peer); + } + + public function fullInfo(string $peer): array + { + return FreshPeerReader::read( + $peer, + $this->handler->getSettings(), + $this->handler->getFullInfo(...), + ); + } + + public function fileInfo(string $fileId): array + { + return $this->handler->getFileInfo($fileId); + } + + public function self(): array + { + return $this->handler->getSelf(); + } +} diff --git a/src/Runtime/FreshPeerReader.php b/src/Runtime/FreshPeerReader.php new file mode 100644 index 0000000..9301e93 --- /dev/null +++ b/src/Runtime/FreshPeerReader.php @@ -0,0 +1,93 @@ +getPeer()->getFullInfoCacheTime() !== 0) { + throw new \RuntimeException('Fresh permissions require the configured zero full-info cache lifetime.'); + } + return self::verified($peer, $fullInfo($peer)); + } + + /** Validate the per-call evidence added by the exact, reviewed PeerHandler patch. */ + public static function verified(string $peer, array $result): array + { + if (!preg_match('/^-[1-9][0-9]*$/D', $peer) || (string)(int)$peer !== $peer) { + throw new \RuntimeException('A canonical signed Telegram group ID is required.'); + } + $reply = $result[self::EVIDENCE_KEY] ?? null; + $full = is_array($reply) ? ($reply['full_chat'] ?? null) : null; + if ( + !is_array($reply) || ($reply['_'] ?? '') !== 'messages.chatFull' + || !is_array($reply['chats'] ?? null) || !array_is_list($reply['chats']) + || !is_array($reply['users'] ?? null) || !array_is_list($reply['users']) + || !is_array($full) || !is_int($full['id'] ?? null) || (string)$full['id'] !== $peer + || !in_array($full['_'] ?? '', ['chatFull', 'channelFull'], true) + || !is_string($full['about'] ?? null) || !is_array($full['notify_settings'] ?? null) + || ($full['notify_settings']['_'] ?? '') !== 'peerNotifySettings' + ) { + throw new \RuntimeException('Telegram did not return verifiable current full-peer evidence.'); + } + $basic = $full['_'] === 'chatFull'; + if ($basic) { + if (!is_array($full['participants'] ?? null) || !in_array($full['participants']['_'] ?? '', ['chatParticipants', 'chatParticipantsForbidden'], true)) { + throw new \RuntimeException('Telegram returned incomplete basic-group information.'); + } + } else { + foreach (['read_inbox_max_id', 'read_outbox_max_id', 'unread_count', 'pts'] as $field) { + if (!is_int($full[$field] ?? null)) { + throw new \RuntimeException('Telegram returned incomplete channel information.'); + } + } + if (!is_array($full['chat_photo'] ?? null) || !is_array($full['bot_info'] ?? null) || !array_is_list($full['bot_info'])) { + throw new \RuntimeException('Telegram returned incomplete channel information.'); + } + } + $matches = []; + foreach ($reply['chats'] as $candidate) { + if (is_array($candidate) && (string)($candidate['id'] ?? '') === $peer) { + $matches[] = $candidate; + } + } + if (count($matches) !== 1) { + throw new \RuntimeException('Telegram returned missing or ambiguous target peer evidence.'); + } + $chat = $matches[0]; + if ( + ($chat['_'] ?? '') !== ($basic ? 'chat' : 'channel') || !is_int($chat['id'] ?? null) + || !is_string($chat['title'] ?? null) || !is_array($chat['photo'] ?? null) || !is_int($chat['date'] ?? null) + || !in_array($chat['photo']['_'] ?? '', ['chatPhoto', 'chatPhotoEmpty'], true) + || ($chat['min'] ?? false) !== false || ($chat['deactivated'] ?? false) !== false + || ($chat['left'] ?? false) !== false || isset($chat['migrated_to']) || ($chat['monoforum'] ?? false) !== false + ) { + throw new \RuntimeException('Telegram returned incomplete, inaccessible or migrated target peer evidence.'); + } + if ($basic && (!is_int($chat['participants_count'] ?? null) || !is_int($chat['version'] ?? null))) { + throw new \RuntimeException('Telegram returned incomplete basic-group information.'); + } + // Absence is meaningful only in this verified, non-min native Chat constructor. + // Never substitute cached default rights, including when Telegram removed them. + if (array_key_exists('default_banned_rights', $chat)) { + $rights = $chat['default_banned_rights']; + if (!is_array($rights) || ($rights['_'] ?? '') !== 'chatBannedRights' || !is_int($rights['until_date'] ?? null)) { + throw new \RuntimeException('Telegram returned malformed current default permissions.'); + } + \Shabbat\Scheduling\Permissions::validate($rights); + } + $result['Chat'] = $chat; + $result['full'] = $full; + $result['bot_api_id'] = $chat['id']; + $result['type'] = $basic ? 'chat' : (($chat['megagroup'] ?? false) ? 'supergroup' : 'channel'); + return $result; + } +} diff --git a/src/Runtime/GroupRegistration.php b/src/Runtime/GroupRegistration.php new file mode 100644 index 0000000..7a4231b --- /dev/null +++ b/src/Runtime/GroupRegistration.php @@ -0,0 +1,32 @@ +gateway->isGroupAdmin($user, $group)) { + return false; + } + $fresh = $info($group); + if (!is_array($fresh) || !in_array($fresh['type'] ?? '', ['chat','supergroup'], true)) { + return false; + } + $title = $fresh['Chat']['title'] ?? $group; + if (!is_string($title) || trim($title) === '') { + $title = $group; + } + if ($app->settings->getGroup($group) === null) { + $app->settings->ensureGroup($group, $title); + } else { + $app->settings->refreshGroupTitle($group, $title); + } + $app->subscribers->recordGroupAdmin($user, $group, true); + return true; + } +} diff --git a/src/Runtime/MadelineGateway.php b/src/Runtime/MadelineGateway.php new file mode 100644 index 0000000..c7b7b91 --- /dev/null +++ b/src/Runtime/MadelineGateway.php @@ -0,0 +1,687 @@ +transport = $bot instanceof MadelineTransport ? $bot : new EventHandlerTransport($bot); + } + + private function call(string $method, array $parameters, ?callable $dispatchGuard = null, bool $translateErrors = true): mixed + { + if (Runner::isStopping() && in_array($method, ['messages.sendMessage','messages.sendMedia','messages.sendMultiMedia','messages.editMessage','messages.setInlineBotResults'], true)) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped new Telegram message operations.')); + } + $plainParameters = self::stripPresentationMetadata(self::plainEmojiParameters($parameters)); + $parameters = self::stripPresentationMetadata($parameters); + try { + return $this->dispatch($method, $parameters, $dispatchGuard, $translateErrors); + } catch (\danog\MadelineProto\RPCErrorException $error) { + if ( + !in_array($error->rpc, ['PREMIUM_ACCOUNT_REQUIRED', 'CUSTOM_EMOJI_INVALID'], true) + || !self::hasCustomEmoji($parameters) + || !in_array($method, ['messages.sendMessage', 'messages.sendMedia', 'messages.editMessage', 'messages.setInlineBotResults', 'messages.sendMultiMedia'], true) + ) { + throw $error; + } + // A definitive server rejection did not accept the first operation. + // Retry once with the same random_id, edit target or inline query ID. + if (Runner::isStopping()) { + throw new CancelledException($error); + } + $this->customEmojiEnabled = false; + $parameters = $plainParameters; + return $this->dispatch($method, $parameters, $dispatchGuard, $translateErrors); + } + } + + /** No application await is allowed between the final fence and transport entry. */ + private function dispatch(string $method, array $parameters, ?callable $guard, bool $translateErrors): mixed + { + $operation = function () use ($method, $parameters, $guard): mixed { + if (Runner::isStopping() && in_array($method, ['messages.sendMessage','messages.sendMedia','messages.sendMultiMedia','messages.editMessage','messages.setInlineBotResults'], true)) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped new Telegram message operations.')); + } + if ($guard !== null) { + $guard(); + } + return $this->transport->call($method, $parameters + ['floodWaitLimit' => 0]); + }; + return $translateErrors ? $this->invoke($operation) : $operation(); + } + + /** Optional per-instance BroadcastManager transport, with native identities intact. */ + public function broadcastCall(string $method, array $parameters): mixed + { + $method = str_starts_with($method, 'messages.') ? $method : 'messages.' . $method; + if (!in_array($method, ['messages.sendMessage','messages.sendMedia','messages.sendMultiMedia','messages.editMessage'], true)) { + throw new \InvalidArgumentException('Unsupported broadcast message operation.'); + } + if (Runner::isStopping()) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped broadcast message operations.')); + } + if (isset($parameters['message'])) { + $parameters = $this->presentNativeParameters($parameters); + } + foreach ($parameters['multi_media'] ?? [] as $index => $single) { + $parameters['multi_media'][$index] = $this->presentNativeParameters($single); + } + if (isset($parameters['reply_markup'])) { + $parameters['reply_markup'] = self::normalizeMarkup($parameters['reply_markup'], $this->customEmojiEnabled); + } + // The native broadcast manager owns its per-peer retry clock. Never let + // an inherited SDK floodWaitLimit suspend this transport for days. + $parameters['floodWaitLimit'] = 0; + return $this->call($method, $parameters, null, false); + } + + private function presentNativeParameters(array $parameters): array + { + $mode = strtolower((string)($parameters['parse_mode'] ?? '')); + if ($mode !== '' && $mode !== 'html') { + return $parameters; // Preserve an existing non-HTML parser contract. + } + $input = ['text' => $parameters['message'] ?? '']; + if (array_key_exists('entities', $parameters)) { + $input['entities'] = $parameters['entities']; + unset($parameters['parse_mode']); + } + $payload = $this->present($input, $mode === ''); + $parameters['message'] = $payload['text']; + if (isset($payload['entities'])) { + $parameters['entities'] = $payload['entities']; + } + return $parameters; + } + private function present(array $payload, bool $plain = false): array + { + if ( + $this->customEmojiEnabled && $this->customEmojiAlternatives === null + && Presentation::requiresCatalog((string)($payload['text'] ?? '')) + ) { + $this->customEmojiAlternatives = []; + try { + $documents = $this->call('messages.getCustomEmojiDocuments', ['document_id' => array_map( + static fn(string $id): int => TelegramInt64::fromDecimal($id), + array_values(Presentation::ICONS) + )]); + if (!is_array($documents) || !array_is_list($documents)) { + throw new \RuntimeException('Invalid custom emoji document catalog.'); + } + foreach ($documents as $document) { + $id = (string)($document['id'] ?? ''); + if (($document['_'] ?? '') !== 'document' || !in_array($id, Presentation::ICONS, true)) { + continue; + } + foreach ($document['attributes'] ?? [] as $attribute) { + $alt = $attribute['alt'] ?? null; + if ( + ($attribute['_'] ?? '') === 'documentAttributeCustomEmoji' + && is_string($alt) && preg_match('/^\X$/uD', $alt) === 1 && trim($alt) !== '' + ) { + $this->customEmojiAlternatives[$id] = $alt; + } + } + } + if ($this->customEmojiAlternatives === []) { + $this->customEmojiEnabled = false; + } + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable) { + // Lookup is a presentation enhancement, never a reason to lose a panel. + $this->customEmojiEnabled = false; + } + } + return Presentation::payload($payload, $this->customEmojiEnabled, $this->customEmojiAlternatives ?? [], !$plain); + } + + private static function hasCustomEmoji(array $parameters): bool + { + if (is_string($parameters['message'] ?? null) && str_contains($parameters['message'], ' $value) { + if (is_array($value)) { + $parameters[$key] = self::stripPresentationMetadata($value); + } + } + return $parameters; + } + private static function plainEmojiParameters(array $parameters): array + { + if (is_string($parameters['message'] ?? null) && strcasecmp((string)($parameters['parse_mode'] ?? ''), 'HTML') === 0) { + $parameters['message'] = Presentation::html($parameters['message'], false); + } + if (isset($parameters['entities'])) { + $parameters['entities'] = array_values(array_filter($parameters['entities'], static fn(array $entity): bool => + !in_array($entity['_'] ?? $entity['type'] ?? '', ['messageEntityCustomEmoji', 'custom_emoji'], true))); + } + if (isset($parameters['reply_markup'])) { + $parameters['reply_markup'] = self::normalizeMarkup($parameters['reply_markup'], false); + } + foreach ($parameters['multi_media'] ?? [] as $index => $single) { + if (is_array($single)) { + $parameters['multi_media'][$index] = self::plainEmojiParameters($single); + } + } + foreach ($parameters['results'] ?? [] as $index => $result) { + if (is_array($result['send_message'] ?? null)) { + $parameters['results'][$index]['send_message'] = self::plainEmojiParameters($result['send_message']); + } + } + return $parameters; + } + private function invoke(callable $operation): mixed + { + try { + return $operation(); + } catch (RateLimitError $error) { + throw new RetryableTelegramException('Telegram flood wait', max(1, $error->getWaitTimeLeft()), $error); + } catch (TimeoutException $error) { + throw new RetryableTelegramException('Telegram request timeout', 30, $error); + } catch (CancelledException $error) { + if ($error->getPrevious() instanceof TimeoutException && !Runner::isStopping()) { + throw new RetryableTelegramException('Telegram request timeout cancellation', 30, $error); + } + throw $error; + } + } + + public function isGroupAdmin(string $userId, string $groupId): bool + { + if (!ctype_digit($userId) || (int) $userId <= 0) { + return false; + } + try { + $info = $this->transport->info($groupId); + if (($info['type'] ?? '') === 'chat') { + $full = $this->invoke(fn () => $this->transport->fullInfo($groupId)); + foreach ($full['full']['participants']['participants'] ?? [] as $participant) { + if ((string) ($participant['user_id'] ?? '') === $userId) { + return in_array($participant['_'] ?? '', ['chatParticipantAdmin', 'chatParticipantCreator'], true); + } + } + return false; + } + $response = $this->call('channels.getParticipant', ['channel' => $groupId, 'participant' => $userId]); + return in_array($response['participant']['_'] ?? '', ['channelParticipantAdmin', 'channelParticipantCreator'], true); + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable) { + // No network error or unverifiable identity grants permission. + return false; + } + } + + public function getPermissions(string $groupId): array + { + $full = $this->invoke(fn () => $this->transport->fullInfo($groupId)); + $current = FreshPeerReader::verified($groupId, $full); + return $current['Chat']['default_banned_rights'] ?? ['_' => 'chatBannedRights', 'until_date' => 0]; + } + public function setPermissions(string $groupId, array $permissions): void + { + if (($permissions['_'] ?? '') !== 'chatBannedRights') { + throw new \InvalidArgumentException('Invalid permissions snapshot.'); + } + if (Runner::isStopping()) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped permission changes.')); + } + if (($permissions['view_messages'] ?? false) === true) { + throw new \InvalidArgumentException('Telegram forbids view_messages in global default permissions.'); + } + try { + if ($this->call('messages.editChatDefaultBannedRights', ['peer' => $groupId, 'banned_rights' => $permissions]) === false) { + throw new \RuntimeException('Telegram did not confirm the permission change.'); + } + } catch (\danog\MadelineProto\RPCErrorException $error) { + // A racing request may have applied the same mask. The coordinator + // still reads and compares all fresh rights after this response. + if ($error->rpc !== 'CHAT_NOT_MODIFIED') { + throw $error; + } + } + } + + public function send(string $peer, array $payload, string $randomId): array + { + return $this->sendPrepared($peer, $payload, $randomId, null); + } + + public function sendGuarded(string $peer, array $payload, string $randomId, callable $guard): array + { + return $this->sendPrepared($peer, $payload, $randomId, $guard); + } + + private function sendPrepared(string $peer, array $payload, string $randomId, ?callable $dispatchGuard): array + { + if (Runner::isStopping()) { + throw new \RuntimeException('Shutdown has stopped new Telegram sends.'); + } + if (!preg_match('/^-?\d+$/', $randomId)) { + throw new \InvalidArgumentException('MTProto random_id must be an integer string.'); + } + $payload = $this->present($payload); + $parameters = ['peer' => $peer, 'random_id' => TelegramInt64::fromDecimal($randomId), 'message' => $payload['text'] ?? '', 'no_webpage' => true]; + if (array_key_exists('entities', $payload)) { + $parameters['entities'] = $payload['entities']; + } else { + $parameters['parse_mode'] = 'HTML'; + } + if (!empty($payload['buttons'])) { + $parameters['reply_markup'] = self::normalizeMarkup(['inline_keyboard' => $payload['buttons']], $this->customEmojiEnabled); + } + if (isset($payload['reply_markup'])) { + $parameters['reply_markup'] = self::normalizeMarkup($payload['reply_markup'], $this->customEmojiEnabled); + } + if (!empty($payload['topic_id'])) { + $topicId = (int) $payload['topic_id']; + $topic = $this->call('messages.getForumTopicsByID', ['peer' => $peer, 'topics' => [$topicId]]); + $found = false; + foreach ($topic['topics'] ?? [] as $item) { + if ((int) ($item['id'] ?? 0) === $topicId && !($item['closed'] ?? false)) { + $found = true; + } + } + if (!$found) { + throw new \RuntimeException('Selected forum topic is missing or closed.'); + } + $parameters['reply_to'] = ['_' => 'inputReplyToMessage', 'reply_to_msg_id' => $topicId, 'top_msg_id' => $topicId]; + } + if (!empty($payload['media'])) { + $media = $payload['media']; + if (is_array($media) && isset($media['bot_api_file_id'])) { + $media = (string) $media['bot_api_file_id']; + } + if (is_string($media)) { + $decoded = $this->transport->fileInfo($media); + $media = $decoded['InputMedia'] ?? null; + } + if (!is_array($media) || !str_starts_with((string) ($media['_'] ?? ''), 'inputMedia')) { + throw new \InvalidArgumentException('Media must be a reusable Telegram file ID or MTProto InputMedia.'); + } + $parameters['media'] = $media; + unset($parameters['no_webpage']); + return $this->call('messages.sendMedia', $parameters, $dispatchGuard); + } + return $this->call('messages.sendMessage', $parameters, $dispatchGuard); + } + + public static function normalizeMarkup(array $markup, bool $customEmojiEnabled = true): array + { + if ($markup === []) { + return []; + } + if (($markup['_'] ?? '') === 'replyInlineMarkup') { + foreach ($markup['rows'] ?? [] as $rowIndex => $row) { + foreach ($row['buttons'] ?? [] as $buttonIndex => $button) { + $button = Presentation::button($button, $customEmojiEnabled, null, true); + $markup['rows'][$rowIndex]['buttons'][$buttonIndex] = $button; + if (isset($button['style'])) { + $markup['rows'][$rowIndex]['buttons'][$buttonIndex]['style'] = self::normalizeStyle($button['style']); + } + } + } + return $markup; + } + if (!isset($markup['inline_keyboard']) || !is_array($markup['inline_keyboard'])) { + throw new \InvalidArgumentException('Unsupported message keyboard.'); + } + $rows = []; + foreach ($markup['inline_keyboard'] as $row) { + if (!is_array($row)) { + throw new \InvalidArgumentException('Invalid keyboard row.'); + } + $buttons = []; + foreach ($row as $button) { + if (!is_array($button) || !is_string($button['text'] ?? null) || $button['text'] === '') { + throw new \InvalidArgumentException('Keyboard button needs text.'); + } + $button = Presentation::button($button, $customEmojiEnabled, null, true); + $native = $button; // Preserve existing button metadata/style. + if (isset($button['style'])) { + $native['style'] = self::normalizeStyle($button['style']); + } + if (isset($button['callback_data'])) { + if (!is_string($button['callback_data']) || strlen($button['callback_data']) > 64) { + throw new \InvalidArgumentException('Invalid callback data.'); + } + $native['_'] = 'keyboardButtonCallback'; + $native['data'] = $button['callback_data']; + unset($native['callback_data']); + } elseif (isset($button['url'])) { + if (!is_string($button['url']) || !preg_match('~^(?:https://|tg://)~i', $button['url'])) { + throw new \InvalidArgumentException('Button URL must use HTTPS or Telegram.'); + } + $native['_'] = 'keyboardButtonUrl'; + } elseif (isset($button['switch_inline_query_current_chat']) || isset($button['switch_inline_query'])) { + $native['_'] = 'keyboardButtonSwitchInline'; + $native['same_peer'] = isset($button['switch_inline_query_current_chat']); + $native['query'] = $button['switch_inline_query_current_chat'] ?? $button['switch_inline_query']; + unset($native['switch_inline_query_current_chat'], $native['switch_inline_query']); + } elseif (isset($button['copy_text'])) { + $copy = is_array($button['copy_text']) ? ($button['copy_text']['text'] ?? null) : $button['copy_text']; + if (!is_string($copy) || $copy === '' || mb_strlen($copy) > 256) { + throw new \InvalidArgumentException('Invalid copy-text button.'); + } + $native['_'] = 'keyboardButtonCopy'; + $native['copy_text'] = $copy; + } elseif (!str_starts_with((string) ($button['_'] ?? ''), 'keyboardButton')) { + throw new \InvalidArgumentException('Unsupported keyboard button action.'); + } + $buttons[] = $native; + } + $rows[] = ['_' => 'keyboardButtonRow', 'buttons' => $buttons]; + } + return ['_' => 'replyInlineMarkup', 'rows' => $rows]; + } + + private static function normalizeStyle(mixed $style): array + { + $native = \Shabbat\Content\TemplateRenderer::nativeStyle($style); + if (isset($native['icon'])) { + $native['icon'] = TelegramInt64::fromDecimal($native['icon']); + } + return $native; + } + + /** Acknowledged identity binds all subsequent diagnostic input to this exact panel. */ + public function sendDiagnosticPanel(string $user, string $text, array $markup): int + { + if (!preg_match('/^[1-9][0-9]{0,19}$/D', $user)) { + throw new \InvalidArgumentException('A private diagnostic peer is required.'); + } + $random = (string)random_int(1, PHP_INT_MAX); + $result = $this->call('messages.sendMessage', ['peer' => $user, 'random_id' => (int)$random, + 'message' => $this->present(['text' => $text])['text'], 'parse_mode' => 'HTML', + 'reply_markup' => self::normalizeMarkup($markup, $this->customEmojiEnabled), 'no_webpage' => true]); + $ids = is_array($result) ? SentMessageIds::extract($result, $random, $user) : []; + if (count($ids) !== 1) { + throw new \RuntimeException('The diagnostic panel acknowledgement is ambiguous.'); + } + return $ids[0]; + } + + /** Pinned 8.7 TL InputFile serialization calls upload(ReadableStream); no filesystem path. */ + public function sendDiagnosticDocument(string $user, string $json, string $caption, string $randomId): void + { + if (!preg_match('/^[1-9][0-9]{0,19}$/D', $user) || strlen($json) > 1048576 || mb_strlen($caption) > 500) { + throw new \InvalidArgumentException('Invalid private diagnostic export.'); + } + $payload = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($payload) || ($payload['schema'] ?? '') !== 'shabbat-diagnostics-1' || ($payload['read_only'] ?? false) !== true) { + throw new \InvalidArgumentException('A diagnostic snapshot is required.'); + } + $random = TelegramInt64::fromDecimal($randomId); + if ($random <= 0) { + throw new \InvalidArgumentException('Invalid diagnostic export identity.'); + } + $this->call('messages.sendMedia', ['peer' => $user, 'random_id' => $random, 'message' => $caption, + 'media' => ['_' => 'inputMediaUploadedDocument', 'force_file' => true, + 'file' => new \Amp\ByteStream\ReadableBuffer($json), 'mime_type' => 'application/json', + 'attributes' => [['_' => 'documentAttributeFilename', 'file_name' => 'shabbat-diagnostics.json']]]]); + } + public function sendPrivate(string $userId, string $text, array $markup = []): void + { + $this->call('messages.sendMessage', ['peer' => $userId, 'random_id' => random_int(1, PHP_INT_MAX), 'message' => $this->present(['text' => $text])['text'], 'parse_mode' => 'HTML', 'reply_markup' => (self::normalizeMarkup($markup, $this->customEmojiEnabled) ?: null), 'no_webpage' => true]); + } + + /** Caller must verify the persisted group/message binding before editing a counter. */ + public function editGroupMarkup(string $groupId, int $messageId, array $markup): void + { + if (!preg_match('/^-[1-9][0-9]{0,19}$/D', $groupId) || $messageId < 1 || $messageId > 2147483647) { + throw new \InvalidArgumentException('A recorded group message is required.'); + } + $parameters = ['peer' => $groupId, 'id' => $messageId, + 'reply_markup' => self::normalizeMarkup($markup, $this->customEmojiEnabled) ?: ['_' => 'replyInlineMarkup', 'rows' => []]]; + try { + $this->call('messages.editMessage', $parameters); + } catch (\danog\MadelineProto\RPCErrorException $error) { + if ($error->rpc !== 'MESSAGE_NOT_MODIFIED') { + throw $error; + } + } + } + public function editPrivate(string $userId, int $messageId, string $text, array $markup = []): void + { + $this->editPrivatePayload($userId, $messageId, ['text' => $text, 'reply_markup' => $markup]); + } + + public function editPrivatePayload(string $userId, int $messageId, array $payload): void + { + if (!preg_match('/^[1-9][0-9]{0,19}$/D', $userId) || $messageId < 1 || $messageId > 2147483647) { + throw new \InvalidArgumentException('A private bot panel is required.'); + } + $payload = $this->present($payload); + $parameters = ['peer' => $userId, 'id' => $messageId, 'message' => $payload['text'] ?? '', 'no_webpage' => true]; + if (array_key_exists('entities', $payload)) { + $parameters['entities'] = $payload['entities']; + } else { + $parameters['parse_mode'] = 'HTML'; + } + $markup = $payload['reply_markup'] ?? (isset($payload['buttons']) ? ['inline_keyboard' => $payload['buttons']] : []); + $parameters['reply_markup'] = self::normalizeMarkup($markup, $this->customEmojiEnabled) ?: ['_' => 'replyInlineMarkup', 'rows' => []]; + if (!empty($payload['media'])) { + $media = $payload['media']; + if (is_array($media) && isset($media['bot_api_file_id'])) { + $media = $media['bot_api_file_id']; + } + if (is_string($media)) { + $media = $this->transport->fileInfo($media)['InputMedia'] ?? null; + } + if (!is_array($media) || !str_starts_with((string)($media['_'] ?? ''), 'inputMedia')) { + throw new \InvalidArgumentException('A reusable Telegram file is required for a preview.'); + } + $parameters['media'] = $media; + unset($parameters['no_webpage']); + } + try { + $this->call('messages.editMessage', $parameters); + } catch (\danog\MadelineProto\RPCErrorException $error) { + if ($error->rpc !== 'MESSAGE_NOT_MODIFIED') { + throw $error; + } + // Repeating the same navigation selection is already the desired result. + } + } + + public function answerCallback(string $queryId, string $text): void + { + $this->call('messages.setBotCallbackAnswer', ['query_id' => TelegramInt64::fromDecimal($queryId), 'message' => str_replace('โ€”', '-', $text), 'alert' => true, 'cache_time' => 0]); + } + + public function answerInline(string $queryId, array $results, array $options = []): void + { + foreach ($results as &$result) { + foreach (['title','description'] as $label) { + if (is_string($result[$label] ?? null)) { + $result[$label] = str_replace('โ€”', '-', $result[$label]); + } + } + if (!is_array($result['send_message'] ?? null)) { + continue; + } + $message = &$result['send_message']; + $input = ['text' => $message['message'] ?? '']; + if (array_key_exists('entities', $message)) { + $input['entities'] = $message['entities']; + unset($message['parse_mode']); + } + $payload = $this->present($input); + $message['message'] = $payload['text']; + if (($payload['entities'] ?? []) !== [] || isset($message['entities'])) { + $message['entities'] = $payload['entities'] ?? []; + } + if (isset($message['reply_markup'])) { + $message['reply_markup'] = self::normalizeMarkup($message['reply_markup'], $this->customEmojiEnabled); + } + unset($message); + } + unset($result); + $this->call('messages.setInlineBotResults', ['query_id' => TelegramInt64::fromDecimal($queryId), 'results' => $results] + $options + ['cache_time' => 0, 'private' => true]); + } + + public function deleteMessages(string $peer, array $messageIds): void + { + if ($messageIds === []) { + return; + } + if (count($messageIds) > 100 || !array_is_list($messageIds)) { + throw new \InvalidArgumentException('Deletion expects at most 100 recorded message IDs.'); + } + foreach ($messageIds as $id) { + if (!is_int($id) || $id < 1 || $id > 2147483647) { + throw new \InvalidArgumentException('Deletion message IDs must be positive native int32 values.'); + } + } + if (Runner::isStopping()) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped new message deletions.')); + } + $info = $this->invoke(fn () => $this->transport->info($peer)); + $type = $info['type'] ?? ''; + if (!in_array($type, ['chat', 'supergroup', 'channel'], true)) { + throw new \RuntimeException('Deletion target is not a verified Telegram group.'); + } + if (Runner::isStopping()) { + throw new CancelledException(new \RuntimeException('Shutdown has stopped new message deletions.')); + } + $ids = array_values(array_unique($messageIds)); + $response = $type === 'chat' + ? $this->call('messages.deleteMessages', ['revoke' => true, 'id' => $ids]) + : $this->call('channels.deleteMessages', ['channel' => $peer, 'id' => $ids]); + if ( + !is_array($response) || ($response['_'] ?? '') !== 'messages.affectedMessages' + || !is_int($response['pts'] ?? null) || $response['pts'] < 0 + || !is_int($response['pts_count'] ?? null) || $response['pts_count'] < 0 + ) { + throw new \RuntimeException('Telegram did not acknowledge message deletion.'); + } + } + + public function senderIsBot(string $group, string $senderId): ?bool + { + try { + $id = TelegramInt64::fromDecimal($senderId); + if ($id <= 0) { + return null; // Anonymous/channel senders are not user accounts. + } + $info = $this->invoke(fn () => $this->transport->info($senderId)); + $user = $info['User'] ?? null; + if ( + !in_array($info['type'] ?? '', ['user', 'bot'], true) || !is_array($user) + || ($user['_'] ?? '') !== 'user' || (string) ($user['id'] ?? '') !== $senderId + || !is_bool($user['min'] ?? false) || !is_bool($user['deleted'] ?? false) + || ($user['min'] ?? false) === true || ($user['deleted'] ?? false) === true + ) { + return null; + } + $bot = $user['bot'] ?? false; + if (!is_bool($bot) || ($info['type'] === 'bot') !== $bot) { + return null; + } + return $bot; + } catch (CancelledException | RetryableTelegramException $error) { + throw $error; + } catch (\Throwable) { + return null; + } + } + + public function botCapabilities(string $group): array + { + $info = $this->invoke(fn () => $this->transport->info($group)); + $full = $this->invoke(fn () => $this->transport->fullInfo($group)); + $chat = $full['Chat'] ?? null; + $type = $full['type'] ?? ''; + if ( + !in_array($type, ['chat', 'supergroup', 'channel'], true) || !is_array($chat) + || ($chat['_'] ?? '') !== ($type === 'chat' ? 'chat' : 'channel') + || (string) ($chat['id'] ?? '') !== (string) ($info['Chat']['id'] ?? '') + || !isset($chat['id']) || !is_bool($chat['min'] ?? false) || ($chat['min'] ?? false) === true + ) { + throw new \RuntimeException('Telegram did not return verifiable bot administrator capabilities.'); + } + $creator = $chat['creator'] ?? false; + if (!is_bool($creator) || (isset($chat['admin_rights']) && !is_array($chat['admin_rights']))) { + throw new \RuntimeException('Malformed bot administrator metadata.'); + } + $rights = AdminRights::normalize($chat['admin_rights'] ?? null); + return ['verified' => true, 'group_id' => $group, 'peer_type' => $type, + 'creator' => $creator, 'is_admin' => $creator || isset($chat['admin_rights']), + 'rights' => $rights, 'can_delete_messages' => $creator || $rights['delete_messages']]; + } + + public function botCanDeleteMessages(string $group): bool + { + return $this->botCapabilities($group)['can_delete_messages']; + } + + public function selfUserId(): string + { + $self = $this->transport->self(); + $id = TelegramInt64::fromDecimal($self['id'] ?? null); + if ($id <= 0) { + throw new \RuntimeException('Telegram did not return a verified self user ID.'); + } + return (string) $id; + } + + public function extractSentMessageIds(array $response, ?string $expectedRandomId = null, ?string $expectedPeer = null): array + { + return SentMessageIds::extract($response, $expectedRandomId, $expectedPeer); + } + + public function botUsername(): string + { + return (string) ($this->transport->self()['username'] ?? ''); + } +} diff --git a/src/Runtime/MadelineTransport.php b/src/Runtime/MadelineTransport.php new file mode 100644 index 0000000..c4f38de --- /dev/null +++ b/src/Runtime/MadelineTransport.php @@ -0,0 +1,17 @@ +text($language, 'operator_manual_result', [ + 'action' => $lang->text($language, $result['action'] === 'lock' ? 'operator_manual_lock' : 'operator_manual_open'), + 'changed' => $result['changed'], 'failed' => $result['failed'], + 'until' => (new \DateTimeImmutable('@' . $result['expires_at']))->setTimezone(new \DateTimeZone($timezone))->format('Y-m-d H:i'), + ]); + $pending = array_filter($result['groups'], static fn(array $row): bool => in_array($row['status'], ['retry_wait','busy','deferred'], true)); + if ($pending !== []) { + $text .= "\n" . $lang->text($language, 'operator_manual_pending', ['count' => count($pending)]); + } + $details = array_values(array_filter($result['groups'], static fn(array $row): bool => !$row['changed'])); + if ($details === []) { + return $text; + } + $text .= "\n\n" . $lang->text($language, 'operator_manual_details'); + foreach (array_slice($details, 0, 10) as $row) { + $code = $row['error_code'] ?? ''; + $key = match (true) { + ($row['verification_failed'] ?? false) === true => 'permission_error_readback', + ($row['recovery_reason'] ?? null) === 'unverified_snapshot' => 'permission_error_unverified', + $row['status'] === 'conflict' => 'permission_error_conflict', + $row['status'] === 'recovery_required' => 'permission_error_snapshot', + $row['status'] === 'retry_wait' => 'permission_status_wait', + in_array($row['status'], ['busy','deferred'], true) => 'permission_status_busy', + $row['status'] === 'prelocked' => 'permission_status_prelocked', + $row['status'] === 'idle' => 'permission_status_idle', + in_array($row['status'], ['restored','locked'], true) => 'permission_status_unchanged', + $code === 'CHAT_ADMIN_REQUIRED' => 'permission_error_admin', + in_array($code, ['CHANNEL_PRIVATE','CHAT_WRITE_FORBIDDEN','CHAT_ID_INVALID','PEER_ID_INVALID','CHANNEL_INVALID'], true) => 'permission_error_access', + $code === 'BANNED_RIGHTS_INVALID' => 'permission_error_flags', + $code === 'SNAPSHOT_UNVERIFIED' => 'permission_error_snapshot', + $code === 'PROVIDER_UNAVAILABLE' => 'permission_error_provider', + default => 'permission_error_unknown', + }; + $title = preg_replace('/[\r\n\t]/u', ' ', mb_substr((string)$row['title'], 0, 70)); + $text .= "\nโ€ข " . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ' (' . $row['group_id'] . '): ' . $lang->text($language, $key); + if ($code !== '') { + $text .= ' [' . $code . ']'; + } + if ($row['status'] === 'conflict' && is_array($row['different_fields'] ?? null)) { + $fields = array_values(array_intersect([...\Shabbat\Scheduling\Permissions::FLAGS, 'until_date', 'unknown'], $row['different_fields'])); + $labels = array_map(static fn(string $field): string => $lang->text($language, 'permission_' . $field), array_slice($fields, 0, 4)); + if ($labels !== []) { + $text .= ' ' . $lang->text($language, 'permission_conflict_fields', ['fields' => implode(', ', $labels)]); + if (count($fields) > 4) { + $text .= ' (+' . (count($fields) - 4) . ')'; + } + } + } + } + if (count($details) > 10) { + $text .= "\n" . $lang->text($language, 'operator_manual_more', ['count' => count($details) - 10]); + } + return $text; + } +} diff --git a/src/Runtime/ManualGroups.php b/src/Runtime/ManualGroups.php new file mode 100644 index 0000000..3b0aa4d --- /dev/null +++ b/src/Runtime/ManualGroups.php @@ -0,0 +1,96 @@ +authorizeOperator = Closure::fromCallable($authorizeOperator); + } + /** Explicit temporary operator override. It does not change calendar/religious time calculations. */ + public function apply(bool $close, string $operatorId, int $ttl = 3600): array + { + SettingsRepository::identifier($operatorId); + if (!$this->authorized($operatorId)) { + throw new \RuntimeException('Configured operator authorization is required.'); + } + if ($ttl < 1 || $ttl > 86400) { + throw new \InvalidArgumentException('Manual override TTL must be 1..86400 seconds.'); + } + $now = $this->clock->now()->getTimestamp(); + $until = $now + $ttl; + $results = []; + for ($offset = 0;; $offset += 100) { + $groups = $this->settings->listGroups($offset, 100); + foreach ($groups as $index => $group) { + if ($index > 0 && $index % 20 === 0) { + \Amp\delay(0); + } + if (Runner::isStopping()) { + throw new \Amp\CancelledException(new \RuntimeException('Manual permission batch stopped during shutdown.')); + } + if (!$this->authorized($operatorId)) { + throw new \RuntimeException('Operator permission was revoked during the batch.'); + } + try { + $saved = $this->settings->updateGroup( + $group['group_id'], + $group['revision'], + ['manual_override_mode' => $close ? 'lock' : 'open','manual_override_until' => $until], + $operatorId + ); + $result = $this->locks->reconcile($group['group_id'], 'manual:' . $saved['revision'] . ':' . $now, $close); + $results[] = ['group_id' => $group['group_id'],'title' => $group['title'],'revision' => $saved['revision']] + $result; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (StaleSettings $error) { + $results[] = ['group_id' => $group['group_id'],'title' => $group['title'],'status' => 'stale','changed' => false,'error' => $error->getMessage(),'error_code' => PermissionFailure::code($error)]; + } catch (\Throwable $error) { + $results[] = ['group_id' => $group['group_id'],'title' => $group['title'],'status' => 'error','changed' => false,'error' => $error->getMessage(),'error_code' => PermissionFailure::code($error)]; + } + } + if (count($groups) < 100) { + break; + } + } + $summary = ['action' => $close ? 'lock' : 'open','operator_id' => $operatorId,'expires_at' => $until, + 'warning' => 'Temporary operator override: ' . ($close ? 'groups are held closed' : 'bot-owned locks are opened, including during an active festival') . '; calendar policy resumes automatically at expiry.', + 'groups' => $results,'changed' => count(array_filter($results, static fn(array $row): bool=>$row['changed'])), + 'failed' => count(array_filter($results, static fn(array $row): bool=>in_array($row['status'], ['error','stale','conflict','recovery_required'], true)))]; + $this->db->pdo()->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,NULL,?,?,?)') + ->execute([$operatorId,'operator.manual_override',Database::encode(['mode' => $summary['action'],'expires_at' => $until,'groups' => count($results),'changed' => $summary['changed'],'failed' => $summary['failed']]),$now]); + return $summary; + } + /** @phpstan-impure Permissions can change while a previous group RPC yields. */ + private function authorized(string $operator): bool + { + return ($this->authorizeOperator)($operator) === true; + } + public static function desired(array $group, bool $calendarDesired, int $now): bool + { + if (($group['manual_override_until'] ?? 0) <= $now) { + return $calendarDesired; + } + return match ($group['manual_override_mode'] ?? 'none') { + 'lock'=>true,'open'=>false,default=>$calendarDesired + }; + } +} diff --git a/src/Runtime/PatchManager.php b/src/Runtime/PatchManager.php new file mode 100644 index 0000000..3acb5f5 --- /dev/null +++ b/src/Runtime/PatchManager.php @@ -0,0 +1,254 @@ +vendor ??= $root . '/vendor'; + $manifestFile ??= $root . '/patches/madelineproto/manifest.json'; + $this->patchDirectory = dirname($manifestFile); + $this->manifest = json_decode(self::read($manifestFile), true, flags: JSON_THROW_ON_ERROR); + $this->packageName = $this->manifest['package'] ?? 'danog/madelineproto'; + if (!preg_match('~^[a-z0-9_.-]+/[a-z0-9_.-]+$~D', $this->packageName)) { + throw new RuntimeException('Invalid patch package identity.'); + } + $this->target = self::relativePath($this->manifest['target'] ?? 'src/Loop/Update/UpdateLoop.php'); + } + + public function process(bool $apply): string + { + $installed = json_decode(self::read($this->vendor . '/composer/installed.json'), true, flags: JSON_THROW_ON_ERROR); + $package = null; + foreach ($installed['packages'] ?? $installed as $candidate) { + if (($candidate['name'] ?? '') === $this->packageName) { + $package = $candidate; + break; + } + } + if ($package === null) { + throw new RuntimeException($this->packageName . ' is not installed. Run composer install from the committed lock.'); + } + $reference = $package['source']['reference'] ?? $package['dist']['reference'] ?? ''; + $version = ltrim((string) $package['version'], 'v'); + $entry = null; + foreach ($this->manifest['approved'] as $candidate) { + if ($candidate['version'] === $version && $candidate['commit'] === $reference) { + $entry = $candidate; + break; + } + } + if ($entry === null) { + throw new RuntimeException('Unapproved ' . $this->packageName . ' version/commit: ' . $version . ' ' . $reference); + } + if (array_key_exists('added_files', $entry)) { + if ($entry['mode'] !== 'patch') { + throw new RuntimeException('Added-file review schema requires patch mode and complete file verification.'); + } + return (new AddedFilePatchManager( + $this->vendor . '/' . $this->packageName, + $this->target, + $this->patchDirectory, + $entry, + $this->writerLockPath ?? $this->defaultWriterLockPath() + ))->process($apply); + } + $path = $this->vendor . '/' . $this->packageName . '/' . $this->target; + if (is_link($path)) { + throw new RuntimeException('Library target must be a regular source file, not a symlink.'); + } + $source = self::read($path); + $hash = hash('sha256', $source); + if ($entry['mode'] === 'official') { + if (!hash_equals($entry['sha256'], $hash)) { + throw new RuntimeException('Official library file is not the reviewed source.'); + } + return 'official-verified'; + } + if ($entry['mode'] !== 'patch') { + throw new RuntimeException('Unknown approved patch mode.'); + } + $baseline = self::read($this->patchDirectory . '/' . self::relativePath($entry['baseline'])); + if (!hash_equals($entry['original_sha256'], hash('sha256', $baseline))) { + throw new RuntimeException('Corresponding-source baseline hash mismatch.'); + } + $patch = self::read($this->patchDirectory . '/' . self::relativePath($entry['patch'])); + if (!hash_equals($entry['patch_sha256'], hash('sha256', $patch))) { + throw new RuntimeException('Patch artifact hash mismatch.'); + } + $knownSources = [$entry['original_sha256'] => $patch]; + $intermediates = $entry['intermediates'] ?? []; + if (!is_array($intermediates)) { + throw new RuntimeException('Invalid approved intermediate source list.'); + } + foreach ($intermediates as $intermediate) { + if ( + !is_array($intermediate) || !is_string($intermediate['sha256'] ?? null) + || !is_string($intermediate['patch_sha256'] ?? null) + || !preg_match('/^[a-f0-9]{64}$/D', $intermediate['sha256']) + || !preg_match('/^[a-f0-9]{64}$/D', $intermediate['patch_sha256']) + || isset($knownSources[$intermediate['sha256']]) + ) { + throw new RuntimeException('Invalid or ambiguous approved intermediate source.'); + } + $previous = self::read($this->patchDirectory . '/' . self::relativePath($intermediate['source'])); + $transition = self::read($this->patchDirectory . '/' . self::relativePath($intermediate['patch'])); + if ( + !hash_equals($intermediate['sha256'], hash('sha256', $previous)) + || !hash_equals($intermediate['patch_sha256'], hash('sha256', $transition)) + ) { + throw new RuntimeException('Approved intermediate source or transition artifact hash mismatch.'); + } + if (!hash_equals($entry['patched_sha256'], hash('sha256', self::applyDiff($previous, $transition)))) { + throw new RuntimeException('Approved transition does not produce the reviewed output.'); + } + $knownSources[$intermediate['sha256']] = $transition; + } + if (hash_equals($entry['patched_sha256'], $hash)) { + return 'already-patched'; + } + if (!isset($knownSources[$hash])) { + throw new RuntimeException('Unknown, partial or modified library source; refusing to continue.'); + } + $patch = $knownSources[$hash]; + if (!$apply) { + throw new RuntimeException('Required library patch is absent. Run php scripts/apply-patches.php before startup.'); + } + // A build may verify a running installation, but may never mutate it. + // Use the exact lock shared by Runner and SQLite maintenance commands. + $writer = SingleInstance::acquire($this->writerLockPath ?? $this->defaultWriterLockPath()); + try { + if (!hash_equals($hash, hash('sha256', self::read($path)))) { + throw new RuntimeException('Library source changed while acquiring the installer lock.'); + } + return $this->install($path, $source, $patch, $entry); + } finally { + $writer->release(); + } + } + + private function defaultWriterLockPath(): string + { + return RuntimePaths::resolve($this->root)->writerLockPath(); + } + private function install(string $path, string $source, string $patch, array $entry): string + { + $patched = self::applyDiff($source, $patch); + if (!hash_equals($entry['patched_sha256'], hash('sha256', $patched))) { + throw new RuntimeException('Patch output does not match the approved SHA256.'); + } + $tokens = token_get_all($patched, TOKEN_PARSE); + if ($tokens === [] || !is_array($tokens[0]) || $tokens[0][0] !== T_OPEN_TAG) { + throw new RuntimeException('Patch output must be a complete PHP source file.'); + } + $temporary = $path . '.shabbat-' . bin2hex(random_bytes(6)) . '.tmp'; + try { + if (file_put_contents($temporary, $patched, LOCK_EX) !== strlen($patched) || !rename($temporary, $path)) { + throw new RuntimeException('Unable to atomically install patch.'); + } + } finally { + if (is_file($temporary)) { + unlink($temporary); + } + } + return 'patched'; + } + + public static function applyDiff(string $source, string $patch): string + { + $sourceLines = explode("\n", $source); + $lines = explode("\n", $patch); + $result = []; + $cursor = 0; + $index = 0; + $hunks = 0; + while ($index < count($lines)) { + $line = $lines[$index++]; + if (!str_starts_with($line, '@@ ')) { + continue; + } + if (!preg_match('/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/', $line, $matches)) { + throw new RuntimeException('Invalid unified-diff hunk.'); + } + $start = (int) $matches[1] === 0 && $matches[2] === '0' ? 0 : (int) $matches[1] - 1; + $oldCount = $matches[2] !== '' ? (int) $matches[2] : 1; + $newCount = isset($matches[4]) ? (int) $matches[4] : 1; + if ($start < $cursor || $start > count($sourceLines)) { + throw new RuntimeException('Overlapping or out-of-range patch hunk.'); + } + while ($cursor < $start) { + $result[] = $sourceLines[$cursor++]; + } + $old = 0; + $new = 0; + while ($index < count($lines) && !str_starts_with($lines[$index], '@@ ')) { + $diffLine = $lines[$index]; + if ($diffLine === '') { + $index++; + break; + } + $kind = $diffLine[0]; + $text = substr($diffLine, 1); + if ($kind !== ' ' && $kind !== '+' && $kind !== '-') { + break; + } + $index++; + if ($kind === ' ' || $kind === '-') { + if (($sourceLines[$cursor] ?? null) !== $text) { + throw new RuntimeException('Patch context differs from the exact approved source.'); + } + $cursor++; + $old++; + } + if ($kind === ' ' || $kind === '+') { + $result[] = $text; + $new++; + } + } + if ($old !== $oldCount || $new !== $newCount) { + throw new RuntimeException('Patch hunk counts differ.'); + } + $hunks++; + } + if ($hunks === 0) { + throw new RuntimeException('Patch has no hunks.'); + } + return implode("\n", array_merge($result, array_slice($sourceLines, $cursor))); + } + + private static function relativePath(string $path): string + { + if ( + $path === '' || str_contains($path, "\0") || str_contains($path, '\\') || str_contains($path, ':') + || str_starts_with($path, '/') || str_contains($path, '//') || preg_match('~(?:^|/)\.\.?(?:/|$)~', $path) + ) { + throw new RuntimeException('Patch paths must be contained relative paths.'); + } + return $path; + } + + private static function read(string $path): string + { + $contents = is_file($path) ? file_get_contents($path) : false; + if ($contents === false) { + throw new RuntimeException('Missing or unreadable patch prerequisite: ' . $path); + } + return $contents; + } +} diff --git a/src/Runtime/PatchSet.php b/src/Runtime/PatchSet.php new file mode 100644 index 0000000..9997646 --- /dev/null +++ b/src/Runtime/PatchSet.php @@ -0,0 +1,22 @@ + */ + public static function process(string $root, bool $apply, ?string $vendor = null): array + { + $result = []; + foreach (self::LIBRARIES as $library) { + $result[$library] = (new PatchManager($root, $vendor, $root . '/patches/' . $library . '/manifest.json'))->process($apply); + } + return $result; + } +} diff --git a/src/Runtime/PermissionFailure.php b/src/Runtime/PermissionFailure.php new file mode 100644 index 0000000..f7ca662 --- /dev/null +++ b/src/Runtime/PermissionFailure.php @@ -0,0 +1,28 @@ +rpc) ? $error->rpc : 'RPC_ERROR'; + } + $text = $error->getMessage(); + if (str_contains($text, 'view_messages') || str_contains($text, 'group restriction')) { + return 'BANNED_RIGHTS_INVALID'; + } + if (str_contains($text, 'snapshot') || str_contains($text, 'current default permissions') || str_contains($text, 'refreshed peer information')) { + return 'SNAPSHOT_UNVERIFIED'; + } + if ($error instanceof \Shabbat\Http\ProviderException) { + return 'PROVIDER_UNAVAILABLE'; + } + return 'OPERATION_UNCONFIRMED'; + } +} diff --git a/src/Runtime/PrivatePeer.php b/src/Runtime/PrivatePeer.php new file mode 100644 index 0000000..068baf0 --- /dev/null +++ b/src/Runtime/PrivatePeer.php @@ -0,0 +1,30 @@ + 0 && $user > 0 && $self !== $user && $replyId > 0 + && ($message['id'] ?? null) === $replyId + && ($message['from_id'] ?? null) === $self + && ($message['peer_id'] ?? null) === $user + && is_string($message['message'] ?? null); + } +} diff --git a/src/Runtime/ReleaseInputs.php b/src/Runtime/ReleaseInputs.php new file mode 100644 index 0000000..f69d5c1 --- /dev/null +++ b/src/Runtime/ReleaseInputs.php @@ -0,0 +1,62 @@ +isFile() && !$file->isLink()) { + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)); + $files[$relative] = $file->getPathname(); + } + } + } + ksort($files); + return $files; + } + + public static function fingerprint(array $files): string + { + $hashes = []; + foreach ($files as $path => $absolute) { + $hash = hash_file('sha256', $absolute); + if (!is_string($hash)) { + throw new RuntimeException('Unable to hash application source: ' . $path); + } + $hashes[$path] = $hash; + } + ksort($hashes); + return hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Runtime/Runner.php b/src/Runtime/Runner.php new file mode 100644 index 0000000..c3b4daa --- /dev/null +++ b/src/Runtime/Runner.php @@ -0,0 +1,242 @@ + self::handleEventError($error, self::$stopping)); + } + + /** @phpstan-impure Shutdown signals can change this value during async work. */ + public static function isStopping(): bool + { + return self::$stopping; + } + + public static function requestShutdown(): void + { + if (self::$stopping) { + return; + } + self::$stopping = true; + (self::$quiesce)?->__invoke(); + } + + /** Optional startup reporting must not delay the update loop indefinitely. */ + public static function notifyStartup(callable $notify, float $timeout = 3.0): bool + { + if (self::$stopping) { + return false; + } + if ($timeout <= 0 || $timeout > 30) { + throw new \InvalidArgumentException('Invalid startup notification deadline.'); + } + try { + $notify(new \Amp\TimeoutCancellation($timeout)); + return true; + } catch (CancelledException $error) { + if (!$error->getPrevious() instanceof TimeoutException) { + throw $error; + } + } catch (\Throwable $error) { + error_log('Startup notification failed: ' . get_class($error)); + } + return false; + } + + /** Stop application work before completing the SDK loop; only one restart may be pending. */ + public static function restart(callable $acknowledge, callable $restart): bool + { + if (self::$stopping || self::$restartPending) { + return false; + } + self::$restartPending = true; + try { + try { + $acknowledge(); + } catch (CancelledException $error) { + if (!$error->getPrevious() instanceof TimeoutException) { + throw $error; + } + // The bounded acknowledgement timed out. Restart does not depend on sending it. + } catch (\Throwable $error) { + error_log('Restart acknowledgement failed: ' . get_class($error)); + } + try { + self::requestShutdown(); + } finally { + $restart(); + } + return true; + } finally { + self::$restartPending = false; + } + } + + /** + * Reference runBot setup, with typed cancellation checks instead of the + * reference's blanket cancellation-message suppression. + */ + public static function handleEventError(\Throwable $error, bool $stopping = false): void + { + if ($error instanceof CancelledException) { + if ($stopping && !($error->getPrevious() instanceof TimeoutException)) { + return; + } + throw $error; + } + $text = $error->getMessage() . "\n" . $error->getTraceAsString(); + if ( + $error instanceof \Error && str_contains($text, 'ReferenceDatabase.php') + && str_contains($error->getMessage(), 'Trying to access array offset on null') + ) { + error_log('MadelineProto ReferenceDatabase error suppressed by the documented reference compatibility handler.'); + return; + } + if ($error instanceof \Error && $error->getMessage() === 'Operation is no longer pending') { + error_log('MadelineProto completed-operation error suppressed by the documented reference compatibility handler.'); + return; + } + throw $error; + } + + public static function settings(): Settings + { + $settings = new Settings(); + $settings->setLogger((new Settings\Logger())->setLevel(Logger::ERROR)); + $settings->setConnection((new Settings\Connection())->setTimeout(600.0)->setRetry(true)); + $settings->setPeer((new Settings\Peer())->setFullInfoCacheTime(0)); + // Official RPC settings clamp to five seconds; helper calls cannot take + // per-call floodWaitLimit. Longer waits remain typed scheduler retries. + $settings->setRPC((new Settings\RPC())->setFloodTimeout(5)); + return $settings; + } + + /** Keep maintenance excluded until the SDK has flushed its pending API destructors. */ + public static function finalizeSession(?SingleInstance $lock, callable $flush): void + { + try { + $flush(); + } finally { + $lock?->release(); + } + } + + /** @param class-string<\danog\MadelineProto\EventHandler> $handlerClass */ + public static function run(string $handlerClass, ?string $root = null): int + { + $root ??= dirname(__DIR__, 2); + $lock = null; + $signals = []; + $exitCode = 1; + self::$stopping = false; + try { + // MP formats PHP trace arguments before our outer error boundary. + // Docker also sets this globally for separately spawned IPC workers. + if (ini_set('zend.exception_ignore_args', '1') === false) { + throw new \RuntimeException('Unable to disable exception argument collection.'); + } + require_once $root . '/scripts/apply-patches.php'; + PatchSet::process($root, false); + $env = is_file($root . '/.env') ? parse_ini_file($root . '/.env', false, INI_SCANNER_RAW) : []; + if (!is_array($env)) { + throw new \RuntimeException('Invalid .env file.'); + } + $value = static function (string $name, string $default = '') use ($env): string { + $environment = getenv($name); + return is_string($environment) && $environment !== '' ? $environment : (string) ($env[$name] ?? $default); + }; + $apiId = $value('API_ID'); + $apiHash = $value('API_HASH'); + $botToken = $value('BOT_TOKEN'); + if (!ctype_digit($apiId) || (int) $apiId <= 0 || !preg_match('/^[a-f0-9]{32}$/i', $apiHash) || $botToken === '') { + throw new \RuntimeException('API_ID, API_HASH and BOT_TOKEN must be configured.'); + } + // Make configured values available consistently to services. + foreach ($env as $name => $configured) { + if (getenv((string) $name) === false && is_scalar($configured)) { + putenv($name . '=' . $configured); + } + } + $paths = RuntimePaths::resolve($root, $env); + $paths->export(); + $lock = SingleInstance::acquire($paths->writerLockPath()); + self::bindErrorHandler(); + if (PHP_OS_FAMILY !== 'Windows') { + foreach ([SIGTERM, SIGINT] as $signal) { + $signals[] = EventLoop::onSignal($signal, static function (): void { + try { + self::requestShutdown(); + } finally { + // SDK stop completes its main loop; finalization below + // persists the session while retaining writer ownership. + self::$handler?->stop(); + } + }); + } + } + $settings = self::settings(); + $settings->setAppInfo((new Settings\AppInfo())->setApiId((int) $apiId)->setApiHash($apiHash)); + + + $session = $paths->sessionPath; + error_log('WizardLoop Shabbat ' . Version::current($root) . ' ' . Version::commit($root)); + $handlerClass::startAndLoopBot($session, $botToken, $settings); + $exitCode = 0; + } catch (\Throwable $error) { + if ($error instanceof CancelledException && self::$stopping && !($error->getPrevious() instanceof TimeoutException)) { + $exitCode = 0; + } else { + // Do not log credentials or arbitrary exception payloads. + fwrite(STDERR, 'Bot stopped: ' . get_class($error) . '. Run the self-check and inspect secured operator logs.' . "\n"); + } + } finally { + try { + self::requestShutdown(); + } catch (\Throwable $error) { + fwrite(STDERR, 'Worker shutdown failed: ' . get_class($error) . "\n"); + $exitCode = 1; + } finally { + foreach ($signals as $signal) { + EventLoop::cancel($signal); + } + self::$handler = null; + self::$quiesce = null; + try { + // Public @internal SDK method: pinned 8.7.0 contract is tested. + self::finalizeSession($lock, \danog\MadelineProto\API::finalize(...)); + } catch (\Throwable $error) { + fwrite(STDERR, 'Session finalization failed: ' . get_class($error) . "\n"); + $exitCode = 1; + } + } + } + return $exitCode; + } +} diff --git a/src/Runtime/RuntimeClientPool.php b/src/Runtime/RuntimeClientPool.php new file mode 100644 index 0000000..373038b --- /dev/null +++ b/src/Runtime/RuntimeClientPool.php @@ -0,0 +1,43 @@ +>|null */ + private static ?\WeakMap $clients = null; + /** @param callable(): object $factory */ + public static function get(object $owner, callable $factory): object + { + self::$clients ??= new \WeakMap(); + $future = self::$clients[$owner] ?? null; + if ($future === null) { + $future = \Amp\async(static function () use ($factory): mixed { + return $factory(); + }); + self::$clients[$owner] = $future; + } + try { + $client = $future->await(); + if (!is_object($client)) { + throw new \UnexpectedValueException('Runtime client factory must return an object.'); + } + return $client; + } catch (\Throwable $error) { + if ((self::$clients[$owner] ?? null) === $future) { + unset(self::$clients[$owner]); + } + throw $error; + } + } + public static function release(object $owner): void + { + if (self::$clients !== null) { + unset(self::$clients[$owner]); + } + } +} diff --git a/src/Runtime/RuntimePaths.php b/src/Runtime/RuntimePaths.php new file mode 100644 index 0000000..632be6d --- /dev/null +++ b/src/Runtime/RuntimePaths.php @@ -0,0 +1,72 @@ + $this->dataDirectory, + 'APP_DB_PATH' => $this->databasePath, + 'SESSION_PATH' => $this->sessionPath, + 'HEARTBEAT_PATH' => $this->heartbeatPath, + ] as $name => $path + ) { + if (!putenv($name . '=' . $path)) { + throw new \RuntimeException('Unable to export the resolved runtime paths.'); + } + } + } + + public function writerLockPath(): string + { + return $this->databasePath . '.writer.lock'; + } + + private static function absolute(string $root, string $path): string + { + if (str_contains($path, "\0")) { + throw new \InvalidArgumentException('A runtime path cannot contain NUL bytes.'); + } + $path = str_replace('\\', '/', $path); + $absolute = str_starts_with($path, '/') + || (strlen($path) >= 3 && ctype_alpha($path[0]) && $path[1] === ':' && $path[2] === '/'); + return $absolute ? $path : rtrim(str_replace('\\', '/', $root), '/') . '/' . $path; + } +} diff --git a/src/Runtime/SingleInstance.php b/src/Runtime/SingleInstance.php new file mode 100644 index 0000000..753caac --- /dev/null +++ b/src/Runtime/SingleInstance.php @@ -0,0 +1,50 @@ +handle = $handle; + if (!flock($instance->handle, LOCK_EX | LOCK_NB)) { + throw new \RuntimeException('Another bot or maintenance operation owns this runtime directory.'); + } + ftruncate($instance->handle, 0); + fwrite($instance->handle, (string) getmypid()); + return $instance; + } + + public function release(): void + { + if (is_resource($this->handle)) { + flock($this->handle, LOCK_UN); + fclose($this->handle); + } + $this->handle = null; + } + + public function __destruct() + { + $this->release(); + } +} diff --git a/src/Runtime/TelegramInt64.php b/src/Runtime/TelegramInt64.php new file mode 100644 index 0000000..0c036d4 --- /dev/null +++ b/src/Runtime/TelegramInt64.php @@ -0,0 +1,28 @@ + strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new \InvalidArgumentException('Decimal integer is outside signed int64 range.'); + } + return (int) $value; + } +} diff --git a/src/Runtime/Version.php b/src/Runtime/Version.php new file mode 100644 index 0000000..60c530f --- /dev/null +++ b/src/Runtime/Version.php @@ -0,0 +1,63 @@ + self::current($root), 'commit' => self::commit($root), 'license' => 'AGPL-3.0-only', 'source_url' => self::sourceUrl($root)]; + } +} diff --git a/src/Scheduling/DeliveryDeferredException.php b/src/Scheduling/DeliveryDeferredException.php new file mode 100644 index 0000000..38c062a --- /dev/null +++ b/src/Scheduling/DeliveryDeferredException.php @@ -0,0 +1,15 @@ +prepare("UPDATE scheduled_jobs AS older SET status='cancelled',completed_at=?,last_error='superseded by newer due tefillin reminder' + WHERE older.group_id=? AND older.module='tefillin' AND older.status='pending' AND older.dispatch_started_at IS NULL AND older.run_at<=? AND older.expires_at>? + AND EXISTS(SELECT 1 FROM scheduled_jobs newer WHERE newer.group_id=older.group_id AND newer.module='tefillin' + AND newer.status='pending' AND newer.run_at<=? AND newer.expires_at>? + AND (newer.run_at>older.run_at OR (newer.run_at=older.run_at AND newer.job_id>older.job_id)))"); + $query->execute([$now,$group,$now,$now,$now,$now]); + } + /** No asynchronous work is permitted between this reservation and the actual network request. */ + public function reserve(array $job, array $settings, string $owner): bool + { + $now = $this->clock->now()->getTimestamp(); + return $this->db->transaction(function (PDO $pdo) use ($job, $settings, $owner, $now): bool { + $current = $this->owned($pdo, (int)$job['job_id'], $owner, $now); + if ($current === null) { + return false; + } + if ($current['module'] === 'tefillin') { + // An uncertain retry keeps its existing logical quota slot and frozen payload. + // A second worker may not spend another slot while a previous request is unresolved. + $query = $pdo->prepare("SELECT MAX(COALESCE(lease_until,retry_at,?)) FROM scheduled_jobs + WHERE group_id=? AND module='tefillin' AND job_id<>? AND dispatch_started_at IS NOT NULL + AND status IN ('leased','pending') AND expires_at>? + AND (? IS NULL OR dispatch_started_at?))"); + $query->execute([$now + 30,$current['group_id'],$current['job_id'],$now,$current['dispatch_started_at'],$current['dispatch_started_at'],$current['dispatch_started_at'],$current['job_id'],$now]); + $blocking = $query->fetchColumn(); + if ($blocking !== false && $blocking !== null) { + throw new DeliveryDeferredException('A previous tefillin delivery remains uncertain.', max($now + 30, (int)$blocking)); + } + $local = $this->clock->now()->setTimezone(new \DateTimeZone($settings['timezone_id'])); + $start = $local->setTime(0, 0)->getTimestamp(); + $end = $local->setTime(0, 0)->modify('+1 day')->getTimestamp(); + $query = $pdo->prepare("SELECT COUNT(*) FROM scheduled_jobs WHERE group_id=? AND module='tefillin' AND job_id<>? + AND ((status='sent' AND completed_at>=? AND completed_at'sent' AND last_dispatch_at>=? AND last_dispatch_atexecute([$current['group_id'],$current['job_id'],$start,$end,$start,$end]); + if ((int)$query->fetchColumn() >= max(1, min(24, (int)($settings['tefillin_max_per_day'] ?? 3)))) { + return false; + } + // Even an old uncertain identity waits after a different confirmed send; replaying many old uncertainties must not burst. + $query = $pdo->prepare("SELECT MAX(CASE WHEN status='sent' THEN completed_at ELSE last_dispatch_at END) + FROM scheduled_jobs WHERE group_id=? AND module='tefillin' AND job_id<>? + AND (status='sent' OR ? IS NULL OR status NOT IN ('pending','leased'))"); + $query->execute([$current['group_id'],$current['job_id'],$current['dispatch_started_at']]); + $last = $query->fetchColumn(); + $retryAt = (int)$last + max(5, (int)($settings['tefillin_min_spacing_minutes'] ?? 30)) * 60; + if ($last !== false && $last !== null && $retryAt > $now) { + throw new DeliveryDeferredException('Actual tefillin delivery spacing has not elapsed.', $retryAt); + } + } + $this->stamp($pdo, (int)$current['job_id'], $owner, $now); + return true; + }); + } + public function mark(int $id, string $owner): void + { + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $now): void { + if ($this->owned($pdo, $id, $owner, $now) === null) { + throw new \RuntimeException('Delivery ownership or deadline was lost before dispatch.'); + } + $this->stamp($pdo, $id, $owner, $now); + }); + } + private function owned(PDO $pdo, int $id, string $owner, int $now): ?array + { + $query = $pdo->prepare("SELECT * FROM scheduled_jobs WHERE job_id=? AND status='leased' AND lease_owner=? AND lease_until>? AND expires_at>?"); + $query->execute([$id,$owner,$now,$now]); + $row = $query->fetch(); + return $row === false ? null : $row; + } + private function stamp(PDO $pdo, int $id, string $owner, int $now): void + { + $query = $pdo->prepare("UPDATE scheduled_jobs SET dispatch_started_at=COALESCE(dispatch_started_at,?),last_dispatch_at=? WHERE job_id=? AND status='leased' AND lease_owner=?"); + $query->execute([$now,$now,$id,$owner]); + } +} diff --git a/src/Scheduling/DispatchRejectedException.php b/src/Scheduling/DispatchRejectedException.php new file mode 100644 index 0000000..e2ecb58 --- /dev/null +++ b/src/Scheduling/DispatchRejectedException.php @@ -0,0 +1,11 @@ +handle = $handle; + ftruncate($handle, 0); + fwrite($handle, (string)getmypid()); + fflush($handle); + } + public function release(): void + { + if (is_resource($this->handle)) { + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + } + public function __destruct() + { + $this->release(); + } +} diff --git a/src/Scheduling/JobRepository.php b/src/Scheduling/JobRepository.php new file mode 100644 index 0000000..e5cea81 --- /dev/null +++ b/src/Scheduling/JobRepository.php @@ -0,0 +1,231 @@ + 256 || $eventKey === '') { + throw new \InvalidArgumentException('Invalid scheduled job.'); + } + $random = (string)random_int(1, PHP_INT_MAX); + $query = $this->db->pdo()->prepare("INSERT INTO scheduled_jobs(group_id,module,event_key,run_at,expires_at,settings_revision,payload_json,random_id,priority,created_at) VALUES(?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(group_id,module,event_key) DO UPDATE SET run_at=excluded.run_at,expires_at=excluded.expires_at,settings_revision=excluded.settings_revision,payload_json=excluded.payload_json,payload_prepared_at=NULL,priority=excluded.priority, + status='pending',retry_at=NULL,lease_owner=NULL,lease_until=NULL,last_error=NULL + WHERE scheduled_jobs.status IN ('pending','cancelled','expired','failed') AND scheduled_jobs.dispatch_started_at IS NULL AND scheduled_jobs.settings_revision<>excluded.settings_revision"); + $query->execute([$group,$module,$eventKey,$runAt,$expiresAt,$settingsRevision,Database::encode($payload),$random,$priority,$this->clock->now()->getTimestamp()]); + $query = $this->db->pdo()->prepare('SELECT job_id FROM scheduled_jobs WHERE group_id=? AND module=? AND event_key=?'); + $query->execute([$group,$module,$eventKey]); + return (int)$query->fetchColumn(); + } + public function get(int $id): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM scheduled_jobs WHERE job_id=?'); + $query->execute([$id]); + $row = $query->fetch(); + return $row === false ? null : self::decode($row); + } + public function lease(string $owner, int $leaseSeconds = 120): ?array + { + if ($owner === '' || $leaseSeconds < 1 || $leaseSeconds > 300) { + throw new \InvalidArgumentException('Invalid worker lease.'); + } + $now = $this->clock->now()->getTimestamp(); + return $this->db->transaction(function (PDO $pdo) use ($owner, $leaseSeconds, $now): ?array { + $pdo->prepare("UPDATE scheduled_jobs SET status='expired',last_error='delivery cutoff elapsed',lease_owner=NULL,lease_until=NULL WHERE status IN ('pending','leased') AND expires_at<=? AND (lease_until IS NULL OR lease_until<=?)")->execute([$now,$now]); + $query = $pdo->prepare("SELECT * FROM scheduled_jobs WHERE run_at<=? AND expires_at>? AND (retry_at IS NULL OR retry_at<=?) + AND (status='pending' OR (status='leased' AND lease_until<=?)) ORDER BY priority,CASE WHEN dispatch_started_at IS NOT NULL THEN 0 ELSE 1 END,run_at,job_id LIMIT 1"); + $row = false; + // Each pass touches only one group. A deferred newest reminder must not starve unrelated groups. + for ($pass = 0; $pass < 20; $pass++) { + $query->execute([$now,$now,$now,$now]); + $row = $query->fetch(); + if ($row === false || $row['module'] !== 'tefillin' || $row['status'] !== 'pending' || $row['dispatch_started_at'] !== null) { + break; + } + DeliverySafety::coalesce($pdo, $now, $row['group_id']); + $current = $pdo->prepare("SELECT * FROM scheduled_jobs WHERE group_id=? AND module='tefillin' AND status='pending' + AND run_at<=? AND expires_at>? AND (retry_at IS NULL OR retry_at<=?) + ORDER BY priority,CASE WHEN dispatch_started_at IS NOT NULL THEN 0 ELSE 1 END,run_at,job_id LIMIT 1"); + $current->execute([$row['group_id'],$now,$now,$now]); + $row = $current->fetch(); + if ($row !== false) { + break; + } + } + if ($row === false) { + return null; + } + if ($row['status'] === 'leased') { + $pdo->prepare("UPDATE delivery_log SET outcome='uncertain',finished_at=?,error='worker lease expired before acknowledged completion' WHERE job_id=? AND outcome='attempting'")->execute([$now,$row['job_id']]); + } + $pdo->prepare("UPDATE scheduled_jobs SET status='leased',attempts=attempts+1,lease_owner=?,lease_until=? WHERE job_id=?")->execute([$owner,$now + $leaseSeconds,$row['job_id']]); + $row['attempts'] = (int)$row['attempts'] + 1; + $row['status'] = 'leased'; + $row['lease_owner'] = $owner; + $row['lease_until'] = $now + $leaseSeconds; + $pdo->prepare("INSERT INTO delivery_log(job_id,attempt,random_id,started_at,outcome) VALUES(?,?,?,?,'attempting')")->execute([$row['job_id'],$row['attempts'],$row['random_id'],$now]); + return self::decode($row); + }); + } + public function preparePayload(int $id, string $owner, array $payload): void + { + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $payload): void { + $job = $this->get($id); + $now = $this->clock->now()->getTimestamp(); + if ($job === null || !$this->owns($id, $owner) || $job['expires_at'] <= $now) { + throw new \RuntimeException('Job ownership or delivery window was lost before payload persistence.'); + } + if ($job['payload_prepared_at'] !== null || $job['dispatch_started_at'] !== null) { + return; + } + $query = $pdo->prepare('UPDATE scheduled_jobs SET payload_json=?,payload_prepared_at=? WHERE job_id=? AND lease_owner=? AND status=\'leased\' AND payload_prepared_at IS NULL'); + $query->execute([Database::encode($payload),$now,$id,$owner]); + if ($query->rowCount() !== 1) { + throw new \RuntimeException('Prepared delivery payload was not persisted.'); + } + }); + } + /** + * Lease ownership depends on mutable database state and the current clock. + * + * @phpstan-impure + */ + public function owns(int $job, string $owner): bool + { + $row = $this->get($job); + return $row !== null && $row['status'] === 'leased' && $row['lease_owner'] === $owner && $row['lease_until'] > $this->clock->now()->getTimestamp(); + } + public function sent(int $job, string $owner, array $response): void + { + $this->finish($job, $owner, 'sent', null, $response); + } + public function cancel(int $job, string $owner, string $reason): void + { + $this->finish($job, $owner, 'cancelled', $reason); + } + public function fail(int $job, string $owner, string $reason): void + { + $this->finish($job, $owner, 'failed', $reason); + } + private function finish(int $id, string $owner, string $status, ?string $error, array $response = []): void + { + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $status, $error, $response): void { + $job = $this->get($id); + if ($job === null || !in_array($job['status'], ['leased','cancelled'], true) || $job['lease_owner'] !== $owner) { + throw new \RuntimeException('Job ownership was lost.'); + } + $now = $this->clock->now()->getTimestamp(); + $pdo->prepare('UPDATE scheduled_jobs SET status=?,completed_at=?,last_error=?,lease_owner=NULL,lease_until=NULL WHERE job_id=? AND lease_owner=?') + ->execute([$status,$now,$error,$id,$owner]); + $outcome = $status === 'sent' ? 'sent' : 'failed'; + $pdo->prepare('UPDATE delivery_log SET outcome=?,finished_at=?,response_json=?,error=? WHERE job_id=? AND attempt=?') + ->execute([$outcome,$now,$response === [] ? null : Database::encode($response),$error,$id,$job['attempts']]); + }); + } + public function defer(int $id, string $owner, string $reason, int $retryAt): void + { + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $reason, $retryAt): void { + $job = $this->get($id); + if ($job === null || !$this->owns($id, $owner)) { + throw new \RuntimeException('Job ownership was lost before eligibility deferral.'); + } + $now = $this->clock->now()->getTimestamp(); + $retryAt = max($now + 1, $retryAt); + $status = $retryAt >= $job['expires_at'] ? 'expired' : 'pending'; + $pdo->prepare('UPDATE scheduled_jobs SET status=?,retry_at=?,deferrals=deferrals+1,last_error=?,lease_owner=NULL,lease_until=NULL WHERE job_id=? AND lease_owner=?') + ->execute([$status, $retryAt, substr($reason, 0, 1024), $id, $owner]); + $pdo->prepare('UPDATE delivery_log SET outcome=?,finished_at=?,error=? WHERE job_id=? AND attempt=?') + ->execute([$status === 'pending' ? 'retry' : 'failed', $now, substr($reason, 0, 1024), $id, $job['attempts']]); + }); + } + + public function retry(int $id, string $owner, string $error, int $waitSeconds = 30): void + { + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $error, $waitSeconds): void { + $job = $this->get($id); + if ($job === null || $job['status'] !== 'leased' || $job['lease_owner'] !== $owner) { + throw new \RuntimeException('Job ownership was lost.'); + } + $now = $this->clock->now()->getTimestamp(); + $retryAt = $now + max(1, $waitSeconds); + $status = $job['attempts'] - $job['deferrals'] >= 5 ? 'failed' : ($retryAt >= $job['expires_at'] ? 'expired' : 'pending'); + $pdo->prepare('UPDATE scheduled_jobs SET status=?,retry_at=?,last_error=?,lease_owner=NULL,lease_until=NULL WHERE job_id=? AND lease_owner=?') + ->execute([$status,$retryAt,substr($error, 0, 1024),$id,$owner]); + $pdo->prepare("UPDATE delivery_log SET outcome=?,finished_at=?,error=? WHERE job_id=? AND attempt=?")->execute([$status === 'pending' ? 'retry' : 'failed',$now,substr($error, 0, 1024),$id,$job['attempts']]); + }); + } + /** Successfully sent reminders await independent pointer/cleanup bookkeeping. */ + public function pendingDeliveryBookkeeping(int $limit = 50): array + { + if ($limit < 1 || $limit > 100) { + throw new \InvalidArgumentException('Invalid delivery bookkeeping batch.'); + } + $query = $this->db->pdo()->prepare("SELECT j.*,d.response_json FROM scheduled_jobs j JOIN delivery_log d ON d.job_id=j.job_id AND d.attempt=j.attempts AND d.outcome='sent' WHERE j.status='sent' AND j.module='tefillin' AND j.module_delivery_processed_at IS NULL ORDER BY j.completed_at,j.job_id LIMIT ?"); + $query->bindValue(1, $limit, PDO::PARAM_INT); + $query->execute(); + return array_map(static function (array $row): array { + $response = $row['response_json']; + unset($row['response_json']); + $row = self::decode($row); + $row['response'] = $response === null ? [] : json_decode($response, true, 64, JSON_THROW_ON_ERROR); + return $row; + }, $query->fetchAll()); + } + /** Explicitly finish unsupported cleanup bookkeeping without changing sent status. */ + public function markDeliveryProcessed(int $jobId): void + { + $this->db->transaction(function (PDO $pdo) use ($jobId): void { + $job = $this->get($jobId); + if ($job === null || $job['status'] !== 'sent') { + throw new \RuntimeException('Only a confirmed sent job can finish delivery bookkeeping.'); + } + $pdo->prepare("UPDATE scheduled_jobs SET module_delivery_processed_at=? WHERE job_id=? AND status='sent' AND module_delivery_processed_at IS NULL") + ->execute([$this->clock->now()->getTimestamp(),$jobId]); + }); + } + /** Local-only atomic quota check and dispatch reservation. Call after all other guards, immediately before RPC. */ + public function guardDelivery(array $job, array $settings, string $owner): bool + { + return (new DeliverySafety($this->db, $this->clock))->reserve($job, $settings, $owner); + } + /** Every actual transport attempt must retain durable uncertainty until acknowledgement. */ + public function markDispatch(int $id, string $owner): void + { + (new DeliverySafety($this->db, $this->clock))->mark($id, $owner); + } + public function queueMetrics(): array + { + return (new QueueMaintenance($this->db, $this->clock))->metrics(); + } + /** Bounded cleanup; zero days disables it. Active/uncertain sends and recovery evidence are never removed. */ + public function maintain(int $retentionDays = 90, int $batch = 200): array + { + return (new QueueMaintenance($this->db, $this->clock))->prune($retentionDays, $batch); + } + public function counts(): array + { + $rows = $this->db->pdo()->query('SELECT status,COUNT(*) AS count FROM scheduled_jobs GROUP BY status')->fetchAll(); + return array_column($rows, 'count', 'status'); + } + private static function decode(array $row): array + { + $row['payload'] = json_decode($row['payload_json'], true, 64, JSON_THROW_ON_ERROR); + unset($row['payload_json']); + foreach (['job_id','run_at','expires_at','settings_revision','attempts','deferrals','priority'] as $field) { + $row[$field] = (int)$row[$field]; + } + return $row; + } +} diff --git a/src/Scheduling/LockCoordinator.php b/src/Scheduling/LockCoordinator.php new file mode 100644 index 0000000..a06568e --- /dev/null +++ b/src/Scheduling/LockCoordinator.php @@ -0,0 +1,526 @@ +owner = bin2hex(random_bytes(16)); + $this->journal = new PermissionRecoveryJournal($db, $clock); + $this->freshDesired = $freshDesired === null ? null : \Closure::fromCallable($freshDesired); + $this->localDesired = $localDesired === null ? null : \Closure::fromCallable($localDesired); + } + public function state(string $group): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM group_lock_state WHERE group_id=?'); + $query->execute([$group]); + $row = $query->fetch(); + return $row === false ? null : $row; + } + /** Desired state wins over an earlier event. Permission retries survive process restarts. */ + public function reconcile(string $group, string $eventKey, bool $shouldLock): array + { + $existing = $this->state($group); + if (!$shouldLock && $existing === null) { + return ['status' => 'idle','changed' => false]; + } + if ($existing !== null && $existing['retry_at'] !== null && $existing['retry_at'] > $this->now()) { + return ['status' => 'retry_wait','changed' => false,'retry_at' => $existing['retry_at']]; + } + if (!$this->acquire($group, $eventKey, $shouldLock)) { + $state = $this->state($group); + if ($state !== null && $state['retry_at'] !== null && $state['retry_at'] > $this->now()) { + return ['status' => 'retry_wait','changed' => false,'retry_at' => $state['retry_at']]; + } + return ['status' => 'busy','changed' => false]; + } + try { + $state = $this->state($group); + if ($state === null) { + return ['status' => 'idle','changed' => false]; + } + $this->assertOwnership($group); + if ( + $state['snapshot_json'] !== null && !in_array($state['status'], ['restored','prelocked'], true) + && !PermissionRecoveryProvenance::verified($state) + ) { + return $this->unverifiedSnapshot($group, $state); + } + // A completed cycle has no restoration debt. Read again only at the next close; + // weekday administrator changes belong to that next fresh baseline. + if (!$shouldLock && in_array($state['status'], ['restored','prelocked'], true)) { + $this->confirmed($group, 'retry_at=NULL,last_error=NULL', []); + return ['status' => $state['status'],'changed' => false]; + } + $current = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + Permissions::validate($current); + $this->journal->observeKnownResult($group, $current); + $state = $this->retireUnwritten($group, $state, $current, $shouldLock); + if ($state === null) { + return ['status' => 'deferred','changed' => false]; + } + return $shouldLock ? $this->close($group, $eventKey, $state, $current) : $this->restore($group, $state, $current); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (RetryableTelegramException $error) { + return $this->retry($group, $error); + } catch (\Throwable $error) { + $code = \Shabbat\Runtime\PermissionFailure::code($error); + if (($existing['status'] ?? null) !== 'error' || ($existing['last_error'] ?? null) !== $error->getMessage()) { + error_log('Group permission operation failed: group=' . $group . ' code=' . $code); + } + return $this->record($group, 'error', $error->getMessage()) + ['error_code' => $code]; + } finally { + $this->db->pdo()->prepare('UPDATE group_lock_state SET lease_owner=NULL,lease_until=NULL WHERE group_id=? AND lease_owner=?')->execute([$group,$this->owner]); + } + } + private function unverifiedSnapshot(string $group, array $state): array + { + $message = 'Original permission snapshot has no verified read provenance; configure intended rights and explicitly adopt a fresh verified state.'; + if ($state['status'] === 'recovery_required' && $state['last_error'] === $message) { + return ['status' => 'recovery_required','changed' => false,'error' => $message, + 'error_code' => 'SNAPSHOT_UNVERIFIED','recovery_reason' => 'unverified_snapshot']; + } + return $this->db->transaction(function (PDO $pdo) use ($group, $state, $message): array { + if ($state['status'] !== 'recovery_required' || $state['last_error'] !== $message) { + $evidence = $state; + unset($evidence['lease_owner'], $evidence['lease_until']); + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(NULL,?,?,?,?)') + ->execute([$group,'permissions.unverified_original',Database::encode($evidence),$this->now()]); + } + return $this->record($group, 'recovery_required', $message) + + ['error_code' => 'SNAPSHOT_UNVERIFIED','recovery_reason' => 'unverified_snapshot']; + }); + } + private function acquire(string $group, string $eventKey, bool $shouldLock): bool + { + $now = $this->now(); + return $this->db->transaction(function (PDO $pdo) use ($group, $eventKey, $shouldLock, $now): bool { + if ($shouldLock) { + $pdo->prepare("INSERT OR IGNORE INTO group_lock_state(group_id,event_key,status,prepared_at) VALUES(?,?,'prepared',?)")->execute([$group,$eventKey,$now]); + } + $query = $pdo->prepare('UPDATE group_lock_state SET lease_owner=?,lease_until=? WHERE group_id=? AND (lease_owner IS NULL OR lease_until<=?) AND (retry_at IS NULL OR retry_at<=?)'); + $query->execute([$this->owner,$now + 120,$group,$now,$now]); + return $query->rowCount() === 1; + }); + } + private function close(string $group, string $eventKey, array $state, array $current): array + { + if (in_array($state['status'], ['conflict','recovery_required'], true) && $state['snapshot_json'] === null) { + return $this->record($group, 'recovery_required', 'Missing original permission snapshot; administrator action required.'); + } + $flags = $this->configuredFlags($group); + $hasDebt = $state['snapshot_json'] !== null && !in_array($state['status'], ['restored','prelocked'], true); + if ($hasDebt) { + [$snapshot,$owned,$previous] = $this->snapshot($state); + if ( + !Permissions::equal($current, $snapshot) && !Permissions::equal($current, $owned) + && ($previous === null || !Permissions::equal($current, $previous)) + ) { + $recovered = Permissions::externalBaseline($snapshot, $owned, $current); + if ($recovered === null) { + return $this->conflict($group, $current, $owned); + } + $snapshot = $recovered; + $this->baseline($group, $snapshot); + } + } else { + if (Permissions::alreadyLocked($current)) { + $this->confirmed($group, "status='prelocked',event_key=?,snapshot_json=NULL,applied_json=NULL,previous_applied_json=NULL,snapshot_sha256=NULL,snapshot_provenance='legacy_unverified',snapshot_evidence_json=NULL,recovery_baseline_json=NULL,recovery_baseline_sha256=NULL,last_error=NULL,retry_at=NULL", [$eventKey]); + return ['status' => 'prelocked','changed' => false]; + } + $snapshot = $current; + } + $applied = Permissions::locked($snapshot, $flags); + if ($hasDebt && Permissions::equal($current, $applied)) { + $this->confirmed($group, "status='locked',event_key=?,applied_json=?,previous_applied_json=NULL,last_error=NULL,retry_at=NULL", [$eventKey,Database::encode($applied)]); + return ['status' => 'locked','changed' => false]; + } + $serialized = Database::encode($snapshot); + $capturedAt = $this->now(); + $evidence = PermissionRecoveryProvenance::capture($group, $serialized, $capturedAt); + $previous = $hasDebt && !Permissions::equal($current, $snapshot) ? Database::encode($current) : null; + $this->db->transaction(function (PDO $pdo) use ($group, $eventKey, $serialized, $applied, $previous, $hasDebt, $capturedAt, $evidence): void { + $snapshotColumns = $hasDebt ? '' : "snapshot_json=?,snapshot_sha256=?,prepared_at=?,snapshot_provenance='verified_native_v1',snapshot_evidence_json=?,recovery_baseline_json=NULL,recovery_baseline_sha256=NULL,"; + $values = [$eventKey,Database::encode($applied),$previous]; + if (!$hasDebt) { + array_push($values, $serialized, hash('sha256', $serialized), $capturedAt, $evidence); + } + $query = $pdo->prepare("UPDATE group_lock_state SET event_key=?,status='prepared',applied_json=?,previous_applied_json=?," . $snapshotColumns . "restored_at=NULL,last_error=NULL,revision=revision+1 WHERE group_id=? AND lease_owner=? AND lease_until>?"); + $query->execute([...$values,$group,$this->owner,$this->now()]); + if ($query->rowCount() !== 1) { + throw new \RuntimeException('Lost group lock ownership before snapshot persistence.'); + } + }); + $operation = $this->journal->begin($group, 'lock', $current, $applied, $this->state($group) ?? []); + if (!$this->stillDesired($group, true) || $flags !== $this->configuredFlags($group)) { + $this->journal->update($operation, 'deferred', 'not_dispatched', $current); + return ['status' => 'deferred','changed' => false]; + } + if (!$this->readyToWrite($group, $current, true, $operation)) { + return ['status' => 'deferred','changed' => false]; + } + $this->journal->update($operation, 'dispatched', 'unknown'); + $this->gateway->setPermissions($group, $applied); + $this->assertOwnership($group); + $observed = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + $this->journal->update($operation, 'verified', Permissions::equal($observed, $applied) ? 'confirmed' : 'conflict', $observed); + if (!Permissions::equal($observed, $applied)) { + return $this->conflict($group, $observed, $applied, true); + } + $this->confirmed($group, "status='locked',locked_at=?,previous_applied_json=NULL,last_error=NULL,retry_at=NULL", [$this->now()]); + if ($hasDebt && !Permissions::equal($current, $snapshot)) { + return ['status' => 'locked','changed' => false,'permissions_updated' => true]; + } + return ['status' => 'locked','changed' => true]; + } + /** A deferred first attempt is provably unowned; elapsed time alone is never evidence. */ + private function retireUnwritten(string $group, array $state, array $current, bool $shouldLock): ?array + { + if ( + !in_array($state['status'], ['prepared','error'], true) || Permissions::alreadyLocked($current) + || $state['previous_applied_json'] !== null || $state['recovery_baseline_json'] !== null + || !is_string($state['snapshot_json']) || !is_string($state['snapshot_sha256']) + || !hash_equals($state['snapshot_sha256'], hash('sha256', $state['snapshot_json'])) + ) { + return $state; + } + $latest = $this->journal->latest($group); + if ( + $latest === null || $latest['action'] !== 'lock' || $latest['outcome'] !== 'not_dispatched' + || $latest['stage'] !== 'deferred' || $latest['before_json'] !== $state['snapshot_json'] + || $latest['original_sha256'] !== $state['snapshot_sha256'] || $latest['requested_json'] !== $state['applied_json'] + ) { + return $state; + } + if (!$this->stillDesired($group, $shouldLock)) { + return null; + } + $latestRights = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + if (!Permissions::equal($current, $latestRights) || !$this->locallyDesired($group, $shouldLock)) { + return null; + } + $this->confirmed($group, "status='restored',restored_at=?,last_error=NULL,retry_at=NULL", [$this->now()]); + $this->journal->update($latest['operation_id'], 'retired_unwritten', 'not_dispatched', $current); + return $this->state($group) ?? throw new \RuntimeException('Recovery state disappeared.'); + } + private function baseline(string $group, array $baseline): void + { + $json = Database::encode($baseline); + $this->confirmed($group, 'recovery_baseline_json=?,recovery_baseline_sha256=?', [$json,hash('sha256', $json)]); + } + /** Explicit recovery shares the normal lock lease; no remote calls run inside a transaction. */ + public function recover(string $group, string $actor, string $action, array $binding, callable $guard, callable $localGuard): array + { + if (!in_array($action, ['adopt','restore'], true)) { + throw new \InvalidArgumentException('Unknown permission recovery action.'); + } + if (($denied = $this->recoveryDenial($guard)) !== null) { + return ['status' => $denied,'changed' => false]; + } + if (!$this->acquire($group, 'recovery', false)) { + $state = $this->state($group); + if ($state !== null && $state['retry_at'] !== null && $state['retry_at'] > $this->now()) { + return ['status' => 'retry_wait','changed' => false,'retry_at' => $state['retry_at']]; + } + return ['status' => 'busy','changed' => false]; + } + try { + $state = $this->state($group); + if ($state === null || !hash_equals($binding['state_sha256'], PermissionRecovery::stateFingerprint($state))) { + return ['status' => 'stale','changed' => false]; + } + $current = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + Permissions::validate($current); + if (!hash_equals($binding['remote_sha256'], hash('sha256', Permissions::canonical($current)))) { + return ['status' => 'stale','changed' => false]; + } + if (($denied = $this->recoveryDenial($guard)) !== null) { + return ['status' => $denied,'changed' => false]; + } + $this->assertOwnership($group); + if ($action === 'adopt') { + if (Permissions::alreadyLocked($current)) { + return ['status' => 'locked_current','changed' => false]; + } + $target = $current; + } else { + if (!PermissionRecoveryProvenance::verified($state)) { + return ['status' => 'unverified_snapshot','changed' => false]; + } + if ( + !is_string($state['snapshot_json']) || !is_string($state['snapshot_sha256']) + || !hash_equals($state['snapshot_sha256'], hash('sha256', $state['snapshot_json'])) + ) { + return ['status' => 'missing_snapshot','changed' => false]; + } + $target = json_decode($state['snapshot_json'], true, 32, JSON_THROW_ON_ERROR); + if (!is_array($target)) { + return ['status' => 'missing_snapshot','changed' => false]; + } + Permissions::validate($target); + } + if (($denied = $this->recoveryDenial($guard)) !== null) { + return ['status' => $denied,'changed' => false]; + } + // No asynchronous guard follows this read: only local fencing may precede mutation. + $latest = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + if (!Permissions::equal($current, $latest)) { + return ['status' => 'stale','changed' => false]; + } + if (($denied = $this->recoveryDenial($localGuard)) !== null) { + return ['status' => $denied,'changed' => false]; + } + if (!hash_equals($binding['state_sha256'], PermissionRecovery::stateFingerprint($this->state($group)))) { + return ['status' => 'stale','changed' => false]; + } + $operation = $this->journal->begin($group, 'administrator_' . $action, $current, $target, $state); + $changed = $action === 'restore' && !Permissions::equal($current, $target); + if ($changed) { + $this->baseline($group, $target); + $this->confirmed($group, "status='restoring'", []); + $this->journal->update($operation, 'dispatched', 'unknown'); + $this->gateway->setPermissions($group, $target); + $this->assertOwnership($group); + $observed = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + $this->journal->update($operation, 'verified', Permissions::equal($observed, $target) ? 'confirmed' : 'conflict', $observed); + if (!Permissions::equal($observed, $target)) { + return $this->conflict($group, $observed, $target, true); + } + } + $commitFingerprint = PermissionRecovery::stateFingerprint($this->state($group)); + if (($denied = $this->recoveryDenial($guard)) !== null) { + return ['status' => $denied,'changed' => $changed]; + } + $final = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + if (!Permissions::equal($final, $target)) { + $this->journal->update($operation, 'verified', 'conflict', $final); + return $this->conflict($group, $final, $target, $changed); + } + if (($denied = $this->recoveryDenial($localGuard)) !== null) { + return ['status' => $denied,'changed' => $changed]; + } + if (!hash_equals($commitFingerprint, PermissionRecovery::stateFingerprint($this->state($group)))) { + return ['status' => 'stale','changed' => $changed]; + } + $this->assertOwnership($group); + $this->db->transaction(function (PDO $pdo) use ($group, $actor, $action, $state, $current, $target, $operation): void { + $this->confirmed($group, "status='restored',restored_at=?,previous_applied_json=NULL,recovery_baseline_json=NULL,recovery_baseline_sha256=NULL,last_error=NULL,retry_at=NULL,revision=revision+1", [$this->now()]); + // Preserve the old original and the administrator's choice across later cycles. + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)') + ->execute([$actor,$group,'permissions.recovery.' . $action,Database::encode(['operation_id' => $operation,'original_json' => $state['snapshot_json'],'original_sha256' => $state['snapshot_sha256'],'original_provenance' => $state['snapshot_provenance'],'original_evidence_json' => $state['snapshot_evidence_json'],'before' => $current,'normal' => $target]),$this->now()]); + $this->journal->update($operation, 'completed', 'confirmed', $target); + }); + return ['status' => $action === 'adopt' ? 'adopted' : 'restored','changed' => $changed]; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (RetryableTelegramException $error) { + return $this->retry($group, $error); + } catch (\Throwable $error) { + return $this->record($group, 'error', $error->getMessage()); + } finally { + $this->db->pdo()->prepare('UPDATE group_lock_state SET lease_owner=NULL,lease_until=NULL WHERE group_id=? AND lease_owner=?')->execute([$group,$this->owner]); + } + } + /** @phpstan-impure Each guard refreshes authority, calendar, settings and shutdown state. */ + private function recoveryDenial(callable $guard): ?string + { + return $guard(); + } + private function configuredFlags(string $group): array + { + $query = $this->db->pdo()->prepare('SELECT lock_block_flags FROM group_settings WHERE group_id=?'); + $query->execute([$group]); + $json = $query->fetchColumn(); + if (!is_string($json)) { + throw new \RuntimeException('Group lock configuration is missing.'); + } + $flags = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($flags)) { + throw new \RuntimeException('Group lock configuration is malformed.'); + } + return Permissions::validateBlockFlags($flags); + } + private function restore(string $group, array $state, array $current): array + { + if (in_array($state['status'], ['restored','prelocked'], true)) { + $this->confirmed($group, 'retry_at=NULL,last_error=NULL', []); + return ['status' => $state['status'],'changed' => false]; + } + if ($state['snapshot_json'] === null) { + return $this->record($group, 'recovery_required', 'Missing original permission snapshot; permissions were not guessed.'); + } + [$snapshot,$applied,$previous] = $this->snapshot($state); + if (Permissions::equal($current, $snapshot)) { + $this->confirmed($group, "status='restored',restored_at=?,previous_applied_json=NULL,last_error=NULL,retry_at=NULL", [$this->now()]); + return ['status' => 'restored','changed' => false]; + } + if (!Permissions::equal($current, $applied) && ($previous === null || !Permissions::equal($current, $previous))) { + $recovered = Permissions::externalBaseline($snapshot, $applied, $current); + if ($recovered === null) { + return $this->conflict($group, $current, $applied); + } + $snapshot = $recovered; + $this->baseline($group, $snapshot); + } + $this->confirmed($group, "status='restoring'", []); + $operation = $this->journal->begin($group, 'restore', $current, $snapshot, $state); + if (!$this->stillDesired($group, false)) { + $this->journal->update($operation, 'deferred', 'not_dispatched', $current); + return ['status' => 'deferred','changed' => false]; + } + if (!$this->readyToWrite($group, $current, false, $operation)) { + return ['status' => 'deferred','changed' => false]; + } + $this->journal->update($operation, 'dispatched', 'unknown'); + $this->gateway->setPermissions($group, $snapshot); + $this->assertOwnership($group); + $observed = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + $this->journal->update($operation, 'verified', Permissions::equal($observed, $snapshot) ? 'confirmed' : 'conflict', $observed); + if (!Permissions::equal($observed, $snapshot)) { + return $this->conflict($group, $observed, $snapshot, true); + } + $this->confirmed($group, "status='restored',restored_at=?,previous_applied_json=NULL,last_error=NULL,retry_at=NULL", [$this->now()]); + return ['status' => 'restored','changed' => true]; + } + private function snapshot(array $state): array + { + if (!is_string($state['snapshot_sha256']) || !hash_equals($state['snapshot_sha256'], hash('sha256', $state['snapshot_json']))) { + throw new \RuntimeException('Permission snapshot checksum mismatch.'); + } + $snapshot = json_decode($state['snapshot_json'], true, 32, JSON_THROW_ON_ERROR); + $applied = json_decode($state['applied_json'] ?? '', true, 32, JSON_THROW_ON_ERROR); + if (!is_array($snapshot) || !is_array($applied)) { + throw new \RuntimeException('Invalid permission snapshot.'); + } + Permissions::validate($snapshot); + Permissions::validate($applied); + $previous = $state['previous_applied_json'] === null ? null : json_decode($state['previous_applied_json'], true, 32, JSON_THROW_ON_ERROR); + if ($previous !== null) { + if (!is_array($previous)) { + throw new \RuntimeException('Invalid previous owned permission mask.'); + } + Permissions::validate($previous); + } + if ($state['recovery_baseline_json'] !== null) { + if (!is_string($state['recovery_baseline_sha256']) || !hash_equals($state['recovery_baseline_sha256'], hash('sha256', $state['recovery_baseline_json']))) { + throw new \RuntimeException('Recovery baseline checksum mismatch.'); + } + $snapshot = json_decode($state['recovery_baseline_json'], true, 32, JSON_THROW_ON_ERROR); + if (!is_array($snapshot)) { + throw new \RuntimeException('Invalid recovery baseline.'); + } + Permissions::validate($snapshot); + } + return [$snapshot,$applied,$previous]; + } + /** Final current-rights read follows every asynchronous intent evaluation. */ + private function readyToWrite(string $group, array $current, bool $expected, int $operation): bool + { + $latest = $this->gateway->getPermissions($group); + $this->assertOwnership($group); + Permissions::validate($latest); + if (!Permissions::equal($current, $latest) || !$this->locallyDesired($group, $expected)) { + $this->journal->update($operation, 'deferred', 'not_dispatched', $latest); + return false; + } + return true; + } + /** No provider or RPC is allowed in this callback after the final remote read. */ + private function locallyDesired(string $group, bool $expected): bool + { + $this->assertOwnership($group); + return $this->localDesired === null || ($this->localDesired)($group, $expected) === true; + } + /** Provider/current-settings evaluation can suspend; no transaction or stale lease crosses it. */ + private function stillDesired(string $group, bool $expected): bool + { + if ($this->freshDesired !== null) { + $desired = ($this->freshDesired)($group); + if (!is_bool($desired)) { + throw new \UnexpectedValueException('Current desired permission state is not a boolean.'); + } + if ($desired !== $expected) { + return false; + } + } + $this->assertOwnership($group); + return true; + } + private function assertOwnership(string $group): void + { + $state = $this->state($group); + if ($state === null || $state['lease_owner'] !== $this->owner || $state['lease_until'] <= $this->now()) { + throw new \RuntimeException('Group ownership expired during Telegram request.'); + } + if ($state['retry_at'] !== null && $state['retry_at'] > $this->now()) { + throw new RetryableTelegramException('Permission request deferred to its persisted quota deadline.', $state['retry_at'] - $this->now()); + } + } + /** Confirmed success is fenced by both lease identity and deadline, including the final UPDATE. */ + private function confirmed(string $group, string $assignments, array $values): void + { + $query = $this->db->pdo()->prepare('UPDATE group_lock_state SET ' . $assignments . ' WHERE group_id=? AND lease_owner=? AND lease_until>? AND (retry_at IS NULL OR retry_at<=?)'); + $query->execute([...$values,$group,$this->owner,$this->now(),$this->now()]); + if ($query->rowCount() !== 1) { + throw new \RuntimeException('Group lock ownership was lost before confirmation.'); + } + } + private function retry(string $group, RetryableTelegramException $error): array + { + $now = $this->now(); + $after = max(1, $error->retryAfterSeconds); + $retryAt = $after > PHP_INT_MAX - $now ? PHP_INT_MAX : $now + $after; + // A known quota deadline remains relevant even if this request outlived its lease. + // It never overwrites another owner's status, permissions or original snapshot. + $this->db->pdo()->prepare('UPDATE group_lock_state SET retry_at=max(coalesce(retry_at,0),?) WHERE group_id=?')->execute([$retryAt,$group]); + $this->db->pdo()->prepare("UPDATE group_lock_state SET last_error=? WHERE group_id=? AND lease_owner=? AND lease_until>?")->execute([substr($error->getMessage(), 0, 1024),$group,$this->owner,$now]); + return ['status' => 'retry_wait','changed' => false,'retry_at' => $this->state($group)['retry_at']]; + } + private function conflict(string $group, array $current, array $expected, bool $mutationAttempted = false): array + { + $fields = Permissions::differentFields($current, $expected); + $latest = $this->journal->latest($group); + if ($latest === null || $latest['observed_json'] !== Database::encode($current) || $latest['requested_json'] !== Database::encode($expected)) { + $operation = $this->journal->begin($group, 'inspect_conflict', $current, $expected, $this->state($group) ?? []); + $this->journal->update($operation, 'verified', 'conflict', $current); + } + return $this->record($group, 'conflict', 'Current permissions differ from the saved state; original snapshot retained. Fields: ' . implode(', ', $fields)) + + ['different_fields' => $fields,'mutation_attempted' => $mutationAttempted,'verification_failed' => $mutationAttempted]; + } + private function record(string $group, string $status, string $error): array + { + $query = $this->db->pdo()->prepare('UPDATE group_lock_state SET status=?,last_error=? WHERE group_id=? AND lease_owner=? AND lease_until>?'); + $query->execute([$status,substr($error, 0, 1024),$group,$this->owner,$this->now()]); + if ($query->rowCount() !== 1) { + return ['status' => 'busy','changed' => false,'error' => 'Group lock ownership was lost; permission result remains unconfirmed.']; + } + return ['status' => $status,'changed' => false,'error' => $error]; + } + private function now(): int + { + return $this->clock->now()->getTimestamp(); + } +} diff --git a/src/Scheduling/MessageCleanup.php b/src/Scheduling/MessageCleanup.php new file mode 100644 index 0000000..203ca71 --- /dev/null +++ b/src/Scheduling/MessageCleanup.php @@ -0,0 +1,205 @@ +shutdown = Closure::fromCallable($shutdown ?? static fn(): bool => false); + } + + public function stop(): void + { + $this->stopping = true; + } + + /** Queue received ordinary messages only; never sweep group history. */ + public function receive(string $group, int $messageId, int $sentAt, string $sender, bool $outgoing = false): bool + { + if ( + $this->stopped() || !$this->gateway instanceof MessageMaintenanceGateway || $outgoing || $messageId < 1 + || !preg_match('/^-[1-9][0-9]*$/D', $group) || !preg_match('/^[1-9][0-9]*$/D', $sender) + ) { + return false; + } + $settings = $this->settings->getGroup($group); + if (!$this->moderationEnabled($settings) || $sender === $this->gateway->selfUserId()) { + return false; + } + $event = $this->holyEvent($settings, $sentAt, $this->clock->now()); + if ($event === null) { + return false; + } + $bot = $this->gateway->senderIsBot($group, $sender); + if ($bot === null || !$settings[$bot ? 'delete_holy_bot_messages' : 'delete_holy_user_messages']) { + return false; + } + $latest = $this->settings->getGroup($group); + $now = $this->clock->now()->getTimestamp(); + if ($this->stopped() || $latest === null || $latest['revision'] !== $settings['revision'] || $now >= $event['end_at']) { + return false; + } + $this->deletions->enqueue($group, $messageId, 'holy_message', [ + 'original_timestamp' => $sentAt, 'sender_id' => $sender, 'sender_is_bot' => $bot, + 'sender_is_self' => false, 'settings_revision' => $settings['revision'], + ], $now, $event['end_at']); + return true; + } + + public function runOnce(string $owner): ?array + { + if ($this->running || $this->stopped() || !$this->gateway instanceof MessageMaintenanceGateway) { + return null; + } + $this->running = true; + try { + return $this->execute($owner); + } finally { + $this->running = false; + } + } + + private function execute(string $owner): ?array + { + $job = $this->deletions->claim($owner); + if ($job === null) { + return null; + } + $id = $job['deletion_id']; + try { + $settings = $this->settings->getGroup($job['group_id']); + if (!$this->allowed($job, $settings)) { + return $this->discard($id, $owner, 'Cleanup preference, period or current message changed.'); + } + if (!$this->gateway instanceof MessageMaintenanceGateway || !$this->gateway->botCanDeleteMessages($job['group_id'])) { + throw new \RuntimeException('Verified delete_messages capability is required.'); + } + if ($job['purpose'] === 'holy_message' && !Permissions::alreadyLocked($this->gateway->getPermissions($job['group_id']))) { + return $this->discard($id, $owner, 'Group is actually open.'); + } + // Native reads may yield: check preferences, holy period and lease before the deletion request. + $latest = $this->settings->getGroup($job['group_id']); + if ( + $this->stopped() || $latest === null || $settings === null || $latest['revision'] !== $settings['revision'] + || !$this->allowed($job, $latest) || !$this->deletions->owns($id, $owner) + ) { + return $this->discard($id, $owner, 'Cleanup changed before deletion.'); + } + // Calendar eligibility may await a provider. This final guard performs no network work. + $current = $this->settings->getGroup($job['group_id']); + if ( + $this->stopped() || $current === null || $current['revision'] !== $latest['revision'] + || !$this->deletions->owns($id, $owner) + || ($job['expires_at'] !== null && $this->clock->now()->getTimestamp() >= $job['expires_at']) + ) { + return $this->discard($id, $owner, 'Cleanup cutoff or ownership changed.'); + } + $this->gateway->deleteMessages($job['group_id'], [$job['message_id']]); + $this->deletions->complete($id, $owner); + return ['status' => 'deleted', 'deletion_id' => $id]; + } catch (CancelledException $error) { + if ($this->deletions->owns($id, $owner)) { + $this->deletions->retry($id, $owner, 'Cleanup interrupted by cancellation.', 30); + } + throw $error; + } catch (RetryableTelegramException $error) { + if ($this->deletions->owns($id, $owner)) { + $this->deletions->retry($id, $owner, $error->getMessage(), $error->retryAfterSeconds); + } + return ['status' => 'retry', 'deletion_id' => $id]; + } catch (\Throwable $error) { + if ($this->deletions->owns($id, $owner)) { + $this->deletions->retry($id, $owner, $error->getMessage(), min(300, 30 * (2 ** min(3, $job['attempts'] - 1)))); + } + return ['status' => 'retry', 'deletion_id' => $id]; + } + } + + private function discard(int $id, string $owner, string $reason): array + { + $row = $this->deletions->get($id); + if ( + $row === null || $row['status'] !== 'leased' || $row['lease_owner'] !== $owner + || $row['lease_until'] <= $this->clock->now()->getTimestamp() + ) { + // The next bounded claim expires/reclaims the row; an expired worker writes no success or cancellation. + return ['status' => 'lease_lost', 'deletion_id' => $id]; + } + $this->deletions->cancel($id, $owner, $reason); + return ['status' => 'cancelled', 'deletion_id' => $id]; + } + private function allowed(array $job, ?array $settings): bool + { + if ($settings === null || !$settings['active']) { + return false; + } + if ($job['purpose'] === 'tefillin_previous') { + return $settings['tefillin_enabled'] && $settings['tefillin_delete_previous'] && $job['sender_is_self'] + && !in_array($job['message_id'], $this->deletions->getLastModuleMessages($job['group_id'], 'tefillin'), true); + } + if ( + $job['purpose'] !== 'holy_message' || !$this->moderationEnabled($settings) || $job['sender_is_self'] + || $settings['revision'] !== $job['settings_revision'] + || !$settings[$job['sender_is_bot'] ? 'delete_holy_bot_messages' : 'delete_holy_user_messages'] + ) { + return false; + } + return $this->holyEvent($settings, $job['original_timestamp'], $this->clock->now()) !== null; + } + + private function moderationEnabled(?array $settings): bool + { + return $settings !== null && $settings['active'] && in_array($settings['mode'], ['lock', 'lock_messages'], true) + && ($settings['delete_holy_user_messages'] || $settings['delete_holy_bot_messages']); + } + + private function holyEvent(array $settings, int $sentAt, \DateTimeImmutable $instant): ?array + { + $now = $instant->getTimestamp(); + if ($sentAt <= 0 || $sentAt > $now) { + return null; + } + $state = $this->calendar->snapshot($settings, $instant); + if (!$state['is_work_prohibited_now'] || !ManualGroups::desired($settings, $state['should_group_be_locked'], $now)) { + return null; + } + foreach ($state['operational_events'] as $event) { + if ($sentAt >= $event['start_at'] && $sentAt < $event['end_at'] && $now >= $event['start_at'] && $now < $event['end_at']) { + return $event; + } + } + return null; + } + + /** @phpstan-impure Shutdown may be requested during awaited Telegram/provider work. */ + private function stopped(): bool + { + return $this->stopping || ($this->shutdown)(); + } +} diff --git a/src/Scheduling/OutboxWorker.php b/src/Scheduling/OutboxWorker.php new file mode 100644 index 0000000..296a8a1 --- /dev/null +++ b/src/Scheduling/OutboxWorker.php @@ -0,0 +1,178 @@ +eligibility = Closure::fromCallable($eligibility); + $this->payloadFactory = $payloadFactory === null ? null : Closure::fromCallable($payloadFactory); + $this->afterDelivery = $afterDelivery === null ? null : Closure::fromCallable($afterDelivery); + $this->dispatchEligibility = $dispatchEligibility === null ? null : Closure::fromCallable($dispatchEligibility); + } + public function stop(): void + { + $this->stopping = true; + } + public function runOnce(string $owner): ?array + { + if ($this->running || $this->stopping) { + return null; + } + $this->running = true; + try { + return $this->executeOnce($owner); + } finally { + $this->running = false; + } + } + private function executeOnce(string $owner): ?array + { + if ($this->stopping) { + return null; + } + $job = $this->jobs->lease($owner); + if ($job === null) { + return null; + } + try { + $settings = $this->settings->getGroup($job['group_id']); + if (!$this->allowed($job, $settings) || !($this->eligibility)($job, $settings, $this->clock->now())) { + $this->jobs->cancel($job['job_id'], $owner, 'delivery no longer eligible'); + return ['status' => 'cancelled','job_id' => $job['job_id']]; + } + if ($this->payloadFactory !== null && $job['payload_prepared_at'] === null) { + // Remote/provider work is outside the persistence transaction. A durable final payload is never regenerated after uncertainty. + $prepared = ($this->payloadFactory)($job, $settings, $this->clock->now()); + if (!is_array($prepared)) { + throw new \RuntimeException('Delivery payload factory must return an array.'); + } + $this->jobs->preparePayload($job['job_id'], $owner, $prepared); + $job = $this->jobs->get($job['job_id']) ?? throw new \RuntimeException('Prepared job disappeared.'); + } + // Re-read revisions/time after eligibility work and immediately before the request. + $settings = $this->settings->getGroup($job['group_id']); + $stillEligible = $settings !== null && ($this->eligibility)($job, $settings, $this->clock->now()); + // Eligibility may itself await provider I/O; no yielding work follows the final cutoff/revision guard. + $settings = $this->settings->getGroup($job['group_id']); + if (!$stillEligible || $this->stopping || !$this->allowed($job, $settings) || !$this->jobs->owns($job['job_id'], $owner)) { + $this->jobs->cancel($job['job_id'], $owner, 'delivery cutoff/revision/ownership changed'); + return ['status' => 'cancelled','job_id' => $job['job_id']]; + } + // Native preflight (emoji catalog, forum topic, reusable media) can await. + // Pass the fence down to the actual send boundary, never store it on a shared gateway. + $guard = function () use ($job, $owner): void { + $current = $this->settings->getGroup($job['group_id']); + if ($this->stopping || !$this->allowed($job, $current) || !$this->jobs->owns($job['job_id'], $owner)) { + throw new DispatchRejectedException('delivery cutoff/revision/ownership changed before transport'); + } + if ($this->dispatchEligibility !== null && !($this->dispatchEligibility)($job, $current, $this->clock->now())) { + throw new DispatchRejectedException('delivery evidence no longer permits transport'); + } + if (!$this->jobs->guardDelivery($job, $current, $owner)) { + throw new DispatchRejectedException('delivery rate/window no longer permits transport'); + } + $this->jobs->markDispatch($job['job_id'], $owner); + // Local SQLite writes can consume wall-clock time even without a Fiber yield. + // Recheck the religious/time evidence after persistence too; a rejected + // reservation remains conservative evidence, never an unattempted job. + $current = $this->settings->getGroup($job['group_id']); + if ( + $this->stopping || !$this->allowed($job, $current) + || ($this->dispatchEligibility !== null && !($this->dispatchEligibility)($job, $current, $this->clock->now())) + ) { + throw new DispatchRejectedException('delivery boundary changed during dispatch persistence'); + } + }; + if ($this->gateway instanceof \Shabbat\Telegram\GuardedTelegramGateway) { + $response = $this->gateway->sendGuarded($job['group_id'], $job['payload'], $job['random_id'], $guard); + } else { + // Compatibility for synchronous in-process gateways without asynchronous preflight. + $guard(); + $response = $this->gateway->send($job['group_id'], $job['payload'], $job['random_id']); + } + $this->jobs->sent($job['job_id'], $owner, $response); + $result = ['status' => 'sent','job_id' => $job['job_id'],'response' => $response]; + if ($this->afterDelivery !== null) { + try { + ($this->afterDelivery)($job, $response); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + // A confirmed send stays sent. Durable bookkeeping is replayed independently after restart. + $result['cleanup_pending'] = true; + } + } + return $result; + } catch (\Amp\CancelledException $error) { + if ($this->jobs->owns($job['job_id'], $owner)) { + $this->jobs->retry($job['job_id'], $owner, 'operation cancelled; worker stopped', 1); + } + throw $error; + } catch (DispatchRejectedException $error) { + if ($this->jobs->owns($job['job_id'], $owner)) { + $this->jobs->cancel($job['job_id'], $owner, $error->getMessage()); + } + return ['status' => 'cancelled', 'job_id' => $job['job_id'], 'dispatch_rejected' => true]; + } catch (DeliveryDeferredException $error) { + if ($this->jobs->owns($job['job_id'], $owner)) { + $this->jobs->defer($job['job_id'], $owner, $error->getMessage(), $error->retryAt); + } + return ['status' => 'deferred', 'job_id' => $job['job_id']]; + } catch (RetryableTelegramException $error) { + $this->jobs->retry($job['job_id'], $owner, $error->getMessage(), $error->retryAfterSeconds); + return ['status' => 'retry','job_id' => $job['job_id'],'error' => $error->getMessage()]; + } catch (\Throwable $error) { + // An unknown transport error may follow successful acceptance. The next try reuses random_id. + if ($this->jobs->owns($job['job_id'], $owner)) { + $this->jobs->retry($job['job_id'], $owner, $error->getMessage(), min(300, 30 * (2 ** min(3, max(0, $job['attempts'] - $job['deferrals'] - 1))))); + } + return ['status' => 'retry','job_id' => $job['job_id'],'error' => $error->getMessage()]; + } + } + private function allowed(array $job, ?array $settings): bool + { + if ($settings === null || !$settings['active'] || $settings['revision'] !== $job['settings_revision'] || $this->clock->now()->getTimestamp() >= $job['expires_at']) { + return false; + } + $field = match ($job['module']) { + 'zmanim'=>'zmanim_enabled','daf'=>'daf_enabled','tefillin'=>'tefillin_enabled','omer','omer_day'=>'omer_enabled', + 'times','shabbat_times','times_alert'=>'times_alert_enabled','close'=>'close_message_enabled','open'=>'open_message_enabled', + default=>null + }; + if ($field === null || !$settings[$field]) { + return false; + } + if ($job['module'] === 'omer_day' && !$settings['omer_day_completion']) { + return false; + } + if (in_array($job['module'], ['close','open'], true) && !in_array($settings['mode'], ['messages','lock_messages'], true)) { + return false; + } + return true; + } +} diff --git a/src/Scheduling/PermissionRecovery.php b/src/Scheduling/PermissionRecovery.php new file mode 100644 index 0000000..e45f5e3 --- /dev/null +++ b/src/Scheduling/PermissionRecovery.php @@ -0,0 +1,194 @@ +authorize = \Closure::fromCallable($authorize); + $this->safeToRecover = \Closure::fromCallable($safeToRecover); + $this->localSafeToRecover = $localSafeToRecover === null ? null : \Closure::fromCallable($localSafeToRecover); + } + public function inspect(string $group, string $actor): array + { + SettingsRepository::identifier($group); + SettingsRepository::identifier($actor); + if (!$this->authorized($actor, $group)) { + return $this->blocked('forbidden'); + } + $binding = $this->binding($group); + $current = $this->gateway->getPermissions($group); + Permissions::validate($current); + $safe = ($this->safeToRecover)($group) === true; + if (!$this->authorized($actor, $group)) { + return $this->blocked('forbidden'); + } + $safe = $safe && ($this->safeToRecover)($group) === true; + if ($binding !== $this->binding($group)) { + return $this->blocked('stale'); + } + $state = $this->locks->state($group); + $original = $this->original($state); + $problem = $state !== null && !in_array($state['status'], ['restored','prelocked'], true); + $reason = !$safe ? 'active_lock' : (!$problem ? 'no_problem' : null); + $canAdopt = $reason === null && !Permissions::alreadyLocked($current); + $originalVerified = $original !== null && PermissionRecoveryProvenance::verified($state); + $canRestore = $reason === null && $originalVerified; + $expected = $state === null ? [] : json_decode($state['applied_json'] ?? '{}', true, 32, JSON_THROW_ON_ERROR); + return ['status' => $state['status'] ?? 'idle','group_id' => $group,'current' => $current,'original' => $original, + 'original_verified' => $originalVerified,'snapshot_provenance' => $state['snapshot_provenance'] ?? 'legacy_unverified', + 'different_fields' => Permissions::differentFields($current, is_array($expected) ? $expected : []), + 'can_adopt' => $canAdopt,'can_restore' => $canRestore,'block_reason' => $reason, + 'adopt_block_reason' => $reason ?? ($canAdopt ? null : 'locked_current'), + 'restore_block_reason' => $reason ?? ($canRestore ? null : ($original === null ? 'missing_snapshot' : 'unverified_snapshot')), + 'binding' => $binding,'remote_sha256' => hash('sha256', Permissions::canonical($current)), + 'evidence' => (new PermissionRecoveryJournal($this->db, $this->clock))->latest($group)]; + } + public function prepare(string $group, string $actor, string $action): array + { + if (!in_array($action, ['adopt','restore'], true)) { + throw new \InvalidArgumentException('Unknown permission recovery action.'); + } + $preview = $this->inspect($group, $actor); + if (!($preview['can_' . $action] ?? false)) { + return $preview + ['token' => null]; + } + $now = $this->clock->now()->getTimestamp(); + $token = bin2hex(random_bytes(16)); + $binding = $preview['binding'] + ['remote_sha256' => $preview['remote_sha256']]; + $this->db->transaction(function (\PDO $pdo) use ($group, $actor, $action, $binding, $now, $token): void { + $pdo->prepare('DELETE FROM permission_recovery_confirmations WHERE expires_at<=? OR consumed_at IS NOT NULL')->execute([$now]); + // A newer preview invalidates older actions for this administrator/group. + $pdo->prepare('DELETE FROM permission_recovery_confirmations WHERE group_id=? AND actor_id=?')->execute([$group,$actor]); + $pdo->prepare('INSERT INTO permission_recovery_confirmations VALUES(?,?,?,?,?,?,?,NULL)') + ->execute([hash('sha256', $token),$group,$actor,$action,Database::encode($binding),$now,$now + 300]); + }); + return $preview + ['token' => $token,'expires_at' => $now + 300,'action' => $action]; + } + public function confirm(string $token, string $actor): array + { + SettingsRepository::identifier($actor); + if (!preg_match('/^[a-f0-9]{32}$/D', $token)) { + return ['status' => 'expired','changed' => false]; + } + $row = $this->db->transaction(function (\PDO $pdo) use ($token, $actor): ?array { + $query = $pdo->prepare('SELECT * FROM permission_recovery_confirmations WHERE token_hash=? AND actor_id=? AND consumed_at IS NULL AND expires_at>?'); + $query->execute([hash('sha256', $token),$actor,$this->clock->now()->getTimestamp()]); + $row = $query->fetch(); + if (!$row) { + return null; + } + $pdo->prepare('UPDATE permission_recovery_confirmations SET consumed_at=? WHERE token_hash=? AND consumed_at IS NULL')->execute([$this->clock->now()->getTimestamp(),$row['token_hash']]); + return $row; + }); + if ($row === null) { + return ['status' => 'expired','changed' => false]; + } + $group = $row['group_id']; + $binding = json_decode($row['binding_json'], true, 32, JSON_THROW_ON_ERROR); + $guardedAt = null; + $guard = function () use ($actor, $group, $binding, $row, &$guardedAt): ?string { + if (!$this->authorized($actor, $group)) { + return 'forbidden'; + } + if (($this->safeToRecover)($group) !== true) { + return 'active_lock'; + } + if (!$this->authorized($actor, $group)) { + return 'forbidden'; + } + // The authorization refresh may suspend across a holy/manual-lock boundary. + if (($this->safeToRecover)($group) !== true) { + return 'active_lock'; + } + if ($row['expires_at'] <= $this->clock->now()->getTimestamp()) { + return 'expired'; + } + if ($binding['settings_sha256'] !== $this->binding($group)['settings_sha256']) { + return 'stale'; + } + $guardedAt = $this->clock->now()->getTimestamp(); + return null; + }; + // Must not suspend: this fence follows the final fresh Telegram read. + $localGuard = function () use ($group, $binding, $row, &$guardedAt): ?string { + if ($row['expires_at'] <= $this->clock->now()->getTimestamp()) { + return 'expired'; + } + if ($binding['settings_sha256'] !== $this->binding($group)['settings_sha256']) { + return 'stale'; + } + if ($this->localSafeToRecover !== null) { + return ($this->localSafeToRecover)($group) === true ? null : 'active_lock'; + } + return $guardedAt === $this->clock->now()->getTimestamp() ? null : 'stale'; + }; + return $this->locks->recover($group, $actor, $row['action'], $binding, $guard, $localGuard); + } + public function binding(string $group): array + { + $query = $this->db->pdo()->prepare('SELECT s.*,g.active FROM group_settings s JOIN groups g USING(group_id) WHERE group_id=?'); + $query->execute([$group]); + $settings = $query->fetch(); + if (!$settings) { + throw new \RuntimeException('Group settings are missing.'); + } + return ['settings_sha256' => hash('sha256', Database::encode($settings)), + 'state_sha256' => self::stateFingerprint($this->locks->state($group))]; + } + public static function stateFingerprint(?array $state): string + { + if ($state !== null) { + unset($state['lease_owner'], $state['lease_until']); + } + return hash('sha256', Database::encode($state ?? [])); + } + public function original(?array $state): ?array + { + if ( + $state === null || !is_string($state['snapshot_json']) || !is_string($state['snapshot_sha256']) + || !hash_equals($state['snapshot_sha256'], hash('sha256', $state['snapshot_json'])) + ) { + return null; + } + try { + $original = json_decode($state['snapshot_json'], true, 32, JSON_THROW_ON_ERROR); + if (!is_array($original)) { + return null; + } + Permissions::validate($original); + return $original; + } catch (\Throwable) { + return null; + } + } + /** @phpstan-impure Administrator rights can change across every remote await. */ + private function authorized(string $actor, string $group): bool + { + return ($this->authorize)($actor, $group) === true; + } + private function blocked(string $reason): array + { + return ['status' => $reason,'can_adopt' => false,'can_restore' => false,'block_reason' => $reason]; + } +} diff --git a/src/Scheduling/PermissionRecoveryJournal.php b/src/Scheduling/PermissionRecoveryJournal.php new file mode 100644 index 0000000..b9c88e9 --- /dev/null +++ b/src/Scheduling/PermissionRecoveryJournal.php @@ -0,0 +1,48 @@ +clock->now()->getTimestamp(); + $this->db->pdo()->prepare('INSERT INTO permission_operations(group_id,action,stage,outcome,before_json,requested_json,original_json,original_sha256,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)') + ->execute([$group,$action,'prepared','not_dispatched',Database::encode($before),Database::encode($requested),$state['snapshot_json'] ?? null,$state['snapshot_sha256'] ?? null,$now,$now]); + $id = (int)$this->db->pdo()->lastInsertId(); + $this->db->pdo()->prepare('DELETE FROM permission_operations WHERE group_id=? AND operation_id NOT IN (SELECT operation_id FROM permission_operations WHERE group_id=? ORDER BY operation_id DESC LIMIT 20)')->execute([$group,$group]); + return $id; + } + public function update(int $id, string $stage, string $outcome, ?array $observed = null): void + { + $this->db->pdo()->prepare('UPDATE permission_operations SET stage=?,outcome=?,observed_json=coalesce(?,observed_json),updated_at=? WHERE operation_id=?') + ->execute([$stage,$outcome,$observed === null ? null : Database::encode($observed),$this->clock->now()->getTimestamp(),$id]); + } + public function observeKnownResult(string $group, array $current): void + { + $latest = $this->latest($group); + if ($latest === null || $latest['outcome'] !== 'unknown' || !is_string($latest['requested_json'])) { + return; + } + $requested = json_decode($latest['requested_json'], true, 32, JSON_THROW_ON_ERROR); + if (is_array($requested) && Permissions::equal($current, $requested)) { + $this->update($latest['operation_id'], 'recovered_readback', 'confirmed', $current); + } + } + public function latest(string $group): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM permission_operations WHERE group_id=? ORDER BY operation_id DESC LIMIT 1'); + $query->execute([$group]); + return $query->fetch() ?: null; + } +} diff --git a/src/Scheduling/PermissionRecoveryProvenance.php b/src/Scheduling/PermissionRecoveryProvenance.php new file mode 100644 index 0000000..ae6df5c --- /dev/null +++ b/src/Scheduling/PermissionRecoveryProvenance.php @@ -0,0 +1,39 @@ + 1,'reader_contract' => 'strict-fresh-default-rights-v1', + 'group_id' => $group,'snapshot_sha256' => hash('sha256', $snapshot),'captured_at' => $capturedAt]); + } + public static function verified(?array $state): bool + { + if ( + $state === null || ($state['snapshot_provenance'] ?? null) !== self::VERIFIED + || !is_string($state['snapshot_evidence_json'] ?? null) || !is_string($state['snapshot_sha256'] ?? null) + ) { + return false; + } + try { + $evidence = json_decode($state['snapshot_evidence_json'], true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return false; + } + return is_array($evidence) && ($evidence['schema'] ?? null) === 1 + && ($evidence['reader_contract'] ?? null) === 'strict-fresh-default-rights-v1' + && ($evidence['group_id'] ?? null) === $state['group_id'] + && ($evidence['snapshot_sha256'] ?? null) === $state['snapshot_sha256'] + && is_int($evidence['captured_at'] ?? null) && $evidence['captured_at'] >= 0 + && $evidence['captured_at'] === $state['prepared_at']; + } +} diff --git a/src/Scheduling/Permissions.php b/src/Scheduling/Permissions.php new file mode 100644 index 0000000..5c5c942 --- /dev/null +++ b/src/Scheduling/Permissions.php @@ -0,0 +1,139 @@ + count(self::FLAGS)) { + throw new \InvalidArgumentException('Lock flags must be a bounded list.'); + } + foreach ($flags as $flag) { + if (!is_string($flag) || !in_array($flag, self::BLOCK_FLAGS, true)) { + throw new \InvalidArgumentException('Unknown native group restriction.'); + } + } + if (count(array_unique($flags)) !== count($flags)) { + throw new \InvalidArgumentException('Duplicate group restrictions.'); + } + return array_values(array_intersect(self::FLAGS, $flags)); + } + public static function validate(array $rights): void + { + if (($rights['_'] ?? 'chatBannedRights') !== 'chatBannedRights') { + throw new \RuntimeException('Invalid permission snapshot constructor.'); + } + foreach ($rights as $key => $value) { + if (!is_string($key) || ($key !== '_' && $key !== 'until_date' && !is_bool($value))) { + throw new \RuntimeException('Malformed permission snapshot.'); + } + if ($key === 'until_date' && (!is_int($value) || $value < 0)) { + throw new \RuntimeException('Malformed permission expiry.'); + } + } + } + public static function locked(array $original, ?array $flags = null): array + { + self::validate($original); + $flags = self::validateBlockFlags($flags ?? self::DEFAULT_BLOCK_FLAGS); + // Current official clients use granular rights. Keep legacy selections useful + // without depending on the server to expand an aggregate request for us. + if (in_array('send_messages', $flags, true)) { + $flags = array_merge($flags, self::MESSAGE_FLAGS); + } elseif (in_array('send_media', $flags, true)) { + $flags = array_merge($flags, self::MEDIA_FLAGS); + } + $original['_'] = 'chatBannedRights'; + $original['until_date'] = 0; + foreach ($flags as $flag) { + $original[$flag] = true; + } + return $original; + } + public static function canonical(array $rights): string + { + return \Shabbat\Storage\Database::encode(self::normalized($rights)); + } + /** Compare global default permissions without rewriting the stored native snapshot. */ + private static function normalized(array $rights): array + { + self::validate($rights); + // TDLib writes 0 but expects INT32_MAX in default restrictions readback. + // Do not discard arbitrary finite expiries or any individual restriction. + $normalized = []; + foreach ($rights as $key => $value) { + if ($key === '_' || ($key === 'until_date' && in_array($value, [0, 2147483647], true)) || $value === false) { + continue; + } + $normalized[$key] = $value; + } + foreach (['send_media' => self::MEDIA_FLAGS, 'send_messages' => self::MESSAGE_FLAGS] as $aggregate => $members) { + if (count(array_filter($members, static fn(string $flag): bool => ($rights[$flag] ?? false) === true)) === count($members)) { + unset($normalized[$aggregate]); + } + } + ksort($normalized, SORT_STRING); + return $normalized; + } + /** Controlled diagnostic keys; unknown native fields are never exposed as chat text. */ + public static function differentFields(array $a, array $b): array + { + $a = self::normalized($a); + $b = self::normalized($b); + $fields = []; + foreach (array_unique([...array_keys($a), ...array_keys($b)]) as $field) { + if (($a[$field] ?? null) !== ($b[$field] ?? null)) { + $fields[] = in_array($field, [...self::FLAGS, 'until_date'], true) ? $field : 'unknown'; + } + } + return array_values(array_unique($fields)); + } + /** Three-way recovery is limited to known fields the bot never changed. */ + public static function externalBaseline(array $baseline, array $applied, array $current): ?array + { + $differences = self::differentFields($current, $applied); + if ($differences === [] || array_diff($differences, self::FLAGS) !== []) { + return null; + } + $owned = self::differentFields($baseline, $applied); + if (array_intersect($differences, $owned) !== []) { + return null; + } + // Raw aggregate changes are ambiguous even when clients normalize other bits. + if (array_intersect($differences, ['view_messages','send_messages','send_media']) !== []) { + return null; + } + foreach ($differences as $field) { + // Relaxing a restriction can also be a partial unlock: require an explicit choice. + if (($current[$field] ?? false) !== true) { + return null; + } + $baseline[$field] = true; + } + return $baseline; + } + public static function equal(array $a, array $b): bool + { + return hash_equals(self::canonical($a), self::canonical($b)); + } + public static function alreadyLocked(array $rights): bool + { + return ($rights['view_messages'] ?? false) || ($rights['send_messages'] ?? false) || ($rights['send_plain'] ?? false); + } +} diff --git a/src/Scheduling/QueueMaintenance.php b/src/Scheduling/QueueMaintenance.php new file mode 100644 index 0000000..ee31dc8 --- /dev/null +++ b/src/Scheduling/QueueMaintenance.php @@ -0,0 +1,101 @@ +clock->now()->getTimestamp(); + $pdo = $this->db->pdo(); + $result = [ + 'active' => (int)$pdo->query("SELECT COUNT(*) FROM scheduled_jobs WHERE status IN ('pending','leased')")->fetchColumn(), + 'uncertain' => (int)$pdo->query("SELECT COUNT(*) FROM scheduled_jobs WHERE status<>'sent' AND dispatch_started_at IS NOT NULL")->fetchColumn(), + ]; + $query = $pdo->prepare("SELECT COUNT(*) AS due,MIN(run_at) AS oldest_due_at FROM scheduled_jobs WHERE status IN ('pending','leased') AND run_at<=? AND expires_at>?"); + $query->execute([$now,$now]); + $row = $query->fetch(); + $result['due'] = (int)$row['due']; + $result['oldest_due_age_seconds'] = $row['oldest_due_at'] === null ? 0 : max(0, $now - (int)$row['oldest_due_at']); + $query = $pdo->prepare("SELECT COUNT(*) AS sent,MAX(MAX(0,completed_at-run_at)) AS delay FROM scheduled_jobs WHERE status='sent' AND completed_at>=?"); + $query->execute([$now - 86400]); + $row = $query->fetch(); + $result['sent_last_24h'] = (int)$row['sent']; + $result['max_delivery_delay_last_24h'] = (int)$row['delay']; + $query = $pdo->prepare("SELECT COUNT(*) FROM scheduled_jobs WHERE status='expired' AND expires_at>=?"); + $query->execute([$now - 86400]); + $result['expired_last_24h'] = (int)$query->fetchColumn(); + $result['observed_at'] = $now; + return $result; + } + /** Never touches permission operations/snapshots, uncertain deliveries, active jobs, or current message pointers. */ + public function prune(int $retentionDays = 90, int $batch = 200): array + { + if (($retentionDays !== 0 && ($retentionDays < 30 || $retentionDays > 3650)) || $batch < 1 || $batch > 1000) { + throw new \InvalidArgumentException('Retention must be disabled or 30..3650 days; batch must be 1..1000.'); + } + $empty = ['jobs' => 0,'delivery_log' => 0,'calendar_cache' => 0,'audit_log' => 0,'message_deletions' => 0,'conversation_states' => 0]; + if ($retentionDays === 0) { + return $empty; + } + $cutoff = $this->clock->now()->getTimestamp() - $retentionDays * 86400; + return $this->db->transaction(function (PDO $pdo) use ($cutoff, $batch, $empty): array { + $result = $empty; + // Sent acknowledgement rounds are retained, so old group buttons/vote totals stay usable. + // Current previous-message pointers and their pending cleanup must also survive pruning. + $query = $pdo->prepare("SELECT j.job_id FROM scheduled_jobs j WHERE j.status IN ('sent','cancelled','expired','failed') + AND j.expires_at'tefillin' OR j.status<>'sent' OR j.module_delivery_processed_at IS NOT NULL) + AND NOT EXISTS(SELECT 1 FROM delivery_log d WHERE d.job_id=j.job_id AND d.outcome IN ('attempting','uncertain')) + AND NOT EXISTS(SELECT 1 FROM module_last_messages m WHERE m.source_job_id=j.job_id) + AND NOT EXISTS(SELECT 1 FROM tefillin_ack_rounds a WHERE a.job_id=j.job_id) + ORDER BY j.expires_at,j.job_id LIMIT ?"); + $query->bindValue(1, $cutoff, PDO::PARAM_INT); + $query->bindValue(2, $cutoff, PDO::PARAM_INT); + $query->bindValue(3, $batch, PDO::PARAM_INT); + $query->execute(); + $ids = $query->fetchAll(PDO::FETCH_COLUMN); + if ($ids !== []) { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $delete = $pdo->prepare('DELETE FROM delivery_log WHERE delivery_id IN (SELECT delivery_id FROM delivery_log WHERE job_id IN (' . $placeholders . ') ORDER BY delivery_id LIMIT ?)'); + foreach ($ids as $index => $id) { + $delete->bindValue($index + 1, (int)$id, PDO::PARAM_INT); + } + $delete->bindValue(count($ids) + 1, $batch, PDO::PARAM_INT); + $delete->execute(); + $result['delivery_log'] = $delete->rowCount(); + $delete = $pdo->prepare('DELETE FROM scheduled_jobs WHERE job_id IN (' . $placeholders . ') AND NOT EXISTS(SELECT 1 FROM delivery_log d WHERE d.job_id=scheduled_jobs.job_id)'); + $delete->execute($ids); + $result['jobs'] = $delete->rowCount(); + } + $result['calendar_cache'] = $this->deleteBatch($pdo, 'calendar_cache', 'cache_key', 'expires_atdeleteBatch($pdo, 'conversation_states', 'token_hash', 'expires_atdeleteBatch($pdo, 'audit_log', 'audit_id', "created_atdeleteBatch($pdo, 'message_deletions', 'deletion_id', "completed_atprepare("DELETE FROM $table WHERE $id IN (SELECT $id FROM $table WHERE $condition ORDER BY $id LIMIT ?)"); + $query->bindValue(1, $cutoff, PDO::PARAM_INT); + $query->bindValue(2, $limit, PDO::PARAM_INT); + $query->execute(); + return $query->rowCount(); + } +} diff --git a/src/Scheduling/QuietHours.php b/src/Scheduling/QuietHours.php new file mode 100644 index 0000000..d3e8805 --- /dev/null +++ b/src/Scheduling/QuietHours.php @@ -0,0 +1,55 @@ +setTimezone(new \DateTimeZone($zone))->format('H:i'); + return $start < $end ? $time >= $start && $time < $end : $time >= $start || $time < $end; + } + + public static function nextBoundary(int $at, string $zone, ?string $start, ?string $end): ?int + { + if ($start === null || $end === null || $start === $end) { + return null; + } + $tz = new \DateTimeZone($zone); + $date = (new \DateTimeImmutable('@' . $at))->setTimezone($tz)->setTime(0, 0); + $candidates = []; + // All offsets retain both occurrences of a repeated wall-clock boundary. + $transitions = $tz->getTransitions($at - 172800, $at + 345600) ?: []; + $offsets = [$tz->getOffset($date)]; + foreach ($transitions as $transition) { + $offsets[] = $transition['offset']; + $candidates[] = $transition['ts']; + } + for ($day = 0; $day <= 3; $day++) { + $ymd = $date->modify('+' . $day . ' days')->format('Y-m-d'); + foreach ([$start, $end] as $time) { + $wall = (new \DateTimeImmutable($ymd . ' ' . $time, new \DateTimeZone('UTC')))->getTimestamp(); + foreach (array_unique($offsets) as $offset) { + $candidate = $wall - $offset; + if ((new \DateTimeImmutable('@' . $candidate))->setTimezone($tz)->format('Y-m-d H:i') === $ymd . ' ' . $time) { + $candidates[] = $candidate; + } + } + } + } + sort($candidates); + foreach ($candidates as $candidate) { + if ($candidate > $at && self::contains($candidate - 1, $zone, $start, $end) !== self::contains($candidate, $zone, $start, $end)) { + return $candidate; + } + } + return null; + } +} diff --git a/src/Scheduling/TefillinAckText.php b/src/Scheduling/TefillinAckText.php new file mode 100644 index 0000000..01b7239 --- /dev/null +++ b/src/Scheduling/TefillinAckText.php @@ -0,0 +1,36 @@ + 'I put on tefillin', + 'ru' => 'ะฏ ะฝะฐะดะตะป ั‚ั„ะธะปะธะฝ', + 'fr' => "J'ai mis les tรฉfilines", + 'es' => 'Me puse tefilรญn', + 'pt-br' => 'Coloquei tefilin', + default => 'ื”ื ื—ืชื™', + }; + } + public static function feedback(string $language, bool $selected, int $count): string + { + $language = Lang::canonical($language); + $caption = match ($language) { + 'en' => $selected ? 'Your acknowledgement was recorded.' : 'Your acknowledgement was removed.', + 'ru' => $selected ? 'ะ’ะฐัˆ ะพั‚ะฒะตั‚ ะทะฐะฟะธัะฐะฝ.' : 'ะ’ะฐัˆ ะพั‚ะฒะตั‚ ัƒะดะฐะปั‘ะฝ.', + 'fr' => $selected ? 'Votre confirmation a รฉtรฉ enregistrรฉe.' : 'Votre confirmation a รฉtรฉ retirรฉe.', + 'es' => $selected ? 'Tu confirmaciรณn quedรณ registrada.' : 'Tu confirmaciรณn se ha eliminado.', + 'pt-br' => $selected ? 'Sua confirmaรงรฃo foi registrada.' : 'Sua confirmaรงรฃo foi removida.', + default => $selected ? 'ื”ื”ื ื—ื” ืฉืœืš ื ืจืฉืžื”.' : 'ื”ืกื™ืžื•ืŸ ืฉืœืš ื‘ื•ื˜ืœ.', + }; + return $caption . ' (' . $count . ')'; + } +} diff --git a/src/Scheduling/TefillinAcknowledgements.php b/src/Scheduling/TefillinAcknowledgements.php new file mode 100644 index 0000000..77b7ab3 --- /dev/null +++ b/src/Scheduling/TefillinAcknowledgements.php @@ -0,0 +1,313 @@ +db->pdo()->query('PRAGMA database_list')->fetch(); + $this->databaseIdentity = ($main['file'] ?? '') !== '' ? (string)$main['file'] : 'memory:' . spl_object_id($db->pdo()); + } + public function preparePayload(array $job, array $group, \DateTimeImmutable $instant, array $payload): array + { + $mode = $payload['buttons_mode'] ?? (empty($payload['buttons']) && empty($payload['reply_markup']) ? 'inherit' : 'custom'); + if (($job['module'] ?? '') !== 'tefillin' || !($group['tefillin_ack_enabled'] ?? true) || $mode !== 'inherit') { + return $payload; + } + $jobId = $job['job_id'] ?? null; + $groupId = $group['group_id'] ?? null; + if (!is_int($jobId) || $jobId < 1 || !is_string($groupId)) { + throw new \InvalidArgumentException('Acknowledgements require a persisted tefillin job.'); + } + self::group($groupId); + $round = $this->db->transaction(function (PDO $pdo) use ($jobId, $groupId, $group, $instant, $payload): array { + $q = $pdo->prepare('SELECT * FROM scheduled_jobs WHERE job_id=?'); + $q->execute([$jobId]); + $stored = $q->fetch(); + if ( + $stored === false || $stored['group_id'] !== $groupId || $stored['module'] !== 'tefillin' + || !in_array($stored['status'], ['pending', 'leased'], true) || (int)$stored['settings_revision'] !== $group['revision'] + ) { + throw new StaleSettings('Acknowledgement job identity or revision changed.'); + } + $current = $this->settings->getGroup($groupId); + if ($current === null || !$current['active'] || !$current['tefillin_ack_enabled'] || $current['revision'] !== $group['revision']) { + throw new StaleSettings('Acknowledgement group settings changed.'); + } + $q = $pdo->prepare('SELECT * FROM tefillin_ack_rounds WHERE job_id=?'); + $q->execute([$jobId]); + $round = $q->fetch(); + if ($round !== false && (int)$round['settings_revision'] === $group['revision']) { + return $round; + } + if ($round !== false) { + $bound = $pdo->prepare('SELECT 1 FROM tefillin_ack_messages WHERE round_token=?'); + $bound->execute([$round['round_token']]); + if ($bound->fetchColumn() !== false) { + throw new \RuntimeException('A delivered acknowledgement round cannot change settings identity.'); + } + $pdo->prepare('DELETE FROM tefillin_ack_rounds WHERE round_token=?')->execute([$round['round_token']]); + } + $token = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); + $label = $group['tefillin_ack_label'] ?? TefillinAckText::label($group['language']); + $markup = $this->appendButton($payload, $token, $label); + $pdo->prepare('INSERT INTO tefillin_ack_rounds VALUES(?,?,?,?,?,?,?,?)')->execute([$token, $jobId, $groupId, $label, $group['language'], $group['revision'], Database::encode($markup), $instant->getTimestamp()]); + return ['round_token' => $token, 'markup_json' => Database::encode($markup)]; + }); + unset($payload['buttons']); + $payload['reply_markup'] = json_decode($round['markup_json'], true, 64, JSON_THROW_ON_ERROR); + $payload['_tefillin_ack_token'] = $round['round_token']; + return $payload; + } + /** Only identifiers present in the durable successful response can become clickable rounds. */ + public function registerDelivery(int $jobId, array $verifiedIds): void + { + $ids = self::messageIds($verifiedIds); + $this->db->transaction(function (PDO $pdo) use ($jobId, $ids): void { + $q = $pdo->prepare("SELECT j.*,d.response_json,r.round_token,r.group_id AS ack_group FROM scheduled_jobs j JOIN delivery_log d ON d.job_id=j.job_id AND d.attempt=j.attempts AND d.outcome='sent' JOIN tefillin_ack_rounds r ON r.job_id=j.job_id WHERE j.job_id=? AND j.status='sent' AND j.module='tefillin'"); + $q->execute([$jobId]); + $job = $q->fetch(); + if ($job === false) { + $q = $pdo->prepare('SELECT 1 FROM tefillin_ack_rounds WHERE job_id=?'); + $q->execute([$jobId]); + if ($q->fetchColumn() === false) { + return; // Explicitly removed/custom buttons create no acknowledgement round. + } + throw new \RuntimeException('Acknowledgements require a durable successful send.'); + } + $payload = json_decode($job['payload_json'], true, 64, JSON_THROW_ON_ERROR); + $response = json_decode($job['response_json'], true, 64, JSON_THROW_ON_ERROR); + if (!isset($payload['_tefillin_ack_token'])) { + return; + } + if ( + ($payload['_tefillin_ack_token'] ?? null) !== $job['round_token'] || $job['group_id'] !== $job['ack_group'] + || !is_array($response) || array_diff($ids, SentMessageIds::extract($response, $job['random_id'], $job['group_id'])) !== [] + ) { + throw new \RuntimeException('Acknowledgement message identity is not verified by the confirmed response.'); + } + foreach ($ids as $messageId) { + $q = $pdo->prepare('SELECT round_token FROM tefillin_ack_messages WHERE group_id=? AND message_id=?'); + $q->execute([$job['group_id'], $messageId]); + $old = $q->fetchColumn(); + if ($old !== false && $old !== $job['round_token']) { + throw new \RuntimeException('A delivered message is already bound to another acknowledgement round.'); + } + $pdo->prepare('INSERT OR IGNORE INTO tefillin_ack_messages(group_id,message_id,round_token,updated_at) VALUES(?,?,?,?)') + ->execute([$job['group_id'], $messageId, $job['round_token'], $this->now()]); + } + }); + } + public function toggle(string $user, string $group, int $message, string $token, string $callbackId, bool $bot = false, bool $anonymous = false, bool $inline = false): array + { + self::group($group); + self::messageIds([$message]); + self::token($token); + if ( + !preg_match('/^[1-9][0-9]{0,18}$/D', $user) || $user === $this->selfBotId || $bot || $anonymous || $inline + || $callbackId === '' || strlen($callbackId) > 128 || preg_match('/[^\x21-\x7e]/', $callbackId) + ) { + throw new \InvalidArgumentException('A real identified group member and native callback are required.'); + } + return $this->db->transaction(function (PDO $pdo) use ($user, $group, $message, $token, $callbackId): array { + $settings = $this->settings->getGroup($group); + if ($settings === null || !$settings['active'] || !$settings['tefillin_ack_enabled'] || !$settings['tefillin_enabled']) { + throw new \RuntimeException('Tefillin acknowledgements are disabled for this group.'); + } + $bound = $this->snapshot($group, $message, $user); + if ($bound === null || !hash_equals($bound['token'], $token)) { + throw new \RuntimeException('This acknowledgement belongs to another group or message.'); + } + $hash = hash('sha256', $callbackId); + $q = $pdo->prepare('SELECT * FROM tefillin_ack_callbacks WHERE callback_hash=?'); + $q->execute([$hash]); + $previous = $q->fetch(); + if ($previous !== false) { + if ($previous['round_token'] !== $token || (int)$previous['message_id'] !== $message || $previous['user_id'] !== $user) { + throw new \RuntimeException('Acknowledgement callback identity was reused in another context.'); + } + return $bound + ['duplicate' => true]; + } + if ($bound['selected']) { + $pdo->prepare('DELETE FROM tefillin_ack_votes WHERE round_token=? AND message_id=? AND user_id=?')->execute([$token, $message, $user]); + } else { + $pdo->prepare('INSERT INTO tefillin_ack_votes VALUES(?,?,?,?)')->execute([$token, $message, $user, $this->now()]); + } + $pdo->prepare('INSERT INTO tefillin_ack_callbacks VALUES(?,?,?,?,?)')->execute([$hash, $token, $message, $user, $this->now()]); + $pdo->prepare('UPDATE tefillin_ack_messages SET revision=revision+1,retry_at=CASE WHEN retry_at>? THEN retry_at ELSE NULL END,edit_attempts=CASE WHEN retry_at>? THEN edit_attempts ELSE 0 END,last_error_code=CASE WHEN retry_at>? THEN last_error_code ELSE NULL END,updated_at=? WHERE group_id=? AND message_id=?') + ->execute([$this->now(), $this->now(), $this->now(), $this->now(), $group, $message]); + return ($this->snapshot($group, $message, $user) ?? throw new \LogicException('Acknowledgement disappeared.')) + ['duplicate' => false]; + }); + } + /** Current state is always read from SQLite, including during an in-flight markup edit. */ + public function snapshot(string $group, int $message, ?string $user = null): ?array + { + $q = $this->db->pdo()->prepare('SELECT m.*,r.label,r.language,r.markup_json,(SELECT COUNT(*) FROM tefillin_ack_votes v WHERE v.round_token=m.round_token AND v.message_id=m.message_id) AS ack_count,EXISTS(SELECT 1 FROM tefillin_ack_votes v WHERE v.round_token=m.round_token AND v.message_id=m.message_id AND v.user_id=?) AS actor_selected FROM tefillin_ack_messages m JOIN tefillin_ack_rounds r USING(round_token) WHERE m.group_id=? AND m.message_id=?'); + $q->execute([$user, $group, $message]); + $row = $q->fetch(); + if ($row === false) { + return null; + } + $count = (int)$row['ack_count']; + $markup = $this->withCount(json_decode($row['markup_json'], true, 64, JSON_THROW_ON_ERROR), $row['round_token'], $row['label'], $count); + return ['group_id' => $group, 'message_id' => $message, 'token' => $row['round_token'], 'language' => $row['language'], + 'count' => $count, 'selected' => (bool)$row['actor_selected'], 'revision' => (int)$row['revision'], + 'rendered_revision' => (int)$row['rendered_revision'], 'retry_at' => $row['retry_at'] === null ? null : (int)$row['retry_at'], + 'edit_attempts' => (int)$row['edit_attempts'], 'markup' => $markup]; + } + /** edit(group, messageId, markup) performs a markup-only RPC and returns only after confirmation. */ + public function refresh(string $group, int $message, callable $edit): array + { + self::group($group); + self::messageIds([$message]); + $key = $this->databaseIdentity . ':' . $group . ':' . $message; + if (isset(self::$editing[$key])) { + return ['status' => 'coalesced']; + } + self::$editing[$key] = true; + try { + for ($pass = 0; $pass < 3; $pass++) { + $s = $this->snapshot($group, $message); + $g = $this->settings->getGroup($group); + if ($s === null || $g === null || !$g['active'] || !$g['tefillin_ack_enabled'] || !$g['tefillin_enabled']) { + return ['status' => 'inactive']; + } + if ($s['revision'] <= $s['rendered_revision']) { + return ['status' => 'current']; + } + if (($s['retry_at'] !== null && $s['retry_at'] > $this->now()) || $s['edit_attempts'] >= 5) { + return ['status' => 'deferred']; + } + try { + $edit($group, $message, $s['markup']); + } catch (CancelledException $error) { + throw $error; + } catch (RetryableTelegramException $error) { + $this->defer($group, $message, max(1, $error->retryAfterSeconds), 'retryable'); + return ['status' => 'retry']; + } catch (\Throwable $error) { + $this->defer($group, $message, min(900, 30 * (2 ** min(4, $s['edit_attempts']))), get_class($error)); + return ['status' => 'retry']; + } + $this->db->pdo()->prepare('UPDATE tefillin_ack_messages SET rendered_revision=MAX(rendered_revision,?),retry_at=NULL,edit_attempts=0,last_error_code=NULL WHERE group_id=? AND message_id=?') + ->execute([$s['revision'], $group, $message]); + } + return ['status' => 'pending']; + } finally { + unset(self::$editing[$key]); + } + } + public function flushPending(callable $edit, int $limit = 20): array + { + if ($limit < 1 || $limit > 100) { + throw new \InvalidArgumentException('Invalid acknowledgement edit batch size.'); + } + $q = $this->db->pdo()->prepare('SELECT m.group_id,m.message_id FROM tefillin_ack_messages m JOIN groups g USING(group_id) JOIN group_settings s USING(group_id) WHERE m.rendered_revisionbindValue(1, $this->now(), PDO::PARAM_INT); + $q->bindValue(2, $limit, PDO::PARAM_INT); + $q->execute(); + $results = []; + foreach ($q->fetchAll() as $row) { + $results[] = $this->refresh($row['group_id'], (int)$row['message_id'], $edit); + } + return $results; + } + private function defer(string $group, int $message, int $seconds, string $code): void + { + $until = $seconds > PHP_INT_MAX - $this->now() ? PHP_INT_MAX : $this->now() + $seconds; + $this->db->pdo()->prepare('UPDATE tefillin_ack_messages SET retry_at=?,edit_attempts=edit_attempts+1,last_error_code=? WHERE group_id=? AND message_id=?') + ->execute([$until, mb_substr($code, 0, 128), $group, $message]); + } + private function appendButton(array $payload, string $token, string $label): array + { + if (!mb_check_encoding($label, 'UTF-8') || trim($label) === '' || strlen(mb_convert_encoding($label, 'UTF-16LE', 'UTF-8')) > 96 || preg_match('/[\x00-\x1F\x7F]/u', $label)) { + throw new \InvalidArgumentException('Invalid tefillin acknowledgement button label.'); + } + $button = ['_' => 'keyboardButtonCallback', 'text' => $label . ' (0)', '_presentation_fallback_text' => '๐Ÿ”” ' . $label . ' (0)', 'data' => 'tf:' . $token, + 'style' => ['_' => 'keyboardButtonStyle', 'bg_primary' => true, 'icon' => self::ICON]]; + $markup = $payload['reply_markup'] ?? ['inline_keyboard' => $payload['buttons'] ?? []]; + if (($markup['_'] ?? '') === 'replyInlineMarkup' && is_array($markup['rows'] ?? null)) { + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [$button]]; + } elseif (is_array($markup['inline_keyboard'] ?? null)) { + $markup['inline_keyboard'][] = [$button]; + } else { + throw new \InvalidArgumentException('Unsupported reminder keyboard format.'); + } + return $markup; + } + private function withCount(array $markup, string $token, string $label, int $count): array + { + $found = 0; + if (($markup['_'] ?? '') === 'replyInlineMarkup') { + foreach ($markup['rows'] as &$row) { + foreach ($row['buttons'] as &$button) { + if (($button['data'] ?? null) === 'tf:' . $token) { + $button['text'] = $label . ' (' . $count . ')'; + $button['_presentation_fallback_text'] = '๐Ÿ”” ' . $label . ' (' . $count . ')'; + $found++; + } + } + unset($button); + } + unset($row); + } else { + foreach ($markup['inline_keyboard'] as &$row) { + foreach ($row as &$button) { + if (($button['data'] ?? $button['callback_data'] ?? null) === 'tf:' . $token) { + $button['text'] = $label . ' (' . $count . ')'; + $button['_presentation_fallback_text'] = '๐Ÿ”” ' . $label . ' (' . $count . ')'; + $found++; + } + } + unset($button); + } + unset($row); + } + if ($found !== 1) { + throw new \RuntimeException('Reminder acknowledgement keyboard identity is invalid.'); + } + return $markup; + } + private static function group(string $group): void + { + if (!preg_match('/^-[1-9][0-9]{0,18}$/D', $group)) { + throw new \InvalidArgumentException('A native group identifier is required.'); + } + } + private static function token(string $token): void + { + if (!preg_match('/^[A-Za-z0-9_-]{24}$/D', $token)) { + throw new \InvalidArgumentException('Invalid tefillin acknowledgement token.'); + } + } + private static function messageIds(array $ids): array + { + if (!array_is_list($ids) || $ids === [] || count($ids) > 100) { + throw new \InvalidArgumentException('Verified message identifiers are required.'); + } + foreach ($ids as $id) { + if (!is_int($id) || $id < 1 || $id > 2147483647) { + throw new \InvalidArgumentException('Invalid reminder message identifier.'); + } + } + return array_values(array_unique($ids)); + } + private function now(): int + { + return $this->clock->now()->getTimestamp(); + } +} diff --git a/src/Storage/AdminAccessRepository.php b/src/Storage/AdminAccessRepository.php new file mode 100644 index 0000000..23224d3 --- /dev/null +++ b/src/Storage/AdminAccessRepository.php @@ -0,0 +1,183 @@ +owners = \Closure::fromCallable($owners); + } + public function isOwner(string $id): bool + { + return in_array($id, $this->ownerIds(), true); + } + public function ownerIds(): array + { + $out = []; + foreach (($this->owners)() as $id) { + $id = (string) $id; + if (preg_match('/^[1-9][0-9]{0,18}$/D', $id)) { + $out[] = $id; + } + } + return array_values(array_unique($out)); + } + public function profile(string $id): ?array + { + if ($this->isOwner($id)) { + return ['id' => $id, 'role' => 'full', 'view_only' => false, 'permissions' => array_fill_keys(self::PERMISSIONS, true), 'revision' => 0, 'source' => 'environment']; + } + $q = $this->db->pdo()->prepare('SELECT * FROM bot_admins WHERE user_id=?'); + $q->execute([$id]); + $row = $q->fetch(); + if ($row === false) { + return null; + } + $p = json_decode($row['permissions_json'], true, 32, JSON_THROW_ON_ERROR); + if (!is_array($p) || !in_array($row['role'], ['full', 'limited'], true)) { + throw new \RuntimeException('Invalid administrator access record.'); + } + return array_replace($row, ['id' => $id, 'permissions' => $p, 'view_only' => (bool) $row['view_only'], 'revision' => (int) $row['revision'], 'source' => 'dynamic']); + } + public function isAdmin(string $id): bool + { + return $this->profile($id) !== null; + } + public function adminIds(): array + { + return array_values(array_unique(array_merge($this->ownerIds(), $this->db->pdo()->query('SELECT user_id FROM bot_admins ORDER BY user_id')->fetchAll(PDO::FETCH_COLUMN)))); + } + public function canView(string $id, string $permission): bool + { + if (!in_array($permission, self::PERMISSIONS, true)) { + return false; + } + $p = $this->profile($id); + return $p !== null && ($p['role'] === 'full' || ($p['permissions'][$permission] ?? false) === true); + } + public function canMutate(string $id, string $permission): bool + { + return $this->canView($id, $permission) && !$this->profile($id)['view_only']; + } + public function require(string $id, string $permission, bool $mutation = false): void + { + if (!($mutation ? $this->canMutate($id, $permission) : $this->canView($id, $permission))) { + throw new \RuntimeException('Administrator permission is required.'); + } + } + /** Existing dynamic administrators are imported once and remain removable afterwards. */ + public function importLegacyOnce(array $ids): void + { + $this->db->transaction(function (PDO $pdo) use ($ids): void { + if ($pdo->query("SELECT 1 FROM admin_meta WHERE meta_key='legacy_admin_import'")->fetchColumn()) { + return; + } + foreach ($ids as $id) { + $id = (string) $id; + if (!preg_match('/^[1-9][0-9]{0,18}$/D', $id) || $this->isOwner($id)) { + continue; + } + $now = $this->clock->now()->getTimestamp(); + $pdo->prepare("INSERT OR IGNORE INTO bot_admins VALUES(?,'full',0,?,1,?,'legacy',?,'legacy')") + ->execute([$id, Database::encode(array_fill_keys(self::PERMISSIONS, true)), $now, $now]); + } + $pdo->prepare("INSERT INTO admin_meta VALUES('legacy_admin_import',?)")->execute([(string) $this->clock->now()->getTimestamp()]); + }); + } + public function add(string $actor, string $target, string $role, bool $viewOnly, array $permissions): array + { + return $this->save($actor, $target, $role, $viewOnly, $permissions, null); + } + public function update(string $actor, string $target, string $role, bool $viewOnly, array $permissions, int $revision): array + { + return $this->save($actor, $target, $role, $viewOnly, $permissions, $revision); + } + private function save(string $actor, string $target, string $role, bool $viewOnly, array $permissions, ?int $revision): array + { + $selection = $this->selection($role, $viewOnly, $permissions); + $this->db->transaction(function (PDO $pdo) use ($actor, $target, $selection, $revision): void { + $this->require($actor, $revision === null ? 'add_admins' : 'manage_admins', true); + $this->assertTarget($actor, $target); + $current = $this->profile($target); + if (($revision === null && $current !== null) || ($revision !== null && ($current === null || $current['revision'] !== $revision))) { + throw new StaleSettings('Administrator changed; reopen the profile.'); + } + $this->assertDelegation($actor, $selection); + if ($current !== null) { + $this->assertDelegation($actor, $current); + } + $now = $this->clock->now()->getTimestamp(); + if ($revision === null) { + $pdo->prepare('INSERT INTO bot_admins VALUES(?,?,?,?,1,?,?,?,?)')->execute([$target, $selection['role'], (int) $selection['view_only'], Database::encode($selection['permissions']), $now, $actor, $now, $actor]); + } else { + $pdo->prepare('UPDATE bot_admins SET role=?,view_only=?,permissions_json=?,revision=revision+1,updated_at=?,updated_by=? WHERE user_id=? AND revision=?')->execute([$selection['role'], (int) $selection['view_only'], Database::encode($selection['permissions']), $now, $actor, $target, $revision]); + } + $this->audit($pdo, $actor, $revision === null ? 'add' : 'update', ['target' => $target, 'selection' => $selection]); + }); + return $this->profile($target) ?? throw new \RuntimeException('Administrator save failed.'); + } + public function remove(string $actor, string $target, int $revision): void + { + $this->db->transaction(function (PDO $pdo) use ($actor, $target, $revision): void { + $this->require($actor, 'manage_admins', true); + $this->assertTarget($actor, $target); + $current = $this->profile($target); + if ($current === null || $current['revision'] !== $revision) { + throw new StaleSettings('Administrator changed; reopen the profile.'); + } + $this->assertDelegation($actor, $current); + $pdo->prepare('DELETE FROM bot_admins WHERE user_id=? AND revision=?')->execute([$target, $revision]); + $this->audit($pdo, $actor, 'remove', ['target' => $target]); + }); + } + private function assertTarget(string $actor, string $target): void + { + if (!preg_match('/^[1-9][0-9]{0,18}$/D', $target) || $actor === $target || $this->isOwner($target)) { + throw new \RuntimeException('Recovery administrators and your own access cannot be changed.'); + } + } + private function assertDelegation(string $actor, array $selection): void + { + $profile = $this->profile($actor) ?? throw new \RuntimeException('Administrator access was revoked.'); + if ($profile['role'] === 'full') { + return; + } + if ($selection['role'] === 'full') { + throw new \RuntimeException('A limited manager cannot grant or manage full access.'); + } + foreach ($selection['permissions'] as $permission => $on) { + if ($on && !($profile['permissions'][$permission] ?? false)) { + throw new \RuntimeException('Cannot delegate or manage permissions beyond your authority.'); + } + } + } + private function selection(string $role, bool $viewOnly, array $permissions): array + { + if (!in_array($role, ['full', 'limited'], true) || array_diff(array_keys($permissions), self::PERMISSIONS) !== []) { + throw new \InvalidArgumentException('Invalid administrator selection.'); + } + $out = []; + foreach (self::PERMISSIONS as $key) { + if (isset($permissions[$key]) && !is_bool($permissions[$key])) { + throw new \InvalidArgumentException('Permissions must be booleans.'); + } + $out[$key] = $role === 'full' || ($permissions[$key] ?? false); + } + return ['role' => $role, 'view_only' => $role === 'full' ? false : $viewOnly, 'permissions' => $out]; + } + private function audit(PDO $pdo, string $actor, string $action, array $details): void + { + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,NULL,?,?,?)')->execute([$actor, 'operator.admin.' . $action, Database::encode($details), $this->clock->now()->getTimestamp()]); + } +} diff --git a/src/Storage/AdminStateFiles.php b/src/Storage/AdminStateFiles.php new file mode 100644 index 0000000..940416d --- /dev/null +++ b/src/Storage/AdminStateFiles.php @@ -0,0 +1,184 @@ +pdo()->query('SELECT user_id FROM admin_state LIMIT 0'); + self::$root = $normalized; + self::$database = $database; + self::$clock = $clock; + } + + private static function database(): Database + { + return self::$database ?? throw new \LogicException('SQLite admin state is not configured.'); + } + + /** @return array{user_id:string,key:string,directory:bool,virtual:bool} */ + private static function resolve(string $path): array + { + self::database(); + $path = str_replace('\\', '/', $path); + if ( + $path === '' || str_contains($path, "\0") || str_contains($path, '://') || str_contains($path, '//') + || preg_match('~(?:^|/)\.\.?(?:/|$)~', $path) + ) { + throw new \InvalidArgumentException('Invalid admin state path.'); + } + if (preg_match('~^(?:[a-zA-Z]:/|/)~', $path)) { + $prefix = self::$root . '/'; + $matches = PHP_OS_FAMILY === 'Windows' + ? strncasecmp($path, $prefix, strlen($prefix)) === 0 + : str_starts_with($path, $prefix); + if (!$matches) { + throw new \InvalidArgumentException('Admin state path is outside the application root.'); + } + $path = substr($path, strlen($prefix)); + } + $path = rtrim($path, '/'); + if ($path === 'data') { + return ['user_id' => '0', 'key' => '', 'directory' => true, 'virtual' => false]; + } + if ($path === 'data/DBgroups.txt') { + return ['user_id' => '0', 'key' => 'DBgroups.txt', 'directory' => false, 'virtual' => true]; + } + if (in_array($path, self::ROOT_KEYS, true)) { + return ['user_id' => '0', 'key' => $path, 'directory' => false, 'virtual' => false]; + } + if (str_starts_with($path, 'data/') && in_array(substr($path, 5), self::DATA_KEYS, true)) { + return ['user_id' => '0', 'key' => substr($path, 5), 'directory' => false, 'virtual' => false]; + } + if (preg_match('~^data/([1-9][0-9]{0,18})(?:/([^/]+))?$~', $path, $match)) { + $userId = $match[1]; + $max = (string) PHP_INT_MAX; + if (strlen($userId) > strlen($max) || (strlen($userId) === strlen($max) && strcmp($userId, $max) > 0)) { + throw new \InvalidArgumentException('Telegram user ID is outside supported int64 range.'); + } + $key = $match[2] ?? ''; + if ($key === '' || in_array($key, self::USER_KEYS, true)) { + return ['user_id' => $userId, 'key' => $key, 'directory' => $key === '', 'virtual' => false]; + } + } + throw new \InvalidArgumentException('Unknown legacy admin state path: ' . $path); + } + + public static function read(string $path): string + { + $target = self::resolve($path); + if ($target['directory']) { + throw new \InvalidArgumentException('Cannot read a virtual directory as a file.'); + } + if ($target['virtual']) { + return implode("\n", self::database()->pdo()->query('SELECT group_id FROM groups WHERE active=1 ORDER BY group_id')->fetchAll(\PDO::FETCH_COLUMN)); + } + $statement = self::database()->pdo()->prepare('SELECT value_text FROM admin_state WHERE user_id=? AND state_key=?'); + $statement->execute([$target['user_id'], $target['key']]); + $value = $statement->fetchColumn(); + if ($value === false) { + throw new \RuntimeException('Admin draft state does not exist.'); + } + return (string) $value; + } + + public static function write(string $path, string $contents): int + { + $target = self::resolve($path); + if ($target['directory'] || $target['virtual']) { + throw new \InvalidArgumentException('This virtual path is read-only or is a directory.'); + } + if (strlen($contents) > 1048576) { + throw new \InvalidArgumentException('Admin state exceeds the supported size.'); + } + $kind = match ($target['key']) { + 'ent.txt', 'BUTTONS.txt' => 'json', + 'media.txt' => 'media', + 'ids.txt', 'idsnew.txt' => 'ids', + default => 'text', + }; + $timestamp = self::$clock?->now()->getTimestamp() ?? throw new \LogicException('Admin clock is not configured.'); + self::database()->transaction(static function (\PDO $pdo) use ($target, $kind, $contents, $timestamp): void { + $pdo->prepare('INSERT INTO admin_directories(user_id,created_at) VALUES(?,?) ON CONFLICT(user_id) DO NOTHING') + ->execute([$target['user_id'], $timestamp]); + $pdo->prepare('INSERT INTO admin_state(user_id,state_key,value_kind,value_text,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(user_id,state_key) DO UPDATE SET value_kind=excluded.value_kind,value_text=excluded.value_text,updated_at=excluded.updated_at') + ->execute([$target['user_id'], $target['key'], $kind, $contents, $timestamp]); + }); + return strlen($contents); + } + + public static function exists(string $path): bool + { + $target = self::resolve($path); + if ($target['virtual'] || ($target['directory'] && $target['user_id'] === '0')) { + return true; + } + $query = $target['directory'] + ? self::database()->pdo()->prepare('SELECT 1 FROM admin_directories WHERE user_id=?') + : self::database()->pdo()->prepare('SELECT 1 FROM admin_state WHERE user_id=? AND state_key=?'); + $query->execute($target['directory'] ? [$target['user_id']] : [$target['user_id'], $target['key']]); + return $query->fetchColumn() !== false; + } + + public static function delete(string $path): void + { + $target = self::resolve($path); + if ($target['directory'] || $target['virtual']) { + throw new \InvalidArgumentException('Cannot delete a virtual directory or the typed groups view.'); + } + self::database()->pdo()->prepare('DELETE FROM admin_state WHERE user_id=? AND state_key=?')->execute([$target['user_id'], $target['key']]); + } + + public static function mkdir(string $path, int $mode = 0700, bool $recursive = true): bool + { + $target = self::resolve($path); + if (!$target['directory']) { + throw new \InvalidArgumentException('Only reviewed virtual admin directories can be created.'); + } + $timestamp = self::$clock?->now()->getTimestamp() ?? throw new \LogicException('Admin clock is not configured.'); + self::database()->pdo()->prepare('INSERT INTO admin_directories(user_id,created_at) VALUES(?,?) ON CONFLICT(user_id) DO NOTHING')->execute([$target['user_id'], $timestamp]); + return true; + } + + public static function isDirectory(string $path): bool + { + $target = self::resolve($path); + return $target['directory'] && self::exists($path); + } + + /** Equivalent to legacy file() for the explicitly ported pagination paths. */ + public static function lines(string $path): array + { + $value = self::read($path); + if ($value === '') { + return []; + } + preg_match_all('/[^\n]*\n|[^\n]+$/', $value, $matches); + return $matches[0]; + } +} diff --git a/src/Storage/BackupService.php b/src/Storage/BackupService.php new file mode 100644 index 0000000..121511d --- /dev/null +++ b/src/Storage/BackupService.php @@ -0,0 +1,158 @@ +pdo()->query('SELECT sqlite_version()')->fetchColumn(), '3.27.0', '<')) { + throw new \RuntimeException('SQLite VACUUM INTO requires SQLite 3.27 or later.'); + } + $query = $database->pdo()->prepare('VACUUM INTO ?'); + $query->execute([$destination]); + try { + $checks = self::verifyFile($destination); + $manifest = ['sha256' => hash_file('sha256', $destination),'created_at' => gmdate(DATE_ATOM),'sqlite_version' => $checks['sqlite_version'], + 'schema_migrations' => $checks['schema_migrations'],'size_bytes' => filesize($destination),'integrity' => 'ok']; + if (file_put_contents($destination . '.manifest.json', Database::encode($manifest) . "\n", LOCK_EX) === false) { + throw new \RuntimeException('Cannot write backup manifest.'); + } + @chmod($destination, 0600); + @chmod($destination . '.manifest.json', 0600); + return $manifest + ['path' => $destination]; + } catch (\Throwable $error) { + if (is_file($destination)) { + unlink($destination); + } + throw $error; + } + } + public static function verifyFile(string $path): array + { + if (!is_file($path) || is_link($path)) { + throw new \RuntimeException('Backup database is missing or symlinked.'); + } + $pdo = new PDO('sqlite:' . $path); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $integrity = $pdo->query('PRAGMA integrity_check')->fetchAll(PDO::FETCH_COLUMN); + $foreignKeys = $pdo->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC); + if ($integrity !== ['ok'] || $foreignKeys !== []) { + throw new \RuntimeException('Backup failed integrity or foreign key checks.'); + } + $migrations = $pdo->query('SELECT version,checksum FROM schema_migrations ORDER BY version')->fetchAll(PDO::FETCH_ASSOC); + return ['sqlite_version' => $pdo->query('SELECT sqlite_version()')->fetchColumn(),'schema_migrations' => $migrations]; + } + /** The same writer lock as the bot prevents maintenance alongside a running application. */ + public static function restore(string $backup, string $destination, bool $writersStopped): array + { + if (!$writersStopped) { + throw new \RuntimeException('Restore requires explicit --writers-stopped after stopping every writer.'); + } + self::verifyFile($backup); + $manifestPath = $backup . '.manifest.json'; + if (is_file($manifestPath)) { + $manifest = json_decode((string)file_get_contents($manifestPath), true, 32, JSON_THROW_ON_ERROR); + if (!isset($manifest['sha256']) || !hash_equals($manifest['sha256'], (string)hash_file('sha256', $backup))) { + throw new \RuntimeException('Backup manifest checksum mismatch.'); + } + } + $parent = realpath(dirname($destination)); + if ($parent === false) { + throw new \RuntimeException('Restore directory does not exist.'); + } + $destination = $parent . DIRECTORY_SEPARATOR . basename($destination); + if (realpath($backup) === realpath($destination)) { + throw new \RuntimeException('Source and restore destination must differ.'); + } + if (is_link($destination)) { + throw new \RuntimeException('Refusing symlinked restore destination.'); + } + $lock = new InstanceLock($destination . '.writer.lock'); + $temporary = $destination . '.restore-' . bin2hex(random_bytes(8)); + try { + if (!copy($backup, $temporary)) { + throw new \RuntimeException('Cannot stage restored database.'); + } + self::verifyFile($temporary); + // A stopped writer can leave WAL state: checkpoint it before keeping the rollback copy. + if (is_file($destination)) { + $old = new PDO('sqlite:' . $destination); + $old->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $old->exec('PRAGMA busy_timeout=100'); + $checkpoint = $old->query('PRAGMA wal_checkpoint(TRUNCATE)')->fetch(PDO::FETCH_NUM); + if ($checkpoint !== false && (int)$checkpoint[0] !== 0) { + throw new \RuntimeException('Destination still has a live SQLite writer.'); + } + $old = null; + } + foreach (['-wal','-shm'] as $suffix) { + if (is_file($destination . $suffix) && !unlink($destination . $suffix)) { + throw new \RuntimeException('Cannot clear stopped SQLite sidecar.'); + } + } + $previous = null; + if (is_file($destination)) { + $previous = $destination . '.before-restore-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4)); + if (!rename($destination, $previous)) { + throw new \RuntimeException('Cannot retain previous database for rollback.'); + } + } + if (!rename($temporary, $destination)) { + if ($previous !== null) { + rename($previous, $destination); + } + throw new \RuntimeException('Cannot install restored database.'); + } + @chmod($destination, 0600); + return ['restored' => $destination,'previous_database' => $previous,'verified' => self::verifyFile($destination)]; + } finally { + if (is_file($temporary)) { + unlink($temporary); + } + $lock->release(); + } + } + /** Deletes only managed backup names in this one explicit directory. */ + public static function retain(string $directory, int $keep): array + { + if ($keep < 1) { + throw new \InvalidArgumentException('Keep at least one backup.'); + } + $resolved = realpath($directory); + if ($resolved === false) { + throw new \RuntimeException('Backup directory does not exist.'); + } + $files = array_filter(glob($resolved . '/shabbat-backup-*.sqlite') ?: [], static fn(string $path): bool=>!is_link($path) && preg_match('/^shabbat-backup-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}\.sqlite$/D', basename($path)) === 1); + rsort($files, SORT_STRING); + $removed = []; + foreach (array_slice($files, $keep) as $path) { + if (realpath(dirname($path)) !== $resolved) { + throw new \RuntimeException('Backup retention path escaped its directory.'); + } + if (!unlink($path)) { + throw new \RuntimeException('Cannot delete old managed backup.'); + } + if (is_file($path . '.manifest.json')) { + unlink($path . '.manifest.json'); + } + $removed[] = basename($path); + } + return $removed; + } +} diff --git a/src/Storage/Database.php b/src/Storage/Database.php new file mode 100644 index 0000000..b7e7e9a --- /dev/null +++ b/src/Storage/Database.php @@ -0,0 +1,142 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $connection->exec('PRAGMA foreign_keys=ON'); + $connection->exec('PRAGMA busy_timeout=100'); + $connection->exec('PRAGMA journal_mode=WAL'); + $connection->exec('PRAGMA synchronous=FULL'); + } + public static function open(string $path): self + { + if ($path !== ':memory:' && !is_dir(dirname($path)) && !mkdir(dirname($path), 0700, true) && !is_dir(dirname($path))) { + throw new \RuntimeException('Cannot create application database directory.'); + } + return new self(new PDO('sqlite:' . $path)); + } + public function pdo(): PDO + { + return $this->connection; + } + /** PDO_SQLite before PHP 8.4 does not report transactions opened with BEGIN IMMEDIATE. */ + public function ownsTransaction(PDO $connection): bool + { + return $this->inTransaction && $connection === $this->connection; + } + /** Database work only: never yield, call a remote API or change transaction control in this callback. */ + public function transaction(callable $callback): mixed + { + if ($this->inTransaction) { + throw new \LogicException('Nested/shared transactions are forbidden.'); + } + $this->connection->exec('BEGIN IMMEDIATE'); + $this->inTransaction = true; + try { + $result = $callback($this->connection); + $this->connection->exec('COMMIT'); + return $result; + } catch (Throwable $error) { + $this->connection->exec('ROLLBACK'); + throw $error; + } finally { + $this->inTransaction = false; + } + } + public function migrate(?string $directory = null): array + { + $directory ??= dirname(__DIR__, 2) . '/migrations'; + $this->connection->exec('CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at INTEGER NOT NULL)'); + $files = glob($directory . '/*.sql') ?: []; + sort($files, SORT_STRING); + $applied = []; + foreach ($files as $file) { + $version = basename($file); + $sql = file_get_contents($file); + if ($sql === false) { + throw new \RuntimeException('Cannot read migration: ' . $version); + } + $checksum = hash('sha256', $sql); + $query = $this->connection->prepare('SELECT checksum FROM schema_migrations WHERE version=?'); + $query->execute([$version]); + $existing = $query->fetchColumn(); + if ($existing !== false) { + if (!hash_equals((string) $existing, $checksum)) { + throw new \RuntimeException('Applied migration was changed: ' . $version); + } + continue; + } + $this->transaction(function (PDO $pdo) use ($sql, $version, $checksum): void { + $pdo->exec($sql); + $pdo->prepare('INSERT INTO schema_migrations VALUES (?,?,?)')->execute([$version, $checksum, time()]); + }); + $applied[] = $version; + } + return $applied; + } + /** Read-only startup gate; maintenance is the only place that applies migrations. */ + public function assertCurrentSchema(?string $directory = null): void + { + $directory ??= dirname(__DIR__, 2) . '/migrations'; + $files = glob($directory . '/*.sql') ?: []; + sort($files, SORT_STRING); + if ($files === []) { + throw new \RuntimeException('Required migration source files are missing.'); + } + $required = []; + foreach ($files as $file) { + $version = basename($file); + $contents = file_get_contents($file); + if ($contents === false) { + throw new \RuntimeException('Cannot read required migration source: ' . $version); + } + $required[$version] = hash('sha256', $contents); + } + try { + $applied = $this->connection->query('SELECT version,checksum FROM schema_migrations ORDER BY version')->fetchAll(); + } catch (\PDOException $error) { + throw new \RuntimeException('Schema migration history is missing or unreadable. Run the stopped-writer maintenance migration before startup.', 0, $error); + } + $seen = []; + foreach ($applied as $record) { + $version = $record['version'] ?? null; + $checksum = $record['checksum'] ?? null; + if (!is_string($version) || !isset($required[$version])) { + throw new \RuntimeException('Unknown schema migration version; use the application version matching this database.'); + } + if (isset($seen[$version]) || !is_string($checksum) || !hash_equals($required[$version], $checksum)) { + throw new \RuntimeException('Applied migration checksum does not match required source: ' . $version); + } + $seen[$version] = true; + } + $missing = array_diff(array_keys($required), array_keys($seen)); + if ($missing !== []) { + throw new \RuntimeException('Missing required schema migrations: ' . implode(', ', $missing) . '. Run the stopped-writer maintenance migration before startup.'); + } + } + public function integrity(): array + { + return ['integrity_check' => $this->connection->query('PRAGMA integrity_check')->fetchAll(PDO::FETCH_COLUMN), + 'foreign_key_check' => $this->connection->query('PRAGMA foreign_key_check')->fetchAll(), + 'sqlite_version' => $this->connection->query('SELECT sqlite_version()')->fetchColumn()]; + } + public static function encode(array $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Storage/DeletionRepository.php b/src/Storage/DeletionRepository.php new file mode 100644 index 0000000..0789bba --- /dev/null +++ b/src/Storage/DeletionRepository.php @@ -0,0 +1,320 @@ +db->transaction(fn(PDO $pdo): int => $this->insert($pdo, $group, $messageId, $purpose, $metadata, $dueAt, $expiresAt)); + } + private function insert(PDO $pdo, string $group, int $messageId, string $purpose, array $metadata, int $dueAt, ?int $expiresAt): int + { + SettingsRepository::identifier($group); + self::messageIds([$messageId]); + if ( + !in_array($purpose, ['holy_message','tefillin_previous'], true) || $dueAt < 0 || ($expiresAt !== null && $expiresAt < $dueAt) + || ($purpose === 'holy_message' && $expiresAt === null) + ) { + throw new \InvalidArgumentException('Invalid deletion purpose or deadline.'); + } + $timestamp = $metadata['original_timestamp'] ?? null; + $bot = $metadata['sender_is_bot'] ?? null; + $self = $metadata['sender_is_self'] ?? false; + $sender = $metadata['sender_id'] ?? null; + $revision = $metadata['settings_revision'] ?? null; + $delivery = $metadata['origin_delivery_key'] ?? null; + if ( + !is_int($timestamp) || $timestamp < 0 || !is_bool($bot) || !is_bool($self) || ($self && !$bot) + || ($revision !== null && (!is_int($revision) || $revision < 1)) + ) { + throw new \InvalidArgumentException('Deletion requires typed original message metadata.'); + } + if ($sender !== null) { + if (!is_string($sender)) { + throw new \InvalidArgumentException('Deletion sender must retain string identity.'); + } + SettingsRepository::identifier($sender); + } + if ($delivery !== null) { + self::deliveryKey($delivery); + } + if ($purpose === 'tefillin_previous' && (!$bot || !$self || $delivery === null)) { + throw new \InvalidArgumentException('Only stored bot-owned reminders can be replaced.'); + } + $now = $this->now(); + $pdo->prepare('INSERT OR IGNORE INTO message_deletions(group_id,message_id,purpose,original_timestamp,sender_id,sender_is_bot,sender_is_self,settings_revision,origin_delivery_key,due_at,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute([$group,$messageId,$purpose,$timestamp,$sender,(int)$bot,(int)$self,$revision,$delivery,$dueAt,$expiresAt,$now,$now]); + $query = $pdo->prepare('SELECT deletion_id FROM message_deletions WHERE group_id=? AND message_id=? AND purpose=?'); + $query->execute([$group,$messageId,$purpose]); + return (int)$query->fetchColumn(); + } + public function get(int $id): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM message_deletions WHERE deletion_id=?'); + $query->execute([$id]); + $row = $query->fetch(); + return $row === false ? null : self::decode($row); + } + public function list(string $status = 'pending', int $limit = 50, int $offset = 0): array + { + if (!in_array($status, ['pending','leased','deleted','cancelled','failed','expired'], true) || $limit < 1 || $limit > 100 || $offset < 0) { + throw new \InvalidArgumentException('Invalid deletion page.'); + } + $query = $this->db->pdo()->prepare('SELECT * FROM message_deletions WHERE status=? ORDER BY due_at,deletion_id LIMIT ? OFFSET ?'); + $query->bindValue(1, $status); + $query->bindValue(2, $limit, PDO::PARAM_INT); + $query->bindValue(3, $offset, PDO::PARAM_INT); + $query->execute(); + return array_map(self::decode(...), $query->fetchAll()); + } + public function claim(string $owner, int $leaseSeconds = 120): ?array + { + self::lease($owner, $leaseSeconds); + $now = $this->now(); + return $this->db->transaction(function (PDO $pdo) use ($owner, $leaseSeconds, $now): ?array { + $pdo->prepare("UPDATE message_deletions SET status='expired',last_error='deletion deadline elapsed',lease_owner=NULL,lease_until=NULL,updated_at=? WHERE status IN ('pending','leased') AND expires_at<=? AND (lease_until IS NULL OR lease_until<=?)") + ->execute([$now,$now,$now]); + $pdo->prepare("UPDATE message_deletions SET status='cancelled',last_error='current reminder protected',lease_owner=NULL,lease_until=NULL,updated_at=? WHERE purpose='tefillin_previous' AND (status='pending' OR (status='leased' AND lease_until<=?)) AND EXISTS(SELECT 1 FROM module_last_messages m,json_each(m.message_ids_json) v WHERE m.group_id=message_deletions.group_id AND m.module='tefillin' AND v.value=message_deletions.message_id)") + ->execute([$now,$now]); + $pdo->prepare("UPDATE message_deletions SET status='cancelled',last_error='group consent or settings revision changed',lease_owner=NULL,lease_until=NULL,updated_at=? WHERE (status='pending' OR (status='leased' AND lease_until<=?)) AND (NOT EXISTS(SELECT 1 FROM groups g WHERE g.group_id=message_deletions.group_id AND g.active=1) OR (purpose='holy_message' AND settings_revision IS NOT NULL AND NOT EXISTS(SELECT 1 FROM group_settings s WHERE s.group_id=message_deletions.group_id AND s.revision=message_deletions.settings_revision)) OR (purpose='tefillin_previous' AND NOT EXISTS(SELECT 1 FROM group_settings s WHERE s.group_id=message_deletions.group_id AND s.tefillin_delete_previous=1)))") + ->execute([$now,$now]); + $pdo->prepare("UPDATE message_deletions SET status='failed',last_error='cleanup retry budget exhausted',lease_owner=NULL,lease_until=NULL,updated_at=? WHERE attempts>=5 AND (status='pending' OR (status='leased' AND lease_until<=?))") + ->execute([$now,$now]); + $query = $pdo->prepare("SELECT * FROM message_deletions WHERE due_at<=? AND (expires_at IS NULL OR expires_at>?) AND (retry_at IS NULL OR retry_at<=?) AND (status='pending' OR (status='leased' AND lease_until<=?)) ORDER BY due_at,deletion_id LIMIT 1"); + $query->execute([$now,$now,$now,$now]); + $row = $query->fetch(); + if ($row === false) { + return null; + } + $until = $row['expires_at'] === null ? $now + $leaseSeconds : min($now + $leaseSeconds, (int)$row['expires_at']); + $pdo->prepare("UPDATE message_deletions SET status='leased',attempts=attempts+1,lease_owner=?,lease_until=?,updated_at=? WHERE deletion_id=?") + ->execute([$owner,$until,$now,$row['deletion_id']]); + return $this->get((int)$row['deletion_id']); + }); + } + /** @phpstan-impure Lease, consent and clock state can change across asynchronous RPC. */ + public function owns(int $id, string $owner): bool + { + $row = $this->get($id); + if ( + $row === null || $row['status'] !== 'leased' || $row['lease_owner'] !== $owner || $row['lease_until'] <= $this->now() + || ($row['expires_at'] !== null && $row['expires_at'] <= $this->now()) + ) { + return false; + } + $query = $this->db->pdo()->prepare('SELECT s.revision,s.tefillin_delete_previous,g.active FROM group_settings s JOIN groups g USING(group_id) WHERE s.group_id=?'); + $query->execute([$row['group_id']]); + $settings = $query->fetch(); + if ( + $settings === false || !$settings['active'] || ($row['purpose'] === 'holy_message' && $row['settings_revision'] !== null && (int)$settings['revision'] !== $row['settings_revision']) + || ($row['purpose'] === 'tefillin_previous' && !$settings['tefillin_delete_previous']) + ) { + return false; + } + return $row['purpose'] !== 'tefillin_previous' || !in_array($row['message_id'], $this->getLastModuleMessages($row['group_id'], 'tefillin'), true); + } + public function renew(int $id, string $owner, int $leaseSeconds = 120): void + { + self::lease($owner, $leaseSeconds); + if (!$this->owns($id, $owner)) { + throw new \RuntimeException('Deletion ownership or deadline was lost.'); + } + $row = $this->get($id); + $until = $row['expires_at'] === null ? $this->now() + $leaseSeconds : min($this->now() + $leaseSeconds, $row['expires_at']); + $this->db->pdo()->prepare("UPDATE message_deletions SET lease_until=?,updated_at=? WHERE deletion_id=? AND status='leased' AND lease_owner=? AND lease_until>?") + ->execute([$until,$this->now(),$id,$owner,$this->now()]); + } + public function complete(int $id, string $owner): void + { + $this->finish($id, $owner, 'deleted', null); + } + public function cancel(int $id, string $owner, string $reason): void + { + $this->finish($id, $owner, 'cancelled', $reason); + } + private function finish(int $id, string $owner, string $status, ?string $error): void + { + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $status, $error): void { + $owned = $this->get($id); + if ( + $owned === null || $owned['status'] !== 'leased' || $owned['lease_owner'] !== $owner || $owned['lease_until'] <= $this->now() + || ($status === 'deleted' && !$this->owns($id, $owner)) + ) { + throw new \RuntimeException('Deletion ownership or deadline was lost before confirmation.'); + } + $query = $pdo->prepare("UPDATE message_deletions SET status=?,last_error=?,completed_at=?,updated_at=?,lease_owner=NULL,lease_until=NULL WHERE deletion_id=? AND status='leased' AND lease_owner=? AND lease_until>?"); + $query->execute([$status,$error === null ? null : mb_strcut($error, 0, 1024, 'UTF-8'),$this->now(),$this->now(),$id,$owner,$this->now()]); + if ($query->rowCount() !== 1) { + throw new \RuntimeException('Deletion ownership was lost during confirmation.'); + } + }); + } + public function retry(int $id, string $owner, string $error, int $waitSeconds = 30): void + { + if ($waitSeconds < 1) { + throw new \InvalidArgumentException('Deletion retries require a positive wait.'); + } + $this->db->transaction(function (PDO $pdo) use ($id, $owner, $error, $waitSeconds): void { + if (!$this->owns($id, $owner)) { + throw new \RuntimeException('Deletion ownership was lost before retry persistence.'); + } + $row = $this->get($id); + $now = $this->now(); + $retryAt = $waitSeconds > PHP_INT_MAX - $now ? PHP_INT_MAX : $now + $waitSeconds; + $status = $row['attempts'] >= 5 || $waitSeconds > 86400 ? 'failed' : (($row['expires_at'] !== null && $retryAt >= $row['expires_at']) ? 'expired' : 'pending'); + $pdo->prepare('UPDATE message_deletions SET status=?,retry_at=?,last_error=?,updated_at=?,lease_owner=NULL,lease_until=NULL WHERE deletion_id=? AND lease_owner=?') + ->execute([$status,$retryAt,mb_strcut($error, 0, 1024, 'UTF-8'),$now,$id,$owner]); + }); + } + public function getLastModuleMessages(string $group, string $module): array + { + $row = $this->last($this->db->pdo(), $group, $module); + return $row === null ? [] : self::messageIds(json_decode($row['message_ids_json'], true, 32, JSON_THROW_ON_ERROR)); + } + private function last(PDO $pdo, string $group, string $module): ?array + { + SettingsRepository::identifier($group); + self::module($module); + $query = $pdo->prepare('SELECT * FROM module_last_messages WHERE group_id=? AND module=?'); + $query->execute([$group,$module]); + $row = $query->fetch(); + return $row === false ? null : $row; + } + public function recordModuleDelivery(string $group, string $module, array $messageIds, string $deliveryKey, bool $deletePrevious = false, ?int $originalTimestamp = null): array + { + return $this->db->transaction(fn(PDO $pdo): array => $this->recordModuleDeliveryInTransaction($pdo, $group, $module, $messageIds, $deliveryKey, $deletePrevious, $originalTimestamp)); + } + public function recordModuleDeliveryInTransaction(PDO $pdo, string $group, string $module, array $messageIds, string $deliveryKey, bool $deletePrevious = false, ?int $originalTimestamp = null, ?int $sourceJobId = null, ?int $completedAt = null): array + { + if (!$this->db->ownsTransaction($pdo)) { + throw new \LogicException('Module delivery bookkeeping requires this database transaction.'); + } + SettingsRepository::identifier($group); + self::module($module); + self::deliveryKey($deliveryKey); + $ids = self::messageIds($messageIds); + $timestamp = $originalTimestamp ?? $this->now(); + $completedAt ??= $this->now(); + if ($timestamp < 0 || $completedAt < 0 || ($deletePrevious && $module !== 'tefillin')) { + throw new \InvalidArgumentException('Invalid confirmed module delivery.'); + } + $previous = $this->last($pdo, $group, $module); + $old = $previous === null ? [] : self::messageIds(json_decode($previous['message_ids_json'], true, 32, JSON_THROW_ON_ERROR)); + if ($previous !== null && $previous['delivery_key'] === $deliveryKey) { + if ($old !== $ids) { + throw new \RuntimeException('A confirmed delivery key changed message identity.'); + } + return ['status' => 'duplicate','queued_deletions' => [],'current_message_ids' => $old]; + } + if ( + $previous !== null && ($completedAt < $previous['delivery_completed_at'] || max($ids) <= $previous['max_message_id'] + || ($completedAt === (int)$previous['delivery_completed_at'] && $sourceJobId !== null && $previous['source_job_id'] !== null && $sourceJobId < $previous['source_job_id'])) + ) { + return ['status' => 'stale','queued_deletions' => [],'current_message_ids' => $old]; + } + $pdo->prepare('INSERT INTO module_last_messages(group_id,module,message_ids_json,max_message_id,delivery_key,original_timestamp,delivery_completed_at,source_job_id,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(group_id,module) DO UPDATE SET message_ids_json=excluded.message_ids_json,max_message_id=excluded.max_message_id,delivery_key=excluded.delivery_key,original_timestamp=excluded.original_timestamp,delivery_completed_at=excluded.delivery_completed_at,source_job_id=excluded.source_job_id,updated_at=excluded.updated_at') + ->execute([$group,$module,Database::encode($ids),max($ids),$deliveryKey,$timestamp,$completedAt,$sourceJobId,$this->now()]); + $setting = $pdo->prepare('SELECT tefillin_delete_previous,revision FROM group_settings WHERE group_id=?'); + $setting->execute([$group]); + $settings = $setting->fetch(); + $queued = []; + if ($deletePrevious && $settings !== false && $settings['tefillin_delete_previous']) { + foreach (array_diff($old, $ids) as $messageId) { + $queued[] = $this->insert($pdo, $group, $messageId, 'tefillin_previous', [ + 'original_timestamp' => (int)$previous['original_timestamp'],'sender_is_bot' => true,'sender_is_self' => true, + 'settings_revision' => (int)$settings['revision'],'origin_delivery_key' => $previous['delivery_key'], + ], $this->now(), null); + } + } + return ['status' => 'recorded','queued_deletions' => $queued,'current_message_ids' => $ids]; + } + public function recordSentJobDelivery(int $jobId, array $messageIds, bool $deletePrevious): array + { + return $this->db->transaction(function (PDO $pdo) use ($jobId, $messageIds, $deletePrevious): array { + $query = $pdo->prepare("SELECT j.*,d.response_json FROM scheduled_jobs j JOIN delivery_log d ON d.job_id=j.job_id AND d.attempt=j.attempts AND d.outcome='sent' WHERE j.job_id=? AND j.status='sent'"); + $query->execute([$jobId]); + $job = $query->fetch(); + if ($job === false || !is_string($job['response_json']) || !is_array(json_decode($job['response_json'], true, 64, JSON_THROW_ON_ERROR))) { + throw new \RuntimeException('Previous-reminder bookkeeping requires a persisted confirmed tefillin response.'); + } + if ($job['module'] !== 'tefillin') { + $pdo->prepare("UPDATE scheduled_jobs SET module_delivery_processed_at=? WHERE job_id=? AND status='sent' AND module_delivery_processed_at IS NULL")->execute([$this->now(),$jobId]); + return ['status' => 'ignored','queued_deletions' => [],'current_message_ids' => []]; + } + if ($job['module_delivery_processed_at'] !== null) { + return ['status' => 'already_processed','queued_deletions' => [],'current_message_ids' => $this->getLastModuleMessages($job['group_id'], 'tefillin')]; + } + $ids = self::messageIds($messageIds); + $response = json_decode($job['response_json'], true, 64, JSON_THROW_ON_ERROR); + if (array_diff($ids, \Shabbat\Telegram\SentMessageIds::extract($response, $job['random_id'], $job['group_id'])) !== []) { + throw new \RuntimeException('Reminder identifiers are absent from the persisted successful response.'); + } + $result = $this->recordModuleDeliveryInTransaction($pdo, $job['group_id'], 'tefillin', $ids, 'job:' . $jobId, $deletePrevious, (int)$job['completed_at'], $jobId, (int)$job['completed_at']); + $pdo->prepare("UPDATE scheduled_jobs SET module_delivery_processed_at=? WHERE job_id=? AND status='sent' AND module_delivery_processed_at IS NULL") + ->execute([$this->now(),$jobId]); + return $result; + }); + } + private static function messageIds(array $ids): array + { + if (!array_is_list($ids) || $ids === [] || count($ids) > 100) { + throw new \InvalidArgumentException('A bounded nonempty list of message identifiers is required.'); + } + foreach ($ids as $id) { + if (!is_int($id) || $id < 1 || $id > 2147483647) { + throw new \InvalidArgumentException('Message identifiers must be positive native integers.'); + } + } + $ids = array_values(array_unique($ids)); + sort($ids, SORT_NUMERIC); + return $ids; + } + private static function module(string $module): void + { + if (!preg_match('/^[a-z][a-z0-9_]{0,31}$/D', $module)) { + throw new \InvalidArgumentException('Invalid module identity.'); + } + } + private static function deliveryKey(mixed $key): void + { + if (!is_string($key) || $key === '' || strlen($key) > 128 || preg_match('/[\x00-\x1F\x7F]/', $key)) { + throw new \InvalidArgumentException('Invalid delivery identity.'); + } + } + private static function lease(string $owner, int $seconds): void + { + if ($owner === '' || strlen($owner) > 128 || $seconds < 1 || $seconds > 300) { + throw new \InvalidArgumentException('Invalid deletion lease.'); + } + } + private static function decode(array $row): array + { + foreach (['deletion_id','message_id','original_timestamp','due_at','attempts','created_at','updated_at'] as $field) { + $row[$field] = (int)$row[$field]; + } + foreach (['settings_revision','expires_at','retry_at','lease_until','completed_at'] as $field) { + $row[$field] = $row[$field] === null ? null : (int)$row[$field]; + } + $row['sender_is_bot'] = (bool)$row['sender_is_bot']; + $row['sender_is_self'] = (bool)$row['sender_is_self']; + return $row; + } + private function now(): int + { + return $this->clock->now()->getTimestamp(); + } +} diff --git a/src/Storage/DiagnosticThrottled.php b/src/Storage/DiagnosticThrottled.php new file mode 100644 index 0000000..9858f22 --- /dev/null +++ b/src/Storage/DiagnosticThrottled.php @@ -0,0 +1,10 @@ +db->pdo()->prepare('SELECT * FROM diagnostic_panels WHERE viewer_id=? AND expires_at>?'); + $q->execute([$user, $this->now()]); + $row = $q->fetch(); + if ($row === false || ($token !== null && !hash_equals($row['token'], $token))) { + return null; + } + $row['report'] = json_decode($row['report_json'], true, 32, JSON_THROW_ON_ERROR); + unset($row['report_json']); + return $row; + } + + /** Reserve before provider I/O. The initial claim owner is the new session token. */ + public function begin(string $user, ?string $group): string + { + return $this->db->transaction(function (\PDO $pdo) use ($user, $group): string { + $q = $pdo->prepare('SELECT group_id,created_at,busy_until,export_at FROM diagnostic_panels WHERE viewer_id=?'); + $q->execute([$user]); + $previous = $q->fetch(); + $now = $this->now(); + $cooldown = $previous !== false && $previous['group_id'] !== $group ? 2 : 30; + if ($previous !== false && ((int)$previous['created_at'] + $cooldown > $now || (int)$previous['busy_until'] > $now)) { + throw new DiagnosticThrottled(); + } + $pdo->prepare('DELETE FROM diagnostic_panels WHERE expires_at<=? AND busy_until<=?')->execute([$now, $now]); + $token = bin2hex(random_bytes(12)); + $pdo->prepare('INSERT INTO diagnostic_panels(viewer_id,token,group_id,created_at,expires_at,busy_until,busy_owner,export_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(viewer_id) DO UPDATE SET token=excluded.token,group_id=excluded.group_id,report_json=\'{}\',page=0,category=\'all\',search=\'\',prompt=NULL,created_at=excluded.created_at,expires_at=excluded.expires_at,busy_until=excluded.busy_until,busy_owner=excluded.busy_owner') + ->execute([$user, $token, $group, $now, $now + self::TTL, $now + 120, $token, $previous === false ? 0 : $previous['export_at']]); + return $token; + }); + } + + public function saveReport(string $user, string $token, array $report, string $owner): void + { + $encoded = Database::encode($report); + if (strlen($encoded) > self::MAX_BYTES || count($report['records'] ?? []) > 500) { + throw new \LengthException('Diagnostic snapshot exceeds the bounded limit.'); + } + $q = $this->db->pdo()->prepare('UPDATE diagnostic_panels SET report_json=? WHERE viewer_id=? AND token=? AND expires_at>? AND busy_owner=? AND busy_until>?'); + $q->execute([$encoded, $user, $token, $this->now(), $owner, $this->now()]); + if ($q->rowCount() !== 1) { + throw new \RuntimeException('Diagnostic ownership expired.'); + } + } + + public function update(string $user, string $token, array $values, string $owner): void + { + $allowed = ['message_id', 'page', 'category', 'search', 'prompt']; + $sets = $args = []; + foreach ($values as $key => $value) { + if (!in_array($key, $allowed, true)) { + throw new \InvalidArgumentException('Invalid diagnostic view field.'); + } + $sets[] = $key . '=?'; + $args[] = $value; + } + if ($sets !== []) { + $q = $this->db->pdo()->prepare('UPDATE diagnostic_panels SET ' . implode(',', $sets) . ' WHERE viewer_id=? AND token=? AND expires_at>? AND busy_owner=? AND busy_until>?'); + $q->execute([...$args, $user, $token, $this->now(), $owner, $this->now()]); + if ($q->rowCount() !== 1) { + throw new \RuntimeException('Diagnostic ownership expired.'); + } + } + } + + public function claim(string $user, string $token): ?string + { + $owner = bin2hex(random_bytes(12)); + $q = $this->db->pdo()->prepare('UPDATE diagnostic_panels SET busy_until=?,busy_owner=? WHERE viewer_id=? AND token=? AND expires_at>? AND busy_until<=?'); + $q->execute([$this->now() + 120, $owner, $user, $token, $this->now(), $this->now()]); + return $q->rowCount() === 1 ? $owner : null; + } + + public function assertOwnership(string $user, string $token, string $owner): void + { + $q = $this->db->pdo()->prepare('SELECT 1 FROM diagnostic_panels WHERE viewer_id=? AND token=? AND expires_at>? AND busy_owner=? AND busy_until>?'); + $q->execute([$user, $token, $this->now(), $owner, $this->now()]); + if ($q->fetchColumn() === false) { + throw new \RuntimeException('Diagnostic ownership expired.'); + } + } + + /** Persist query identity before upload; uncertain sends are never automatically replayed. */ + public function claimExport(string $user, string $token, string $owner, string $queryId): bool + { + if (!preg_match('/^-?[0-9]{1,20}$/D', $queryId)) { + return false; + } + return $this->db->transaction(function (\PDO $pdo) use ($user, $token, $owner, $queryId): bool { + $this->assertOwnership($user, $token, $owner); + $row = $this->get($user, $token); + $queries = json_decode($row['export_queries_json'], true, 8, JSON_THROW_ON_ERROR); + if ((int)$row['export_at'] + 60 > $this->now() || in_array($queryId, $queries, true)) { + return false; + } + $queries[] = $queryId; + $pdo->prepare('UPDATE diagnostic_panels SET export_at=?,export_queries_json=? WHERE viewer_id=? AND token=? AND busy_owner=?') + ->execute([$this->now(), Database::encode(array_slice($queries, -64)), $user, $token, $owner]); + return true; + }); + } + + public function release(string $user, string $token, string $owner): void + { + $this->db->pdo()->prepare('UPDATE diagnostic_panels SET busy_until=0,busy_owner=NULL WHERE viewer_id=? AND token=? AND busy_owner=?')->execute([$user, $token, $owner]); + } + + public function cancelInput(string $user): void + { + $this->db->pdo()->prepare('UPDATE diagnostic_panels SET prompt=NULL WHERE viewer_id=?')->execute([$user]); + } + + private function now(): int + { + return $this->clock->now()->getTimestamp(); + } +} diff --git a/src/Storage/FlowTokenRepository.php b/src/Storage/FlowTokenRepository.php new file mode 100644 index 0000000..8b7d60d --- /dev/null +++ b/src/Storage/FlowTokenRepository.php @@ -0,0 +1,156 @@ +settings = new SettingsRepository($db, $clock); + } + public function create(string $user, string $scope, ?string $group, int $revision, string $action, int $ttl = 600, array $payload = []): string + { + $this->settings->getUser($user); + if ($ttl < 1 || $ttl > 3600 || !in_array($scope, ['user','group','share'], true) || strlen($action) > 64) { + throw new \InvalidArgumentException('Invalid selection flow.'); + } + if (($scope === 'user') !== ($group === null)) { + throw new \InvalidArgumentException('Selection scope mismatch.'); + } + if ($group !== null && $this->settings->getGroup($group) === null) { + throw new \InvalidArgumentException('Unknown group.'); + } + $token = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); + $now = $this->clock->now()->getTimestamp(); + $this->db->pdo()->prepare('INSERT INTO conversation_states(token_hash,user_id,scope,group_id,settings_revision,action,payload_json,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?)') + ->execute([hash('sha256', $token),$user,$scope,$group,$revision,$action,Database::encode($payload),$now,$now + $ttl]); + return $token; + } + public function peek(string $token, string $user, string $action): ?array + { + if (!preg_match('/^[A-Za-z0-9_-]{24}$/D', $token)) { + return null; + } + $query = $this->db->pdo()->prepare('SELECT * FROM conversation_states WHERE token_hash=? AND user_id=? AND action=? AND expires_at>? AND consumed_at IS NULL AND revoked_at IS NULL'); + $query->execute([hash('sha256', $token),$user,$action,$this->clock->now()->getTimestamp()]); + $row = $query->fetch(); + if ($row === false) { + return null; + } + $row['payload'] = json_decode($row['payload_json'], true, 64, JSON_THROW_ON_ERROR); + $row['revision'] = (int)$row['settings_revision']; + unset($row['payload_json'], $row['token_hash']); + return $row; + } + /** An explicit prompt may resolve its hidden token; arbitrary messages cannot create a flow. */ + public function latest(string $user, string $action): ?array + { + $query = $this->db->pdo()->prepare('SELECT * FROM conversation_states WHERE user_id=? AND action=? AND expires_at>? AND consumed_at IS NULL AND revoked_at IS NULL ORDER BY created_at DESC,rowid DESC LIMIT 1'); + $query->execute([$user,$action,$this->clock->now()->getTimestamp()]); + $row = $query->fetch(); + if ($row === false) { + return null; + } + $row['payload'] = json_decode($row['payload_json'], true, 64, JSON_THROW_ON_ERROR); + $row['revision'] = (int)$row['settings_revision']; + unset($row['payload_json'], $row['token_hash']); + return $row; + } + public function revokeAction(string $user, string $action): void + { + $this->db->pdo()->prepare('UPDATE conversation_states SET revoked_at=? WHERE user_id=? AND action=? AND consumed_at IS NULL AND revoked_at IS NULL') + ->execute([$this->clock->now()->getTimestamp(),$user,$action]); + } + public function updatePayload(string $token, string $user, string $action, array $payload): void + { + $row = $this->peek($token, $user, $action); + if ($row === null) { + throw new StaleSettings('Selection expired or invalid.'); + } + $query = $this->db->pdo()->prepare('UPDATE conversation_states SET payload_json=? WHERE token_hash=? AND user_id=? AND action=? AND expires_at>? AND consumed_at IS NULL AND revoked_at IS NULL'); + $query->execute([Database::encode($payload),hash('sha256', $token),$user,$action,$this->clock->now()->getTimestamp()]); + if ($query->rowCount() !== 1) { + throw new StaleSettings('Selection expired or invalid.'); + } + } + /** Authorization runs immediately before the transaction and must recheck Telegram admin rights. */ + public function confirm(string $token, string $user, string $action, callable $authorize, array $changes = []): array + { + return $this->confirmAndApply($token, $user, $action, $authorize, $changes, static fn(): null=>null); + } + /** localMutation(PDO, saved settings, flow) must only do local database work. */ + public function confirmAndApply(string $token, string $user, string $action, callable $authorize, array $changes, callable $localMutation): array + { + $flow = $this->peek($token, $user, $action); + if ($flow === null) { + throw new StaleSettings('Selection expired, consumed, revoked, or belongs to another user.'); + } + if ($flow['scope'] === 'share') { + throw new \RuntimeException('A sharing flow cannot write settings.'); + } + if ($flow['scope'] === 'group' && !$authorize($flow['group_id'], $user)) { + throw new \RuntimeException('Group administrator permission is required.'); + } + return $this->db->transaction(function (PDO $pdo) use ($token, $user, $action, $changes, $localMutation): array { + $flow = $this->peek($token, $user, $action); + if ($flow === null) { + throw new StaleSettings('Selection is no longer valid.'); + } + // Authorization can await Telegram. Revalidate the original prompt after + // that wait, in the same transaction as consuming and applying its child. + if (array_key_exists('source_input_token', $flow['payload'])) { + $source = $flow['payload']['source_input_token']; + $parent = is_string($source) ? $this->peek($source, $user, 'location_input') : null; + if ( + $parent === null || $parent['scope'] !== $flow['scope'] + || $parent['group_id'] !== $flow['group_id'] || $parent['revision'] !== $flow['revision'] + ) { + throw new StaleSettings('The original location prompt expired, was replaced, or no longer matches.'); + } + } + if (array_key_exists('source_wizard_token', $flow['payload'])) { + $source = $flow['payload']['source_wizard_token']; + $parent = is_string($source) ? $this->peek($source, $user, 'settings_wizard') : null; + if ( + $parent === null || $parent['scope'] !== $flow['scope'] + || $parent['group_id'] !== $flow['group_id'] || $parent['revision'] !== $flow['revision'] + ) { + throw new StaleSettings('The original settings question expired, was replaced, or no longer matches.'); + } + } + if (array_key_exists('source_template_input_token', $flow['payload'])) { + $source = $flow['payload']['source_template_input_token']; + $parent = is_string($source) ? $this->peek($source, $user, 'template_input') : null; + if ( + $parent === null || $parent['scope'] !== $flow['scope'] + || $parent['group_id'] !== $flow['group_id'] || $parent['revision'] !== $flow['revision'] + ) { + throw new StaleSettings('The original template input expired, was replaced, or no longer matches.'); + } + } + $now = $this->clock->now()->getTimestamp(); + $query = $pdo->prepare('UPDATE conversation_states SET consumed_at=? WHERE token_hash=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?'); + $query->execute([$now,hash('sha256', $token),$now]); + if ($query->rowCount() !== 1) { + throw new StaleSettings('Selection was already confirmed.'); + } + $saved = $flow['scope'] === 'group' + ? $this->settings->updateGroupInTransaction($pdo, $flow['group_id'], $flow['revision'], $changes, $user) + : $this->settings->updateUserInTransaction($pdo, $user, $flow['revision'], $changes); + $localMutation($pdo, $saved, $flow); + return $saved; + }); + } + public function revoke(string $token, string $user, string $action): void + { + $this->db->pdo()->prepare('UPDATE conversation_states SET revoked_at=? WHERE token_hash=? AND user_id=? AND action=? AND consumed_at IS NULL') + ->execute([$this->clock->now()->getTimestamp(),hash('sha256', $token),$user,$action]); + } +} diff --git a/src/Storage/LegacyImporter.php b/src/Storage/LegacyImporter.php new file mode 100644 index 0000000..a40b22a --- /dev/null +++ b/src/Storage/LegacyImporter.php @@ -0,0 +1,407 @@ +settings = new SettingsRepository($db, $clock); + } + /** Startup bridge: only add missing list members; never rewrite preferences or migrate schema. */ + public function importGroupListAutomatically(string $legacyRoot): array + { + [$data] = $this->paths($legacyRoot); + $report = ['added' => [],'existing' => [],'warnings' => []]; + foreach ($this->groupIds($data, $report['warnings']) as $id) { + if ($this->settings->getGroup($id) !== null) { + $report['existing'][] = $id; + continue; + } + $this->settings->ensureGroup($id, '', $this->legacyDefaults($data, $id)); + $this->db->pdo()->prepare('UPDATE group_settings SET imported_at=? WHERE group_id=? AND updated_by IS NULL')->execute([$this->clock->now()->getTimestamp(),$id]); + $report['added'][] = $id; + } + return $report; + } + /** Reads only whitelisted legacy names. Does not delete legacy data or invoke Telegram. */ + public function run(string $legacyRoot, bool $dryRun = true): array + { + [$data,$base] = $this->paths($legacyRoot); + $report = ['dry_run' => $dryRun,'groups' => [],'users' => [],'templates' => [],'recovery' => [],'warnings' => [], + 'admin_state' => [],'known_peers' => [],'counts' => ['groups' => 0,'users' => 0,'templates' => 0,'snapshots' => 0,'admin_state' => 0,'known_peers' => 0]]; + $groups = $this->groupIds($data, $report['warnings']); + $globalStates = []; + foreach (['state_open.txt','close_lock.txt','open_lock.txt','alert_lock.txt','close_lock2.txt','open_lock2.txt'] as $name) { + foreach (array_unique([$base . '/' . $name,$data . '/' . $name]) as $path) { + if (is_file($path)) { + $globalStates[$name] = $this->read($path); + if (!$dryRun) { + $this->recordSource($path, ['kind' => 'legacy_global_state','requires_review' => true]); + } + } + } + } + if ($globalStates !== []) { + $report['warnings'][] = 'Old global dates are recorded for recovery only; they do not set the current calendar week.'; + } + $report['warnings'][] = 'Legacy Jerusalem requested b=18 but Hebcal city rules use forty minutes; imported candle_minutes=null preserves city behavior. Closure remains ten minutes before candles. Old opening checks omitted the ten-minute closure offset.'; + foreach ($groups as $id) { + $existing = $this->settings->getGroup($id); + $report['groups'][] = ['group_id' => $id,'action' => $existing === null ? 'insert' : 'preserve_existing','settings_revision' => $existing['revision'] ?? 1]; + $report['counts']['groups']++; + if (!$dryRun && $existing === null) { + $this->settings->ensureGroup($id, '', $this->legacyDefaults($data, $id)); + $this->db->pdo()->prepare('UPDATE group_settings SET imported_at=? WHERE group_id=?')->execute([$this->clock->now()->getTimestamp(),$id]); + } + foreach (['open' => 'MsgOpener','close' => 'MsgCloser'] as $kind => $prefix) { + $template = $this->template($data . '/' . $id, $prefix, $report['warnings']); + if ($template === null) { + continue; + } + $query = $this->db->pdo()->prepare('SELECT template_id FROM message_templates WHERE group_id=? AND kind=?'); + $query->execute([$id,$kind]); + $exists = $query->fetchColumn() !== false; + $report['templates'][] = ['group_id' => $id,'kind' => $kind,'action' => $exists ? 'preserve_existing' : 'insert']; + $report['counts']['templates']++; + if (!$dryRun && !$exists) { + $this->db->pdo()->prepare('INSERT INTO message_templates(group_id,kind,text,entities_json,media_json,buttons_json,updated_at,imported_at,buttons_mode) VALUES(?,?,?,?,?,?,?,?,?)') + ->execute([$id,$kind,$template['text'],Database::encode($template['entities']),$template['media'] === null ? null : Database::encode($template['media']),Database::encode($template['buttons']),$this->clock->now()->getTimestamp(),$this->clock->now()->getTimestamp(),$template['buttons'] === [] ? 'inherit' : 'custom']); + } + } + $snapshotFile = $data . '/' . $id . '/chatb1.json'; + if (is_file($snapshotFile)) { + $report['counts']['snapshots']++; + $error = null; + $snapshot = null; + try { + $decoded = json_decode($this->read($snapshotFile), true, 32, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new \RuntimeException('Snapshot is not an object.'); + } + Permissions::validate($decoded); + $snapshot = $decoded; + } catch (\Throwable $exception) { + $error = $exception->getMessage(); + $report['warnings'][] = $id . ': malformed original permissions: ' . $error; + } + $report['recovery'][] = ['group_id' => $id,'snapshot_valid' => $error === null,'action' => 'requires_current_permission_and_calendar_check']; + if (!$dryRun) { + $serialized = $snapshot === null ? null : Database::encode($snapshot); + $applied = $snapshot === null ? null : Permissions::locked($snapshot); + // The old source also set these flags. Retain them in the expected legacy lock. + if ($applied !== null) { + $applied['edit_rank'] = true; + $applied['send_reactions'] = true; + } + $this->db->pdo()->prepare("INSERT OR IGNORE INTO group_lock_state(group_id,event_key,status,snapshot_json,applied_json,snapshot_sha256,prepared_at,last_error) VALUES(?,?,'recovery_required',?,?,?,?,?)") + ->execute([$id,'legacy:' . hash('sha256', Database::encode($globalStates)),$serialized,$applied === null ? null : Database::encode($applied),$serialized === null ? null : hash('sha256', $serialized),$this->clock->now()->getTimestamp(),$error]); + $this->recordSource($snapshotFile, ['group_id' => $id,'snapshot_valid' => $error === null]); + } + } elseif ($globalStates !== []) { + $report['recovery'][] = ['group_id' => $id,'snapshot_valid' => false,'action' => 'manual_review_without_permission_guesses']; + if (!$dryRun) { + $this->db->pdo()->prepare("INSERT OR IGNORE INTO group_lock_state(group_id,event_key,status,prepared_at,last_error) VALUES(?,'legacy:missing-snapshot','recovery_required',?,'Global legacy state exists without original permissions; administrator review required')") + ->execute([$id,$this->clock->now()->getTimestamp()]); + } + } + if (!$dryRun) { + foreach (['alertshabat.txt','alertshabat2.txt','msgclosermotan.txt','msgclosermotan2.txt','MsgOpener.txt','MsgOpener2.txt','MsgOpenerMedia.txt','MsgOpenerButtons.txt','MsgCloser.txt','MsgCloser2.txt','MsgCloserMedia.txt','MsgCloserButtons.txt'] as $name) { + $path = $data . '/' . $id . '/' . $name; + if (is_file($path)) { + $this->recordSource($path, ['group_id' => $id]); + } + } + } + } + $knownUsers = []; + $idsFile = $base . '/ids.txt'; + if (is_file($idsFile)) { + $knownPeers = []; + foreach (preg_split('/\R/', $this->read($idsFile)) ?: [] as $line) { + $peer = trim($line); + if ($peer === '') { + continue; + } + if (!$this->validPeerId($peer)) { + $report['warnings'][] = 'Skipped unsupported dialog identifier in ids.txt.'; + continue; + } + $knownPeers[$peer] = true; + if ($this->validUserId($peer)) { + $knownUsers[$peer] = true; + } + } + foreach (array_keys($knownPeers) as $peer) { + $peer = (string)$peer; + $query = $this->db->pdo()->prepare('SELECT 1 FROM known_broadcast_peers WHERE peer_id=?'); + $query->execute([$peer]); + $report['known_peers'][] = ['peer_id' => $peer,'action' => $query->fetchColumn() === false ? 'insert_unknown' : 'preserve_existing','authority' => 'none']; + $report['counts']['known_peers']++; + if (!$dryRun) { + $now = $this->clock->now()->getTimestamp(); + $this->db->pdo()->prepare("INSERT OR IGNORE INTO known_broadcast_peers(peer_id,peer_type,created_at,updated_at,imported_at) VALUES(?,'unknown',?,?,?)")->execute([$peer,$now,$now,$now]); + } + } + foreach (array_keys($knownUsers) as $user) { + $user = (string)$user; + $report['users'][] = ['user_id' => $user,'action' => 'insert_known_user_without_admin_authority']; + $report['counts']['users']++; + if (!$dryRun) { + $this->settings->getUser($user); + } + } + } + foreach (['ids.txt','idsnew.txt'] as $name) { + $this->importAdminState($base . '/' . $name, '0', $name, $dryRun, $report); + } + foreach (['BUTTONS.txt','broadcastsend.txt','pinmessage.txt','startline_cat.txt','endline_cat.txt','page_var.txt'] as $name) { + $this->importAdminState($data . '/' . $name, '0', $name, $dryRun, $report); + } + // Numeric directories outside the subscribed group list contain personal/private drafts. + foreach (glob($data . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + $user = basename($dir); + if (!$this->validUserId($user) || in_array($user, $groups, true)) { + continue; + } + $selectedFile = $dir . '/groupid.txt'; + $hasDraft = is_file($selectedFile); + foreach (['grs1.txt','messagetodelete.txt','txt.txt','ent.txt','media.txt','BUTTONS.txt','broadcastsend.txt','pinmessage.txt','page_var.txt'] as $name) { + $hasDraft = $hasDraft || is_file($dir . '/' . $name); + } + if (!$hasDraft) { + continue; + } + if (!isset($knownUsers[$user])) { + $report['users'][] = ['user_id' => $user,'action' => 'preserve_personal_drafts_without_admin_authority']; + $report['counts']['users']++; + $knownUsers[$user] = true; + } + foreach (['grs1.txt','messagetodelete.txt','txt.txt','ent.txt','media.txt','BUTTONS.txt','broadcastsend.txt','pinmessage.txt','page_var.txt'] as $name) { + $this->importAdminState($dir . '/' . $name, $user, $name, $dryRun, $report); + } + if (!$dryRun) { + $this->settings->getUser($user); + $draft = $this->userDraft($dir, $report['warnings']); + if ($draft !== null) { + $this->db->pdo()->prepare("INSERT OR IGNORE INTO message_templates(user_id,kind,text,entities_json,media_json,buttons_json,updated_at,imported_at,buttons_mode) VALUES(?,'broadcast',?,?,?,?,?,?,?)") + ->execute([$user,$draft['text'],Database::encode($draft['entities']),$draft['media'] === null ? null : Database::encode($draft['media']),Database::encode($draft['buttons']),$this->clock->now()->getTimestamp(),$this->clock->now()->getTimestamp(),$draft['buttons'] === [] ? 'inherit' : 'custom']); + } + foreach (['groupid.txt','grs1.txt','VAR-MENU','txt.txt','ent.txt','media.txt'] as $name) { + if (is_file($dir . '/' . $name)) { + $this->recordSource($dir . '/' . $name, ['user_id' => $user,'authority' => 'none','requires_new_authenticated_flow' => true]); + } + } + } + } + if (!$dryRun && is_file($data . '/DBgroups.txt')) { + $this->recordSource($data . '/DBgroups.txt', ['groups' => $groups]); + } + return $report; + } + private function validPeerId(string $id): bool + { + if (!preg_match('/^-?[1-9][0-9]{0,18}$/D', $id)) { + return false; + } + $negative = $id[0] === '-'; + $digits = $negative ? substr($id, 1) : $id; + $max = $negative ? substr((string)PHP_INT_MIN, 1) : (string)PHP_INT_MAX; + return strlen($digits) < strlen($max) || (strlen($digits) === strlen($max) && strcmp($digits, $max) <= 0); + } + private function validUserId(string $id): bool + { + if (!preg_match('/^[1-9][0-9]{0,18}$/D', $id)) { + return false; + } + $max = (string)PHP_INT_MAX; + return strlen($id) < strlen($max) || (strlen($id) === strlen($max) && strcmp($id, $max) <= 0); + } + private function importAdminState(string $path, string $user, string $key, bool $dryRun, array &$report): void + { + if (!is_file($path)) { + return; + } + $contents = $this->read($path); + if (strlen($contents) > 1048576) { + $report['warnings'][] = $key . ': exceeds SQLite draft limit; retained in legacy source for explicit review.'; + return; + } + $query = $this->db->pdo()->prepare('SELECT 1 FROM admin_state WHERE user_id=? AND state_key=?'); + $query->execute([$user,$key]); + $exists = $query->fetchColumn() !== false; + $report['admin_state'][] = ['user_id' => $user,'key' => $key,'action' => $exists ? 'preserve_existing' : 'insert']; + $report['counts']['admin_state']++; + if ($dryRun) { + return; + } + $kind = match ($key) { + 'ent.txt','BUTTONS.txt'=>'json','media.txt'=>'media','ids.txt','idsnew.txt'=>'ids',default=>'text' + }; + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(static function (\PDO $pdo) use ($user, $key, $kind, $contents, $now): void { + $pdo->prepare('INSERT OR IGNORE INTO admin_directories(user_id,created_at) VALUES(?,?)')->execute([$user,$now]); + $pdo->prepare('INSERT OR IGNORE INTO admin_state(user_id,state_key,value_kind,value_text,updated_at) VALUES(?,?,?,?,?)')->execute([$user,$key,$kind,$contents,$now]); + }); + $this->recordSource($path, ['user_id' => $user,'kind' => 'admin_draft','authority' => 'none','requires_new_authenticated_flow' => true]); + } + private function legacyDefaults(string $data, string $id): array + { + $messages = is_file($data . '/' . $id . '/alertshabat2.txt'); + return ['location_name' => 'Jerusalem','country' => 'IL','geoname_id' => 281184,'latitude' => 31.76904,'longitude' => 35.21633, + 'timezone_id' => 'Asia/Jerusalem','location_source' => 'legacy','israel' => true,'language' => 'he','candle_minutes' => null, + 'havdalah_method' => 'degrees','havdalah_value' => 8.5,'close_before_candles_minutes' => 10,'open_after_havdalah_minutes' => 0, + 'mode' => $messages ? 'lock_messages' : 'lock','shabbat_enabled' => true,'holidays_enabled' => true, + 'times_alert_enabled' => is_file($data . '/' . $id . '/alertshabat.txt'),'times_alert_time' => '13:30', + 'close_message_enabled' => $messages,'open_message_enabled' => $messages,'zmanim_enabled' => false,'daf_enabled' => false,'tefillin_enabled' => false,'omer_enabled' => false]; + } + private function paths(string $root): array + { + $resolved = realpath($root); + if ($resolved === false || !is_dir($resolved)) { + throw new \InvalidArgumentException('Legacy source directory does not exist.'); + } + $data = is_dir($resolved . '/data') ? $resolved . '/data' : $resolved; + $base = basename($data) === 'data' ? dirname($data) : $resolved; + return [$data,$base]; + } + private function groupIds(string $data, array &$warnings): array + { + $file = $data . '/DBgroups.txt'; + if (!is_file($file)) { + return []; + } + $ids = []; + foreach (preg_split('/\R/', $this->read($file)) ?: [] as $line) { + $id = trim($line); + if ($id === '') { + continue; + } + if (!preg_match('/^-?[0-9]{1,20}$/D', $id)) { + $warnings[] = 'Skipped invalid group identifier in DBgroups.txt: ' . substr($id, 0, 40); + continue; + } + $ids[$id] = $id; + } + return array_values($ids); + } + private function template(string $dir, string $prefix, array &$warnings): ?array + { + $textFile = $dir . '/' . $prefix . '.txt'; + $entityFile = $dir . '/' . $prefix . '2.txt'; + $mediaFile = $dir . '/' . $prefix . 'Media.txt'; + $buttonFile = $dir . '/' . $prefix . 'Buttons.txt'; + if (!is_file($textFile) && !is_file($mediaFile) && !is_file($buttonFile)) { + return null; + } + return ['text' => is_file($textFile) ? $this->read($textFile) : '','entities' => $this->jsonArray($entityFile, $warnings), + 'media' => is_file($mediaFile) ? ['bot_api_file_id' => trim($this->read($mediaFile))] : null, + 'buttons' => is_file($buttonFile) ? $this->buttons($this->read($buttonFile), $warnings) : []]; + } + private function userDraft(string $dir, array &$warnings): ?array + { + if (!is_file($dir . '/txt.txt') && !is_file($dir . '/media.txt')) { + return null; + } + $buttons = is_file($dir . '/BUTTONS.txt') ? $dir . '/BUTTONS.txt' : dirname($dir) . '/BUTTONS.txt'; + return ['text' => is_file($dir . '/txt.txt') ? $this->read($dir . '/txt.txt') : '','entities' => $this->jsonArray($dir . '/ent.txt', $warnings), + 'media' => is_file($dir . '/media.txt') ? ['bot_api_file_id' => trim($this->read($dir . '/media.txt'))] : null,'buttons' => TemplateRepository::buttonRows($this->jsonArray($buttons, $warnings))]; + } + private function jsonArray(string $path, array &$warnings): array + { + if (!is_file($path)) { + return []; + } + try { + $data = json_decode($this->read($path), true, 64, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new \RuntimeException('Expected JSON array.'); + } + return $data; + } catch (\Throwable $error) { + $warnings[] = basename($path) . ': malformed JSON retained in legacy source, omitted from normalized template: ' . $error->getMessage(); + return []; + } + } + private function buttons(string $text, array &$warnings): array + { + // Some older operators stored the later JSON format; support both without executing content. + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return TemplateRepository::buttonRows($decoded); + } + $rows = []; + foreach (preg_split('/\R/', $text) ?: [] as $line) { + if (trim($line) === '') { + continue; + } + $row = []; + foreach (explode('&&', $line) as $part) { + $pieces = array_map('trim', explode(' - ', trim($part))); + if (count($pieces) < 2 || trim($pieces[0]) === '') { + $warnings[] = 'Malformed legacy button omitted; source text retained.'; + continue; + } + [$label,$action] = $pieces; + $button = ['text' => trim($label)]; + $style = []; + foreach (array_slice($pieces, 2) as $option) { + if (in_array($option, ['style:bg_success','style:bg_primary','style:bg_danger'], true)) { + $style[substr($option, 6)] = true; + } + if (str_starts_with($option, 'icon:') && preg_match('/^[0-9]{1,20}$/D', substr($option, 5))) { + $style['icon'] = (int)substr($option, 5); + } + } + if ($style !== []) { + $button['style'] = ['_' => 'keyboardButtonStyle'] + $style; + } + if (preg_match('#^https?://#i', $action)) { + $button['url'] = $action; + } elseif (str_starts_with($action, 'data:')) { + $button['callback_data'] = substr($action, 5); + } elseif (str_starts_with($action, 'copy:')) { + $button['copy_text'] = ['_' => 'copyTextButton','text' => substr($action, 5)]; + } elseif (str_starts_with($action, 'alert:') || str_starts_with($action, 'popup:')) { + [$type,$value] = explode(':', $action, 2); + $button['callback_data'] = $type . ':' . base64_encode(mb_substr($value, 0, 180)); + } else { + $warnings[] = 'Unsupported legacy button action omitted; source text retained.'; + continue; + } + $row[] = $button; + } + if ($row !== []) { + $rows[] = $row; + } + } + return $rows; + } + private function read(string $path): string + { + if (is_link($path)) { + throw new \RuntimeException('Symlinked legacy sources require explicit operator review.'); + } + $size = filesize($path); + if ($size === false || $size > 2 * 1024 * 1024) { + throw new \RuntimeException('Legacy file is too large or unreadable: ' . basename($path)); + } + $text = file_get_contents($path); + if ($text === false) { + throw new \RuntimeException('Cannot read legacy file: ' . basename($path)); + } + return $text; + } + private function recordSource(string $path, array $details): void + { + $query = $this->db->pdo()->prepare('INSERT OR IGNORE INTO legacy_imports(source_path,sha256,imported_at,details_json) VALUES(?,?,?,?)'); + $query->execute([$path,hash('sha256', $this->read($path)),$this->clock->now()->getTimestamp(),Database::encode($details)]); + } +} diff --git a/src/Storage/SettingsRepository.php b/src/Storage/SettingsRepository.php new file mode 100644 index 0000000..34cae42 --- /dev/null +++ b/src/Storage/SettingsRepository.php @@ -0,0 +1,362 @@ +db; + } + /** @phpstan-impure Database state may change across an asynchronous operation. */ + public function getGroup(string $id): ?array + { + self::identifier($id); + $query = $this->db->pdo()->prepare('SELECT s.*,g.title,g.active FROM group_settings s JOIN groups g USING(group_id) WHERE group_id=?'); + $query->execute([$id]); + $row = $query->fetch(); + return $row === false ? null : self::decode($row); + } + public function listGroups(int $offset = 0, int $limit = 20, string $search = ''): array + { + if ($offset < 0 || $limit < 1 || $limit > 100) { + throw new \InvalidArgumentException('Invalid page.'); + } + $query = $this->db->pdo()->prepare("SELECT s.*,g.title,g.active FROM groups g JOIN group_settings s USING(group_id) WHERE g.active=1 AND (g.title LIKE :search ESCAPE '\\' OR g.group_id LIKE :search ESCAPE '\\') ORDER BY g.group_id LIMIT :lim OFFSET :off"); + $query->bindValue(':search', '%' . strtr($search, ['\\' => '\\\\','%' => '\\%','_' => '\\_']) . '%'); + $query->bindValue(':lim', $limit, PDO::PARAM_INT); + $query->bindValue(':off', $offset, PDO::PARAM_INT); + $query->execute(); + return array_map(self::decode(...), $query->fetchAll()); + } + /** Stable keyset traversal for bounded scheduler batches, independent of UI pagination. */ + public function groupsAfter(?string $after, int $limit = 20): array + { + if ($limit < 1 || $limit > 100) { + throw new \InvalidArgumentException('Invalid scheduler batch size.'); + } + $query = $this->db->pdo()->prepare('SELECT s.*,g.title,g.active FROM groups g JOIN group_settings s USING(group_id) WHERE g.active=1 AND (:after IS NULL OR g.group_id > :after) ORDER BY g.group_id LIMIT :lim'); + $query->bindValue(':after', $after, $after === null ? PDO::PARAM_NULL : PDO::PARAM_STR); + $query->bindValue(':lim', $limit, PDO::PARAM_INT); + $query->execute(); + return array_map(self::decode(...), $query->fetchAll()); + } + /** Refresh only metadata after the caller has completed a fresh Telegram lookup. */ + public function refreshGroupTitle(string $id, string $title): void + { + self::identifier($id); + if ( + !mb_check_encoding($title, 'UTF-8') || trim($title) === '' || strlen($title) > 1024 + || mb_strlen($title, 'UTF-8') > 256 || preg_match('/[\x00-\x1F\x7F]/u', $title) + ) { + throw new \InvalidArgumentException('Group title must be non-empty bounded UTF-8 without control characters.'); + } + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(static function (PDO $pdo) use ($id, $title, $now): void { + $known = $pdo->prepare('SELECT 1 FROM groups g JOIN group_settings s USING(group_id) WHERE g.group_id=?'); + $known->execute([$id]); + if ($known->fetchColumn() === false) { + throw new \InvalidArgumentException('Cannot refresh metadata for an unregistered group.'); + } + $pdo->prepare('UPDATE groups SET title=?,updated_at=? WHERE group_id=?')->execute([$title, $now, $id]); + }); + } + public function ensureGroup(string $id, string $title = '', ?array $settings = null): array + { + self::identifier($id); + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(function (PDO $pdo) use ($id, $title, $settings, $now): void { + $pdo->prepare('INSERT OR IGNORE INTO groups(group_id,title,created_at,updated_at) VALUES(?,?,?,?)')->execute([$id,$title,$now,$now]); + $query = $pdo->prepare('SELECT 1 FROM group_settings WHERE group_id=?'); + $query->execute([$id]); + if ($query->fetchColumn() === false) { + $pdo->prepare('INSERT INTO group_settings(group_id,updated_at) VALUES(?,?)')->execute([$id,$now]); + if ($settings !== null && $settings !== []) { + $this->apply($pdo, 'group_settings', 'group_id', $id, 1, $settings, null, false); + } + } + }); + return $this->getGroup($id) ?? throw new \LogicException('Missing newly created group.'); + } + /** Explicit rejoin after the caller has freshly verified administrator authorization. */ + public function reactivateGroup(string $id, string $actorId, ?string $title = null): array + { + self::identifier($id); + self::identifier($actorId); + if ( + $title !== null && ( + !mb_check_encoding($title, 'UTF-8') || trim($title) === '' || strlen($title) > 1024 + || mb_strlen($title, 'UTF-8') > 256 || preg_match('/[\x00-\x1F\x7F]/u', $title) + ) + ) { + throw new \InvalidArgumentException('Group title must be non-empty bounded UTF-8 without control characters.'); + } + return $this->db->transaction(function (PDO $pdo) use ($id, $actorId, $title): array { + $group = $this->getGroup($id); + if ($group === null) { + throw new \InvalidArgumentException('Only an explicitly registered group can be reactivated.'); + } + if ($group['active']) { + return $group; + } + $revision = $group['revision']; + $this->apply($pdo, 'group_settings', 'group_id', $id, $revision, [], $actorId); + $now = $this->clock->now()->getTimestamp(); + $pdo->prepare('UPDATE groups SET active=1,title=COALESCE(?,title),updated_at=? WHERE group_id=? AND active=0') + ->execute([$title, $now, $id]); + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)') + ->execute([$actorId, $id, 'group.reactivate', Database::encode(['revision' => $revision + 1]), $now]); + return $this->getGroup($id) ?? throw new \LogicException('Reactivated group disappeared.'); + }); + } + public function updateGroup(string $id, int $expectedRevision, array $changes, string $actorId): array + { + return $this->db->transaction(fn(PDO $pdo): array => $this->updateGroupInTransaction($pdo, $id, $expectedRevision, $changes, $actorId)); + } + /** For flow confirmations sharing this database's already-open transaction only. */ + public function updateGroupInTransaction(PDO $pdo, string $id, int $expectedRevision, array $changes, string $actorId): array + { + $this->apply($pdo, 'group_settings', 'group_id', $id, $expectedRevision, $changes, $actorId); + return $this->getGroup($id) ?? throw new \LogicException('Group disappeared.'); + } + public function getUser(string $id): array + { + self::identifier($id); + $now = $this->clock->now()->getTimestamp(); + $pdo = $this->db->pdo(); + $pdo->prepare('INSERT OR IGNORE INTO users(user_id,created_at,updated_at) VALUES(?,?,?)')->execute([$id,$now,$now]); + $pdo->prepare('INSERT OR IGNORE INTO user_preferences(user_id,updated_at) VALUES(?,?)')->execute([$id,$now]); + $query = $pdo->prepare('SELECT * FROM user_preferences WHERE user_id=?'); + $query->execute([$id]); + return self::decode($query->fetch()); + } + public function updateUser(string $id, int $expectedRevision, array $changes): array + { + $this->getUser($id); + return $this->db->transaction(fn(PDO $pdo): array => $this->updateUserInTransaction($pdo, $id, $expectedRevision, $changes)); + } + public function updateUserInTransaction(PDO $pdo, string $id, int $expectedRevision, array $changes): array + { + $this->apply($pdo, 'user_preferences', 'user_id', $id, $expectedRevision, $changes, $id); + $query = $pdo->prepare('SELECT * FROM user_preferences WHERE user_id=?'); + $query->execute([$id]); + return self::decode($query->fetch()); + } + public function deactivateGroup(string $id, string $actorId): void + { + $state = $this->db->pdo()->prepare("SELECT status FROM group_lock_state WHERE group_id=? AND status NOT IN ('restored','prelocked')"); + $state->execute([$id]); + if ($state->fetchColumn() !== false) { + throw new \RuntimeException('Restore or explicitly resolve group permissions before removing this group.'); + } + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(function (PDO $pdo) use ($id, $actorId, $now): void { + $pdo->prepare('UPDATE groups SET active=0,updated_at=? WHERE group_id=?')->execute([$now,$id]); + $pdo->prepare("UPDATE scheduled_jobs SET status='cancelled',last_error='group removed' WHERE group_id=? AND status IN ('pending','leased') AND module NOT IN ('lock','restore')")->execute([$id]); + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([$actorId,$id,'group.remove','{}',$now]); + }); + } + private function apply(PDO $pdo, string $table, string $key, string $id, int $revision, array $changes, ?string $actor, bool $increment = true): void + { + self::identifier($id); + if ($revision < 1) { + throw new \InvalidArgumentException('Invalid revision.'); + } + $columns = array_column($pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll(), 'name'); + $forbidden = [$key,'revision','updated_at','updated_by','imported_at']; + $assignments = []; + $values = []; + foreach ($changes as $name => $value) { + if (!is_string($name) || !in_array($name, $columns, true) || in_array($name, $forbidden, true)) { + throw new \InvalidArgumentException('Unsupported setting: ' . (string)$name); + } + $value = $this->validate($name, $value); + if (in_array($name, self::ARRAY_FIELDS, true)) { + $value = Database::encode($value); + } + if (in_array($name, self::BOOL_FIELDS, true)) { + $value = (int)$value; + } + $assignments[] = $name . '=?'; + $values[] = $value; + } + $current = $pdo->prepare('SELECT * FROM ' . $table . ' WHERE ' . $key . '=? AND revision=?'); + $current->execute([$id,$revision]); + $existing = $current->fetch(); + if ($existing === false) { + throw new StaleSettings('Settings changed. Open the current panel again.'); + } + $candidate = array_replace($existing, $changes); + foreach (['havdalah','omer_nightfall'] as $prefix) { + if (!isset($candidate[$prefix . '_method'])) { + continue; + } + $number = (float)$candidate[$prefix . '_value']; + if ($candidate[$prefix . '_method'] === 'degrees' && !in_array($number, [8.5,7.083], true)) { + throw new \InvalidArgumentException('Supported nightfall angles are 8.5 and 7.083 degrees.'); + } + if ($candidate[$prefix . '_method'] === 'minutes' && floor($number) !== $number) { + throw new \InvalidArgumentException('Fixed nightfall minutes must be a whole number.'); + } + } + $assignments[] = 'updated_at=?'; + $values[] = $this->clock->now()->getTimestamp(); + if ($increment) { + $assignments[] = 'revision=revision+1'; + } + if ($table === 'group_settings') { + $assignments[] = 'updated_by=?'; + $values[] = $actor; + } + $query = $pdo->prepare('UPDATE ' . $table . ' SET ' . implode(',', $assignments) . ' WHERE ' . $key . '=? AND revision=?'); + $query->execute([...$values,$id,$revision]); + if ($query->rowCount() !== 1) { + throw new StaleSettings('Settings changed. Open the current panel again.'); + } + if ($increment) { + $now = $this->clock->now()->getTimestamp(); + if ($table === 'group_settings') { + $pdo->prepare("UPDATE scheduled_jobs SET status='cancelled',last_error='settings revision changed' WHERE group_id=? AND settings_revision<>? AND status IN ('pending','leased') AND module NOT IN ('lock','restore')")->execute([$id,$revision + 1]); + $pdo->prepare('UPDATE conversation_states SET revoked_at=? WHERE group_id=? AND settings_revision<>? AND consumed_at IS NULL AND revoked_at IS NULL')->execute([$now,$id,$revision + 1]); + } else { + $pdo->prepare("UPDATE conversation_states SET revoked_at=? WHERE user_id=? AND scope='user' AND settings_revision<>? AND consumed_at IS NULL AND revoked_at IS NULL")->execute([$now,$id,$revision + 1]); + } + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([$actor,$table === 'group_settings' ? $id : null,'settings.update',Database::encode(['revision' => $revision + 1,'fields' => array_keys($changes)]),$now]); + } + } + private function validate(string $name, mixed $value): mixed + { + if ($name === 'lock_block_flags') { + if (!is_array($value)) { + throw new \InvalidArgumentException('Lock restrictions must be a list.'); + } + return \Shabbat\Scheduling\Permissions::validateBlockFlags($value); + } + if (in_array($name, self::BOOL_FIELDS, true)) { + if (!is_bool($value) && !in_array($value, [0,1], true)) { + throw new \InvalidArgumentException('Expected boolean: ' . $name); + } + return (bool)$value; + } + if ($name === 'tefillin_ack_label') { + if ( + $value !== null && (!is_string($value) || trim($value) === '' || !mb_check_encoding($value, 'UTF-8') + || strlen(mb_convert_encoding($value, 'UTF-16LE', 'UTF-8')) > 96 || preg_match('/[\x00-\x1F\x7F]/u', $value)) + ) { + throw new \InvalidArgumentException('Acknowledgement label must be bounded one-line UTF-8 or null for the localized default.'); + } + } + if ($name === 'timezone_id') { + if (!is_string($value) || !in_array($value, DateTimeZone::listIdentifiers(), true)) { + throw new \InvalidArgumentException('An IANA timezone is required.'); + } + } + if ($name === 'latitude' || $name === 'longitude') { + $max = $name === 'latitude' ? 90 : 180; + if ((!is_float($value) && !is_int($value)) || !is_finite((float)$value) || abs($value) > $max) { + throw new \InvalidArgumentException('Invalid coordinates.'); + } + } + $integers = ['candle_minutes' => [1,120],'close_before_candles_minutes' => [0,180],'open_after_havdalah_minutes' => [0,180], + 'times_alert_before_minutes' => [0,1440],'tefillin_interval_minutes' => [5,1440],'tefillin_max_per_day' => [1,24], + 'tefillin_min_spacing_minutes' => [5,1440],'tefillin_sunset_safety_minutes' => [1,120], + 'omer_after_nightfall_minutes' => [0,240],'omer_after_open_minutes' => [0,180],'target_topic_id' => [1,2147483647],'geoname_id' => [1,2147483647]]; + if (isset($integers[$name]) && $value !== null && (!is_int($value) || $value < $integers[$name][0] || $value > $integers[$name][1])) { + throw new \InvalidArgumentException('Invalid integer setting: ' . $name); + } + if (in_array($name, ['havdalah_value','omer_nightfall_value'], true)) { + if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value <= 0 || $value > 120) { + throw new \InvalidArgumentException('Invalid time method value.'); + } + } + if (in_array($name, self::ARRAY_FIELDS, true)) { + if (!is_array($value) || !array_is_list($value) || count($value) > 96) { + throw new \InvalidArgumentException('Expected bounded list: ' . $name); + } + foreach ($value as $item) { + if (str_ends_with($name, '_days') && (!is_int($item) || $item < 0 || $item > 6)) { + throw new \InvalidArgumentException('Invalid weekday.'); + } + if ($name === 'tefillin_fixed_times') { + self::timeOfDay($item); + } + if ($name === 'tefillin_sunset_offsets' && (!is_int($item) || $item < 1 || $item > 360)) { + throw new \InvalidArgumentException('Invalid sunset offset.'); + } + } + return array_values(array_unique($value, SORT_REGULAR)); + } + if (str_ends_with($name, '_time') || in_array($name, ['tefillin_window_start','tefillin_window_end','quiet_start','quiet_end'], true)) { + self::timeOfDay($value); + } + $enums = ['language' => ['he','en','ru','fr','es','pt-br'],'mode' => ['off','messages','lock','lock_messages'],'havdalah_method' => ['degrees','minutes'], + 'omer_nightfall_method' => ['degrees','minutes'],'omer_mode' => ['fixed','nightfall'],'omer_format' => ['short','full'], + 'zmanim_format' => ['short','full'],'omer_nusach' => ['ashkenaz','sephard'],'omer_early_policy' => ['defer','skip'], + 'tefillin_tisha_bav_policy' => ['off','mincha'],'times_alert_mode' => ['local_time','before_close']]; + if (isset($enums[$name]) && !in_array($value, $enums[$name], true)) { + throw new \InvalidArgumentException('Unsupported option: ' . $name); + } + if ($name === 'tefillin_suspended_date' && $value !== null) { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', (string)$value, new DateTimeZone('UTC')); + if ($date === false || $date->format('Y-m-d') !== $value) { + throw new \InvalidArgumentException('Invalid suspension date.'); + } + } + if (!is_scalar($value) && $value !== null) { + throw new \InvalidArgumentException('Invalid scalar setting: ' . $name); + } + if (is_string($value) && strlen($value) > 16384) { + throw new \InvalidArgumentException('Setting is too large.'); + } + return $value; + } + private static function timeOfDay(mixed $value): void + { + if ($value !== null && (!is_string($value) || !preg_match('/^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/D', $value))) { + throw new \InvalidArgumentException('Use local HH:MM.'); + } + } + public static function identifier(string $id): void + { + if (!preg_match('/^-?[0-9]{1,20}$/D', $id)) { + throw new \InvalidArgumentException('Invalid Telegram identifier.'); + } + } + private static function decode(array $row): array + { + foreach (self::ARRAY_FIELDS as $field) { + if (isset($row[$field])) { + $row[$field] = json_decode($row[$field], true, 32, JSON_THROW_ON_ERROR); + } + } + foreach (self::BOOL_FIELDS as $field) { + if (isset($row[$field])) { + $row[$field] = (bool)$row[$field]; + } + } + foreach (['revision','target_topic_id','geoname_id'] as $field) { + if (isset($row[$field])) { + $row[$field] = (int)$row[$field]; + } + } + foreach (['latitude','longitude','havdalah_value','omer_nightfall_value'] as $field) { + if (isset($row[$field])) { + $row[$field] = (float)$row[$field]; + } + } + return $row; + } +} diff --git a/src/Storage/StaleSettings.php b/src/Storage/StaleSettings.php new file mode 100644 index 0000000..3e9e2b0 --- /dev/null +++ b/src/Storage/StaleSettings.php @@ -0,0 +1,10 @@ + 256, 'username' => 64, 'first_name' => 256, 'last_name' => 256, 'language_code' => 32] as $key => $max) { + if (array_key_exists($key, $metadata) && $metadata[$key] !== null) { + self::text($metadata[$key], $max); + } + } + foreach (['bot', 'deleted', 'premium', 'contact'] as $key) { + if (array_key_exists($key, $metadata) && !is_bool($metadata[$key]) && $metadata[$key] !== null) { + throw new \InvalidArgumentException('Invalid observed boolean.'); + } + } + $now = $this->clock->now()->getTimestamp(); + $this->db->transaction(static function (PDO $pdo) use ($id, $metadata, $type, $activity, $opening, $privateChat, $now): void { + $pdo->prepare("INSERT INTO known_broadcast_peers(peer_id,peer_type,title,created_at,updated_at) VALUES(?,?,?,?,?) + ON CONFLICT(peer_id) DO UPDATE SET peer_type=CASE WHEN excluded.peer_type='unknown' THEN peer_type ELSE excluded.peer_type END, + title=CASE WHEN excluded.title='' THEN title ELSE excluded.title END,updated_at=excluded.updated_at") + ->execute([$id, $type, $metadata['title'] ?? '', $now, $now]); + $fields = ['username', 'first_name', 'last_name', 'language_code', 'bot', 'deleted', 'premium', 'contact']; + $values = [$id]; + foreach ($fields as $field) { + $value = $metadata[$field] ?? null; + $values[] = is_bool($value) ? (int) $value : $value; + } + array_push($values, $activity ? $now : null, $activity ? $now : null, (int) $activity, (int) $opening, $opening ? $now : null, $now, $privateChat ? 1 : null); + $pdo->prepare("INSERT INTO subscriber_profiles(peer_id,username,first_name,last_name,language_code,is_bot,is_deleted,is_premium,is_contact, + first_activity_at,last_activity_at,activity_count,start_count,last_start_at,metadata_at,private_chat_access) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(peer_id) DO UPDATE SET username=COALESCE(excluded.username,username),first_name=COALESCE(excluded.first_name,first_name), + last_name=COALESCE(excluded.last_name,last_name),language_code=COALESCE(excluded.language_code,language_code),is_bot=COALESCE(excluded.is_bot,is_bot), + is_deleted=COALESCE(excluded.is_deleted,is_deleted),is_premium=COALESCE(excluded.is_premium,is_premium),is_contact=COALESCE(excluded.is_contact,is_contact), + first_activity_at=COALESCE(first_activity_at,excluded.first_activity_at),last_activity_at=COALESCE(excluded.last_activity_at,last_activity_at), + activity_count=activity_count+excluded.activity_count,start_count=start_count+excluded.start_count, + last_start_at=COALESCE(excluded.last_start_at,last_start_at),metadata_at=excluded.metadata_at,private_chat_access=COALESCE(excluded.private_chat_access,private_chat_access)")->execute($values); + }); + } + + /** An actual incoming private update is the only new-chat proof of direct-message eligibility. */ + public function observePrivate(string $id, array $metadata = [], bool $activity = true, bool $opening = false): void + { + if (!in_array($metadata['type'] ?? 'unknown', ['unknown', 'user', 'bot'], true)) { + throw new \InvalidArgumentException('Private observations require a private peer.'); + } + $this->observe($id, $metadata, $activity, $opening, true); + } + /** Call only after a fresh Telegram administrator lookup; never use this cache for authorization. */ + public function recordGroupAdmin(string $user, string $group, bool $isAdmin, string $role = 'admin'): void + { + self::id($user); + self::id($group); + if (!in_array($role, ['owner', 'admin', 'member', 'left', 'unknown'], true)) { + throw new \InvalidArgumentException('Unknown observed group role.'); + } + $this->db->pdo()->prepare('INSERT INTO subscriber_group_admins(user_id,group_id,is_admin,role,checked_at) VALUES(?,?,?,?,?) + ON CONFLICT(user_id,group_id) DO UPDATE SET is_admin=excluded.is_admin,role=excluded.role,checked_at=excluded.checked_at') + ->execute([$user, $group, (int) $isAdmin, $role, $this->clock->now()->getTimestamp()]); + } + + public function page(int $page = 0, int $size = 10, string $search = '', string $sort = 'activity', string $filter = 'all'): array + { + if ($page < 0 || $page > 999999 || $size < 1 || $size > 20 || !in_array($sort, self::SORTS, true) || !in_array($filter, self::FILTERS, true)) { + throw new \InvalidArgumentException('Invalid directory page.'); + } + self::text($search, 128); + [$where, $parameters] = $this->where($filter, $search); + $query = $this->db->pdo()->prepare(self::SOURCE . 'SELECT COUNT(*) FROM directory WHERE ' . $where); + $query->execute($parameters); + $total = (int) $query->fetchColumn(); + $pages = max(1, (int) ceil($total / $size)); + $page = min($page, $pages - 1); + $order = match ($sort) { + 'joined' => 'joined_at DESC,id COLLATE BINARY', + 'name' => 'name COLLATE NOCASE,id COLLATE BINARY', + 'id' => 'id COLLATE BINARY', + default => 'last_activity_at DESC,joined_at DESC,id COLLATE BINARY', + }; + $query = $this->db->pdo()->prepare(self::SOURCE . 'SELECT * FROM directory WHERE ' . $where . ' ORDER BY ' . $order . ' LIMIT ? OFFSET ?'); + foreach (array_values($parameters) as $index => $parameter) { + $query->bindValue($index + 1, $parameter, PDO::PARAM_STR); + } + $query->bindValue(count($parameters) + 1, $size, PDO::PARAM_INT); + $query->bindValue(count($parameters) + 2, $page * $size, PDO::PARAM_INT); + $query->execute(); + return ['items' => $query->fetchAll(), 'page' => $page, 'pages' => $pages, 'total' => $total, 'search' => $search, 'sort' => $sort, 'filter' => $filter]; + } + + public function detail(string $id): ?array + { + self::id($id); + $query = $this->db->pdo()->prepare(self::SOURCE . 'SELECT * FROM directory WHERE id=?'); + $query->execute([$id]); + $item = $query->fetch(); + if ($item === false) { + return null; + } + $profile = $this->db->pdo()->prepare('SELECT * FROM subscriber_profiles WHERE peer_id=?'); + $profile->execute([$id]); + $item['profile'] = $profile->fetch() ?: []; + $preferences = $this->db->pdo()->prepare('SELECT * FROM user_preferences WHERE user_id=?'); + $preferences->execute([$id]); + $item['preferences'] = $preferences->fetch() ?: null; + $group = $this->db->pdo()->prepare('SELECT s.*,g.title,g.active FROM group_settings s JOIN groups g USING(group_id) WHERE group_id=?'); + $group->execute([$id]); + $item['group'] = $group->fetch() ?: null; + $admins = $this->db->pdo()->prepare('SELECT a.*,g.title,g.active FROM subscriber_group_admins a JOIN groups g USING(group_id) WHERE a.user_id=? ORDER BY a.is_admin DESC,a.checked_at DESC,a.group_id LIMIT 51'); + $admins->execute([$id]); + $item['group_roles'] = $admins->fetchAll(); + $item['group_roles_truncated'] = count($item['group_roles']) > 50; + $item['group_roles'] = array_slice($item['group_roles'], 0, 50); + $item['known_group_admin'] = count(array_filter($item['group_roles'], static fn(array $row): bool => (int) $row['is_admin'] === 1)) > 0; + return $item; + } + + /** Explicit local permission for the reference's authorized chat audiences; default denies. */ + public function setBroadcastAuthorized(string $id, bool $authorized): void + { + $this->db->transaction(fn(PDO $pdo) => $this->setBroadcastAuthorizedInTransaction($pdo, $id, $authorized)); + } + + /** Use only inside this database's already-open, freshly authorized confirmation transaction. */ + public function setBroadcastAuthorizedInTransaction(PDO $pdo, string $id, bool $authorized): void + { + if ($pdo !== $this->db->pdo()) { + throw new \LogicException('Broadcast authorization requires the same database transaction.'); + } + self::id($id); + $item = $this->detail($id); + if ($item === null || !in_array($item['type'], ['chat', 'supergroup', 'group', 'channel'], true)) { + throw new \InvalidArgumentException('Only a known group or channel can be explicitly authorized.'); + } + $now = $this->clock->now()->getTimestamp(); + $pdo->prepare("INSERT OR IGNORE INTO known_broadcast_peers(peer_id,peer_type,title,created_at,updated_at) VALUES(?,'unknown',?,?,?)") + ->execute([$id, $item['name'], $now, $now]); + $pdo->prepare('INSERT OR IGNORE INTO subscriber_profiles(peer_id,metadata_at) VALUES(?,?)')->execute([$id, $now]); + $pdo->prepare('UPDATE subscriber_profiles SET broadcast_authorized=? WHERE peer_id=?')->execute([(int) $authorized, $id]); + } + + public function targets(string $audience): array + { + if (!in_array($audience, self::AUDIENCES, true)) { + throw new \InvalidArgumentException('Unknown broadcast audience.'); + } + $where = match ($audience) { + 'users' => "type='user' AND private_chat_access=1", + 'groups' => "type IN ('chat','supergroup','group')", + 'channels' => "type='channel'", + 'groups_channels' => "type IN ('chat','supergroup','group','channel')", + 'authorized_channels' => "type='channel' AND broadcast_authorized=1", + 'authorized_groups' => "type IN ('chat','supergroup','group') AND broadcast_authorized=1", + 'authorized_groups_channels' => "type IN ('chat','supergroup','group','channel') AND broadcast_authorized=1", + default => "(type IN ('user','bot') AND private_chat_access=1) OR type IN ('chat','supergroup','group','channel')", + }; + return $this->db->pdo()->query(self::SOURCE . 'SELECT id FROM directory WHERE ' . $where . ' ORDER BY id COLLATE BINARY')->fetchAll(PDO::FETCH_COLUMN); + } + + /** Complete role observations are paged independently of the bounded profile summary. */ + public function roles(string $user, int $page = 0, int $size = 10): array + { + self::id($user); + if ($page < 0 || $size < 1 || $size > 20) { + throw new \InvalidArgumentException('Invalid role page.'); + } + $count = $this->db->pdo()->prepare('SELECT COUNT(*) FROM subscriber_group_admins WHERE user_id=?'); + $count->execute([$user]); + $total = (int) $count->fetchColumn(); + $pages = max(1, (int) ceil($total / $size)); + $page = min($page, $pages - 1); + $query = $this->db->pdo()->prepare('SELECT a.*,g.title,g.active FROM subscriber_group_admins a JOIN groups g USING(group_id) + WHERE a.user_id=? ORDER BY a.is_admin DESC,a.checked_at DESC,a.group_id LIMIT ? OFFSET ?'); + $query->bindValue(1, $user); + $query->bindValue(2, $size, PDO::PARAM_INT); + $query->bindValue(3, $page * $size, PDO::PARAM_INT); + $query->execute(); + return ['items' => $query->fetchAll(), 'total' => $total, 'page' => $page, 'pages' => $pages]; + } + + public function stats(): array + { + $result = array_fill_keys(['users', 'bots', 'chats', 'supergroups', 'groups', 'channels', 'unknown', 'total', 'active_24h', 'active_7d', 'active_30d', 'starts', 'activity', 'registered_groups', 'active_groups', 'locking', 'locked', 'recovery', 'pending_jobs', 'failed_jobs'], 0); + foreach ($this->db->pdo()->query(self::SOURCE . 'SELECT type,COUNT(*) AS amount FROM directory GROUP BY type')->fetchAll() as $row) { + $key = match ($row['type']) { + 'user' => 'users', 'bot' => 'bots', 'chat' => 'chats', 'supergroup' => 'supergroups', 'group' => 'groups', 'channel' => 'channels', default => 'unknown' + }; + $result[$key] = (int) $row['amount']; + $result['total'] += (int) $row['amount']; + } + foreach (['active_24h' => 86400, 'active_7d' => 604800, 'active_30d' => 2592000] as $key => $seconds) { + $query = $this->db->pdo()->prepare('SELECT COUNT(*) FROM subscriber_profiles WHERE last_activity_at>=?'); + $query->execute([$this->clock->now()->getTimestamp() - $seconds]); + $result[$key] = (int) $query->fetchColumn(); + } + $totals = $this->db->pdo()->query('SELECT COALESCE(SUM(start_count),0) AS starts,COALESCE(SUM(activity_count),0) AS activity FROM subscriber_profiles')->fetch(); + $result['starts'] = (int) $totals['starts']; + $result['activity'] = (int) $totals['activity']; + foreach ( + [ + 'registered_groups' => 'SELECT COUNT(*) FROM group_settings', + 'active_groups' => 'SELECT COUNT(*) FROM groups WHERE active=1', + 'locking' => "SELECT COUNT(*) FROM groups g JOIN group_settings s USING(group_id) WHERE g.active=1 AND s.mode IN ('lock','lock_messages')", + 'locked' => "SELECT COUNT(*) FROM group_lock_state WHERE status='locked'", + 'recovery' => "SELECT COUNT(*) FROM group_lock_state WHERE status IN ('recovery_required','conflict','error')", + 'pending_jobs' => "SELECT COUNT(*) FROM scheduled_jobs WHERE status IN ('pending','leased')", + 'failed_jobs' => "SELECT COUNT(*) FROM scheduled_jobs WHERE status='failed'", + ] as $key => $sql + ) { + $result[$key] = (int) $this->db->pdo()->query($sql)->fetchColumn(); + } + return $result; + } + + public function view(string $viewer): array + { + self::id($viewer); + $query = $this->db->pdo()->prepare('SELECT search,sort,filter,page,prompt FROM subscriber_directory_views WHERE viewer_id=?'); + $query->execute([$viewer]); + $row = $query->fetch(); + return $row === false ? ['search' => '', 'sort' => 'activity', 'filter' => 'all', 'page' => 0, 'prompt' => null] : $row; + } + + public function saveView(string $viewer, array $changes): array + { + $view = array_replace($this->view($viewer), $changes); + if ( + array_diff(array_keys($changes), ['search', 'sort', 'filter', 'page', 'prompt']) !== [] || + !is_string($view['search']) || !in_array($view['sort'], self::SORTS, true) || !in_array($view['filter'], self::FILTERS, true) || + !is_int($view['page']) || $view['page'] < 0 || $view['page'] > 999999 || !in_array($view['prompt'], [null, 'search', 'page'], true) + ) { + throw new \InvalidArgumentException('Invalid directory view.'); + } + self::text($view['search'], 128); + $this->db->pdo()->prepare('INSERT INTO subscriber_directory_views(viewer_id,search,sort,filter,page,prompt,updated_at) VALUES(?,?,?,?,?,?,?) + ON CONFLICT(viewer_id) DO UPDATE SET search=excluded.search,sort=excluded.sort,filter=excluded.filter,page=excluded.page,prompt=excluded.prompt,updated_at=excluded.updated_at') + ->execute([$viewer, $view['search'], $view['sort'], $view['filter'], $view['page'], $view['prompt'], $this->clock->now()->getTimestamp()]); + return $view; + } + + private function where(string $filter, string $search): array + { + $type = match ($filter) { + 'users' => "type='user'", 'bots' => "type='bot'", 'groups' => "type IN ('chat','supergroup','group')", 'channels' => "type='channel'", default => '1=1' + }; + if ($search === '') { + return [$type, []]; + } + $needle = '%' . strtr(ltrim($search, '@'), ['\\' => '\\\\', '%' => '\\%', '_' => '\\_']) . '%'; + return ["(" . $type . ") AND (name LIKE ? ESCAPE '\\' OR id LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\')", [$needle, $needle, $needle]]; + } + + private static function id(string $id): void + { + if (preg_match('/^-?[1-9][0-9]{0,19}$/D', $id) !== 1) { + throw new \InvalidArgumentException('Invalid directory identifier.'); + } + } + + private static function text(mixed $value, int $max): void + { + if (!is_string($value) || !mb_check_encoding($value, 'UTF-8') || mb_strlen($value, 'UTF-8') > $max || preg_match('/[\\x00-\\x1f\\x7f]/u', $value)) { + throw new \InvalidArgumentException('Invalid directory text.'); + } + } +} diff --git a/src/Storage/TemplateRepository.php b/src/Storage/TemplateRepository.php new file mode 100644 index 0000000..55b5db2 --- /dev/null +++ b/src/Storage/TemplateRepository.php @@ -0,0 +1,113 @@ +db->pdo()->prepare('SELECT * FROM message_templates WHERE group_id=? AND kind=?'); + $query->execute([$group,$kind]); + $row = $query->fetch(); + return $row === false ? null : self::decode($row); + } + public function save(string $group, string $kind, array $content, string $actor, ?int $expectedRevision = null): array + { + return $this->db->transaction(fn(PDO $pdo): array=>$this->saveInTransaction($pdo, $group, $kind, $content, $actor, $expectedRevision)); + } + public function saveInTransaction(PDO $pdo, string $group, string $kind, array $content, string $actor, ?int $expectedRevision = null): array + { + if ($pdo !== $this->db->pdo()) { + throw new \LogicException('Template mutation must use its repository database.'); + } + if (!in_array($kind, ['open','close','times_alert','zmanim','daf','tefillin','omer'], true)) { + throw new \InvalidArgumentException('Unsupported template kind.'); + } + $text = $content['text'] ?? ''; + if (!is_string($text) || mb_strlen($text) > 4096) { + throw new \InvalidArgumentException('Template text exceeds Telegram limits.'); + } + foreach (['entities','buttons'] as $field) { + if (isset($content[$field]) && !is_array($content[$field])) { + throw new \InvalidArgumentException('Invalid template metadata.'); + } + } + if (isset($content['media']) && !is_array($content['media'])) { + throw new \InvalidArgumentException('Media must be a validated metadata object.'); + } + $mutation = function (PDO $pdo) use ($group, $kind, $content, $actor, $expectedRevision, $text): void { + $now = $this->clock->now()->getTimestamp(); + $current = $this->get($group, $kind); + $mode = $content['buttons_mode'] ?? ((!empty($content['buttons'])) ? 'custom' : ($current['buttons_mode'] ?? 'inherit')); + if (!in_array($mode, ['inherit', 'custom', 'none'], true) || ($mode !== 'custom' && !empty($content['buttons']))) { + throw new \InvalidArgumentException('Invalid template button inheritance mode.'); + } + if ($current === null) { + if ($expectedRevision !== null && $expectedRevision !== 0) { + throw new StaleSettings('Template changed.'); + } + $pdo->prepare('INSERT INTO message_templates(group_id,kind,text,entities_json,media_json,buttons_json,updated_at,buttons_mode) VALUES(?,?,?,?,?,?,?,?)') + ->execute([$group,$kind,$text,Database::encode($content['entities'] ?? []),isset($content['media']) ? Database::encode($content['media']) : null,Database::encode($content['buttons'] ?? []),$now,$mode]); + } else { + if ($expectedRevision !== null && $expectedRevision !== $current['revision']) { + throw new StaleSettings('Template changed.'); + } + $query = $pdo->prepare('UPDATE message_templates SET text=?,entities_json=?,media_json=?,buttons_json=?,buttons_mode=?,revision=revision+1,updated_at=? WHERE group_id=? AND kind=? AND revision=?'); + $query->execute([$text,Database::encode($content['entities'] ?? []),isset($content['media']) ? Database::encode($content['media']) : null,Database::encode($content['buttons'] ?? []),$mode,$now,$group,$kind,$current['revision']]); + if ($query->rowCount() !== 1) { + throw new StaleSettings('Template changed.'); + } + } + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,?,?,?,?)')->execute([$actor,$group,'template.update',Database::encode(['kind' => $kind]),$now]); + }; + $mutation($pdo); + return $this->get($group, $kind) ?? throw new \LogicException('Template save failed.'); + } + public static function decode(array $row): array + { + $row['entities'] = json_decode($row['entities_json'], true, 64, JSON_THROW_ON_ERROR); + $row['buttons'] = self::buttonRows(json_decode($row['buttons_json'], true, 64, JSON_THROW_ON_ERROR)); + $row['media'] = $row['media_json'] === null ? null : json_decode($row['media_json'], true, 64, JSON_THROW_ON_ERROR); + $row['buttons_mode'] ??= $row['buttons'] === [] ? 'inherit' : 'custom'; + $row['revision'] = (int)$row['revision']; + unset($row['entities_json'], $row['buttons_json'], $row['media_json']); + return $row; + } + + /** Normalize only recognized full keyboard wrappers; every button field remains intact. */ + public static function buttonRows(array $buttons): array + { + if (array_is_list($buttons)) { + return $buttons; + } + if (array_key_exists('inline_keyboard', $buttons)) { + if (!is_array($buttons['inline_keyboard']) || !array_is_list($buttons['inline_keyboard'])) { + throw new \InvalidArgumentException('Malformed legacy inline keyboard wrapper.'); + } + return $buttons['inline_keyboard']; + } + if (($buttons['_'] ?? '') === 'replyInlineMarkup') { + if (!is_array($buttons['rows'] ?? null) || !array_is_list($buttons['rows'])) { + throw new \InvalidArgumentException('Malformed legacy native keyboard wrapper.'); + } + $rows = []; + foreach ($buttons['rows'] as $row) { + if (!is_array($row) || !is_array($row['buttons'] ?? null) || !array_is_list($row['buttons'])) { + throw new \InvalidArgumentException('Malformed legacy native keyboard row.'); + } + $rows[] = $row['buttons']; + } + return $rows; + } + return $buttons; + } +} diff --git a/src/Storage/TrackingLinkRepository.php b/src/Storage/TrackingLinkRepository.php new file mode 100644 index 0000000..a6cccb1 --- /dev/null +++ b/src/Storage/TrackingLinkRepository.php @@ -0,0 +1,140 @@ + 80 || preg_match('/[\x00-\x1f]/u', $label)) { + throw new \InvalidArgumentException('Invalid tracking label or public destination.'); + } + $id = $this->db->transaction(function (PDO $pdo) use ($actor, $param, $label, $destination): int { + $this->access->require($actor, 'tracking_links', true); + if ((int) $pdo->query('SELECT COUNT(*) FROM tracking_links')->fetchColumn() >= 10000) { + throw new \RuntimeException('Tracking link capacity reached.'); + } + $pdo->prepare('INSERT INTO tracking_links(param,label,destination,created_by,created_at) VALUES(?,?,?,?,?)')->execute([$param, $label === '' ? $param : $label, $destination, $actor, $this->clock->now()->getTimestamp()]); + return (int) $pdo->lastInsertId(); + }); + return $this->get($actor, $id) ?? throw new \RuntimeException('Tracking link save failed.'); + } + public function page(string $actor, int $page = 0): array + { + $this->access->require($actor, 'tracking_links'); + $total = (int) $this->db->pdo()->query('SELECT COUNT(*) FROM tracking_links')->fetchColumn(); + $pages = max(1, (int) ceil($total / 5)); + $page = max(0, min($page, $pages - 1)); + $items = $this->db->pdo()->query('SELECT * FROM tracking_links ORDER BY id DESC LIMIT 5 OFFSET ' . ($page * 5))->fetchAll(); + return compact('items', 'total', 'page', 'pages'); + } + public function get(string $actor, int $id): ?array + { + $this->access->require($actor, 'tracking_links'); + $q = $this->db->pdo()->prepare('SELECT * FROM tracking_links WHERE id=?'); + $q->execute([$id]); + $row = $q->fetch(); + if ($row === false) { + return null; + } + $q = $this->db->pdo()->prepare('SELECT day,clicks FROM tracking_link_days WHERE tracker_id=? AND day>=? ORDER BY day DESC'); + $q->execute([$id, $this->clock->now()->setTimezone(new \DateTimeZone('UTC'))->modify('-6 days')->format('Y-m-d')]); + $row['days'] = $q->fetchAll(); + $row['repeat_clicks'] = max(0, (int) $row['total_clicks'] - (int) $row['unique_users']); + return $row; + } + /** Only a private /start with a positive Telegram message ID counts; redelivery is deduplicated. */ + public function recordStart(string $param, string $user, int $messageId, bool $private = true): ?string + { + if (!$private || !preg_match('/^[1-9][0-9]{0,18}$/D', $user) || $messageId < 1) { + return null; + } + try { + $param = self::normalize($param); + } catch (\InvalidArgumentException) { + return null; + } + $q = $this->db->pdo()->prepare('SELECT id FROM tracking_links WHERE param=?'); + $q->execute([$param]); + if ($q->fetchColumn() === false) { + return null; + } + return $this->db->transaction(function (PDO $pdo) use ($param, $user, $messageId): ?string { + $q = $pdo->prepare('SELECT * FROM tracking_links WHERE param=?'); + $q->execute([$param]); + $tracker = $q->fetch(); + if ($tracker === false) { + return null; + } + $salt = $pdo->query("SELECT meta_value FROM admin_meta WHERE meta_key='tracking_salt'")->fetchColumn(); + if ($salt === false) { + $salt = bin2hex(random_bytes(32)); + $pdo->prepare("INSERT INTO admin_meta VALUES('tracking_salt',?)")->execute([$salt]); + } + if (!is_string($salt) || !preg_match('/^[a-f0-9]{64}$/D', $salt)) { + throw new \RuntimeException('Tracking privacy key is invalid.'); + } + $hash = hash_hmac('sha256', $user, $salt); + $q = $pdo->prepare('SELECT last_message_id FROM tracking_link_users WHERE tracker_id=? AND user_hash=?'); + $q->execute([$tracker['id'], $hash]); + $last = $q->fetchColumn(); + if ($last !== false && (int) $last >= $messageId) { + return (string) $tracker['destination']; + } + $now = $this->clock->now()->getTimestamp(); + $pdo->prepare('INSERT INTO tracking_link_users VALUES(?,?,?) ON CONFLICT(tracker_id,user_hash) DO UPDATE SET last_message_id=excluded.last_message_id')->execute([$tracker['id'], $hash, $messageId]); + $pdo->prepare('UPDATE tracking_links SET total_clicks=total_clicks+1,unique_users=unique_users+?,first_click_at=COALESCE(first_click_at,?),last_click_at=? WHERE id=?')->execute([$last === false ? 1 : 0, $now, $now, $tracker['id']]); + $day = $this->clock->now()->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d'); + $pdo->prepare('INSERT INTO tracking_link_days VALUES(?,?,1) ON CONFLICT(tracker_id,day) DO UPDATE SET clicks=clicks+1')->execute([$tracker['id'], $day]); + $pdo->prepare('DELETE FROM tracking_link_days WHERE tracker_id=? AND dayexecute([$tracker['id'], $this->clock->now()->setTimezone(new \DateTimeZone('UTC'))->modify('-90 days')->format('Y-m-d')]); + return (string) $tracker['destination']; + }); + } + public function reset(string $actor, int $id, int $revision): void + { + $this->mutate($actor, $id, $revision, false); + } + public function delete(string $actor, int $id, int $revision): void + { + $this->mutate($actor, $id, $revision, true); + } + private function mutate(string $actor, int $id, int $revision, bool $delete): void + { + $this->db->transaction(function (PDO $pdo) use ($actor, $id, $revision, $delete): void { + $this->access->require($actor, 'tracking_links', true); + $q = $pdo->prepare('SELECT revision FROM tracking_links WHERE id=?'); + $q->execute([$id]); + if ((int) $q->fetchColumn() !== $revision || $revision < 1) { + throw new StaleSettings('Tracking link changed; reopen the details.'); + } + if ($delete) { + $pdo->prepare('DELETE FROM tracking_links WHERE id=?')->execute([$id]); + } else { + $pdo->prepare('DELETE FROM tracking_link_users WHERE tracker_id=?')->execute([$id]); + $pdo->prepare('DELETE FROM tracking_link_days WHERE tracker_id=?')->execute([$id]); + $pdo->prepare('UPDATE tracking_links SET revision=revision+1,total_clicks=0,unique_users=0,first_click_at=NULL,last_click_at=NULL WHERE id=?')->execute([$id]); + } + $pdo->prepare('INSERT INTO audit_log(actor_id,group_id,action,details_json,created_at) VALUES(?,NULL,?,?,?)')->execute([$actor, 'operator.tracking.' . ($delete ? 'delete' : 'reset'), Database::encode(['id' => $id]), $this->clock->now()->getTimestamp()]); + }); + } +} diff --git a/src/Telegram/AdminRights.php b/src/Telegram/AdminRights.php new file mode 100644 index 0000000..d271180 --- /dev/null +++ b/src/Telegram/AdminRights.php @@ -0,0 +1,39 @@ + $value) { + if ($flag === '_') { + continue; + } + if (!in_array($flag, self::FLAGS, true) || !is_bool($value)) { + throw new \RuntimeException('Unknown or malformed administrator-rights flag.'); + } + $result[$flag] = $value; + } + return $result; + } +} diff --git a/src/Telegram/GuardedTelegramGateway.php b/src/Telegram/GuardedTelegramGateway.php new file mode 100644 index 0000000..6d90218 --- /dev/null +++ b/src/Telegram/GuardedTelegramGateway.php @@ -0,0 +1,17 @@ + $messageIds Positive native int32 IDs, at most 100 per call. */ + public function deleteMessages(string $peer, array $messageIds): void; + /** null means the sender type is unverified and must never be guessed. */ + public function senderIsBot(string $group, string $senderId): ?bool; + public function botCapabilities(string $group): array; + public function botCanDeleteMessages(string $group): bool; + public function selfUserId(): string; + /** @return list Verified sent IDs; unknown response shapes return an empty list. */ + public function extractSentMessageIds(array $response, ?string $expectedRandomId = null, ?string $expectedPeer = null): array; +} diff --git a/src/Telegram/RetryableTelegramException.php b/src/Telegram/RetryableTelegramException.php new file mode 100644 index 0000000..adfeee3 --- /dev/null +++ b/src/Telegram/RetryableTelegramException.php @@ -0,0 +1,14 @@ + $response['updates'] ?? [], + 'updateShort' => [$response['update'] ?? []], + default => [], + }; + if (!is_array($updates)) { + return []; + } + $mapped = []; + $outgoing = []; + $hasMapping = false; + foreach ($updates as $update) { + if (!is_array($update)) { + continue; + } + if (($update['_'] ?? '') === 'updateMessageID') { + $hasMapping = true; + $id = self::id($update['id'] ?? null); + if ($id !== null && self::randomMatches($update['random_id'] ?? null, $randomId)) { + $mapped[$id] = $id; + } + continue; + } + if (!in_array($update['_'] ?? '', ['updateNewMessage', 'updateNewChannelMessage'], true)) { + continue; + } + $message = $update['message'] ?? null; + if ( + !is_array($message) || ($message['_'] ?? '') !== 'message' || ($message['out'] ?? false) !== true + || ($peer !== null && self::peer($message['peer_id'] ?? null) !== $peer) + ) { + continue; + } + $id = self::id($message['id'] ?? null); + if ($id !== null) { + $outgoing[$id] = $id; + } + } + if ($mapped !== [] || ($expectedRandomId !== null && $hasMapping)) { + return array_values($mapped); + } + return array_values($outgoing); + } + + private static function randomMatches(mixed $value, ?int $expected): bool + { + if (is_int($value)) { + return $expected === null || $value === $expected; + } + if (!is_string($value) || strlen($value) !== 8) { + return false; + } + return $expected === null || hash_equals(pack('P', $expected), $value); + } + + private static function peer(mixed $value): ?int + { + if (is_int($value)) { + return $value === 0 ? null : $value; + } + if (!is_array($value)) { + return null; + } + $key = match ($value['_'] ?? '') { + 'peerUser' => 'user_id', + 'peerChat' => 'chat_id', + 'peerChannel' => 'channel_id', + default => null, + }; + $id = $key === null ? null : ($value[$key] ?? null); + if (!is_int($id) || $id <= 0) { + return null; + } + try { + return match ($key) { + 'user_id' => $id, + 'chat_id' => DialogId::fromChatId($id), + 'channel_id' => DialogId::fromSupergroupOrChannelId($id), + default => null, + }; + } catch (\AssertionError) { + return null; + } + } + + private static function id(mixed $value): ?int + { + return is_int($value) && $value > 0 && $value <= 2147483647 ? $value : null; + } +} diff --git a/src/Telegram/TelegramGateway.php b/src/Telegram/TelegramGateway.php new file mode 100644 index 0000000..bcd51d0 --- /dev/null +++ b/src/Telegram/TelegramGateway.php @@ -0,0 +1,16 @@ +instant->setTimezone(new \DateTimeZone('UTC')); + } + public function set(\DateTimeImmutable $instant): void + { + $this->instant = $instant; + } +} diff --git a/src/Time/LocalTime.php b/src/Time/LocalTime.php new file mode 100644 index 0000000..29f9656 --- /dev/null +++ b/src/Time/LocalTime.php @@ -0,0 +1,35 @@ +format('Y-m-d H:i') !== "$date $time") { + throw new \InvalidArgumentException('Invalid local date.'); + } + $stamp = $wall->getTimestamp(); + $offsets = [$zone->getOffset($wall)]; + foreach ($zone->getTransitions($stamp - 172800, $stamp + 172800) ?: [] as $transition) { + $offsets[] = $transition['offset']; + } + $matches = []; + foreach (array_unique($offsets) as $offset) { + $candidate = $stamp - $offset; + if ((new \DateTimeImmutable('@' . $candidate))->setTimezone($zone)->format('Y-m-d H:i') === "$date $time") { + $matches[] = $candidate; + } + } + return $matches === [] ? null : min($matches); + } +} diff --git a/src/Time/SystemClock.php b/src/Time/SystemClock.php new file mode 100644 index 0000000..c3d9a95 --- /dev/null +++ b/src/Time/SystemClock.php @@ -0,0 +1,14 @@ +guard($user, $private); + if (preg_match('/^adm:list:([0-9]{1,6})$/D', $data, $m)) { + $this->clear($user); + return $this->managers($user, (int) $m[1]); + } + if ($data === 'adm:add') { + $this->access->require($user, 'add_admins', true); + $this->flow($user, 'add_input', []); + return $this->frame($this->t($user, 'prompt_admin'), [[$this->b($user, 'cancel', $this->access->canView($user, 'manage_admins') ? 'adm:list:0' : 'ื—ื–ืจื”ืžื ื”ืœ', 'bg_danger')]]); + } + if (preg_match('/^adm:(view|edit|remove):([1-9][0-9]{0,18})$/D', $data, $m)) { + $target = $m[2]; + $this->access->require($user, 'manage_admins', $m[1] !== 'view'); + $p = $this->access->profile($target) ?? throw new StaleSettings('Administrator no longer exists.'); + if ($m[1] === 'view') { + $this->clear($user); + return $this->profile($user, $target); + } + if ($this->access->isOwner($target) || $target === $user) { + throw new \RuntimeException('Recovery administrators and your own access cannot be changed.'); + } + $nonce = $this->flow($user, $m[1] === 'edit' ? 'edit' : 'remove', ['id' => $target, 'role' => $p['role'], 'view_only' => $p['view_only'], 'permissions' => $p['permissions'], 'revision' => $p['revision']]); + return $m[1] === 'edit' ? $this->editor($user, $nonce) : $this->frame($this->t($user, 'confirm_remove') . "\n" . $target . '', [[$this->b($user, 'confirm', 'adm:save:' . $nonce, 'bg_danger')], [$this->b($user, 'cancel', 'adm:view:' . $target)]]); + } + if (preg_match('/^adm:(role|viewonly|perm|save):([a-f0-9]{16})(?::([a-z0-9_]+))?$/D', $data, $m)) { + $flow = $this->getFlow($user, $m[2]); + if (!in_array($flow['kind'], ['add', 'edit', 'remove'], true)) { + throw new StaleSettings('Invalid administrator editor.'); + } + $this->access->require($user, $flow['kind'] === 'add' ? 'add_admins' : 'manage_admins', true); + $p = $flow['payload']; + if ($m[1] === 'save') { + $this->clear($user); + if ($flow['kind'] === 'remove') { + $this->access->remove($user, $p['id'], $p['revision']); + return $this->managers($user); + } + if ($flow['kind'] === 'add') { + $this->access->add($user, $p['id'], $p['role'], $p['view_only'], $p['permissions']); + } else { + $this->access->update($user, $p['id'], $p['role'], $p['view_only'], $p['permissions'], $p['revision']); + } + return $this->access->canView($user, 'manage_admins') ? $this->profile($user, $p['id']) : $this->frame('' . $this->t($user, 'add') . '' . $p['id'] . '', [[$this->b($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]]); + } + if ($flow['kind'] === 'remove') { + throw new StaleSettings('Invalid removal confirmation.'); + } + if ($m[1] === 'role') { + if (!in_array($m[3] ?? '', ['full', 'limited'], true) || ($m[3] === 'full' && $this->access->profile($user)['role'] !== 'full')) { + throw new \RuntimeException('Invalid delegated administrator role.'); + } + $p['role'] = $m[3]; + $p['view_only'] = $m[3] === 'limited'; + } elseif ($m[1] === 'viewonly') { + if ($p['role'] !== 'limited') { + throw new \RuntimeException('View-only applies to selected permissions.'); + } + $p['view_only'] = !$p['view_only']; + } else { + $permission = $m[3] ?? ''; + if ($p['role'] !== 'limited' || !$this->access->canView($user, $permission)) { + throw new \RuntimeException('Cannot delegate unavailable permission.'); + } + $p['permissions'][$permission] = !($p['permissions'][$permission] ?? false); + } + $this->updateFlow($user, $m[2], $p); + return $this->editor($user, $m[2]); + } + if (preg_match('/^sys:list:([0-9]{1,6})$/D', $data, $m)) { + $this->clear($user); + return $this->systemLinks($user, (int) $m[1]); + } + if (preg_match('/^trk:list:([0-9]{1,6})$/D', $data, $m)) { + $this->clear($user); + return $this->trackers($user, (int) $m[1]); + } + if ($data === 'trk:add') { + $this->access->require($user, 'tracking_links', true); + $this->flow($user, 'tracking_input', []); + return $this->frame($this->t($user, 'prompt_tracking'), [[$this->b($user, 'cancel', 'trk:list:0', 'bg_danger')]]); + } + if (preg_match('/^trk:(view|reset|delete):([1-9][0-9]{0,9})$/D', $data, $m)) { + $this->access->require($user, 'tracking_links', $m[1] !== 'view'); + $p = $this->tracking->get($user, (int) $m[2]) ?? throw new StaleSettings('Tracking link no longer exists.'); + if ($m[1] === 'view') { + $this->clear($user); + return $this->tracker($user, (int) $m[2]); + } + $nonce = $this->flow($user, 'tracking_' . $m[1], ['id' => (int) $m[2], 'revision' => (int) $p['revision']]); + return $this->frame($this->t($user, 'confirm_tracking') . "\n" . self::h($p['param']) . '', [[$this->b($user, 'confirm', 'trk:save:' . $nonce, 'bg_danger')], [$this->b($user, 'cancel', 'trk:view:' . $m[2])]]); + } + if (preg_match('/^trk:(destination|save):([a-f0-9]{16})(?::([a-z_]+))?$/D', $data, $m)) { + $this->access->require($user, 'tracking_links', true); + $flow = $this->getFlow($user, $m[2]); + $p = $flow['payload']; + if ($m[1] === 'destination') { + $dest = $m[3] ?? ''; + if ($flow['kind'] !== 'tracking_add' || !isset(SystemLinks::DEFINITIONS[$dest]) || isset(SystemLinks::DEFINITIONS[$dest]['permission'])) { + throw new \InvalidArgumentException('Invalid tracking destination.'); + } + $p['destination'] = $dest; + $this->updateFlow($user, $m[2], $p); + return $this->trackingEditor($user, $m[2]); + } + if (!in_array($flow['kind'], ['tracking_add', 'tracking_reset', 'tracking_delete'], true)) { + throw new StaleSettings('Invalid tracking confirmation.'); + } + $this->clear($user); + if ($flow['kind'] === 'tracking_add') { + $record = $this->tracking->create($user, $p['param'], $p['label'], $p['destination']); + return $this->tracker($user, (int) $record['id']); + } + if ($flow['kind'] === 'tracking_reset') { + $this->tracking->reset($user, $p['id'], $p['revision']); + return $this->tracker($user, $p['id']); + } + $this->tracking->delete($user, $p['id'], $p['revision']); + return $this->trackers($user); + } + throw new \InvalidArgumentException('Unknown administrator callback.'); + } + public function capture(string $user, string $text, ?string $forwardedUserId = null, bool $private = true): ?array + { + if (!$private || str_starts_with(ltrim($text), '/')) { + return null; + } + $flow = $this->current($user); + if ($flow === null || !in_array($flow['kind'], ['add_input', 'tracking_input'], true)) { + return null; + } + $this->guard($user, $private); + if ($flow['kind'] === 'add_input') { + $this->access->require($user, 'add_admins', true); + $id = $forwardedUserId ?? trim($text); + if (!preg_match('/^[1-9][0-9]{0,18}$/D', $id) || $id === $user || $id === $this->botId || $this->access->profile($id) !== null) { + throw new \InvalidArgumentException('Send an eligible numeric Telegram user ID.'); + } + $nonce = $this->flow($user, 'add', ['id' => $id, 'role' => 'limited', 'view_only' => true, 'permissions' => array_fill_keys(AdminAccessRepository::PERMISSIONS, false)]); + return $this->editor($user, $nonce); + } + $this->access->require($user, 'tracking_links', true); + $parts = preg_split('/\s+/u', trim($text), 2) ?: []; + $param = TrackingLinkRepository::normalize($parts[0] ?? ''); + $label = $parts[1] ?? $param; + if (mb_strlen($label) > 80 || preg_match('/[\x00-\x1f]/u', $label)) { + throw new \InvalidArgumentException('Tracking labels must be one line up to 80 characters.'); + } + $nonce = $this->flow($user, 'tracking_add', ['param' => $param, 'label' => $label, 'destination' => 'home']); + return $this->trackingEditor($user, $nonce); + } + public function cancel(string $user): void + { + $this->clear($user); + } + public function managers(string $user, int $page = 0): array + { + $this->access->require($user, 'manage_admins'); + $ids = $this->access->adminIds(); + $pages = max(1, (int) ceil(count($ids) / 8)); + $page = max(0, min($page, $pages - 1)); + $rows = []; + foreach (array_slice($ids, $page * 8, 8) as $id) { + $p = $this->access->profile((string) $id); + $rows[] = [['text' => ($this->access->isOwner((string) $id) ? '๐Ÿ”’ ' : '๐Ÿ‘ค ') . $id . ' - ' . $this->t($user, $p['role']), 'data' => 'adm:view:' . $id]]; + } + $rows = array_merge($rows, $this->navigation($user, 'adm:list:', $page, $pages)); + if ($this->access->canMutate($user, 'add_admins')) { + $rows[] = [$this->b($user, 'add', 'adm:add', 'bg_primary')]; + } + $rows[] = [$this->b($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame('' . $this->t($user, 'managers') . ' ยท ' . count($ids), $rows); + } + public function profile(string $user, string $target): array + { + $this->access->require($user, 'manage_admins'); + $p = $this->access->profile($target) ?? throw new StaleSettings('Administrator no longer exists.'); + $text = '' . $this->t($user, 'managers') . '\n' . self::h($target) . '\n' . $this->t($user, $p['role']) . "\n" . $this->t($user, 'view_only') . ': ' . ($p['view_only'] ? 'โœ…' : 'โŒ'); + if ($this->access->isOwner($target)) { + $text .= "\n๐Ÿ”’ " . $this->t($user, 'immutable'); + } + foreach (AdminAccessRepository::PERMISSIONS as $key) { + $text .= "\n" . (($p['role'] === 'full' || ($p['permissions'][$key] ?? false)) ? 'โœ… ' : 'โ–ซ๏ธ ') . $this->t($user, $key); + } + $rows = []; + if ($target !== $user && !$this->access->isOwner($target) && $this->access->canMutate($user, 'manage_admins')) { + $rows[] = [$this->b($user, 'edit', 'adm:edit:' . $target, 'bg_primary'), $this->b($user, 'remove', 'adm:remove:' . $target, 'bg_danger')]; + } + $rows[] = [$this->b($user, 'back', 'adm:list:0')]; + return $this->frame(str_replace('\\n', "\n", $text), $rows); + } + public function systemLinks(string $user, int $page = 0): array + { + $this->access->require($user, 'system_links'); + $items = array_filter(SystemLinks::DEFINITIONS, fn(array $d): bool => !isset($d['permission']) || ($d['permission'] === 'panel' ? $this->access->isAdmin($user) : $this->access->canView($user, $d['permission']))); + $pages = max(1, (int) ceil(count($items) / 6)); + $page = max(0, min($page, $pages - 1)); + $text = '' . $this->t($user, 'system_links') . "\n\n"; + $rows = []; + foreach (array_slice($items, $page * 6, 6, true) as $param => $item) { + $url = SystemLinks::url($this->username, $param); + $text .= '' . $this->t($user, $item['label']) . "\n" . self::h($url) . "\n\n"; + $rows[] = [['text' => '๐Ÿ“‹ ' . $this->t($user, $item['label']), 'copy' => $url]]; + } + $text .= '' . $this->t($user, 'commands_note') . ''; + $rows = array_merge($rows, $this->navigation($user, 'sys:list:', $page, $pages), [[$this->b($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]]); + return $this->frame($text, $rows); + } + public function trackers(string $user, int $page = 0): array + { + $p = $this->tracking->page($user, $page); + $rows = []; + foreach ($p['items'] as $item) { + $rows[] = [['text' => '๐Ÿ”— ' . mb_substr($item['label'], 0, 36) . ' ยท ' . $item['total_clicks'] . '/' . $item['unique_users'], 'data' => 'trk:view:' . $item['id']]]; + } + $rows = array_merge($rows, $this->navigation($user, 'trk:list:', $p['page'], $p['pages'])); + if ($this->access->canMutate($user, 'tracking_links')) { + $rows[] = [$this->b($user, 'create_tracking', 'trk:add', 'bg_primary')]; + } + $rows[] = [$this->b($user, 'refresh', 'trk:list:' . $p['page'])]; + $rows[] = [$this->b($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame('' . $this->t($user, 'tracking_links') . ' ยท ' . $p['total'] . "\n\n" . $this->t($user, 'analytics_note') . '', $rows); + } + public function tracker(string $user, int $id): array + { + $p = $this->tracking->get($user, $id) ?? throw new StaleSettings('Tracking link no longer exists.'); + $text = '' . self::h($p['label']) . "\n" . SystemLinks::url($this->username, $p['param']) . "\n" . $this->t($user, 'destination') . ': ' . $this->t($user, SystemLinks::DEFINITIONS[$p['destination']]['label']); + foreach (['total' => 'total_clicks', 'unique' => 'unique_users', 'repeat' => 'repeat_clicks'] as $label => $key) { + $text .= "\n" . $this->t($user, $label) . ': ' . number_format((int) $p[$key]) . ''; + } + foreach (['created' => 'created_at', 'first' => 'first_click_at', 'last' => 'last_click_at'] as $label => $key) { + $text .= "\n" . $this->t($user, $label) . ': ' . ($p[$key] === null ? '-' : gmdate('Y-m-d H:i', (int) $p[$key]) . ' UTC') . ''; + } + $text .= "\n\n" . $this->t($user, 'days') . "\n"; + foreach ($p['days'] as $day) { + $text .= '' . self::h($day['day']) . ': ' . number_format((int) $day['clicks']) . "\n"; + } + $text .= "\n" . $this->t($user, 'analytics_note') . ''; + $rows = [[['text' => '๐Ÿ“‹ ' . $p['param'], 'copy' => SystemLinks::url($this->username, $p['param'])]]]; + if ($this->access->canMutate($user, 'tracking_links')) { + $rows[] = [$this->b($user, 'reset', 'trk:reset:' . $id, 'bg_danger'), $this->b($user, 'delete', 'trk:delete:' . $id, 'bg_danger')]; + } + $rows[] = [$this->b($user, 'refresh', 'trk:view:' . $id)]; + $rows[] = [$this->b($user, 'back', 'trk:list:0')]; + return $this->frame($text, $rows); + } + private function editor(string $user, string $nonce): array + { + $f = $this->getFlow($user, $nonce); + $p = $f['payload']; + $text = '' . $this->t($user, $f['kind'] === 'add' ? 'add' : 'edit') . "\n" . $p['id'] . "\n" . $this->t($user, $p['role']) . "\n" . $this->t($user, 'view_only') . ': ' . ($p['view_only'] ? 'โœ…' : 'โŒ'); + $rows = []; + if ($this->access->profile($user)['role'] === 'full') { + $rows[] = [$this->b($user, 'full', 'adm:role:' . $nonce . ':full', $p['role'] === 'full' ? 'bg_primary' : null)]; + } + $rows[] = [$this->b($user, 'limited', 'adm:role:' . $nonce . ':limited', $p['role'] === 'limited' ? 'bg_primary' : null)]; + if ($p['role'] === 'limited') { + $rows[] = [['text' => ($p['view_only'] ? 'โœ… ' : 'โŒ ') . $this->t($user, 'view_only'), 'data' => 'adm:viewonly:' . $nonce]]; + foreach (AdminAccessRepository::PERMISSIONS as $key) { + if ($this->access->canView($user, $key)) { + $rows[] = [['text' => (($p['permissions'][$key] ?? false) ? 'โœ… ' : 'โ–ซ๏ธ ') . $this->t($user, $key), 'data' => 'adm:perm:' . $nonce . ':' . $key]]; + } + } + } + $rows[] = [$this->b($user, 'save', 'adm:save:' . $nonce, 'bg_primary')]; + $rows[] = [$this->b($user, 'cancel', $this->access->canView($user, 'manage_admins') ? 'adm:list:0' : 'ื—ื–ืจื”ืžื ื”ืœ', 'bg_danger')]; + return $this->frame($text, $rows); + } + private function trackingEditor(string $user, string $nonce): array + { + $p = $this->getFlow($user, $nonce)['payload']; + $rows = []; + foreach (SystemLinks::DEFINITIONS as $param => $def) { + if (!isset($def['permission'])) { + $rows[] = [['text' => ($p['destination'] === $param ? 'โœ… ' : '') . $this->t($user, $def['label']), 'data' => 'trk:destination:' . $nonce . ':' . $param]]; + } + } + $rows[] = [$this->b($user, 'save', 'trk:save:' . $nonce, 'bg_primary')]; + $rows[] = [$this->b($user, 'cancel', 'trk:list:0', 'bg_danger')]; + return $this->frame('' . $this->t($user, 'create_tracking') . "\n" . self::h($p['label']) . "\n" . SystemLinks::url($this->username, $p['param']) . "\n\n" . $this->t($user, 'destination'), $rows); + } + private function guard(string $user, bool $private): void + { + if (!$private || !$this->access->isAdmin($user)) { + throw new \RuntimeException('Private bot administrator access is required.'); + } + } + private function flow(string $user, string $kind, array $payload): string + { + $nonce = bin2hex(random_bytes(8)); + $this->db->pdo()->prepare('INSERT INTO admin_panel_flows VALUES(?,?,?,?,?) ON CONFLICT(actor_id) DO UPDATE SET nonce=excluded.nonce,kind=excluded.kind,payload_json=excluded.payload_json,expires_at=excluded.expires_at')->execute([$user, $nonce, $kind, Database::encode($payload), $this->clock->now()->getTimestamp() + 600]); + return $nonce; + } + private function current(string $user): ?array + { + $q = $this->db->pdo()->prepare('SELECT * FROM admin_panel_flows WHERE actor_id=? AND expires_at>?'); + $q->execute([$user, $this->clock->now()->getTimestamp()]); + $f = $q->fetch(); + if ($f === false) { + return null; + } + $f['payload'] = json_decode($f['payload_json'], true, 64, JSON_THROW_ON_ERROR); + return $f; + } + private function getFlow(string $user, string $nonce): array + { + $f = $this->current($user); + if ($f === null || !hash_equals($f['nonce'], $nonce)) { + throw new StaleSettings('Administrator panel expired or was replaced.'); + } + return $f; + } + private function updateFlow(string $user, string $nonce, array $payload): void + { + $this->getFlow($user, $nonce); + $this->db->pdo()->prepare('UPDATE admin_panel_flows SET payload_json=? WHERE actor_id=? AND nonce=?')->execute([Database::encode($payload), $user, $nonce]); + } + private function clear(string $user): void + { + $this->db->pdo()->prepare('DELETE FROM admin_panel_flows WHERE actor_id=?')->execute([$user]); + } + private function t(string $user, string $key): string + { + return AdminText::text($this->settings->getUser($user)['language'], $key); + } + private function b(string $user, string $key, string $data, ?string $color = null): array + { + return ['text' => $this->t($user, $key), 'data' => $data, 'color' => $color]; + } + private static function h(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + private function navigation(string $user, string $prefix, int $page, int $pages): array + { + if ($pages <= 1) { + return []; + } + $row = []; + if ($page > 0) { + $row[] = $this->b($user, 'previous', $prefix . ($page - 1)); + } + $row[] = ['text' => ($page + 1) . '/' . $pages, 'data' => $prefix . $page]; + if ($page + 1 < $pages) { + $row[] = $this->b($user, 'next', $prefix . ($page + 1)); + } + return [$row]; + } + private function frame(string $text, array $rows): array + { + $markup = ['_' => 'replyInlineMarkup', 'rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $b) { + if (isset($b['copy'])) { + $button = ['_' => 'keyboardButtonCopy', 'text' => $b['text'], 'copy_text' => $b['copy']]; + } else { + if (strlen($b['data']) > 64) { + throw new \InvalidArgumentException('Administrator callback too long.'); + } + $button = ['_' => 'keyboardButtonCallback', 'text' => $b['text'], 'data' => $b['data']]; + } + if (isset($b['color'])) { + $button['style'] = ['_' => 'keyboardButtonStyle', $b['color'] => true]; + } + $buttons[] = $button; + } + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => $buttons]; + } + return ['text' => $text, 'markup' => $markup]; + } +} diff --git a/src/UI/AdminDirectoryText.php b/src/UI/AdminDirectoryText.php new file mode 100644 index 0000000..9e429e0 --- /dev/null +++ b/src/UI/AdminDirectoryText.php @@ -0,0 +1,848 @@ + [ + 'workflow' => 'โ‘  ืชื•ื›ืŸ โ‘ก ืงื”ืœ ื•ื”ื’ื“ืจื•ืช โ‘ข ืชืฆื•ื’ื” ื•ืื™ืฉื•ืจ', + 'content_prompt' => 'ืฉืœื—ื• ืืช ื”ื”ื•ื“ืขื” ืฉืชืจืฆื• ืœื”ืคื™ืฅ: ื˜ืงืกื˜ ืžืขื•ืฆื‘, ืชืžื•ื ื”, ืกืจื˜ื•ืŸ ืื• ืงื•ื‘ืฅ ืขื ื›ื™ืชื•ื‘. ืื—ืจ ื›ืš ื‘ื—ืจื• ืงื”ืœ ื™ืขื“ ื•ื‘ื“ืงื• ืชืฆื•ื’ื” ืžืงื“ื™ืžื”.', + 'buttons_note' => 'ื”ื•ืกื™ืคื• ื›ืคืชื•ืจ ืงื™ืฉื•ืจ ื‘ืฉื ื™ ืฆืขื“ื™ื ืคืฉื•ื˜ื™ื. ืืคืฉืจ ื’ื ืœื ื”ืœ ืžื‘ื ื” ืžืชืงื“ื ืฉืœ ื›ืคืชื•ืจื™ื.', + 'add_button' => 'โž• ื”ื•ืกืคืช ื›ืคืชื•ืจ ืงื™ืฉื•ืจ', + 'clear_buttons' => 'ื”ืกืจืช ื”ื›ืคืชื•ืจื™ื', + 'advanced_buttons' => 'ืืคืฉืจื•ื™ื•ืช ืžืชืงื“ืžื•ืช', + 'button_label_prompt' => 'ืฉืœื—ื• ืืช ื”ื˜ืงืกื˜ ืฉื™ื•ืคื™ืข ืขืœ ื”ื›ืคืชื•ืจ (ืขื“ 64 ืชื•ื•ื™ื).', + 'button_url_prompt' => 'ืฉืœื—ื• ืืช ื”ืงื™ืฉื•ืจ ืฉืืœื™ื• ื™ื•ื‘ื™ืœ ื”ื›ืคืชื•ืจ, ืœืžืฉืœ https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'ืฉืœื—ื• ื›ืคืชื•ืจื™ื ื‘ืคื•ืจืžื˜ JSON, ืื• ืฉื•ืจื” ืœื›ืœ ื›ืคืชื•ืจ: ืฉื - https://example.com. JSON ืชื•ืžืš ื’ื ื‘ืฉื™ืชื•ืฃ, ื”ืขืชืงื”, ืฆื‘ืขื™ื ื•ืื™ืžื•ื’ืณื™ื ืžื•ืชืืžื™ื.', + 'resume_draft' => '๐Ÿ“ ื”ืžืฉืš ื˜ื™ื•ื˜ื”', + 'content_ready' => 'โœ… ืชื•ื›ืŸ ื ืฉืžืจ', + 'content_missing' => 'โœ๏ธ ืžื—ื›ื” ืœืชื•ื›ืŸ', + 'button_count' => 'ื›ืคืชื•ืจื™ื', + 'audience_note' => 'ื‘ื—ืจื• ืœืžื™ ืžื™ื•ืขื“ ื”ืฉื™ื“ื•ืจ. ื”ืžืกืคืจ ื”ืกื•ืคื™ ื™ื•ืฆื’ ืœืคื ื™ ื”ืื™ืฉื•ืจ.', + 'minutes' => 'ื“ืงื•ืช', + 'confirm_title' => '๐Ÿ”Ž ื‘ื“ื™ืงื” ืื—ืจื•ื ื” ืœืคื ื™ ืฉื™ื“ื•ืจ', + 'recipients' => 'ื ืžืขื ื™ื', + 'stats_overview' => '๐Ÿ“Š ืฉื•ืžืจ ืฉื‘ืช ื‘ืžืกืคืจื™ื', + 'stats_groups' => '๐Ÿ‘ฅ ืงื‘ื•ืฆื•ืช ื•ืฉืžื™ืจื”', + 'stats_activity' => '๐Ÿ“ˆ ืคืขื™ืœื•ืช ื‘ื‘ื•ื˜', + 'stats_delivery' => '๐Ÿ“ฌ ืฉืœื™ื—ื” ื•ืชื–ื›ื•ืจื•ืช', + 'stats_audience' => '๐Ÿ‘ค ืงื”ืœ ื”ื‘ื•ื˜', + 'stats_modules' => '๐Ÿ”” ืชื–ื›ื•ืจื•ืช ืคืขื™ืœื•ืช', + 'stats_refresh_at' => 'ืขื•ื“ื›ืŸ', + 'stats_public_note' => 'ื”ืžืกืคืจื™ื ืžืฆื™ื’ื™ื ืืช ืžืฆื‘ ื”ืงื‘ื•ืฆื•ืช ื”ืจืฉื•ืžื•ืช ื‘ื‘ื•ื˜. ื ืขื™ืœื” ื‘ืคื•ืขืœ ืžื•ืฆื’ืช ื‘ื ืคืจื“ ืžื”ืคืขืœืช ื”ืฉืžื™ืจื”.', + 'mode_off' => 'ื›ื‘ื•ื™', + 'mode_messages' => 'ื”ื•ื“ืขื•ืช ื‘ืœื‘ื“', + 'mode_lock' => 'ื ืขื™ืœื” ื‘ืœื‘ื“', + 'mode_lock_messages' => 'ื”ื•ื“ืขื•ืช ื•ื ืขื™ืœื”', + 'basic_groups' => 'ืงื‘ื•ืฆื•ืช ืจื’ื™ืœื•ืช', + 'supergroups' => 'ืงื‘ื•ืฆื•ืช ืขืœ', + 'other_groups' => 'ืงื‘ื•ืฆื•ืช ืฉืกื•ื’ืŸ ื˜ืจื ื ื‘ื“ืง', + 'back_summary' => 'ืกื™ื›ื•ื', + 'stats_health_note' => 'ื”ื ืชื•ื ื™ื ืžืงื•ืžื™ื™ื; ื”ื ืื™ื ื ื‘ื“ื™ืงืช ื–ืžื™ื ื•ืช ื—ื™ื” ืฉืœ Telegram.', + ], + 'en' => [ + 'workflow' => 'โ‘  Content โ‘ก Audience and options โ‘ข Preview and confirm', + 'content_prompt' => 'Send the message to broadcast: formatted text, a photo, video or file with a caption. Then choose an audience and preview it.', + 'buttons_note' => 'Add a link button in two simple steps, or manage an advanced keyboard.', + 'add_button' => 'โž• Add link button', + 'clear_buttons' => 'Remove buttons', + 'advanced_buttons' => 'Advanced options', + 'button_label_prompt' => 'Send the button label (up to 64 UTF-16 units).', + 'button_url_prompt' => 'Send the destination link, for example https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'Send a JSON keyboard, or one button per line: Label - https://example.com. JSON also supports sharing, copying, colors and custom emoji.', + 'resume_draft' => '๐Ÿ“ Resume draft', + 'content_ready' => 'โœ… Content saved', + 'content_missing' => 'โœ๏ธ Waiting for content', + 'button_count' => 'Buttons', + 'audience_note' => 'Choose the broadcast audience. The final recipient count appears before confirmation.', + 'minutes' => 'minutes', + 'confirm_title' => '๐Ÿ”Ž Final broadcast review', + 'recipients' => 'Recipients', + 'stats_overview' => '๐Ÿ“Š Shabbat Bot in numbers', + 'stats_groups' => '๐Ÿ‘ฅ Groups and protection', + 'stats_activity' => '๐Ÿ“ˆ Bot activity', + 'stats_delivery' => '๐Ÿ“ฌ Delivery and reminders', + 'stats_audience' => '๐Ÿ‘ค Bot audience', + 'stats_modules' => '๐Ÿ”” Active reminders', + 'stats_refresh_at' => 'Updated', + 'stats_public_note' => 'These figures describe registered groups. Confirmed locks are shown separately from enabled protection.', + 'mode_off' => 'Off', + 'mode_messages' => 'Messages only', + 'mode_lock' => 'Lock only', + 'mode_lock_messages' => 'Messages and lock', + 'basic_groups' => 'Basic groups', + 'supergroups' => 'Supergroups', + 'other_groups' => 'Groups with unverified type', + 'back_summary' => 'Overview', + 'stats_health_note' => 'Local records; this is not a live Telegram availability check.', + ], + 'ru' => [ + 'workflow' => 'โ‘  ะกะพะพะฑั‰ะตะฝะธะต โ‘ก ะŸะพะปัƒั‡ะฐั‚ะตะปะธ ะธ ะฟะฐั€ะฐะผะตั‚ั€ั‹ โ‘ข ะŸั€ะพัะผะพั‚ั€ ะธ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต', + 'content_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั‚ะตะบัั‚ ั ะพั„ะพั€ะผะปะตะฝะธะตะผ, ั„ะพั‚ะพ, ะฒะธะดะตะพ ะธะปะธ ั„ะฐะนะป ั ะฟะพะดะฟะธััŒัŽ. ะ—ะฐั‚ะตะผ ะฒั‹ะฑะตั€ะธั‚ะต ะฟะพะปัƒั‡ะฐั‚ะตะปะตะน ะธ ะฟั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะตะดะฟั€ะพัะผะพั‚ั€.', + 'buttons_note' => 'ะ”ะพะฑะฐะฒัŒั‚ะต ะบะฝะพะฟะบัƒ ัะพ ััั‹ะปะบะพะน ะฒ ะดะฒะฐ ัˆะฐะณะฐ ะธะปะธ ะฝะฐัั‚ั€ะพะนั‚ะต ั€ะฐััˆะธั€ะตะฝะฝัƒัŽ ะบะปะฐะฒะธะฐั‚ัƒั€ัƒ.', + 'add_button' => 'โž• ะ”ะพะฑะฐะฒะธั‚ัŒ ะบะฝะพะฟะบัƒ-ััั‹ะปะบัƒ', + 'clear_buttons' => 'ะฃะดะฐะปะธั‚ัŒ ะบะฝะพะฟะบะธ', + 'advanced_buttons' => 'ะ ะฐััˆะธั€ะตะฝะฝั‹ะต ะฝะฐัั‚ั€ะพะนะบะธ', + 'button_label_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะฟะพะดะฟะธััŒ ะบะฝะพะฟะบะธ (ะดะพ 64 ะตะดะธะฝะธั† UTF-16).', + 'button_url_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ััั‹ะปะบัƒ, ะฝะฐะฟั€ะธะผะตั€ https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะบะปะฐะฒะธะฐั‚ัƒั€ัƒ JSON ะธะปะธ ะฟะพ ะพะดะฝะพะน ะบะฝะพะฟะบะต ะฒ ัั‚ั€ะพะบะต: ะะฐะทะฒะฐะฝะธะต - https://example.com. JSON ั‚ะฐะบะถะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ ะพั‚ะฟั€ะฐะฒะบัƒ, ะบะพะฟะธั€ะพะฒะฐะฝะธะต, ั†ะฒะตั‚ะฐ ะธ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ัะผะพะดะทะธ.', + 'resume_draft' => '๐Ÿ“ ะŸั€ะพะดะพะปะถะธั‚ัŒ ั‡ะตั€ะฝะพะฒะธะบ', + 'content_ready' => 'โœ… ะกะพะพะฑั‰ะตะฝะธะต ัะพั…ั€ะฐะฝะตะฝะพ', + 'content_missing' => 'โœ๏ธ ะžะถะธะดะฐะฝะธะต ัะพะพะฑั‰ะตะฝะธั', + 'button_count' => 'ะšะฝะพะฟะบะธ', + 'audience_note' => 'ะ’ั‹ะฑะตั€ะธั‚ะต ะฟะพะปัƒั‡ะฐั‚ะตะปะตะน. ะ˜ั‚ะพะณะพะฒะพะต ั‡ะธัะปะพ ะฑัƒะดะตั‚ ะฟะพะบะฐะทะฐะฝะพ ะฟะตั€ะตะด ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะตะผ.', + 'minutes' => 'ะผะธะฝัƒั‚', + 'confirm_title' => '๐Ÿ”Ž ะŸั€ะพะฒะตั€ะบะฐ ะฟะตั€ะตะด ั€ะฐััั‹ะปะบะพะน', + 'recipients' => 'ะŸะพะปัƒั‡ะฐั‚ะตะปะธ', + 'stats_overview' => '๐Ÿ“Š ะจะพะผะตั€ ะจะฐะฑะฐั‚ ะฒ ั†ะธั„ั€ะฐั…', + 'stats_groups' => '๐Ÿ‘ฅ ะ“ั€ัƒะฟะฟั‹ ะธ ะทะฐั‰ะธั‚ะฐ', + 'stats_activity' => '๐Ÿ“ˆ ะะบั‚ะธะฒะฝะพัั‚ัŒ ะฑะพั‚ะฐ', + 'stats_delivery' => '๐Ÿ“ฌ ะ”ะพัั‚ะฐะฒะบะฐ ะธ ะฝะฐะฟะพะผะธะฝะฐะฝะธั', + 'stats_audience' => '๐Ÿ‘ค ะัƒะดะธั‚ะพั€ะธั ะฑะพั‚ะฐ', + 'stats_modules' => '๐Ÿ”” ะะบั‚ะธะฒะฝั‹ะต ะฝะฐะฟะพะผะธะฝะฐะฝะธั', + 'stats_refresh_at' => 'ะžะฑะฝะพะฒะปะตะฝะพ', + 'stats_public_note' => 'ะ”ะฐะฝะฝั‹ะต ะพ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ั… ะณั€ัƒะฟะฟะฐั…. ะŸะพะดั‚ะฒะตั€ะถะดั‘ะฝะฝั‹ะต ะฑะปะพะบะธั€ะพะฒะบะธ ะฟะพะบะฐะทะฐะฝั‹ ะพั‚ะดะตะปัŒะฝะพ ะพั‚ ะฒะบะปัŽั‡ั‘ะฝะฝะพะน ะทะฐั‰ะธั‚ั‹.', + 'mode_off' => 'ะ’ั‹ะบะปัŽั‡ะตะฝะพ', + 'mode_messages' => 'ะขะพะปัŒะบะพ ัะพะพะฑั‰ะตะฝะธั', + 'mode_lock' => 'ะขะพะปัŒะบะพ ะฑะปะพะบะธั€ะพะฒะบะฐ', + 'mode_lock_messages' => 'ะกะพะพะฑั‰ะตะฝะธั ะธ ะฑะปะพะบะธั€ะพะฒะบะฐ', + 'basic_groups' => 'ะžะฑั‹ั‡ะฝั‹ะต ะณั€ัƒะฟะฟั‹', + 'supergroups' => 'ะกัƒะฟะตั€ะณั€ัƒะฟะฟั‹', + 'other_groups' => 'ะ“ั€ัƒะฟะฟั‹ ั ะฝะตะฟั€ะพะฒะตั€ะตะฝะฝั‹ะผ ั‚ะธะฟะพะผ', + 'back_summary' => 'ะžะฑะทะพั€', + 'stats_health_note' => 'ะ›ะพะบะฐะปัŒะฝั‹ะต ะดะฐะฝะฝั‹ะต, ะฐ ะฝะต ะฟั€ะพะฒะตั€ะบะฐ ะดะพัั‚ัƒะฟะฝะพัั‚ะธ Telegram ะฒ ั€ะตะฐะปัŒะฝะพะผ ะฒั€ะตะผะตะฝะธ.', + ], + 'fr' => [ + 'workflow' => 'โ‘  Contenu โ‘ก Destinataires et options โ‘ข Aperรงu et confirmation', + 'content_prompt' => 'Envoyez le texte mis en forme, une photo, une vidรฉo ou un fichier avec lรฉgende. Choisissez ensuite les destinataires et vรฉrifiez lโ€™aperรงu.', + 'buttons_note' => 'Ajoutez un bouton avec lien en deux รฉtapes, ou configurez un clavier avancรฉ.', + 'add_button' => 'โž• Ajouter un bouton lien', + 'clear_buttons' => 'Retirer les boutons', + 'advanced_buttons' => 'Options avancรฉes', + 'button_label_prompt' => 'Envoyez le libellรฉ du bouton (64 unitรฉs UTF-16 maximum).', + 'button_url_prompt' => 'Envoyez le lien, par exemple https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'Envoyez un clavier JSON ou une ligne par bouton : Libellรฉ - https://example.com. JSON accepte aussi partage, copie, couleurs et emoji personnalisรฉs.', + 'resume_draft' => '๐Ÿ“ Reprendre le brouillon', + 'content_ready' => 'โœ… Contenu enregistrรฉ', + 'content_missing' => 'โœ๏ธ En attente du contenu', + 'button_count' => 'Boutons', + 'audience_note' => 'Choisissez les destinataires. Leur nombre final sera indiquรฉ avant confirmation.', + 'minutes' => 'minutes', + 'confirm_title' => '๐Ÿ”Ž Vรฉrification finale de la diffusion', + 'recipients' => 'Destinataires', + 'stats_overview' => '๐Ÿ“Š Chomer Chabbat en chiffres', + 'stats_groups' => '๐Ÿ‘ฅ Groupes et protection', + 'stats_activity' => '๐Ÿ“ˆ Activitรฉ du bot', + 'stats_delivery' => '๐Ÿ“ฌ Envois et rappels', + 'stats_audience' => '๐Ÿ‘ค Audience du bot', + 'stats_modules' => '๐Ÿ”” Rappels actifs', + 'stats_refresh_at' => 'Mis ร  jour', + 'stats_public_note' => 'Ces chiffres concernent les groupes inscrits. Les verrouillages confirmรฉs sont distinguรฉs de la protection activรฉe.', + 'mode_off' => 'Dรฉsactivรฉ', + 'mode_messages' => 'Messages seuls', + 'mode_lock' => 'Verrouillage seul', + 'mode_lock_messages' => 'Messages et verrouillage', + 'basic_groups' => 'Groupes classiques', + 'supergroups' => 'Supergroupes', + 'other_groups' => 'Groupes au type non vรฉrifiรฉ', + 'back_summary' => 'Vue gรฉnรฉrale', + 'stats_health_note' => 'Donnรฉes locales ; ceci ne vรฉrifie pas la disponibilitรฉ de Telegram en direct.', + ], + 'es' => [ + 'workflow' => 'โ‘  Contenido โ‘ก Destinatarios y opciones โ‘ข Vista previa y confirmaciรณn', + 'content_prompt' => 'Envรญa texto con formato, una foto, vรญdeo o archivo con descripciรณn. Despuรฉs elige destinatarios y revisa la vista previa.', + 'buttons_note' => 'Aรฑade un botรณn de enlace en dos pasos o configura un teclado avanzado.', + 'add_button' => 'โž• Aรฑadir botรณn de enlace', + 'clear_buttons' => 'Quitar botones', + 'advanced_buttons' => 'Opciones avanzadas', + 'button_label_prompt' => 'Envรญa el texto del botรณn (hasta 64 unidades UTF-16).', + 'button_url_prompt' => 'Envรญa el enlace de destino, por ejemplo https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'Envรญa un teclado JSON o un botรณn por lรญnea: Texto - https://example.com. JSON tambiรฉn permite compartir, copiar, colores y emoji personalizados.', + 'resume_draft' => '๐Ÿ“ Continuar borrador', + 'content_ready' => 'โœ… Contenido guardado', + 'content_missing' => 'โœ๏ธ Esperando contenido', + 'button_count' => 'Botones', + 'audience_note' => 'Elige los destinatarios. El nรบmero final se mostrarรก antes de confirmar.', + 'minutes' => 'minutos', + 'confirm_title' => '๐Ÿ”Ž Revisiรณn final del envรญo', + 'recipients' => 'Destinatarios', + 'stats_overview' => '๐Ÿ“Š Shomer Shabat en cifras', + 'stats_groups' => '๐Ÿ‘ฅ Grupos y protecciรณn', + 'stats_activity' => '๐Ÿ“ˆ Actividad del bot', + 'stats_delivery' => '๐Ÿ“ฌ Envรญos y recordatorios', + 'stats_audience' => '๐Ÿ‘ค Audiencia del bot', + 'stats_modules' => '๐Ÿ”” Recordatorios activos', + 'stats_refresh_at' => 'Actualizado', + 'stats_public_note' => 'Las cifras describen los grupos registrados. Los bloqueos confirmados se muestran separados de la protecciรณn activada.', + 'mode_off' => 'Desactivado', + 'mode_messages' => 'Solo mensajes', + 'mode_lock' => 'Solo bloqueo', + 'mode_lock_messages' => 'Mensajes y bloqueo', + 'basic_groups' => 'Grupos bรกsicos', + 'supergroups' => 'Supergrupos', + 'other_groups' => 'Grupos con tipo sin verificar', + 'back_summary' => 'Resumen', + 'stats_health_note' => 'Datos locales; no es una comprobaciรณn en vivo de la disponibilidad de Telegram.', + ], + 'pt-br' => [ + 'workflow' => 'โ‘  Conteรบdo โ‘ก Destinatรกrios e opรงรตes โ‘ข Prรฉvia e confirmaรงรฃo', + 'content_prompt' => 'Envie texto formatado, foto, vรญdeo ou arquivo com legenda. Depois escolha os destinatรกrios e confira a prรฉvia.', + 'buttons_note' => 'Adicione um botรฃo de link em duas etapas ou configure um teclado avanรงado.', + 'add_button' => 'โž• Adicionar botรฃo de link', + 'clear_buttons' => 'Remover botรตes', + 'advanced_buttons' => 'Opรงรตes avanรงadas', + 'button_label_prompt' => 'Envie o texto do botรฃo (atรฉ 64 unidades UTF-16).', + 'button_url_prompt' => 'Envie o link de destino, por exemplo https://t.me/shabatrobot', + 'advanced_buttons_prompt' => 'Envie um teclado JSON ou um botรฃo por linha: Texto - https://example.com. JSON tambรฉm permite compartilhar, copiar, cores e emojis personalizados.', + 'resume_draft' => '๐Ÿ“ Continuar rascunho', + 'content_ready' => 'โœ… Conteรบdo salvo', + 'content_missing' => 'โœ๏ธ Aguardando conteรบdo', + 'button_count' => 'Botรตes', + 'audience_note' => 'Escolha os destinatรกrios. O total final aparece antes da confirmaรงรฃo.', + 'minutes' => 'minutos', + 'confirm_title' => '๐Ÿ”Ž Revisรฃo final da transmissรฃo', + 'recipients' => 'Destinatรกrios', + 'stats_overview' => '๐Ÿ“Š Shomer Shabat em nรบmeros', + 'stats_groups' => '๐Ÿ‘ฅ Grupos e proteรงรฃo', + 'stats_activity' => '๐Ÿ“ˆ Atividade do bot', + 'stats_delivery' => '๐Ÿ“ฌ Envios e lembretes', + 'stats_audience' => '๐Ÿ‘ค Pรบblico do bot', + 'stats_modules' => '๐Ÿ”” Lembretes ativos', + 'stats_refresh_at' => 'Atualizado', + 'stats_public_note' => 'Os nรบmeros descrevem os grupos cadastrados. Bloqueios confirmados aparecem separados da proteรงรฃo ativada.', + 'mode_off' => 'Desativado', + 'mode_messages' => 'Sรณ mensagens', + 'mode_lock' => 'Sรณ bloqueio', + 'mode_lock_messages' => 'Mensagens e bloqueio', + 'basic_groups' => 'Grupos bรกsicos', + 'supergroups' => 'Supergrupos', + 'other_groups' => 'Grupos com tipo nรฃo verificado', + 'back_summary' => 'Resumo', + 'stats_health_note' => 'Dados locais; isto nรฃo verifica a disponibilidade do Telegram em tempo real.', + ], + ]; + + private const TEXT = [ + 'he' => [ + 'directory' => '๐Ÿ‘ฅ ืžื ื•ื™ื™ื', + 'search' => '๐Ÿ”Ž ื—ื™ืคื•ืฉ', + 'search_prompt' => 'ืฉืœื—ื• ืฉื, ืฉื ืžืฉืชืžืฉ ืื• ืžื–ื”ื”. ืฉืœื—ื• - ืœื ื™ืงื•ื™ ื”ื—ื™ืคื•ืฉ.', + 'page_prompt' => 'ืฉืœื—ื• ืžืกืคืจ ืขืžื•ื“.', + 'sort' => 'โ†•๏ธ ืžื™ื•ืŸ', + 'filter' => '๐Ÿ—‚ ืกื™ื ื•ืŸ', + 'clear' => '๐Ÿงน ื ื™ืงื•ื™ ื—ื™ืคื•ืฉ', + 'refresh' => '๐Ÿ”„ ืจืขื ื•ืŸ', + 'activity' => 'ืคืขื™ืœื•ืช ืื—ืจื•ื ื”', + 'joined' => 'ื”ืฆื˜ืจืคื•ืช', + 'name' => 'ืฉื', + 'id' => 'ืžื–ื”ื”', + 'all' => 'ื”ื›ืœ', + 'users' => 'ืžืฉืชืžืฉื™ื', + 'groups' => 'ืงื‘ื•ืฆื•ืช', + 'channels' => 'ืขืจื•ืฆื™ื', + 'bots' => 'ื‘ื•ื˜ื™ื', + 'unknown' => 'ืœื ื™ื“ื•ืข', + 'yes' => 'ื›ืŸ', + 'no' => 'ืœื', + 'back' => 'ื—ื–ืจื”', + 'previous' => 'ื”ืงื•ื“ื', + 'next' => 'ื”ื‘ื', + 'empty' => 'ืœื ื ืžืฆืื• ืžื ื•ื™ื™ื ื”ืชื•ืืžื™ื ืœื—ื™ืคื•ืฉ ื•ืœืกื™ื ื•ืŸ.', + 'total' => 'ืกืš ื”ื›ืœ', + 'page' => 'ืขืžื•ื“', + 'profile' => '๐Ÿ‘ค ื›ืจื˜ื™ืก ืžื ื•ื™', + 'username' => 'ืฉื ืžืฉืชืžืฉ', + 'first_name' => 'ืฉื ืคืจื˜ื™', + 'last_name' => 'ืฉื ืžืฉืคื—ื”', + 'type' => 'ืกื•ื’', + 'language' => 'ืฉืคื”', + 'location' => 'ืžื™ืงื•ื', + 'timezone' => 'ืื–ื•ืจ ื–ืžืŸ', + 'country' => 'ืžื“ื™ื ื”', + 'first_activity' => 'ืคืขื™ืœื•ืช ืจืืฉื•ื ื”', + 'last_activity' => 'ืคืขื™ืœื•ืช ืื—ืจื•ื ื”', + 'activities' => 'ืคืขื•ืœื•ืช ืฉื ืฆืคื•', + 'starts' => 'ืคืชื™ื—ื•ืช ื”ื‘ื•ื˜', + 'last_start' => 'ืคืชื™ื—ื” ืื—ืจื•ื ื”', + 'premium' => 'Premium', + 'deleted' => 'ื—ืฉื‘ื•ืŸ ืžื—ื•ืง', + 'contact' => 'ืื™ืฉ ืงืฉืจ', + 'group_admin' => 'ืžื ื”ืœ ืงื‘ื•ืฆื”', + 'role_observation' => 'ืชืคืงื™ื“ื™ื ืฉื ื‘ื“ืงื• ื‘ืงื‘ื•ืฆื•ืช', + 'observed' => 'ื ื‘ื“ืง', + 'historical' => 'ืืœื” ืชืฆืคื™ื•ืช ืฉืžื•ืจื•ืช; ื”ืจืฉืื•ืช ื ื‘ื“ืงื•ืช ืžื—ื“ืฉ ืœืคื ื™ ื›ืœ ืฉื™ื ื•ื™.', + 'registered' => 'ืงื‘ื•ืฆื” ืจืฉื•ืžื”', + 'enabled' => 'ืคืขื™ืœ', + 'mode' => 'ืžืฆื‘', + 'modules' => 'ืžื•ื“ื•ืœื™ื ืคืขื™ืœื™ื', + 'authorized' => 'ืชืคื•ืฆื” ืžื•ืจืฉื™ืช', + 'authorize' => 'ืืคืฉืจ ืชืคื•ืฆื” ืžื•ืจืฉื™ืช', + 'unauthorize' => 'ื‘ื˜ืœ ืชืคื•ืฆื” ืžื•ืจืฉื™ืช', + 'stats' => '๐Ÿ“Š ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช ื ื™ื”ื•ืœ', + 'subscribers' => 'ืžื ื•ื™ื™ื', + 'active_24h' => 'ืคืขื™ืœื™ื ื‘-24 ืฉืขื•ืช', + 'active_7d' => 'ืคืขื™ืœื™ื ื‘-7 ื™ืžื™ื', + 'active_30d' => 'ืคืขื™ืœื™ื ื‘-30 ื™ืžื™ื', + 'registered_groups' => 'ืงื‘ื•ืฆื•ืช ืจืฉื•ืžื•ืช', + 'active_groups' => 'ืงื‘ื•ืฆื•ืช ืคืขื™ืœื•ืช', + 'locking' => 'ืฉืžื™ืจื” ืคืขื™ืœื”', + 'locked' => 'ืกื’ื•ืจื•ืช ื›ืขืช', + 'recovery' => 'ื“ื•ืจืฉื•ืช ื‘ื“ื™ืงื”', + 'pending_jobs' => 'ืžืฉื™ืžื•ืช ืžืžืชื™ื ื•ืช', + 'failed_jobs' => 'ืžืฉื™ืžื•ืช ืฉื ื›ืฉืœื•', + 'stats_note' => 'ืžื“ื“ื™ ื”ืคืขื™ืœื•ืช ืžืชื—ื™ืœื™ื ื‘ื’ืจืกื” ื”ื–ืืช; ื”ื™ืกื˜ื•ืจื™ื” ืฉืœื ื ืฉืžืจื” ืื™ื ื” ืžืฉื•ื—ื–ืจืช.', + 'broadcast' => '๐Ÿ“ฎ ืžืขืจื›ืช ืฉื™ื“ื•ืจ', + 'send' => 'ืฉืœื™ื—ืช ืฉื™ื“ื•ืจ', + 'edit' => 'ืขืจื™ื›ืช ื”ืฉื™ื“ื•ืจ ื”ืื—ืจื•ืŸ', + 'content' => 'ืขืจื™ื›ืช ืชื•ื›ืŸ', + 'buttons' => 'ืขืจื™ื›ืช ื›ืคืชื•ืจื™ื', + 'preview' => 'ืชืฆื•ื’ื” ืžืงื“ื™ืžื”', + 'pin' => 'ื ืขื™ืฆืช ื”ื”ื•ื“ืขื”', + 'on' => 'ืคื•ืขืœ', + 'off' => 'ื›ื‘ื•ื™', + 'audience' => 'ืงื”ืœ ื™ืขื“', + 'groups_channels' => 'ืงื‘ื•ืฆื•ืช ื•ืขืจื•ืฆื™ื', + 'authorized_channels' => 'ืขืจื•ืฆื™ื ืžื•ืจืฉื™ื', + 'authorized_groups' => 'ืงื‘ื•ืฆื•ืช ืžื•ืจืฉื•ืช', + 'authorized_groups_channels' => 'ืงื‘ื•ืฆื•ืช ื•ืขืจื•ืฆื™ื ืžื•ืจืฉื™ื', + 'self_destruct' => 'ืžื—ื™ืงื” ืื•ื˜ื•ืžื˜ื™ืช', + 'hours' => 'ืฉืขื•ืช', + 'schedule' => 'ืชื–ืžื•ืŸ', + 'now' => 'ืขื›ืฉื™ื•', + 'custom' => 'ืื—ืจ', + 'cancel' => 'ื‘ื™ื˜ื•ืœ', + 'sd_prompt' => 'ืฉืœื—ื• ืžืกืคืจ ืฉืขื•ืช ืฉืœื ื‘ื™ืŸ 1 ืœ-48, ืื• off ืœื›ื™ื‘ื•ื™.', + 'schedule_prompt' => 'ืฉืœื—ื• ืžืกืคืจ ื“ืงื•ืช ืžืขื›ืฉื™ื•, ืื• YYYY-MM-DD HH:MM ืœืคื™ ืื–ื•ืจ ื”ื–ืžืŸ ืฉืœื›ื. off ืœืฉืœื™ื—ื” ืžื™ื™ื“ื™ืช.', + 'confirm' => 'ื”ืžืฉืš ืœืื™ืฉื•ืจ', + 'progress' => 'ื ืชื•ื ื™ ื”ืฉื™ื“ื•ืจ ื”ืื—ืจื•ืŸ', + 'jobs' => 'ืฉื™ื“ื•ืจื™ื ืžืชื•ื–ืžื ื™ื', + 'delete_last' => 'ืžื—ื™ืงืช ื”ืฉื™ื“ื•ืจ ื”ืื—ืจื•ืŸ', + 'delete_all' => 'ืžื—ื™ืงืช ื›ืœ ื”ืฉื™ื“ื•ืจื™ื', + 'unpin' => 'ื‘ื™ื˜ื•ืœ ื ืขื™ืฆื•ืช', + 'no_draft' => 'ื”ื•ืกื™ืคื• ื˜ืงืกื˜, ืžื“ื™ื” ืื• ืฉื ื™ื”ื. ืืคืฉืจ ืœืฉืžื•ืจ ืขื™ืฆื•ื‘ ื•ืœื”ื•ืกื™ืฃ ื›ืคืชื•ืจื™ื ืœืคื ื™ ื”ืื™ืฉื•ืจ.', + 'operation' => 'ืคืขื•ืœื”', + 'send_note' => 'ืฉืœื™ื—ื” ื•ืžื—ื™ืงื” ื“ื•ืจืฉื•ืช ืื™ืฉื•ืจ ื ื•ืกืฃ. ืฉื™ื ื•ื™ ืžื‘ื˜ืœ ืื™ืฉื•ืจ ืงื•ื“ื.', + 'edit_note' => 'ืขืจื™ื›ื” ื—ืœื” ืขืœ ื”ืฉื™ื“ื•ืจ ื”ืื—ืจื•ืŸ ืฉื ืฉืžืจ. ืชื–ืžื•ืŸ ื•ืžื—ื™ืงื” ืื•ื˜ื•ืžื˜ื™ืช ืื™ื ื ื—ืœื™ื ืขืœ ืขืจื™ื›ื”.', + ], + 'en' => [ + 'directory' => '๐Ÿ‘ฅ Subscribers', + 'search' => '๐Ÿ”Ž Search', + 'search_prompt' => 'Send a name, username or ID. Send - to clear.', + 'page_prompt' => 'Send a page number.', + 'sort' => 'โ†•๏ธ Sort', + 'filter' => '๐Ÿ—‚ Filter', + 'clear' => '๐Ÿงน Clear search', + 'refresh' => '๐Ÿ”„ Refresh', + 'activity' => 'Recent activity', + 'joined' => 'Joined', + 'name' => 'Name', + 'id' => 'ID', + 'all' => 'All', + 'users' => 'Users', + 'groups' => 'Groups', + 'channels' => 'Channels', + 'bots' => 'Bots', + 'unknown' => 'Unknown', + 'yes' => 'Yes', + 'no' => 'No', + 'back' => 'Back', + 'previous' => 'Previous', + 'next' => 'Next', + 'empty' => 'No subscribers match these criteria.', + 'total' => 'Total', + 'page' => 'Page', + 'profile' => '๐Ÿ‘ค Subscriber profile', + 'username' => 'Username', + 'first_name' => 'First name', + 'last_name' => 'Last name', + 'type' => 'Type', + 'language' => 'Language', + 'location' => 'Location', + 'timezone' => 'Time zone', + 'country' => 'Country', + 'first_activity' => 'First activity', + 'last_activity' => 'Last activity', + 'activities' => 'Observed actions', + 'starts' => 'Bot starts', + 'last_start' => 'Last start', + 'premium' => 'Premium', + 'deleted' => 'Deleted account', + 'contact' => 'Contact', + 'group_admin' => 'Group administrator', + 'role_observation' => 'Verified group roles', + 'observed' => 'Checked', + 'historical' => 'These are saved observations; permissions are checked again before changes.', + 'registered' => 'Registered group', + 'enabled' => 'Active', + 'mode' => 'Mode', + 'modules' => 'Enabled modules', + 'authorized' => 'Authorized audience', + 'authorize' => 'Authorize broadcasting', + 'unauthorize' => 'Remove authorization', + 'stats' => '๐Ÿ“Š Administration statistics', + 'subscribers' => 'Subscribers', + 'active_24h' => 'Active in 24 hours', + 'active_7d' => 'Active in 7 days', + 'active_30d' => 'Active in 30 days', + 'registered_groups' => 'Registered groups', + 'active_groups' => 'Active groups', + 'locking' => 'Protection enabled', + 'locked' => 'Currently closed', + 'recovery' => 'Require attention', + 'pending_jobs' => 'Pending jobs', + 'failed_jobs' => 'Failed jobs', + 'stats_note' => 'Activity metrics start with this version; missing history is not reconstructed.', + 'broadcast' => '๐Ÿ“ฎ Broadcasting', + 'send' => 'Send broadcast', + 'edit' => 'Edit the last broadcast', + 'content' => 'Edit content', + 'buttons' => 'Edit buttons', + 'preview' => 'Preview', + 'pin' => 'Pin message', + 'on' => 'On', + 'off' => 'Off', + 'audience' => 'Audience', + 'groups_channels' => 'Groups and channels', + 'authorized_channels' => 'Authorized channels', + 'authorized_groups' => 'Authorized groups', + 'authorized_groups_channels' => 'Authorized groups and channels', + 'self_destruct' => 'Automatic deletion', + 'hours' => 'hours', + 'schedule' => 'Schedule', + 'now' => 'Now', + 'custom' => 'Other', + 'cancel' => 'Cancel', + 'sd_prompt' => 'Send a whole number from 1 to 48 hours, or off.', + 'schedule_prompt' => 'Send minutes from now, or YYYY-MM-DD HH:MM in your time zone. off sends immediately.', + 'confirm' => 'Continue to confirmation', + 'progress' => 'Last broadcast data', + 'jobs' => 'Scheduled broadcasts', + 'delete_last' => 'Delete last broadcast', + 'delete_all' => 'Delete all broadcasts', + 'unpin' => 'Unpin messages', + 'no_draft' => 'Add text, media or both. Preserve formatting and add buttons before confirming.', + 'operation' => 'Operation', + 'send_note' => 'Sending and deleting require confirmation. Changes invalidate earlier confirmation.', + 'edit_note' => 'Editing affects the last recorded broadcast. Scheduling and automatic deletion do not apply.', + ], + 'ru' => [ + 'directory' => '๐Ÿ‘ฅ ะŸะพะดะฟะธัั‡ะธะบะธ', + 'search' => '๐Ÿ”Ž ะŸะพะธัะบ', + 'search_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะธะผั, ะธะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะธะปะธ ID. ะ”ะปั ะพั‡ะธัั‚ะบะธ: -.', + 'page_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะฝะพะผะตั€ ัั‚ั€ะฐะฝะธั†ั‹.', + 'sort' => 'โ†•๏ธ ะกะพั€ั‚ะธั€ะพะฒะบะฐ', + 'filter' => '๐Ÿ—‚ ะคะธะปัŒั‚ั€', + 'clear' => '๐Ÿงน ะžั‡ะธัั‚ะธั‚ัŒ ะฟะพะธัะบ', + 'refresh' => '๐Ÿ”„ ะžะฑะฝะพะฒะธั‚ัŒ', + 'activity' => 'ะŸะพัะปะตะดะฝัั ะฐะบั‚ะธะฒะฝะพัั‚ัŒ', + 'joined' => 'ะ”ะฐั‚ะฐ ะดะพะฑะฐะฒะปะตะฝะธั', + 'name' => 'ะ˜ะผั', + 'id' => 'ID', + 'all' => 'ะ’ัะต', + 'users' => 'ะŸะพะปัŒะทะพะฒะฐั‚ะตะปะธ', + 'groups' => 'ะ“ั€ัƒะฟะฟั‹', + 'channels' => 'ะšะฐะฝะฐะปั‹', + 'bots' => 'ะ‘ะพั‚ั‹', + 'unknown' => 'ะะตะธะทะฒะตัั‚ะฝะพ', + 'yes' => 'ะ”ะฐ', + 'no' => 'ะะตั‚', + 'back' => 'ะะฐะทะฐะด', + 'previous' => 'ะŸั€ะตะดั‹ะดัƒั‰ะฐั', + 'next' => 'ะกะปะตะดัƒัŽั‰ะฐั', + 'empty' => 'ะŸะพะดะฟะธัั‡ะธะบะธ ะฝะต ะฝะฐะนะดะตะฝั‹.', + 'total' => 'ะ’ัะตะณะพ', + 'page' => 'ะกั‚ั€ะฐะฝะธั†ะฐ', + 'profile' => '๐Ÿ‘ค ะšะฐั€ั‚ะพั‡ะบะฐ ะฟะพะดะฟะธัั‡ะธะบะฐ', + 'username' => 'ะ˜ะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั', + 'first_name' => 'ะ˜ะผั', + 'last_name' => 'ะคะฐะผะธะปะธั', + 'type' => 'ะขะธะฟ', + 'language' => 'ะฏะทั‹ะบ', + 'location' => 'ะœะตัั‚ะพ', + 'timezone' => 'ะงะฐัะพะฒะพะน ะฟะพัั', + 'country' => 'ะกั‚ั€ะฐะฝะฐ', + 'first_activity' => 'ะŸะตั€ะฒะฐั ะฐะบั‚ะธะฒะฝะพัั‚ัŒ', + 'last_activity' => 'ะŸะพัะปะตะดะฝัั ะฐะบั‚ะธะฒะฝะพัั‚ัŒ', + 'activities' => 'ะ—ะฐะผะตั‡ะตะฝะฝั‹ะต ะดะตะนัั‚ะฒะธั', + 'starts' => 'ะ—ะฐะฟัƒัะบะธ ะฑะพั‚ะฐ', + 'last_start' => 'ะŸะพัะปะตะดะฝะธะน ะทะฐะฟัƒัะบ', + 'premium' => 'Premium', + 'deleted' => 'ะฃะดะฐะปั‘ะฝะฝั‹ะน ะฐะบะบะฐัƒะฝั‚', + 'contact' => 'ะšะพะฝั‚ะฐะบั‚', + 'group_admin' => 'ะะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ ะณั€ัƒะฟะฟั‹', + 'role_observation' => 'ะŸั€ะพะฒะตั€ะตะฝะฝั‹ะต ั€ะพะปะธ ะฒ ะณั€ัƒะฟะฟะฐั…', + 'observed' => 'ะŸั€ะพะฒะตั€ะตะฝะพ', + 'historical' => 'ะญั‚ะพ ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ะฝะฐะฑะปัŽะดะตะฝะธั; ะฟั€ะฐะฒะฐ ะฟั€ะพะฒะตั€ััŽั‚ัั ะฟะตั€ะตะด ะธะทะผะตะฝะตะฝะธัะผะธ.', + 'registered' => 'ะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝะฐั ะณั€ัƒะฟะฟะฐ', + 'enabled' => 'ะะบั‚ะธะฒะฝะฐ', + 'mode' => 'ะ ะตะถะธะผ', + 'modules' => 'ะ’ะบะปัŽั‡ั‘ะฝะฝั‹ะต ะผะพะดัƒะปะธ', + 'authorized' => 'ะ ะฐะทั€ะตัˆั‘ะฝะฝะฐั ั€ะฐััั‹ะปะบะฐ', + 'authorize' => 'ะ ะฐะทั€ะตัˆะธั‚ัŒ ั€ะฐััั‹ะปะบัƒ', + 'unauthorize' => 'ะžั‚ะผะตะฝะธั‚ัŒ ั€ะฐะทั€ะตัˆะตะฝะธะต', + 'stats' => '๐Ÿ“Š ะกั‚ะฐั‚ะธัั‚ะธะบะฐ ัƒะฟั€ะฐะฒะปะตะฝะธั', + 'subscribers' => 'ะŸะพะดะฟะธัั‡ะธะบะธ', + 'active_24h' => 'ะะบั‚ะธะฒะฝั‹ ะทะฐ 24 ั‡ะฐัะฐ', + 'active_7d' => 'ะะบั‚ะธะฒะฝั‹ ะทะฐ 7 ะดะฝะตะน', + 'active_30d' => 'ะะบั‚ะธะฒะฝั‹ ะทะฐ 30 ะดะฝะตะน', + 'registered_groups' => 'ะ—ะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹', + 'active_groups' => 'ะะบั‚ะธะฒะฝั‹ะต ะณั€ัƒะฟะฟั‹', + 'locking' => 'ะ—ะฐั‰ะธั‚ะฐ ะฒะบะปัŽั‡ะตะฝะฐ', + 'locked' => 'ะกะตะนั‡ะฐั ะทะฐะบั€ั‹ั‚ั‹', + 'recovery' => 'ะขั€ะตะฑัƒัŽั‚ ะฒะฝะธะผะฐะฝะธั', + 'pending_jobs' => 'ะžะถะธะดะฐัŽั‰ะธะต ะทะฐะดะฐะฝะธั', + 'failed_jobs' => 'ะžัˆะธะฑะบะธ ะทะฐะดะฐะฝะธะน', + 'stats_note' => 'ะœะตั‚ั€ะธะบะธ ะฝะฐั‡ะธะฝะฐัŽั‚ัั ั ัั‚ะพะน ะฒะตั€ัะธะธ; ะพั‚ััƒั‚ัั‚ะฒัƒัŽั‰ะฐั ะธัั‚ะพั€ะธั ะฝะต ะฒะพััั‚ะฐะฝะฐะฒะปะธะฒะฐะตั‚ัั.', + 'broadcast' => '๐Ÿ“ฎ ะ ะฐััั‹ะปะบะฐ', + 'send' => 'ะžั‚ะฟั€ะฐะฒะธั‚ัŒ ั€ะฐััั‹ะปะบัƒ', + 'edit' => 'ะ˜ะทะผะตะฝะธั‚ัŒ ะฟะพัะปะตะดะฝัŽัŽ ั€ะฐััั‹ะปะบัƒ', + 'content' => 'ะ˜ะทะผะตะฝะธั‚ัŒ ัะพะดะตั€ะถะธะผะพะต', + 'buttons' => 'ะ˜ะทะผะตะฝะธั‚ัŒ ะบะฝะพะฟะบะธ', + 'preview' => 'ะŸั€ะตะดะฟั€ะพัะผะพั‚ั€', + 'pin' => 'ะ—ะฐะบั€ะตะฟะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธะต', + 'on' => 'ะ’ะบะปัŽั‡ะตะฝะพ', + 'off' => 'ะ’ั‹ะบะปัŽั‡ะตะฝะพ', + 'audience' => 'ะŸะพะปัƒั‡ะฐั‚ะตะปะธ', + 'groups_channels' => 'ะ“ั€ัƒะฟะฟั‹ ะธ ะบะฐะฝะฐะปั‹', + 'authorized_channels' => 'ะ ะฐะทั€ะตัˆั‘ะฝะฝั‹ะต ะบะฐะฝะฐะปั‹', + 'authorized_groups' => 'ะ ะฐะทั€ะตัˆั‘ะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹', + 'authorized_groups_channels' => 'ะ ะฐะทั€ะตัˆั‘ะฝะฝั‹ะต ะณั€ัƒะฟะฟั‹ ะธ ะบะฐะฝะฐะปั‹', + 'self_destruct' => 'ะะฒั‚ะพัƒะดะฐะปะตะฝะธะต', + 'hours' => 'ั‡.', + 'schedule' => 'ะ ะฐัะฟะธัะฐะฝะธะต', + 'now' => 'ะกะตะนั‡ะฐั', + 'custom' => 'ะ”ั€ัƒะณะพะต', + 'cancel' => 'ะžั‚ะผะตะฝะฐ', + 'sd_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั†ะตะปะพะต ั‡ะธัะปะพ ั‡ะฐัะพะฒ ะพั‚ 1 ะดะพ 48 ะธะปะธ off.', + 'schedule_prompt' => 'ะžั‚ะฟั€ะฐะฒัŒั‚ะต ะผะธะฝัƒั‚ั‹ ะธะปะธ YYYY-MM-DD HH:MM ะฒ ะฒะฐัˆะตะผ ั‡ะฐัะพะฒะพะผ ะฟะพััะต. off ะดะปั ะพั‚ะฟั€ะฐะฒะบะธ ัะตะนั‡ะฐั.', + 'confirm' => 'ะŸะตั€ะตะนั‚ะธ ะบ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธัŽ', + 'progress' => 'ะ”ะฐะฝะฝั‹ะต ะฟะพัะปะตะดะฝะตะน ั€ะฐััั‹ะปะบะธ', + 'jobs' => 'ะ—ะฐะฟะปะฐะฝะธั€ะพะฒะฐะฝะฝั‹ะต ั€ะฐััั‹ะปะบะธ', + 'delete_last' => 'ะฃะดะฐะปะธั‚ัŒ ะฟะพัะปะตะดะฝัŽัŽ ั€ะฐััั‹ะปะบัƒ', + 'delete_all' => 'ะฃะดะฐะปะธั‚ัŒ ะฒัะต ั€ะฐััั‹ะปะบะธ', + 'unpin' => 'ะžั‚ะบั€ะตะฟะธั‚ัŒ ัะพะพะฑั‰ะตะฝะธั', + 'no_draft' => 'ะ”ะพะฑะฐะฒัŒั‚ะต ั‚ะตะบัั‚, ะผะตะดะธะฐ ะธะปะธ ะพะฑะฐ. ะคะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต ัะพั…ั€ะฐะฝัะตั‚ัั; ะดะพะฑะฐะฒัŒั‚ะต ะบะฝะพะฟะบะธ.', + 'operation' => 'ะ”ะตะนัั‚ะฒะธะต', + 'send_note' => 'ะžั‚ะฟั€ะฐะฒะบะฐ ะธ ัƒะดะฐะปะตะฝะธะต ั‚ั€ะตะฑัƒัŽั‚ ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธั. ะ˜ะทะผะตะฝะตะฝะธั ะพั‚ะผะตะฝััŽั‚ ะฟั€ะตะถะฝะตะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะธะต.', + 'edit_note' => 'ะ˜ะทะผะตะฝัะตั‚ัั ะฟะพัะปะตะดะฝัั ัะพั…ั€ะฐะฝั‘ะฝะฝะฐั ั€ะฐััั‹ะปะบะฐ. ะ ะฐัะฟะธัะฐะฝะธะต ะธ ะฐะฒั‚ะพัƒะดะฐะปะตะฝะธะต ะฝะต ะฟั€ะธะผะตะฝััŽั‚ัั.', + ], + 'fr' => [ + 'directory' => '๐Ÿ‘ฅ Abonnรฉs', + 'search' => '๐Ÿ”Ž Rechercher', + 'search_prompt' => 'Envoyez un nom, un pseudo ou un ID. Pour effacer : -.', + 'page_prompt' => 'Envoyez un numรฉro de page.', + 'sort' => 'โ†•๏ธ Tri', + 'filter' => '๐Ÿ—‚ Filtre', + 'clear' => '๐Ÿงน Effacer la recherche', + 'refresh' => '๐Ÿ”„ Actualiser', + 'activity' => 'Activitรฉ rรฉcente', + 'joined' => 'Inscription', + 'name' => 'Nom', + 'id' => 'ID', + 'all' => 'Tout', + 'users' => 'Utilisateurs', + 'groups' => 'Groupes', + 'channels' => 'Chaรฎnes', + 'bots' => 'Bots', + 'unknown' => 'Inconnu', + 'yes' => 'Oui', + 'no' => 'Non', + 'back' => 'Retour', + 'previous' => 'Prรฉcรฉdent', + 'next' => 'Suivant', + 'empty' => 'Aucun abonnรฉ ne correspond.', + 'total' => 'Total', + 'page' => 'Page', + 'profile' => '๐Ÿ‘ค Fiche abonnรฉ', + 'username' => 'Pseudo', + 'first_name' => 'Prรฉnom', + 'last_name' => 'Nom', + 'type' => 'Type', + 'language' => 'Langue', + 'location' => 'Lieu', + 'timezone' => 'Fuseau horaire', + 'country' => 'Pays', + 'first_activity' => 'Premiรจre activitรฉ', + 'last_activity' => 'Derniรจre activitรฉ', + 'activities' => 'Actions observรฉes', + 'starts' => 'Dรฉmarrages du bot', + 'last_start' => 'Dernier dรฉmarrage', + 'premium' => 'Premium', + 'deleted' => 'Compte supprimรฉ', + 'contact' => 'Contact', + 'group_admin' => 'Administrateur de groupe', + 'role_observation' => 'Rรดles de groupe vรฉrifiรฉs', + 'observed' => 'Vรฉrifiรฉ', + 'historical' => 'Observations enregistrรฉes ; les droits sont revรฉrifiรฉs avant modification.', + 'registered' => 'Groupe inscrit', + 'enabled' => 'Actif', + 'mode' => 'Mode', + 'modules' => 'Modules actifs', + 'authorized' => 'Diffusion autorisรฉe', + 'authorize' => 'Autoriser la diffusion', + 'unauthorize' => 'Retirer lโ€™autorisation', + 'stats' => '๐Ÿ“Š Statistiques de gestion', + 'subscribers' => 'Abonnรฉs', + 'active_24h' => 'Actifs sur 24 heures', + 'active_7d' => 'Actifs sur 7 jours', + 'active_30d' => 'Actifs sur 30 jours', + 'registered_groups' => 'Groupes inscrits', + 'active_groups' => 'Groupes actifs', + 'locking' => 'Protection active', + 'locked' => 'Actuellement fermรฉs', + 'recovery' => 'ร€ vรฉrifier', + 'pending_jobs' => 'Tรขches en attente', + 'failed_jobs' => 'Tรขches รฉchouรฉes', + 'stats_note' => 'Les mesures dรฉbutent avec cette version ; lโ€™historique absent nโ€™est pas reconstruit.', + 'broadcast' => '๐Ÿ“ฎ Diffusion', + 'send' => 'Envoyer une diffusion', + 'edit' => 'Modifier la derniรจre diffusion', + 'content' => 'Modifier le contenu', + 'buttons' => 'Modifier les boutons', + 'preview' => 'Aperรงu', + 'pin' => 'ร‰pingler le message', + 'on' => 'Activรฉ', + 'off' => 'Dรฉsactivรฉ', + 'audience' => 'Destinataires', + 'groups_channels' => 'Groupes et chaรฎnes', + 'authorized_channels' => 'Chaรฎnes autorisรฉes', + 'authorized_groups' => 'Groupes autorisรฉs', + 'authorized_groups_channels' => 'Groupes et chaรฎnes autorisรฉs', + 'self_destruct' => 'Suppression automatique', + 'hours' => 'heures', + 'schedule' => 'Planification', + 'now' => 'Maintenant', + 'custom' => 'Autre', + 'cancel' => 'Annuler', + 'sd_prompt' => 'Envoyez un nombre entier de 1 ร  48 heures, ou off.', + 'schedule_prompt' => 'Envoyez des minutes ou YYYY-MM-DD HH:MM dans votre fuseau. off pour envoyer maintenant.', + 'confirm' => 'Passer ร  la confirmation', + 'progress' => 'Derniรจre diffusion', + 'jobs' => 'Diffusions planifiรฉes', + 'delete_last' => 'Supprimer la derniรจre diffusion', + 'delete_all' => 'Supprimer toutes les diffusions', + 'unpin' => 'Dรฉsรฉpingler les messages', + 'no_draft' => 'Ajoutez du texte, un mรฉdia ou les deux. Conservez la mise en forme et ajoutez des boutons.', + 'operation' => 'Action', + 'send_note' => 'Lโ€™envoi et la suppression demandent confirmation. Les modifications lโ€™invalident.', + 'edit_note' => 'La derniรจre diffusion enregistrรฉe sera modifiรฉe. Sans planification ni suppression automatique.', + ], + 'es' => [ + 'directory' => '๐Ÿ‘ฅ Suscriptores', + 'search' => '๐Ÿ”Ž Buscar', + 'search_prompt' => 'Envรญa un nombre, usuario o ID. Para borrar: -.', + 'page_prompt' => 'Envรญa un nรบmero de pรกgina.', + 'sort' => 'โ†•๏ธ Orden', + 'filter' => '๐Ÿ—‚ Filtro', + 'clear' => '๐Ÿงน Borrar bรบsqueda', + 'refresh' => '๐Ÿ”„ Actualizar', + 'activity' => 'Actividad reciente', + 'joined' => 'Registro', + 'name' => 'Nombre', + 'id' => 'ID', + 'all' => 'Todos', + 'users' => 'Usuarios', + 'groups' => 'Grupos', + 'channels' => 'Canales', + 'bots' => 'Bots', + 'unknown' => 'Desconocido', + 'yes' => 'Sรญ', + 'no' => 'No', + 'back' => 'Volver', + 'previous' => 'Anterior', + 'next' => 'Siguiente', + 'empty' => 'No hay suscriptores con estos criterios.', + 'total' => 'Total', + 'page' => 'Pรกgina', + 'profile' => '๐Ÿ‘ค Ficha del suscriptor', + 'username' => 'Usuario', + 'first_name' => 'Nombre', + 'last_name' => 'Apellido', + 'type' => 'Tipo', + 'language' => 'Idioma', + 'location' => 'Ubicaciรณn', + 'timezone' => 'Zona horaria', + 'country' => 'Paรญs', + 'first_activity' => 'Primera actividad', + 'last_activity' => 'รšltima actividad', + 'activities' => 'Acciones observadas', + 'starts' => 'Inicios del bot', + 'last_start' => 'รšltimo inicio', + 'premium' => 'Premium', + 'deleted' => 'Cuenta eliminada', + 'contact' => 'Contacto', + 'group_admin' => 'Administrador de grupo', + 'role_observation' => 'Roles de grupo verificados', + 'observed' => 'Verificado', + 'historical' => 'Observaciones guardadas; los permisos se verifican antes de cambios.', + 'registered' => 'Grupo registrado', + 'enabled' => 'Activo', + 'mode' => 'Modo', + 'modules' => 'Mรณdulos activos', + 'authorized' => 'Difusiรณn autorizada', + 'authorize' => 'Autorizar difusiรณn', + 'unauthorize' => 'Retirar autorizaciรณn', + 'stats' => '๐Ÿ“Š Estadรญsticas de administraciรณn', + 'subscribers' => 'Suscriptores', + 'active_24h' => 'Activos en 24 horas', + 'active_7d' => 'Activos en 7 dรญas', + 'active_30d' => 'Activos en 30 dรญas', + 'registered_groups' => 'Grupos registrados', + 'active_groups' => 'Grupos activos', + 'locking' => 'Protecciรณn activa', + 'locked' => 'Cerrados ahora', + 'recovery' => 'Requieren atenciรณn', + 'pending_jobs' => 'Tareas pendientes', + 'failed_jobs' => 'Tareas fallidas', + 'stats_note' => 'Las mรฉtricas comienzan con esta versiรณn; no se reconstruye el historial ausente.', + 'broadcast' => '๐Ÿ“ฎ Difusiรณn', + 'send' => 'Enviar difusiรณn', + 'edit' => 'Editar la รบltima difusiรณn', + 'content' => 'Editar contenido', + 'buttons' => 'Editar botones', + 'preview' => 'Vista previa', + 'pin' => 'Fijar mensaje', + 'on' => 'Activado', + 'off' => 'Desactivado', + 'audience' => 'Destinatarios', + 'groups_channels' => 'Grupos y canales', + 'authorized_channels' => 'Canales autorizados', + 'authorized_groups' => 'Grupos autorizados', + 'authorized_groups_channels' => 'Grupos y canales autorizados', + 'self_destruct' => 'Eliminaciรณn automรกtica', + 'hours' => 'horas', + 'schedule' => 'Programaciรณn', + 'now' => 'Ahora', + 'custom' => 'Otro', + 'cancel' => 'Cancelar', + 'sd_prompt' => 'Envรญa un nรบmero entero de 1 a 48 horas, u off.', + 'schedule_prompt' => 'Envรญa minutos o YYYY-MM-DD HH:MM en tu zona horaria. off para enviar ahora.', + 'confirm' => 'Continuar a confirmaciรณn', + 'progress' => 'Datos de la รบltima difusiรณn', + 'jobs' => 'Difusiones programadas', + 'delete_last' => 'Eliminar รบltima difusiรณn', + 'delete_all' => 'Eliminar todas las difusiones', + 'unpin' => 'Desfijar mensajes', + 'no_draft' => 'Aรฑade texto, archivos o ambos. Conserva el formato y aรฑade botones.', + 'operation' => 'Acciรณn', + 'send_note' => 'Enviar y eliminar requieren confirmaciรณn. Los cambios invalidan la anterior.', + 'edit_note' => 'Se edita la รบltima difusiรณn registrada. Sin programaciรณn ni eliminaciรณn automรกtica.', + ], + 'pt-br' => [ + 'directory' => '๐Ÿ‘ฅ Inscritos', + 'search' => '๐Ÿ”Ž Buscar', + 'search_prompt' => 'Envie um nome, usuรกrio ou ID. Para limpar: -.', + 'page_prompt' => 'Envie um nรบmero de pรกgina.', + 'sort' => 'โ†•๏ธ Ordenaรงรฃo', + 'filter' => '๐Ÿ—‚ Filtro', + 'clear' => '๐Ÿงน Limpar busca', + 'refresh' => '๐Ÿ”„ Atualizar', + 'activity' => 'Atividade recente', + 'joined' => 'Cadastro', + 'name' => 'Nome', + 'id' => 'ID', + 'all' => 'Todos', + 'users' => 'Usuรกrios', + 'groups' => 'Grupos', + 'channels' => 'Canais', + 'bots' => 'Bots', + 'unknown' => 'Desconhecido', + 'yes' => 'Sim', + 'no' => 'Nรฃo', + 'back' => 'Voltar', + 'previous' => 'Anterior', + 'next' => 'Prรณximo', + 'empty' => 'Nenhum inscrito corresponde.', + 'total' => 'Total', + 'page' => 'Pรกgina', + 'profile' => '๐Ÿ‘ค Perfil do inscrito', + 'username' => 'Usuรกrio', + 'first_name' => 'Nome', + 'last_name' => 'Sobrenome', + 'type' => 'Tipo', + 'language' => 'Idioma', + 'location' => 'Local', + 'timezone' => 'Fuso horรกrio', + 'country' => 'Paรญs', + 'first_activity' => 'Primeira atividade', + 'last_activity' => 'รšltima atividade', + 'activities' => 'Aรงรตes observadas', + 'starts' => 'Inรญcios do bot', + 'last_start' => 'รšltimo inรญcio', + 'premium' => 'Premium', + 'deleted' => 'Conta excluรญda', + 'contact' => 'Contato', + 'group_admin' => 'Administrador de grupo', + 'role_observation' => 'Funรงรตes verificadas em grupos', + 'observed' => 'Verificado', + 'historical' => 'Observaรงรตes salvas; as permissรตes sรฃo verificadas antes de alteraรงรตes.', + 'registered' => 'Grupo cadastrado', + 'enabled' => 'Ativo', + 'mode' => 'Modo', + 'modules' => 'Mรณdulos ativos', + 'authorized' => 'Transmissรฃo autorizada', + 'authorize' => 'Autorizar transmissรฃo', + 'unauthorize' => 'Remover autorizaรงรฃo', + 'stats' => '๐Ÿ“Š Estatรญsticas de administraรงรฃo', + 'subscribers' => 'Inscritos', + 'active_24h' => 'Ativos em 24 horas', + 'active_7d' => 'Ativos em 7 dias', + 'active_30d' => 'Ativos em 30 dias', + 'registered_groups' => 'Grupos cadastrados', + 'active_groups' => 'Grupos ativos', + 'locking' => 'Proteรงรฃo ativa', + 'locked' => 'Fechados agora', + 'recovery' => 'Exigem atenรงรฃo', + 'pending_jobs' => 'Tarefas pendentes', + 'failed_jobs' => 'Tarefas com falha', + 'stats_note' => 'As mรฉtricas comeรงam nesta versรฃo; o histรณrico ausente nรฃo รฉ reconstruรญdo.', + 'broadcast' => '๐Ÿ“ฎ Transmissรฃo', + 'send' => 'Enviar transmissรฃo', + 'edit' => 'Editar a รบltima transmissรฃo', + 'content' => 'Editar conteรบdo', + 'buttons' => 'Editar botรตes', + 'preview' => 'Prรฉvia', + 'pin' => 'Fixar mensagem', + 'on' => 'Ativado', + 'off' => 'Desativado', + 'audience' => 'Destinatรกrios', + 'groups_channels' => 'Grupos e canais', + 'authorized_channels' => 'Canais autorizados', + 'authorized_groups' => 'Grupos autorizados', + 'authorized_groups_channels' => 'Grupos e canais autorizados', + 'self_destruct' => 'Exclusรฃo automรกtica', + 'hours' => 'horas', + 'schedule' => 'Agendamento', + 'now' => 'Agora', + 'custom' => 'Outro', + 'cancel' => 'Cancelar', + 'sd_prompt' => 'Envie um nรบmero inteiro de 1 a 48 horas, ou off.', + 'schedule_prompt' => 'Envie minutos ou YYYY-MM-DD HH:MM no seu fuso. off para envio imediato.', + 'confirm' => 'Continuar para confirmaรงรฃo', + 'progress' => 'Dados da รบltima transmissรฃo', + 'jobs' => 'Transmissรตes agendadas', + 'delete_last' => 'Excluir รบltima transmissรฃo', + 'delete_all' => 'Excluir todas as transmissรตes', + 'unpin' => 'Desafixar mensagens', + 'no_draft' => 'Adicione texto, mรญdia ou ambos. Preserve a formataรงรฃo e adicione botรตes.', + 'operation' => 'Aรงรฃo', + 'send_note' => 'Enviar e excluir exigem confirmaรงรฃo. Alteraรงรตes invalidam a anterior.', + 'edit_note' => 'A ediรงรฃo afeta a รบltima transmissรฃo registrada. Sem agendamento nem exclusรฃo automรกtica.', + ], + ]; + + public static function text(string $language, string $key): string + { + return self::EXTRA[$language][$key] ?? self::TEXT[$language][$key] ?? throw new \InvalidArgumentException('Missing administration translation.'); + } + + public static function keys(string $language): array + { + return array_keys((self::TEXT[$language] ?? throw new \InvalidArgumentException('Unsupported language.')) + self::EXTRA[$language]); + } +} diff --git a/src/UI/AdminText.php b/src/UI/AdminText.php new file mode 100644 index 0000000..ae3e1ad --- /dev/null +++ b/src/UI/AdminText.php @@ -0,0 +1,78 @@ + ["โš™๏ธ ื ื™ื”ื•ืœ ื”ื‘ื•ื˜","โš™๏ธ Bot administration","โš™๏ธ ะฃะฟั€ะฐะฒะปะตะฝะธะต ะฑะพั‚ะพะผ","โš™๏ธ Administration du bot","โš™๏ธ Administraciรณn del bot","โš™๏ธ Administraรงรฃo do bot"], + 'managers' => ["๐Ÿ‘ฅ ืžื ื”ืœื™ื ื•ื”ืจืฉืื•ืช","๐Ÿ‘ฅ Managers and permissions","๐Ÿ‘ฅ ะœะตะฝะตะดะถะตั€ั‹ ะธ ะฟั€ะฐะฒะฐ","๐Ÿ‘ฅ Gestionnaires et droits","๐Ÿ‘ฅ Gestores y permisos","๐Ÿ‘ฅ Gestores e permissรตes"], + 'add' => ["โž• ื”ื•ืกืคืช ืžื ื”ืœ","โž• Add manager","โž• ะ”ะพะฑะฐะฒะธั‚ัŒ ะผะตะฝะตะดะถะตั€ะฐ","โž• Ajouter un gestionnaire","โž• Aรฑadir gestor","โž• Adicionar gestor"], + 'edit' => ["๐ŸŽ› ืฉื™ื ื•ื™ ื”ืจืฉืื•ืช","๐ŸŽ› Edit permissions","๐ŸŽ› ะ˜ะทะผะตะฝะธั‚ัŒ ะฟั€ะฐะฒะฐ","๐ŸŽ› Modifier les droits","๐ŸŽ› Editar permisos","๐ŸŽ› Editar permissรตes"], + 'remove' => ["๐Ÿ—‘ ื”ืกืจืช ืžื ื”ืœ","๐Ÿ—‘ Remove manager","๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ะผะตะฝะตะดะถะตั€ะฐ","๐Ÿ—‘ Retirer le gestionnaire","๐Ÿ—‘ Eliminar gestor","๐Ÿ—‘ Remover gestor"], + 'full' => ["ื”ืจืฉืื•ืช ืžืœืื•ืช","Full permissions","ะŸะพะปะฝั‹ะต ะฟั€ะฐะฒะฐ","Tous les droits","Permisos completos","Permissรตes completas"], + 'limited' => ["ื”ืจืฉืื•ืช ื ื‘ื—ืจื•ืช","Selected permissions","ะ’ั‹ะฑั€ะฐะฝะฝั‹ะต ะฟั€ะฐะฒะฐ","Droits sรฉlectionnรฉs","Permisos seleccionados","Permissรตes selecionadas"], + 'view_only' => ["ืฆืคื™ื™ื” ื‘ืœื‘ื“","View only","ะขะพะปัŒะบะพ ะฟั€ะพัะผะพั‚ั€","Lecture seule","Solo lectura","Somente leitura"], + 'immutable' => ["ื‘ืขืœื™ื ืžื•ื’ืŸ ืžื”ื’ื“ืจื•ืช ื”ืฉืจืช","Protected configured owner","ะ—ะฐั‰ะธั‰ั‘ะฝะฝั‹ะน ะฒะปะฐะดะตะปะตั†","Propriรฉtaire protรฉgรฉ","Propietario protegido","Proprietรกrio protegido"], + 'statistics' => ["๐Ÿ“Š ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช","๐Ÿ“Š Statistics","๐Ÿ“Š ะกั‚ะฐั‚ะธัั‚ะธะบะฐ","๐Ÿ“Š Statistiques","๐Ÿ“Š Estadรญsticas","๐Ÿ“Š Estatรญsticas"], + 'subscribers' => ["๐Ÿ‘ฅ ืžื ื•ื™ื™ื","๐Ÿ‘ฅ Subscribers","๐Ÿ‘ฅ ะŸะพะดะฟะธัั‡ะธะบะธ","๐Ÿ‘ฅ Abonnรฉs","๐Ÿ‘ฅ Suscriptores","๐Ÿ‘ฅ Assinantes"], + 'broadcast' => ["๐Ÿ“ฃ ืžืขืจื›ืช ืฉื™ื“ื•ืจ","๐Ÿ“ฃ Broadcasts","๐Ÿ“ฃ ะ ะฐััั‹ะปะบะธ","๐Ÿ“ฃ Diffusions","๐Ÿ“ฃ Difusiones","๐Ÿ“ฃ Transmissรตes"], + 'commands' => ["๐Ÿ“‹ ื”ื’ื“ืจืช ืชืคืจื™ื˜ ืคืงื•ื“ื•ืช","๐Ÿ“‹ Command menu setup","๐Ÿ“‹ ะะฐัั‚ั€ะพะนะบะฐ ะบะพะผะฐะฝะด","๐Ÿ“‹ Configuration des commandes","๐Ÿ“‹ Configurar comandos","๐Ÿ“‹ Configurar comandos"], + 'system_links' => ["โš™๏ธ ืงื™ืฉื•ืจื™ ืžืขืจื›ืช","โš™๏ธ System links","โš™๏ธ ะกะธัั‚ะตะผะฝั‹ะต ััั‹ะปะบะธ","โš™๏ธ Liens systรจme","โš™๏ธ Enlaces del sistema","โš™๏ธ Links do sistema"], + 'tracking_links' => ["โ„น๏ธ ืงื™ืฉื•ืจื™ ืžืขืงื‘","โ„น๏ธ Tracking links","โ„น๏ธ ะกัั‹ะปะบะธ ะพั‚ัะปะตะถะธะฒะฐะฝะธั","โ„น๏ธ Liens de suivi","โ„น๏ธ Enlaces de seguimiento","โ„น๏ธ Links de rastreamento"], + 'add_admins' => ["ื”ื•ืกืคืช ืžื ื”ืœื™ื","Add managers","ะ”ะพะฑะฐะฒะปะตะฝะธะต ะผะตะฝะตะดะถะตั€ะพะฒ","Ajouter des gestionnaires","Aรฑadir gestores","Adicionar gestores"], + 'manage_admins' => ["ื ื™ื”ื•ืœ ืžื ื”ืœื™ื ื•ื”ืจืฉืื•ืช","Manage managers and permissions","ะฃะฟั€ะฐะฒะปะตะฝะธะต ะผะตะฝะตะดะถะตั€ะฐะผะธ ะธ ะฟั€ะฐะฒะฐะผะธ","Gรฉrer les gestionnaires et droits","Gestionar gestores y permisos","Gerenciar gestores e permissรตes"], + 'group_operations' => ["ืคืชื™ื—ื” ื•ืกื’ื™ืจื” ื™ื“ื ื™ืช","Manual group opening and closing","ะ ัƒั‡ะฝะพะต ะพั‚ะบั€ั‹ั‚ะธะต ะธ ะทะฐะบั€ั‹ั‚ะธะต","Ouverture et fermeture manuelles","Apertura y cierre manual","Abertura e fechamento manual"], + 'system_operations' => ["ืชืคืขื•ืœ ื•ืชื—ื–ื•ืงืช ื”ืžืขืจื›ืช","System maintenance","ะžะฑัะปัƒะถะธะฒะฐะฝะธะต ัะธัั‚ะตะผั‹","Maintenance du systรจme","Mantenimiento del sistema","Manutenรงรฃo do sistema"], + 'save' => ["๐Ÿ’พ ืฉืžื™ืจื”","๐Ÿ’พ Save","๐Ÿ’พ ะกะพั…ั€ะฐะฝะธั‚ัŒ","๐Ÿ’พ Enregistrer","๐Ÿ’พ Guardar","๐Ÿ’พ Salvar"], + 'back' => ["โฌ…๏ธ ื—ื–ืจื”","โฌ…๏ธ Back","โฌ…๏ธ ะะฐะทะฐะด","โฌ…๏ธ Retour","โฌ…๏ธ Volver","โฌ…๏ธ Voltar"], + 'cancel' => ["ื‘ื™ื˜ื•ืœ","Cancel","ะžั‚ะผะตะฝะฐ","Annuler","Cancelar","Cancelar"], + 'next' => ["ื”ื‘ื โžก๏ธ","Next โžก๏ธ","ะ”ะฐะปะตะต โžก๏ธ","Suivant โžก๏ธ","Siguiente โžก๏ธ","Prรณximo โžก๏ธ"], + 'previous' => ["โฌ…๏ธ ื”ืงื•ื“ื","โฌ…๏ธ Previous","โฌ…๏ธ ะะฐะทะฐะด","โฌ…๏ธ Prรฉcรฉdent","โฌ…๏ธ Anterior","โฌ…๏ธ Anterior"], + 'refresh' => ["๐Ÿ”„ ืจืขื ื•ืŸ","๐Ÿ”„ Refresh","๐Ÿ”„ ะžะฑะฝะพะฒะธั‚ัŒ","๐Ÿ”„ Actualiser","๐Ÿ”„ Actualizar","๐Ÿ”„ Atualizar"], + 'confirm' => ["โœ… ืื™ืฉื•ืจ","โœ… Confirm","โœ… ะŸะพะดั‚ะฒะตั€ะดะธั‚ัŒ","โœ… Confirmer","โœ… Confirmar","โœ… Confirmar"], + 'confirm_remove' => ["ืœื”ืกื™ืจ ืืช ื”ื’ื™ืฉื” ืœื ื™ื”ื•ืœ ื”ื‘ื•ื˜?","Remove access to bot administration?","ะฃะดะฐะปะธั‚ัŒ ะดะพัั‚ัƒะฟ ะบ ัƒะฟั€ะฐะฒะปะตะฝะธัŽ ะฑะพั‚ะพะผ?","Retirer l'accรจs ร  l'administration du bot ?","ยฟEliminar acceso a la administraciรณn del bot?","Remover acesso ร  administraรงรฃo do bot?"], + 'prompt_admin' => ["ืฉืœื—ื• ืžื–ื”ื” Telegram ืžืกืคืจื™ ืฉืœ ื”ืžื ื”ืœ ื”ื—ื“ืฉ, ืื• ื”ืขื‘ื™ืจื• ื”ื•ื“ืขื” ืžืžื ื• ืื ื”ืžื–ื”ื” ื’ืœื•ื™. ื‘ื—ืจื• ื”ืจืฉืื•ืช ื•ืฉืžืจื•.","Send the new manager's numeric Telegram ID, or forward a message with visible sender ID. Select permissions and save.","ะžั‚ะฟั€ะฐะฒัŒั‚ะต ั‡ะธัะปะพะฒะพะน Telegram ID ะผะตะฝะตะดะถะตั€ะฐ ะธะปะธ ะฟะตั€ะตัˆะปะธั‚ะต ัะพะพะฑั‰ะตะฝะธะต ั ะฒะธะดะธะผั‹ะผ ID ะพั‚ะฟั€ะฐะฒะธั‚ะตะปั. ะ’ั‹ะฑะตั€ะธั‚ะต ะฟั€ะฐะฒะฐ ะธ ัะพั…ั€ะฐะฝะธั‚ะต.","Envoyez l'ID Telegram numรฉrique du gestionnaire ou transfรฉrez un message avec un ID visible. Choisissez les droits et enregistrez.","Envรญa el ID numรฉrico de Telegram del gestor o reenvรญa un mensaje con ID visible. Elige permisos y guarda.","Envie o ID numรฉrico do Telegram do gestor ou encaminhe uma mensagem com ID visรญvel. Escolha permissรตes e salve."], + 'prompt_tracking' => ["ืฉืœื—ื• ืคืจืžื˜ืจ ื™ื™ื—ื•ื“ื™: 1-64 ืื•ืชื™ื•ืช ื‘ืื ื’ืœื™ืช, ืกืคืจื•ืช, ืžืงืฃ ืื• ืงื• ืชื—ืชื•ืŸ. ืืคืฉืจ ืœื”ื•ืกื™ืฃ ืฉื ืื—ืจื™ ืจื•ื•ื—. ืœื“ื•ื’ืžื”: community ืงื”ื™ืœืช ื”ืฉื›ื•ื ื”","Send a unique parameter: 1-64 English letters, digits, hyphens or underscores. Add an optional label after a space. Example: community Neighborhood community","ะžั‚ะฟั€ะฐะฒัŒั‚ะต ัƒะฝะธะบะฐะปัŒะฝั‹ะน ะฟะฐั€ะฐะผะตั‚ั€: 1-64 ะปะฐั‚ะธะฝัะบะธั… ะฑัƒะบะฒั‹, ั†ะธั„ั€ั‹, ะดะตั„ะธัั‹ ะธะปะธ ะฟะพะดั‡ั‘ั€ะบะธะฒะฐะฝะธั. ะŸะพัะปะต ะฟั€ะพะฑะตะปะฐ ะผะพะถะฝะพ ะดะพะฑะฐะฒะธั‚ัŒ ะฝะฐะทะฒะฐะฝะธะต.","Envoyez un paramรจtre unique : 1-64 lettres latines, chiffres, tirets ou traits de soulignement. Ajoutez un nom aprรจs un espace.","Envรญa un parรกmetro รบnico: 1-64 letras inglesas, dรญgitos, guiones o guiones bajos. Puedes aรฑadir un nombre tras un espacio.","Envie um parรขmetro รบnico: 1-64 letras em inglรชs, nรบmeros, hรญfens ou sublinhados. Adicione um nome apรณs um espaรงo."], + 'create_tracking' => ["โž• ื™ืฆื™ืจืช ืงื™ืฉื•ืจ","โž• Create link","โž• ะกะพะทะดะฐั‚ัŒ ััั‹ะปะบัƒ","โž• Crรฉer un lien","โž• Crear enlace","โž• Criar link"], + 'destination' => ["ืชืคืจื™ื˜ ื™ืขื“","Destination menu","ะœะตะฝัŽ ะฝะฐะทะฝะฐั‡ะตะฝะธั","Menu de destination","Menรบ de destino","Menu de destino"], + 'reset' => ["โ™ป๏ธ ืื™ืคื•ืก ื ืชื•ื ื™ื","โ™ป๏ธ Reset statistics","โ™ป๏ธ ะกะฑั€ะพัะธั‚ัŒ ัั‚ะฐั‚ะธัั‚ะธะบัƒ","โ™ป๏ธ Rรฉinitialiser les statistiques","โ™ป๏ธ Reiniciar estadรญsticas","โ™ป๏ธ Redefinir estatรญsticas"], + 'delete' => ["๐Ÿ—‘ ืžื—ื™ืงืช ืงื™ืฉื•ืจ","๐Ÿ—‘ Delete link","๐Ÿ—‘ ะฃะดะฐะปะธั‚ัŒ ััั‹ะปะบัƒ","๐Ÿ—‘ Supprimer le lien","๐Ÿ—‘ Eliminar enlace","๐Ÿ—‘ Excluir link"], + 'confirm_tracking' => ["ื”ืคืขื•ืœื” ืชืฉื ื” ืืช ืงื™ืฉื•ืจ ื”ืžืขืงื‘ ืื• ืืช ื”ื ืชื•ื ื™ื ืฉืœื•. ืœื”ืžืฉื™ืš?","This changes the tracking link or its statistics. Continue?","ะญั‚ะพ ะธะทะผะตะฝะธั‚ ััั‹ะปะบัƒ ะธะปะธ ะตั‘ ัั‚ะฐั‚ะธัั‚ะธะบัƒ. ะŸั€ะพะดะพะปะถะธั‚ัŒ?","Cette action modifie le lien ou ses statistiques. Continuer ?","Esto modifica el enlace o sus estadรญsticas. ยฟContinuar?","Isso modifica o link ou suas estatรญsticas. Continuar?"], + 'analytics_note' => ["ื ืกืคืจื•ืช ื”ืคืขืœื•ืช /start ืชืงื™ื ื•ืช ื‘ืœื‘ื“. ืžื–ื”ื™ ืžื‘ืงืจื™ื ื ืฉืžืจื™ื ื›ื—ืชื™ืžื•ืช ืขื ืžืคืชื— ืœืกืคื™ืจื” ื™ื™ื—ื•ื“ื™ืช, ื‘ืœื™ ื”ื™ืกื˜ื•ืจื™ื™ืช ืœื—ื™ืฆื•ืช.","Only valid /start activations count. Visitor IDs are stored as keyed hashes for unique counts, without click histories.","ะฃั‡ะธั‚ั‹ะฒะฐัŽั‚ัั ั‚ะพะปัŒะบะพ ะทะฐะฟัƒัะบะธ /start. ID ะฟะพัะตั‚ะธั‚ะตะปะตะน ัะพั…ั€ะฐะฝััŽั‚ัั ะบะฐะบ ั…ะตัˆะธ ั ะบะปัŽั‡ะพะผ ะฑะตะท ะธัั‚ะพั€ะธะธ ะบะปะธะบะพะฒ.","Seuls les lancements /start sont comptรฉs. Les ID sont hachรฉs avec une clรฉ, sans historique des clics.","Solo se cuentan activaciones /start. Los ID se guardan como hashes con clave, sin historial de clics.","Apenas ativaรงรตes /start sรฃo contadas. IDs sรฃo armazenados como hashes com chave, sem histรณrico de cliques."], + 'total' => ["ืกื”ืดื› ื”ืคืขืœื•ืช","Total activations","ะ’ัะตะณะพ ะทะฐะฟัƒัะบะพะฒ","Activations totales","Activaciones totales","Total de ativaรงรตes"], + 'unique' => ["ืžืฉืชืžืฉื™ื ื™ื™ื—ื•ื“ื™ื™ื","Unique users","ะฃะฝะธะบะฐะปัŒะฝั‹ะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปะธ","Utilisateurs uniques","Usuarios รบnicos","Usuรกrios รบnicos"], + 'repeat' => ["ื”ืคืขืœื•ืช ื—ื•ื–ืจื•ืช","Repeat activations","ะŸะพะฒั‚ะพั€ะฝั‹ะต ะทะฐะฟัƒัะบะธ","Activations rรฉpรฉtรฉes","Activaciones repetidas","Ativaรงรตes repetidas"], + 'first' => ["ื”ืคืขืœื” ืจืืฉื•ื ื”","First activation","ะŸะตั€ะฒั‹ะน ะทะฐะฟัƒัะบ","Premiรจre activation","Primera activaciรณn","Primeira ativaรงรฃo"], + 'last' => ["ื”ืคืขืœื” ืื—ืจื•ื ื”","Last activation","ะŸะพัะปะตะดะฝะธะน ะทะฐะฟัƒัะบ","Derniรจre activation","รšltima activaciรณn","รšltima ativaรงรฃo"], + 'created' => ["ื ื•ืฆืจ","Created","ะกะพะทะดะฐะฝะพ","Crรฉรฉ","Creado","Criado"], + 'days' => ["ืคืขื™ืœื•ืช ื‘-7 ื”ื™ืžื™ื ื”ืื—ืจื•ื ื™ื (UTC)","Activity in the last 7 days (UTC)","ะะบั‚ะธะฒะฝะพัั‚ัŒ ะทะฐ 7 ะดะฝะตะน (UTC)","Activitรฉ des 7 derniers jours (UTC)","Actividad de los รบltimos 7 dรญas (UTC)","Atividade dos รบltimos 7 dias (UTC)"], + 'empty' => ["ืื™ืŸ ืขื“ื™ื™ืŸ ื ืชื•ื ื™ื","No data yet","ะ”ะฐะฝะฝั‹ั… ะฟะพะบะฐ ะฝะตั‚","Aucune donnรฉe","Aรบn no hay datos","Ainda nรฃo hรก dados"], + 'home' => ["โœก๏ธ ืชืคืจื™ื˜ ืจืืฉื™","โœก๏ธ Main menu","โœก๏ธ ะ“ะปะฐะฒะฝะพะต ะผะตะฝัŽ","โœก๏ธ Menu principal","โœก๏ธ Menรบ principal","โœก๏ธ Menu principal"], + 'preferences' => ["โš™๏ธ ื”ื”ืขื“ืคื•ืช ืฉืœื™","โš™๏ธ My preferences","โš™๏ธ ะœะพะธ ะฝะฐัั‚ั€ะพะนะบะธ","โš™๏ธ Mes prรฉfรฉrences","โš™๏ธ Mis preferencias","โš™๏ธ Minhas preferรชncias"], + 'language' => ["๐ŸŒ ืฉืคื”","๐ŸŒ Language","๐ŸŒ ะฏะทั‹ะบ","๐ŸŒ Langue","๐ŸŒ Idioma","๐ŸŒ Idioma"], + 'groups' => ["๐Ÿ‘ฅ ื”ืงื‘ื•ืฆื•ืช ืฉืœื™","๐Ÿ‘ฅ My groups","๐Ÿ‘ฅ ะœะพะธ ะณั€ัƒะฟะฟั‹","๐Ÿ‘ฅ Mes groupes","๐Ÿ‘ฅ Mis grupos","๐Ÿ‘ฅ Meus grupos"], + 'shabbat' => ["๐Ÿ•ฏ ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’","๐Ÿ•ฏ Shabbat and holidays","๐Ÿ•ฏ ะจะฐะฑะฑะฐั‚ ะธ ะฟั€ะฐะทะดะฝะธะบะธ","๐Ÿ•ฏ Chabbat et fรชtes","๐Ÿ•ฏ Shabat y fiestas","๐Ÿ•ฏ Shabat e festas"], + 'zmanim' => ["โ˜€๏ธ ื–ืžื ื™ ื”ื™ื•ื","โ˜€๏ธ Daily zmanim","โ˜€๏ธ ะ’ั€ะตะผะตะฝะฐ ะดะฝั","โ˜€๏ธ Horaires du jour","โ˜€๏ธ Horarios del dรญa","โ˜€๏ธ Horรกrios do dia"], + 'daf' => ["๐Ÿ“– ื“ืฃ ื™ื•ืžื™","๐Ÿ“– Daf Yomi","๐Ÿ“– ะ”ะฐั„ ะนะพะผะธ","๐Ÿ“– Daf Yomi","๐Ÿ“– Daf Yomi","๐Ÿ“– Daf Yomi"], + 'omer' => ["๐ŸŒพ ืกืคื™ืจืช ื”ืขื•ืžืจ","๐ŸŒพ Counting the Omer","๐ŸŒพ ะกั‡ั‘ั‚ ะžะผะตั€ะฐ","๐ŸŒพ Compte de l'Omer","๐ŸŒพ Cuenta del ร“mer","๐ŸŒพ Contagem do ร”mer"], + 'help' => ["โ„น๏ธ ืžื™ื“ืข ื•ืขื–ืจื”","โ„น๏ธ Information and help","โ„น๏ธ ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะธ ะฟะพะผะพั‰ัŒ","โ„น๏ธ Informations et aide","โ„น๏ธ Informaciรณn y ayuda","โ„น๏ธ Informaรงรตes e ajuda"], + 'support' => ["โญ ืชืžื™ื›ื” ื•ืชืจื•ืžื”","โญ Support and donate","โญ ะŸะพะดะดะตั€ะถะบะฐ ะธ ะฟะพะถะตั€ั‚ะฒะพะฒะฐะฝะธะต","โญ Soutien et don","โญ Apoyo y donaciรณn","โญ Apoio e doaรงรฃo"], + 'source' => ["๐Ÿ’ป ืงื•ื“ ืžืงื•ืจ","๐Ÿ’ป Source code","๐Ÿ’ป ะ˜ัั…ะพะดะฝั‹ะน ะบะพะด","๐Ÿ’ป Code source","๐Ÿ’ป Cรณdigo fuente","๐Ÿ’ป Cรณdigo-fonte"], + 'license' => ["โ„น๏ธ ืจื™ืฉื™ื•ืŸ","โ„น๏ธ License","โ„น๏ธ ะ›ะธั†ะตะฝะทะธั","โ„น๏ธ Licence","โ„น๏ธ Licencia","โ„น๏ธ Licenรงa"], + 'version' => ["โ„น๏ธ ื’ืจืกืช ื”ื‘ื•ื˜","โ„น๏ธ Bot version","โ„น๏ธ ะ’ะตั€ัะธั ะฑะพั‚ะฐ","โ„น๏ธ Version du bot","โ„น๏ธ Versiรณn del bot","โ„น๏ธ Versรฃo do bot"], + 'commands_note' => ["ืคืงื•ื“ื•ืช ืœืคื™ ื”ื”ืจืฉืื•ืช ืฉืœื›ื. ืคืชื™ื—ืช ืงื™ืฉื•ืจ ืื™ื ื” ืžืฉื ื” ื”ื’ื“ืจื•ืช.","Commands depend on your permissions. Opening a link does not change settings.","ะšะพะผะฐะฝะดั‹ ะทะฐะฒะธััั‚ ะพั‚ ะฒะฐัˆะธั… ะฟั€ะฐะฒ. ะžั‚ะบั€ั‹ั‚ะธะต ััั‹ะปะบะธ ะฝะต ะผะตะฝัะตั‚ ะฝะฐัั‚ั€ะพะนะบะธ.","Les commandes dรฉpendent de vos droits. Ouvrir un lien ne modifie pas les rรฉglages.","Los comandos dependen de tus permisos. Abrir un enlace no cambia ajustes.","Os comandos dependem das suas permissรตes. Abrir um link nรฃo muda configuraรงรตes."], + ]; + public static function text(string $language, string $key): string + { + $index = array_search(Lang::canonical($language), Lang::CODES, true); + return self::WORDS[$key][$index] ?? throw new \InvalidArgumentException('Missing admin translation ' . $key); + } + public static function keys(): array + { + return array_keys(self::WORDS); + } +} diff --git a/src/UI/BotController.php b/src/UI/BotController.php new file mode 100644 index 0000000..37ec0db --- /dev/null +++ b/src/UI/BotController.php @@ -0,0 +1,1636 @@ + ['candle_minutes','havdalah_method','havdalah_value','close_before_candles_minutes','open_after_havdalah_minutes','close_message_enabled','open_message_enabled','times_alert_enabled','times_alert_time','times_alert_mode','times_alert_before_minutes','quiet_start','quiet_end','target_topic_id','lock_block_flags','delete_holy_user_messages','delete_holy_bot_messages'], + 'zmanim' => ['time','days','format'], 'daf' => ['time','days','template'], + 'tefillin' => ['days','fixed_times','interval_minutes','window_start','window_end','sunset_offsets','max_per_day','min_spacing_minutes','chol_hamoed','tisha_bav_policy','sunset_safety_minutes','quiet_start','quiet_end','template','delete_previous','ack_enabled','ack_label'], + 'omer' => ['time','mode','after_nightfall_minutes','nightfall_method','nightfall_value','nusach','format','day_completion','early_policy','after_open_minutes'], + ]; + public function __construct(private SettingsRepository $settings, private FlowTokenRepository $flows, private CalendarService $calendar, private UiGateway $gateway, private GeoNames $geo, private Clock $clock, ?Lang $lang = null, ?callable $authorizeOperator = null, private ?PermissionRecovery $permissionRecovery = null) + { + $this->lang = $lang ?? new Lang(); + $this->authorizeOperator = $authorizeOperator === null ? static fn(string $user): bool => false : \Closure::fromCallable($authorizeOperator); + } + /** Message: user_id,text,private,group_id?,message_id?,location?. */ + public function handleMessage(array $message): bool + { + $user = (string) ($message['user_id'] ?? ''); + if (!preg_match('/^[0-9]{1,20}$/D', $user)) { + return false; + } + if (($message['private'] ?? false) && $this->wizardInput($user, $message)) { + return true; + } + if (($message['private'] ?? false) && $this->locationInput($user, $message)) { + return true; + } + if (isset($message['location']) && ($message['private'] ?? false)) { + try { + if (!($message['replied_bot'] ?? false) || !preg_match('/^GPS ([A-Za-z0-9_-]{24})$/m', (string) ($message['reply_text'] ?? ''), $gpsToken)) { + throw new \RuntimeException($this->t($user, 'gps_reply_required')); + } + $context = $this->context($user, $gpsToken[1], 'gps'); + $gps = $message['location']; + $place = $this->coordinatePlace($user, $gps['latitude'] ?? $gps['lat'] ?? null, $gps['longitude'] ?? $gps['long'] ?? null, $context['_flow']['payload']['timezone_id'], 'GPS', 'telegram_gps'); + $this->changes($user, $context, $place, true); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->sendError($user, $error); + } + return true; + } + $text = trim((string) ($message['text'] ?? '')); + if (!preg_match('~^[/!.]([a-z]+)(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$~is', $text, $match)) { + return false; + } + $command = strtolower($match[1]); + $arguments = trim($match[2] ?? ''); + if (!in_array($command, ['start','settings','groups','preferences','language','lang','shabat','shabbat','zmanim','daf','omer','help','city','coordinates','location','timezone','configure','viewdate'], true)) { + return false; + } + try { + if ($command === 'settings') { + $group = isset($message['group_id']) ? (string) $message['group_id'] : $arguments; + if ($group === '') { + $this->groupList($user); + } else { + $this->panel($user, $this->group($user, $group)); + } + return true; + } + if (in_array($command, ['language','lang'], true)) { + $context = isset($message['group_id']) ? $this->group($user, (string) $message['group_id']) : $this->settings->getUser($user); + $this->screen($user, $context, 'language'); + return true; + } + if (!($message['private'] ?? false)) { + return false; + } + if ($command === 'start') { + if (preg_match('/^g_(-?[0-9]+)$/D', $arguments, $g)) { + $this->panel($user, $this->group($user, $g[1])); + } elseif (preg_match('/^loc_([A-Za-z0-9_-]{24})$/D', $arguments, $p)) { + $this->locationPreview($user, $p[1]); + } else { + $this->start($user); + } + } elseif ($command === 'groups') { + $this->groupList($user, 0, $arguments); + } elseif ($command === 'preferences') { + $this->panel($user, $this->settings->getUser($user)); + } elseif ($command === 'help') { + $this->help($user); + } elseif (in_array($command, ['shabat','shabbat','zmanim','daf','omer'], true)) { + $this->view($user, $this->settings->getUser($user), in_array($command, ['shabat','shabbat'], true) ? 'shabbat' : $command, $arguments ?: null); + } elseif ($command === 'city') { + [$token, $query] = $this->twoArguments($arguments); + $context = $this->context($user, $token, 'location'); + $this->cityResults($user, $context, $query); + } elseif ($command === 'coordinates') { + $parts = preg_split('/\s+/u', $arguments, 5); + if (count($parts) < 4) { + throw new \InvalidArgumentException('Use /coordinates TOKEN latitude longitude IANA-zone name'); + } + $context = $this->context($user, $parts[0], 'location'); + $place = $this->coordinatePlace($user, $parts[1], $parts[2], $parts[3], $parts[4] ?? 'Coordinates', 'coordinates'); + $this->changes($user, $context, $place, true); + } elseif ($command === 'location') { + [$token, $zone] = $this->twoArguments($arguments); + $context = $this->context($user, $token, 'location'); + if ($zone !== 'auto' && !in_array($zone, \DateTimeZone::listIdentifiers(), true)) { + throw new \InvalidArgumentException('An IANA timezone is required'); + } + if ($zone === 'auto' && !$this->geo->configured()) { + throw new \RuntimeException($this->t($user, 'auto_timezone_missing')); + } + $gpsToken = $this->token($user, $context, 'gps', ['timezone_id' => $zone]); + $this->send($user, $this->t($user, 'gps_prompt', ['title' => $this->title($user, $context), 'timezone' => $zone === 'auto' ? $this->t($user, 'timezone_auto') : $zone]) . "\nGPS " . $gpsToken); + } elseif ($command === 'timezone') { + [$token, $query] = $this->twoArguments($arguments); + $context = $this->context($user, $token, 'location'); + $buttons = []; + $zones = array_values(array_filter(\DateTimeZone::listIdentifiers(), static fn(string $zone): bool => stripos($zone, $query) !== false)); + foreach (array_slice($zones, 0, 25) as $zone) { + $choice = $this->token($user, $context, 'location_choice', ['timezone_id' => $zone]); + $buttons[] = [['text' => $zone, 'data' => 'sb:lp:' . $choice]]; + } + $this->send($user, $this->t($user, 'timezone_warning'), $this->keyboard($buttons)); + } elseif ($command === 'configure') { + [$token, $form] = $this->twoArguments($arguments); + $context = $this->context($user, $token, 'edit'); + $changes = $this->parseForm($context['_flow']['payload']['module'], $form); + if (!isset($context['group_id']) && array_diff(array_keys($changes), ['candle_minutes','havdalah_method','havdalah_value'])) { + throw new \InvalidArgumentException('Personal settings only accept candle and havdalah fields'); + } + $this->changes($user, $context, $changes); + } elseif ($command === 'viewdate') { + [$token, $date] = $this->twoArguments($arguments); + $context = $this->context($user, $token, 'date'); + $this->view($user, $context, $context['_flow']['payload']['module'], $this->date($date)); + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->sendError($user, $error); + } + return true; + } + /** Callback: user_id,data,query_id,message_id,private. */ + public function handleCallback(array $query): bool + { + $data = (string)($query['data'] ?? ''); + if (!str_starts_with($data, 'sb:')) { + return false; + } + // Inline city-result selection opens a private confirmation; it is not private-panel navigation. + if (($query['inline_selection'] ?? false) === true && str_starts_with($data, 'sb:lp:')) { + return $this->dispatchCallback($query); + } + try { + $target = CallbackUiGateway::fromQuery($this->gateway, $query); + } catch (\InvalidArgumentException) { + $user = (string)($query['user_id'] ?? ''); + $this->gateway->answerCallback((string)($query['query_id'] ?? ''), $this->t($user, 'callback_panel_unavailable')); + return true; + } + $request = new self($this->settings, $this->flows, $this->calendar, $target, $this->geo, $this->clock, $this->lang, $this->authorizeOperator, $this->permissionRecovery); + return $request->dispatchCallback($query); + } + private function dispatchCallback(array $query): bool + { + $data = (string) ($query['data'] ?? ''); + if (!str_starts_with($data, 'sb:')) { + return false; + } + $user = (string) ($query['user_id'] ?? ''); + try { + if (!($query['private'] ?? false)) { + throw new \RuntimeException($this->t($user, 'not_private')); + } + $notification = ''; + $parts = explode(':', $data, 5); + $action = $parts[1] ?? ''; + $token = $parts[2] ?? ''; + $value = $parts[3] ?? ''; + if (!in_array($action, ['wo','wi','wd','wc','save','cancel'], true)) { + $this->flows->revokeAction($user, 'settings_wizard'); + } + if (!in_array($action, ['pc', 'px'], true)) { + $this->flows->revokeAction($user, 'permission_recovery_confirm'); + } + if (in_array($action, ['pr', 'pa', 'pc', 'px'], true)) { + if ($this->permissionRecovery === null) { + throw new \RuntimeException('Permission recovery is unavailable.'); + } + $context = $this->context($user, $token, in_array($action, ['pc', 'px'], true) ? 'permission_recovery_confirm' : 'panel'); + $recovery = new PermissionRecoveryController($this->settings, $this->flows, $this->gateway, $this->permissionRecovery, $this->lang); + match ($action) { + 'pr' => $recovery->show($user, $context), + 'pa' => $recovery->prepare($user, $context, $value), + 'pc' => $recovery->confirm($user, $context, $token), + 'px' => $recovery->cancel($user, $context, $token), + }; + } elseif (in_array($action, ['wd','wc'], true)) { + $context = $this->context($user, $token, 'settings_wizard'); + $this->wizardDays($user, $context, $action === 'wd' ? $this->integer($value, 0, 6) : null); + } elseif ($action === 'start') { + $this->start($user); + } elseif ($action === 'groups') { + $state = $this->flows->peek($token, $user, 'groups'); + $this->groupList($user, $state['payload']['offset'] ?? 0, $state['payload']['search'] ?? ''); + } elseif ($action === 'personal') { + $this->panel($user, $this->settings->getUser($user)); + } elseif ($action === 'group') { + $this->panel($user, $this->context($user, $token, 'panel')); + } elseif ($action === 'help') { + $this->help($user, $token); + } elseif ($action === 'show') { + $this->view($user, $this->settings->getUser($user), $token); + } elseif ($action === 'lp') { + $this->locationPreview($user, $token); + } elseif ($action === 'lc') { + $context = $this->context($user, $token, 'location_page'); + $payload = $context['_flow']['payload']; + $this->cityResults($user, $context, $payload['query'], (int) $payload['offset']); + } elseif ($action === 'save') { + $context = $this->context($user, $token, 'save'); + $saved = $this->flows->confirm($token, $user, 'save', fn(string $group, string $actor): bool => $this->gateway->isGroupAdmin($actor, $group), $context['_flow']['payload']['changes']); + $notification = $this->t($user, 'saved', ['title' => $this->title($user, $saved)]); + $this->resumeSettings($user, $saved, $context['_flow']['payload']); + } elseif ($action === 'cancel') { + $context = $this->context($user, $token, 'save'); + $this->flows->revoke($token, $user, 'save'); + $this->resumeSettings($user, $context, $context['_flow']['payload']); + } else { + $context = $this->context($user, $token, 'panel'); + if (in_array($action, ['wf','wp','wo','wi'], true)) { + $module = $context['_flow']['payload']['wizard_module'] ?? $context['_flow']['payload']['return_wizard'] ?? ''; + if ($action === 'wp') { + $this->wizardFields($user, $context, $module, $this->integer($value, 0, 100)); + } elseif ($action === 'wf') { + $fields = SettingsWizard::fields($module, isset($context['group_id'])); + $field = $fields[$this->integer($value, 0, count($fields) - 1)] ?? throw new \InvalidArgumentException('Unknown field.'); + $this->wizardField($user, $context, $module, $field); + } else { + $field = $context['_flow']['payload']['wizard_field'] ?? ''; + if ($action === 'wi') { + $this->wizardPrompt($user, $context, $module, $field); + } else { + $definition = SettingsWizard::definition($module, $field, isset($context['group_id'])); + $options = $definition['options'] ?? []; + $index = $this->integer($value, 0, count($options) - 1); + $changes = SettingsWizard::changes($module, $field, $options[$index], $context, fn(string $m, string $form): array => $this->parseForm($m, $form)); + $this->changes($user, $context, $changes); + } + } + } elseif ($action === 'v') { + $this->screen($user, $context, $value); + } elseif ($action === 's') { + $raw = $parts[4] ?? ''; + $changes = match ($value) { + 'l' => in_array($raw, Lang::CODES, true) ? ['language' => $raw] : throw new \InvalidArgumentException('Invalid language'), + 'c' => ['candle_minutes' => $raw === 'city' ? null : $this->integer($raw, 1, 120)], + 'h' => in_array($raw, ['8.5','42','50','72'], true) ? ['havdalah_method' => $raw === '8.5' ? 'degrees' : 'minutes', 'havdalah_value' => (float) $raw] : throw new \InvalidArgumentException('Invalid havdalah method'), + 'i' => in_array($raw, ['0','1'], true) ? ['israel' => $raw === '1'] : throw new \InvalidArgumentException('Invalid calendar choice'), + 'm' => in_array($raw, ['off','messages','lock','lock_messages'], true) ? ['mode' => $raw] : throw new \InvalidArgumentException('Invalid mode'), + 's' => ['shabbat_enabled' => !($context['shabbat_enabled'] ?? true)], 'y' => ['holidays_enabled' => !($context['holidays_enabled'] ?? true)], + default => throw new \InvalidArgumentException('Unknown setting'), + }; + $this->changes($user, $context, $changes); + } elseif (in_array($action, ['flag','mask','toggle','at'], true)) { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + if ($action === 'flag') { + if (!in_array($value, Permissions::BLOCK_FLAGS, true)) { + throw new \InvalidArgumentException('Unknown group restriction'); + } + $mask = $context['lock_block_flags'] ?? Permissions::DEFAULT_BLOCK_FLAGS; + $mask = in_array($value, $mask, true) ? array_values(array_diff($mask, [$value])) : [...$mask, $value]; + $changes = ['lock_block_flags' => Permissions::validateBlockFlags($mask)]; + } elseif ($action === 'mask') { + $changes = ['lock_block_flags' => match ($value) { + 'default' => Permissions::DEFAULT_BLOCK_FLAGS, 'all' => Permissions::BLOCK_FLAGS, 'none' => [], + default => throw new \InvalidArgumentException('Unknown restriction preset'), + }]; + } elseif ($action === 'at') { + if (!preg_match('/^(?:[01][0-9]|2[0-3])[0-5][0-9]$/D', $value)) { + throw new \InvalidArgumentException('Invalid announcement time'); + } + $changes = ['times_alert_time' => substr($value, 0, 2) . ':' . substr($value, 2), 'times_alert_mode' => 'local_time']; + } else { + $field = match ($value) { + 'du' => 'delete_holy_user_messages', 'db' => 'delete_holy_bot_messages', 'tp' => 'tefillin_delete_previous', 'ta' => 'times_alert_enabled', + default => throw new \InvalidArgumentException('Unknown group-only toggle'), + }; + $changes = [$field => !($context[$field] ?? false)]; + } + $this->changes($user, $context, $changes); + } elseif ($action === 'ap') { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + $payload = (new \Shabbat\Content\ShabbatTimesFormatter($this->lang))->overview($context, $this->safeSnapshot($context)); + $this->send($user, $payload['text'], $this->keyboard([[$this->button($user, 'back', 'sb:v:' . $token . ':announcement')]])); + } elseif ($action === 't' && in_array($value, self::MODULES, true)) { + $this->changes($user, $context, [$value . '_enabled' => !($context[$value . '_enabled'] ?? false)]); + } elseif ($action === 'e') { + $this->edit($user, $context, $value); + } elseif ($action === 'r') { + $this->changes($user, $context, $this->moduleDefaults($value, isset($context['group_id']))); + } elseif ($action === 'calendar') { + $this->shabbatView($user, $context, $this->integer($value, 0, 100)); + } elseif ($action === 'p') { + $this->view($user, $context, $value); + } elseif ($action === 'f' && in_array($value, ['short','full'], true)) { + $context['zmanim_format'] = $value; + $this->view($user, $context, 'zmanim'); + } elseif ($action === 'date') { + $dateToken = $this->token($user, $context, 'date', ['module' => $value]); + $this->send($user, $this->t($user, 'date_prompt', ['token' => $dateToken]), $this->keyboard([[$this->button($user, 'back', 'sb:p:' . $token . ':' . $value)]])); + } elseif ($action === 'day') { + $this->view($user, $context, $value, $parts[4] ?? null); + } elseif ($action === 'loc') { + $this->locationScreen($user, $context); + } elseif ($action === 'lookup') { + $this->locationPrompt($user, $context, $value); + } elseif ($action === 'common') { + $this->changes($user, $context, Place::common()[$value] ?? throw new \InvalidArgumentException('Unknown curated place'), true); + } elseif ($action === 'copy') { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group required'); + } + $keys = ['location_name','country','geoname_id','latitude','longitude','timezone_id','location_source','israel','candle_minutes','havdalah_method','havdalah_value']; + $this->changes($user, $context, array_intersect_key($this->settings->getUser($user), array_flip($keys)), true); + } elseif ($action === 'pause') { + $this->changes($user, $context, ['tefillin_suspended_date' => $this->localNow($context)->format('Y-m-d')]); + } else { + throw new \InvalidArgumentException('Unsupported button'); + } + } + $this->gateway->answerCallback((string) ($query['query_id'] ?? ''), $notification); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + $this->gateway->answerCallback((string) ($query['query_id'] ?? ''), $this->t($user, $error instanceof PanelEditException ? 'callback_panel_unavailable' : 'denied')); + } + return true; + } + /** Inline results never write settings or infer groups from peer_type. */ + public function handleInline(array $update): bool + { + $user = (string) ($update['user_id'] ?? ''); + $query = trim((string) ($update['query'] ?? '')); + $id = (string) ($update['query_id'] ?? ''); + $offset = (string) ($update['offset'] ?? ''); + $results = []; + $key = $user . ':' . (preg_match('/^loc\s+([A-Za-z0-9_-]{24})\s/u', $query, $flowMatch) ? $flowMatch[1] : 'public'); + ($this->inlineCancellation[$key] ?? null)?->cancel(); + $cancel = new \Amp\DeferredCancellation(); + $this->inlineCancellation[$key] = $cancel; + try { + if (preg_match('/^share\s+([A-Za-z0-9_-]{24})$/D', $query, $m)) { + $context = $this->context($user, $m[1], 'share_snapshot'); + $payload = $context['_flow']['payload']; + $snapshot = $payload['settings']; + $text = $this->escape($this->title($user, $context)) . "\n" . $this->renderView($user, $snapshot, $payload['module'], $payload['date'] ?? null); + $results[] = $this->article(hash('sha256', $m[1]), $this->t($user, $payload['module']) . ' - ' . $snapshot['location_name'], $snapshot['timezone_id'], $text); + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true]); + } elseif (preg_match('/^(tz|timezone)(?:\s+(.*))?$/uiD', $query, $m) || in_array($query, \DateTimeZone::listIdentifiers(), true)) { + $context = null; + $zoneQuery = isset($m[1]) ? trim($m[2] ?? '') : $query; + if (isset($m[1]) && strtolower($m[1]) === 'timezone') { + $flow = $this->flows->latest($user, 'location_input'); + if ($flow === null || ($flow['payload']['kind'] ?? '') !== 'timezone') { + throw new \RuntimeException('Reopen the private timezone search panel.'); + } + $context = $this->context($user, $flow['payload']['token'], 'location_input'); + } + $this->inlineTimezones($user, $id, $zoneQuery, $context); + } elseif (preg_match('/^location(?:\s+(.*))?$/usD', $query, $m)) { + $flow = $this->flows->latest($user, 'location_input'); + if ($flow === null || ($flow['payload']['kind'] ?? '') !== 'city') { + throw new \RuntimeException('Reopen the private location search panel.'); + } + $context = $this->context($user, $flow['payload']['token'], 'location_input'); + $this->inlinePlaces($user, $id, trim($m[1] ?? ''), $offset, $cancel->getCancellation(), $context); + } elseif (preg_match('/^loc\s+([A-Za-z0-9_-]{24})\s*(.*)$/us', $query, $m)) { + $context = $this->context($user, $m[1], 'location'); + if (mb_strlen(trim($m[2])) < 2) { + $this->gateway->answerInline($id, [], ['cache_time' => 0, 'private' => true]); + return true; + } + $search = $this->geo->search($m[2], $offset === '' ? 0 : $this->integer($offset, 0, 1000), $user, $cancel->getCancellation(), $this->userLanguage($user)); + foreach ($search['items'] as $place) { + $choice = $this->token($user, $context, 'location_choice', ['geoname_id' => $place['geoname_id'], 'query_key' => $search['query_key']]); + $markup = $this->keyboard([[$this->button($user, 'select_private', 'sb:lp:' . $choice)]]); + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [['_' => 'keyboardButtonUrl', 'text' => $this->t($user, 'select_private'), 'url' => 'https://t.me/' . $this->gateway->botUsername() . '?start=loc_' . $choice]]]; + $results[] = $this->article(substr(hash('sha256', $search['query_key'] . ':' . $m[1] . ':' . $place['geoname_id']), 0, 32), $this->placeTitle($place), $this->t($user, 'select_location'), $this->escape($this->placeTitle($place)), $markup); + } + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true, 'next_offset' => (string) ($search['next_offset'] ?? '')]); + } elseif (preg_match('/^loc\s+(.+)$/us', $query, $m)) { + $search = $this->geo->search($m[1], $offset === '' ? 0 : $this->integer($offset, 0, 1000), $user, $cancel->getCancellation(), $this->userLanguage($user)); + foreach ($search['items'] as $place) { + $text = $this->escape($this->placeTitle($place)) . "\n" . $place['latitude'] . ', ' . $place['longitude'] . "\n" . $this->t($user, 'public_location') . "\n" . $this->t($user, 'location_attribution'); + $results[] = $this->article(substr(hash('sha256', $search['query_key'] . ':' . $place['geoname_id']), 0, 32), $this->placeTitle($place), $this->t($user, 'public_location'), $text); + } + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true, 'next_offset' => (string) ($search['next_offset'] ?? '')]); + } elseif (in_array(mb_strtolower($query), ['shabat','shabbat','ืฉื‘ืช','zmanim','daf','omer'], true)) { + $module = in_array(mb_strtolower($query), ['shabat','shabbat','ืฉื‘ืช'], true) ? 'shabbat' : mb_strtolower($query); + $context = $this->settings->getUser($user); + $results[] = $this->article(hash('sha256', $module . ':' . $user . ':' . $context['revision'] . ':' . $this->localNow($context)->format('Y-m-d')), $this->t($user, $module) . ' - ' . $context['location_name'], $context['timezone_id'], $this->renderView($user, $context, $module)); + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true]); + } elseif ($query !== '') { + $this->inlinePlaces($user, $id, $query, $offset, $cancel->getCancellation()); + } else { + $context = $this->settings->getUser($user); + foreach (['shabbat','zmanim','daf','omer'] as $module) { + $results[] = $this->article(hash('sha256', $module . ':' . $user . ':' . $context['revision']), $this->t($user, $module) . ' - ' . $context['location_name'], $context['timezone_id'], $this->renderView($user, $context, $module)); + } + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true]); + } + } catch (\Amp\CancelledException $error) { + if (!$cancel->getCancellation()->isRequested()) { + throw $error; + } return true; + } catch (\Throwable $error) { + $results = [$this->article('location-unavailable', $this->t($user, 'unavailable'), '', $this->t($user, 'search_error', ['error' => $this->t($user, $this->geo->configured() ? 'unavailable' : 'geonames_missing')]))]; + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true]); + } finally { + if (($this->inlineCancellation[$key] ?? null) === $cancel) { + unset($this->inlineCancellation[$key]); + } + } + return true; + } + private function inlineTimezones(string $user, string $id, string $query, ?array $context): void + { + $results = []; + if (mb_strlen($query) >= 2) { + $zones = array_values(array_filter(\DateTimeZone::listIdentifiers(), static fn(string $zone): bool => stripos($zone, $query) !== false)); + foreach (array_slice($zones, 0, 25) as $zone) { + $markup = []; + if ($context !== null) { + $choice = $this->token($user, $context, 'location_choice', ['timezone_id' => $zone]); + $markup = $this->keyboard([[$this->button($user, 'select_private', 'sb:lp:' . $choice)]]); + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [['_' => 'keyboardButtonUrl', 'text' => $this->t($user, 'select_private'), 'url' => 'https://t.me/' . $this->gateway->botUsername() . '?start=loc_' . $choice]]]; + } + $local = $this->clock->now()->setTimezone(new \DateTimeZone($zone))->format('Y-m-d H:i (P)'); + $text = '' . $this->escape($zone) . '' . "\n" . $local . "\n\n" . $this->t($user, 'timezone_warning'); + if ($context !== null) { + $text .= "\n" . $this->escape($this->title($user, $context)); + } + $results[] = $this->article(hash('sha256', $user . ':' . $zone . ':' . ($context['group_id'] ?? 'public')), $zone, $local, $text, $markup); + } + } + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true]); + } + private function inlinePlaces(string $user, string $id, string $query, string $offset, \Amp\Cancellation $cancellation, ?array $context = null): void + { + if (mb_strlen($query) < 2) { + $this->gateway->answerInline($id, [], ['cache_time' => 0, 'private' => true]); + return; + } + $search = $this->geo->search($query, $offset === '' ? 0 : $this->integer($offset, 0, 1000), $user, $cancellation, $this->userLanguage($user)); + $results = []; + foreach ($search['items'] as $place) { + if ($context !== null) { + $choice = $this->token($user, $context, 'location_choice', ['geoname_id' => $place['geoname_id']]); + $markup = $this->keyboard([[$this->button($user, 'select_private', 'sb:lp:' . $choice)]]); + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [['_' => 'keyboardButtonUrl', 'text' => $this->t($user, 'select_private'), 'url' => 'https://t.me/' . $this->gateway->botUsername() . '?start=loc_' . $choice]]]; + $description = $this->t($user, 'select_location') . ' ยท ' . $this->title($user, $context); + $text = $this->escape($this->placeTitle($place)) . "\n" . $this->escape($this->title($user, $context)); + } else { + $markup = []; + $description = $this->t($user, 'public_location'); + $text = $this->escape($this->placeTitle($place)) . "\n" . $place['latitude'] . ', ' . $place['longitude'] . "\n" . $this->t($user, 'public_location') . "\n" . $this->t($user, 'location_attribution'); + } + $results[] = $this->article(hash('sha256', $user . ':' . $search['query_key'] . ':' . ($context['group_id'] ?? 'user') . ':' . $place['geoname_id']), $this->placeTitle($place), $description, $text, $markup); + } + $this->gateway->answerInline($id, $results, ['cache_time' => 0, 'private' => true, 'next_offset' => (string)($search['next_offset'] ?? '')]); + } + public function start(string $user): void + { + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $rows = []; + foreach (array_chunk(['shabbat','zmanim','daf','omer'], 2) as $modules) { + $rows[] = array_map(fn(string $module): array => $this->button($user, $module, 'sb:show:' . $module), $modules); + } + $rows[] = [Presentation::button(['text' => $this->t($user, 'add'), 'url' => 'https://t.me/' . $this->gateway->botUsername() . '?startgroup=true'], true, 'add')]; + $rows[] = [$this->button($user, 'groups', 'sb:groups'), $this->button($user, 'preferences', 'sb:personal')]; + $rows[] = [$this->button($user, 'help', 'sb:help'), $this->button($user, 'donate', 'v3:donate')]; + $rows[] = [$this->button($user, 'source', 'v3:source'), $this->button($user, 'command_stats', 'ps:overview')]; + if (($this->authorizeOperator)($user)) { + $rows[] = [$this->button($user, 'info_operators_title', 'sb:help:operators')]; + } + $this->send($user, $this->t($user, 'welcome'), $this->keyboard($rows)); + } + private function help(string $user, string $category = ''): void + { + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $categories = ['personal','calendar','groups','permissions','notifications','messages','privacy']; + if (($this->authorizeOperator)($user)) { + $categories[] = 'operators'; + } + $rows = []; + if ($category === '') { + $text = $this->t($user, 'info_intro'); + foreach (array_chunk($categories, 2) as $chunk) { + $rows[] = array_map(fn(string $name): array => $this->button($user, 'info_' . $name . '_title', 'sb:help:' . $name), $chunk); + } + } else { + if (!in_array($category, $categories, true)) { + throw new \RuntimeException('This information category is unavailable.'); + } + $text = '' . $this->escape($this->t($user, 'info_' . $category . '_title')) . '' . "\n\n" . $this->t($user, 'info_' . $category . '_text'); + if ($category === 'personal') { + $rows[] = [$this->button($user, 'preferences', 'sb:personal')]; + } elseif ($category === 'calendar') { + $rows[] = [$this->button($user, 'shabbat', 'sb:show:shabbat'), $this->button($user, 'zmanim', 'sb:show:zmanim')]; + $rows[] = [$this->button($user, 'daf', 'sb:show:daf'), $this->button($user, 'omer', 'sb:show:omer')]; + } elseif (in_array($category, ['groups','permissions','notifications','messages'], true)) { + $rows[] = [$this->button($user, 'groups', 'sb:groups')]; + } elseif ($category === 'privacy') { + $rows[] = [$this->button($user, 'source', 'v3:source')]; + if (($this->authorizeOperator)($user)) { + $rows[] = [$this->button($user, 'info_operators_title', 'sb:help:operators')]; + } + } + $rows[] = [$this->button($user, 'back', 'sb:help')]; + } + $rows[] = [$this->button($user, 'home', 'sb:start')]; + $this->send($user, $text, $this->keyboard($rows)); + } + public function panel(string $user, array $context): void + { + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + unset($context['_flow']); + $token = $this->token($user, $context, 'panel'); + $rows = []; + if (isset($context['group_id'])) { + $this->group($user, (string) $context['group_id']); + $rows[] = [$this->button($user, 'language', 'sb:v:' . $token . ':language'), $this->button($user, 'location', 'sb:v:' . $token . ':location')]; + $shabbat = $this->button($user, 'shabbat', 'sb:v:' . $token . ':shabbat'); + $shabbat['text'] .= ' ยท ' . $this->t($user, 'mode_' . $context['mode']); + $rows[] = [$shabbat]; + foreach ([['zmanim', 'daf'], ['tefillin', 'omer']] as $screens) { + $row = []; + foreach ($screens as $screen) { + $button = $this->button($user, $screen, 'sb:v:' . $token . ':' . $screen); + $button['text'] .= ' ยท ' . $this->t($user, ($context[$screen . '_enabled'] ?? false) ? 'on' : 'off'); + $row[] = $button; + } + $rows[] = $row; + } + $rows[] = [$this->button($user, 'moderation', 'sb:v:' . $token . ':moderation'), + $this->button($user, 'messages', 'v3:templates:' . $context['group_id'])]; + $rows[] = [$this->button($user, 'schedule', 'sb:v:' . $token . ':schedule'), + $this->button($user, 'status', 'sb:v:' . $token . ':status')]; + if ($this->permissionRecovery !== null) { + $rows[] = [$this->button($user, 'permission_recovery', 'sb:pr:' . $token)]; + } + $snapshot = $this->safeSnapshot($context); + $next = isset($snapshot['next_close_at']) ? $this->time($snapshot['next_close_at'], $context) : $this->t($user, 'unavailable'); + $text = $this->t($user, 'group_panel', ['title' => $this->title($user, $context), 'location' => $context['location_name'], 'timezone' => $context['timezone_id'], 'mode' => $this->t($user, 'mode_' . $context['mode']), 'next' => $next]); + if (isset($snapshot['error'])) { + $text .= "\n" . $this->t($user, 'provider_unavailable'); + } + } else { + foreach (['language','location','candles','havdalah'] as $screen) { + $rows[] = [$this->button($user, $screen, 'sb:v:' . $token . ':' . $screen)]; + } + $text = $this->t($user, 'personal_panel', ['location' => $context['location_name'], 'timezone' => $context['timezone_id']]); + } + $rows[] = [$this->button($user, 'back', 'sb:start')]; + $this->send($user, $text, $this->keyboard($rows)); + } + private function groupList(string $user, int $offset = 0, string $search = ''): void + { + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $rows = []; + $groups = $this->settings->listGroups($offset, 20, $search); + foreach ($groups as $group) { + if (!$this->gateway->isGroupAdmin($user, (string) $group['group_id'])) { + continue; + } + $token = $this->token($user, $group, 'panel'); + $rows[] = [['text' => (string) ($group['title'] ?: $group['group_id']), 'data' => 'sb:group:' . $token]]; + } + $paging = []; + if ($offset > 0) { + $prev = $this->token($user, $this->settings->getUser($user), 'groups', ['offset' => max(0, $offset - 20), 'search' => $search]); + $paging[] = $this->button($user, 'previous', 'sb:groups:' . $prev); + } + if (count($groups) === 20) { + $next = $this->token($user, $this->settings->getUser($user), 'groups', ['offset' => $offset + 20, 'search' => $search]); + $paging[] = $this->button($user, 'next', 'sb:groups:' . $next); + } + if ($paging) { + $rows[] = $paging; + } $rows[] = [$this->button($user, 'back', 'sb:start')]; + $this->send($user, $this->t($user, count($rows) > 1 ? 'settings_search' : 'no_groups'), $this->keyboard($rows)); + } + private function screen(string $user, array $context, string $screen): void + { + unset($context['_flow']['payload']['return_wizard'], $context['_flow']['payload']['return_offset']); + $context['_flow']['payload']['return_screen'] = $screen === 'shabbat_mode' ? 'shabbat' : $screen; + $token = $this->token($user, $context, 'panel'); + $rows = []; + $text = ''; + if ($screen === 'language') { + $text = $this->t($user, 'language_choose'); + foreach (array_chunk(Lang::CODES, 2) as $languages) { + $rows[] = array_map(static fn(string $code): array=>['text' => Lang::NATIVE_NAMES[$code],'data' => 'sb:s:' . $token . ':l:' . $code], $languages); + } + } elseif ($screen === 'location') { + $this->locationScreen($user, $context); + return; + } elseif ($screen === 'candles') { + $text = $this->t($user, 'candle_choose'); + foreach (array_chunk([10,15,18,20,22,30,40], 4) as $chunk) { + $rows[] = array_map(fn(int $v): array => ['text' => (string) $v, 'data' => 'sb:s:' . $token . ':c:' . $v], $chunk); + } + $rows[] = [$this->button($user, 'city_custom', 'sb:s:' . $token . ':c:city')]; + $rows[] = [$this->button($user, 'edit', 'sb:e:' . $token . ':shabbat')]; + } elseif ($screen === 'havdalah') { + $text = $this->t($user, 'havdalah_choose'); + $rows[] = [$this->button($user, 'degrees', 'sb:s:' . $token . ':h:8.5')]; + foreach ([42,50,72] as $v) { + $rows[] = [['text' => $this->t($user, 'minutes', ['value' => $v]), 'data' => 'sb:s:' . $token . ':h:' . $v]]; + } + $rows[] = [$this->button($user, 'edit', 'sb:e:' . $token . ':shabbat')]; + } elseif (in_array($screen, ['moderation','restrictions','announcement','botpermissions'], true)) { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + if ($screen === 'botpermissions') { + $capabilities = $this->capabilities($user, $context); + $verified = ($capabilities['verified'] ?? false) === true; + $text = $this->t($user, 'bot_permissions_intro'); + foreach (['bot_role_creator' => 'creator','bot_role_administrator' => 'is_admin'] as $label => $field) { + $text .= "\n" . $this->t($user, $label) . ': ' . $this->t($user, $verified ? (($capabilities[$field] ?? false) ? 'on' : 'off') : 'unavailable'); + } + foreach (\Shabbat\Telegram\AdminRights::FLAGS as $flag) { + $text .= "\n" . $this->t($user, 'admin_right_' . $flag) . ': ' . $this->t($user, $verified ? (($capabilities['rights'][$flag] ?? false) ? 'on' : 'off') : 'unavailable'); + } + $text .= "\n\n" . $this->t($user, 'moderation_delete_capability', ['state' => $this->t($user, $verified ? (($capabilities['can_delete_messages'] ?? false) ? 'on' : 'off') : 'unavailable')]); + } elseif ($screen === 'restrictions') { + $text = $this->t($user, 'moderation_intro') . "\n\n" . $this->t($user, 'moderation_visibility_warning'); + $mask = $context['lock_block_flags'] ?? Permissions::DEFAULT_BLOCK_FLAGS; + foreach (Permissions::BLOCK_FLAGS as $flag) { + $selected = in_array($flag, $mask, true); + $rows[] = [['text' => ($selected ? 'โœ… ' : 'โฌœ ') . $this->t($user, 'permission_' . $flag), 'data' => 'sb:flag:' . $token . ':' . $flag, 'style' => ['_' => 'keyboardButtonStyle', ($selected ? 'bg_primary' : 'bg_danger') => true]]]; + } + foreach (['default','none','all'] as $preset) { + $rows[] = [$this->button($user, 'moderation_' . $preset, 'sb:mask:' . $token . ':' . $preset)]; + } + } elseif ($screen === 'announcement') { + $text = $this->t($user, 'announcement_schedule_intro', ['timezone' => $context['timezone_id']]) . "\n\n" . $this->describe($user, array_intersect_key($context, array_flip(['times_alert_enabled','times_alert_time','times_alert_mode','times_alert_before_minutes']))); + $rows[] = [$this->button($user, $context['times_alert_enabled'] ? 'on' : 'off', 'sb:toggle:' . $token . ':ta')]; + foreach (array_chunk(['10:00','11:00','12:00','12:30','13:00','13:30','14:00'], 3) as $chunk) { + $rows[] = array_map(static fn(string $hour): array => ['text' => $hour, 'data' => 'sb:at:' . $token . ':' . str_replace(':', '', $hour)], $chunk); + } + $wizard = $this->token($user, $context, 'panel', ['wizard_module' => 'shabbat']); + array_push($rows, ...$this->wizardFieldButtons($user, $context, 'shabbat', $wizard, fields: SettingsWizard::sectionFields('announcement'))); + $rows[] = [$this->button($user, 'preview', 'sb:ap:' . $token), $this->button($user, 'messages', 'v3:templates:' . $context['group_id'] . ':times_alert')]; + } else { + $text = $this->t($user, 'moderation_intro') . "\n\n" . $this->t($user, 'moderation_cleanup_hint') . "\n\n" . $this->t($user, 'moderation_bot_mode_hint'); + $rows[] = [$this->button($user, 'moderation_permissions', 'sb:v:' . $token . ':restrictions')]; + foreach (['du' => 'delete_holy_user_messages', 'db' => 'delete_holy_bot_messages'] as $short => $field) { + $rows[] = [['text' => $this->t($user, 'field_' . $field) . ' ยท ' . $this->t($user, ($context[$field] ?? false) ? 'on' : 'off'), 'data' => 'sb:toggle:' . $token . ':' . $short]]; + } + $capabilities = $this->capabilities($user, $context); + $text .= "\n\n" . $this->t($user, 'moderation_delete_capability', ['state' => ($capabilities['verified'] ?? false) ? $this->t($user, ($capabilities['can_delete_messages'] ?? false) ? 'on' : 'off') : $this->t($user, 'unavailable')]); + $rows[] = [$this->button($user, 'bot_permissions', 'sb:v:' . $token . ':botpermissions')]; + $rows[] = [$this->button($user, 'moderation_default', 'sb:mask:' . $token . ':default')]; + } + } elseif ($screen === 'shabbat_mode') { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + $text = $this->t($user, 'settings_mode_intro', ['title' => $this->title($user, $context)]); + foreach (array_chunk(['off', 'messages', 'lock', 'lock_messages'], 2) as $modes) { + $row = []; + foreach ($modes as $mode) { + $button = $this->button($user, 'mode_' . $mode, 'sb:s:' . $token . ':m:' . $mode); + $button['text'] = (($context['mode'] ?? 'off') === $mode ? 'โœ… ' : '') . $button['text']; + $row[] = $button; + } + $rows[] = $row; + } + } elseif (in_array($screen, ['shabbat_timing', 'shabbat_messages'], true)) { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + $text = $this->t($user, 'wizard_title', ['module' => $this->t($user, 'settings_' . $screen), 'title' => $this->title($user, $context)]) + . "\n\n" . $this->t($user, 'settings_' . $screen . '_intro'); + $wizard = $this->token($user, $context, 'panel', ['wizard_module' => 'shabbat']); + $rows = $this->wizardFieldButtons($user, $context, 'shabbat', $wizard, fields: SettingsWizard::sectionFields($screen)); + if ($screen === 'shabbat_messages') { + $rows[] = [$this->button($user, 'template_kind_close', 'v3:templates:' . $context['group_id'] . ':close'), + $this->button($user, 'template_kind_open', 'v3:templates:' . $context['group_id'] . ':open')]; + $rows[] = [$this->button($user, 'announcement_schedule', 'sb:v:' . $token . ':announcement')]; + } else { + $rows[] = [$this->button($user, 'preview', 'sb:p:' . $token . ':shabbat')]; + } + } elseif ($screen === 'shabbat') { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group setting required'); + } + $text = $this->t($user, 'settings_shabbat_summary', [ + 'title' => $this->title($user, $context), 'mode' => $this->t($user, 'mode_' . $context['mode']), + 'location' => $context['location_name'], 'timezone' => $context['timezone_id'], + 'shabbat' => $this->t($user, ($context['shabbat_enabled'] ?? true) ? 'on' : 'off'), + 'holidays' => $this->t($user, ($context['holidays_enabled'] ?? true) ? 'on' : 'off'), + 'candles' => \Shabbat\Calendar\CalendarEngine::candleMinutes($context), + 'havdalah' => SettingsWizard::current('shabbat', 'havdalah_value', $context, $this->userLanguage($user)), + 'close' => $context['close_before_candles_minutes'], 'open' => $context['open_after_havdalah_minutes'], + ]); + $rows[] = [['text' => $this->t($user, 'settings_mode_button', ['mode' => $this->t($user, 'mode_' . $context['mode'])]), + 'data' => 'sb:v:' . $token . ':shabbat_mode', 'style' => ['_' => 'keyboardButtonStyle', ($context['mode'] === 'off' ? 'bg_danger' : 'bg_primary') => true]]]; + $saturday = $this->button($user, 'shabbat_enabled', 'sb:s:' . $token . ':s:1'); + $saturday['text'] .= ' ยท ' . $this->t($user, ($context['shabbat_enabled'] ?? true) ? 'on' : 'off'); + $holidays = $this->button($user, 'holidays_enabled', 'sb:s:' . $token . ':y:1'); + $holidays['text'] .= ' ยท ' . $this->t($user, ($context['holidays_enabled'] ?? true) ? 'on' : 'off'); + $rows[] = [$saturday, $holidays]; + $rows[] = [$this->button($user, 'settings_shabbat_timing', 'sb:v:' . $token . ':shabbat_timing'), + $this->button($user, 'settings_shabbat_messages', 'sb:v:' . $token . ':shabbat_messages')]; + $rows[] = [$this->button($user, 'announcement_schedule', 'sb:v:' . $token . ':announcement'), + $this->button($user, 'moderation', 'sb:v:' . $token . ':moderation')]; + $rows[] = [$this->button($user, 'preview', 'sb:p:' . $token . ':shabbat'), + $this->button($user, 'wizard_more', 'sb:e:' . $token . ':shabbat')]; + $rows[] = [$this->button($user, 'reset', 'sb:r:' . $token . ':shabbat')]; + } elseif (in_array($screen, self::MODULES, true)) { + if (!isset($context['group_id'])) { + throw new \RuntimeException('Group module required'); + } + $text = $this->t($user, 'module_panel', ['module' => $this->t($user, $screen), 'title' => $this->title($user, $context), 'settings' => $this->formSummary($user, $context, $screen)]); + $rows[] = [$this->button($user, ($context[$screen . '_enabled'] ?? false) ? 'on' : 'off', 'sb:t:' . $token . ':' . $screen)]; + $wizardToken = $this->token($user, $context, 'panel', ['wizard_module' => $screen]); + array_push($rows, ...$this->wizardFieldButtons($user, $context, $screen, $wizardToken)); + if (count(SettingsWizard::fields($screen)) > 8) { + $rows[] = [$this->button($user, 'wizard_more', 'sb:wp:' . $wizardToken . ':8')]; + } + $rows[] = [$this->button($user, 'preview', 'sb:p:' . $token . ':' . $screen), + $this->button($user, 'messages', 'v3:templates:' . $context['group_id'] . ':' . $screen)]; + $rows[] = [$this->button($user, 'reset', 'sb:r:' . $token . ':' . $screen)]; + if ($screen === 'tefillin') { + $rows[] = [$this->button($user, 'suspend', 'sb:pause:' . $token)]; + $rows[] = [['text' => $this->t($user, 'field_delete_previous') . ' ยท ' . $this->t($user, ($context['tefillin_delete_previous'] ?? false) ? 'on' : 'off'), 'data' => 'sb:toggle:' . $token . ':tp']]; + } + } elseif ($screen === 'status' || $screen === 'schedule') { + $snap = $this->safeSnapshot($context); + if ($screen === 'status') { + $text = $this->t($user, 'status_view', ['title' => $this->title($user, $context), 'utc' => $this->clock->now()->format('Y-m-d H:i:s T'), 'local' => $this->localNow($context)->format('Y-m-d H:i:s T'), 'coverage' => json_encode($snap['coverage'] ?? [], JSON_UNESCAPED_UNICODE), 'fetched' => isset($snap['fetched_at']) ? $this->time($snap['fetched_at'], $context) : '-', 'current' => $snap['current_event']['title'] ?? '-', 'next' => $snap['next_event']['title'] ?? '-', 'error' => $snap['error'] ?? '-']); + } else { + $q = $this->settings->database()->pdo()->prepare("SELECT module,run_at,status,last_error FROM scheduled_jobs WHERE group_id=? AND status IN ('pending','leased') ORDER BY run_at LIMIT 20"); + $q->execute([$context['group_id']]); + $content = []; + foreach ($q->fetchAll() as $job) { + $content[] = $this->time((int) $job['run_at'], $context) . ' ยท ' . $job['module'] . ' ยท ' . $job['status'] . ' ยท ' . ($job['last_error'] ?? '') . ' ยท Hebcal / revision ' . $context['revision']; + } + $text = $this->t($user, 'schedule_view', ['title' => $this->title($user, $context), 'content' => implode("\n", $content) ?: '-']); + } + } else { + throw new \InvalidArgumentException('Unknown screen'); + } + if ($this->permissionRecovery !== null && isset($context['group_id']) && in_array($screen, ['shabbat', 'moderation', 'restrictions', 'botpermissions', 'status'], true)) { + $rows[] = [$this->button($user, 'permission_recovery', 'sb:pr:' . $token)]; + } + $parent = match ($screen) { + 'shabbat_mode', 'shabbat_timing', 'shabbat_messages', 'announcement', 'moderation' => 'shabbat', + 'restrictions', 'botpermissions' => 'moderation', + default => null, + }; + $rows[] = [$this->button($user, 'back', $parent === null ? 'sb:group:' . $token : 'sb:v:' . $token . ':' . $parent)]; + $this->send($user, $text, $this->keyboard($rows)); + } + private function cityResults(string $user, array $context, string $query, int $offset = 0): void + { + $search = $this->geo->search($query, $offset, $user, null, $this->userLanguage($user)); + $buttons = []; + foreach ($search['items'] as $place) { + $choice = $this->token($user, $context, 'location_choice', ['geoname_id' => $place['geoname_id']]); + $buttons[] = [['text' => $this->placeTitle($place), 'data' => 'sb:lp:' . $choice]]; + } + if ($search['next_offset'] !== null) { + $next = $this->token($user, $context, 'location_page', ['query' => $query, 'offset' => $search['next_offset']]); + $buttons[] = [$this->button($user, 'next', 'sb:lc:' . $next)]; + } + $panel = $this->token($user, $context, 'panel'); + $buttons[] = [$this->button($user, 'back', 'sb:loc:' . $panel)]; + $this->send($user, $this->t($user, $search['items'] !== [] ? 'location_choose' : 'no_results'), $this->keyboard($buttons)); + } + private function locationScreen(string $user, array $context): void + { + $panel = $this->token($user, $context, 'panel'); + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $rows = []; + $text = $this->t($user, 'location_menu_intro', ['title' => $this->title($user, $context), 'location' => $context['location_name'], 'timezone' => $context['timezone_id']]); + $rows[] = [$this->button($user, 'location_input_city_title', 'sb:lookup:' . $panel . ':city'), $this->button($user, 'location_input_gps_title', 'sb:lookup:' . $panel . ':gps')]; + $rows[] = [$this->button($user, 'location_input_coordinates_title', 'sb:lookup:' . $panel . ':coordinates'), $this->button($user, 'location_input_geoname_title', 'sb:lookup:' . $panel . ':geoname')]; + $rows[] = [$this->button($user, 'location_input_timezone_title', 'sb:lookup:' . $panel . ':timezone')]; + if (!$this->geo->configured()) { + $text .= "\n\n" . $this->t($user, 'geonames_missing'); + } + foreach (Place::common() as $key => $place) { + $rows[] = [['text' => $place['location_name'], 'data' => 'sb:common:' . $panel . ':' . $key]]; + } + $rows[] = [$this->button($user, 'calendar_israel', 'sb:s:' . $panel . ':i:1'), $this->button($user, 'calendar_diaspora', 'sb:s:' . $panel . ':i:0')]; + if (isset($context['group_id'])) { + $rows[] = [$this->button($user, 'copy_personal', 'sb:copy:' . $panel)]; + } + $rows[] = [$this->button($user, 'back', 'sb:group:' . $panel)]; + $this->send($user, $text, $this->keyboard($rows)); + } + private function locationPrompt(string $user, array $context, string $kind): void + { + if (!in_array($kind, ['city','timezone','geoname','coordinates','gps'], true) || !$this->gateway instanceof CallbackUiGateway) { + throw new \InvalidArgumentException('An active private location panel is required.'); + } + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $payload = ['kind' => $kind, 'message_id' => $this->gateway->messageId()]; + $token = $this->token($user, $context, 'location_input', $payload); + $this->flows->updatePayload($token, $user, 'location_input', $payload + ['token' => $token]); + $text = '' . $this->escape($this->t($user, 'location_input_' . $kind . '_title')) . '' . "\n" . $this->escape($this->title($user, $context)) . "\n\n" . $this->t($user, 'location_input_' . $kind . '_prompt'); + $panel = $this->token($user, $context, 'panel'); + $rows = []; + if (($kind === 'city' && $this->geo->configured()) || $kind === 'timezone') { + $text .= "\n\n" . $this->t($user, $kind === 'city' ? 'location_inline_hint' : 'timezone_inline_hint', ['bot' => $this->gateway->botUsername()]); + $rows[] = [['text' => $this->t($user, 'inline_search'), 'switch_inline' => $kind === 'city' ? 'location ' : 'timezone ', 'same_peer' => true]]; + } + if (in_array($kind, ['city','geoname','gps'], true) && !$this->geo->configured()) { + $text .= "\n\n" . $this->t($user, 'geonames_missing'); + } + $rows[] = [$this->button($user, 'cancel', 'sb:loc:' . $panel)]; + $this->send($user, $text, $this->keyboard($rows)); + } + private function locationInput(string $user, array $message): bool + { + $text = trim((string)($message['text'] ?? '')); + if ($text !== '' && preg_match('~^[/!.][a-z]+(?:@|\s|$)~i', $text)) { + return false; + } + $flow = $this->flows->latest($user, 'location_input'); + if ($flow === null) { + return false; + } + $kind = $flow['payload']['kind'] ?? ''; + if (($text === '' && !isset($message['location'])) || (isset($message['location']) && $kind !== 'gps')) { + return false; + } + $request = new self($this->settings, $this->flows, $this->calendar, new CallbackUiGateway($this->gateway, $user, (int)$flow['payload']['message_id']), $this->geo, $this->clock, $this->lang, $this->authorizeOperator, $this->permissionRecovery); + try { + $context = $this->context($user, $flow['payload']['token'], 'location_input'); + if ($kind === 'city') { + $request->cityResults($user, $context, $text); + } elseif ($kind === 'geoname') { + $id = $this->integer($text, 1, 2147483647); + $request->changes($user, $context, $this->geo->get($id, null, $this->userLanguage($user)), true); + } elseif ($kind === 'coordinates') { + if (!preg_match('/^(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)(?:\s+([A-Za-z0-9_+\/-]+))?$/D', $text, $parts)) { + throw new \InvalidArgumentException('Coordinates expected.'); + } + $place = $this->coordinatePlace($user, $parts[1], $parts[2], $parts[3] ?? $context['timezone_id'], $this->t($user, 'location_input_coordinates_title'), 'coordinates'); + $request->changes($user, $context, $place, true); + } elseif ($kind === 'timezone') { + $zones = array_values(array_filter(\DateTimeZone::listIdentifiers(), static fn(string $zone): bool => stripos($zone, $text) !== false)); + if ($zones === []) { + throw new \InvalidArgumentException('An IANA timezone is required.'); + } + $rows = []; + foreach (array_slice($zones, 0, 25) as $zone) { + $choice = $this->token($user, $context, 'location_choice', ['timezone_id' => $zone]); + $rows[] = [['text' => $zone, 'data' => 'sb:lp:' . $choice]]; + } + $panel = $this->token($user, $context, 'panel'); + $rows[] = [$this->button($user, 'back', 'sb:loc:' . $panel)]; + $request->send($user, $this->t($user, 'timezone_warning'), $this->keyboard($rows)); + } elseif ($kind === 'gps') { + if (!isset($message['location']) || ($message['replied_bot'] ?? false) !== true || ($message['reply_message_id'] ?? null) !== $flow['payload']['message_id']) { + throw new \InvalidArgumentException('Reply to the active bot location prompt.'); + } + $gps = $message['location']; + $place = $this->coordinatePlace($user, $gps['latitude'] ?? $gps['lat'] ?? null, $gps['longitude'] ?? $gps['long'] ?? null, $this->geo->configured() ? 'auto' : $context['timezone_id'], $this->t($user, 'location_input_gps_title'), 'telegram_gps'); + $request->changes($user, $context, $place, true); + } else { + return false; + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (PanelEditException) { + // Missing panels require an explicit new /settings command; never create fallback messages. + } catch (\Throwable) { + try { + if (!isset($context)) { + $this->flows->revokeAction($user, 'location_input'); + $this->flows->revokeAction($user, 'settings_wizard'); + $request->send($user, $this->t($user, 'denied'), $this->keyboard([[$this->button($user, 'groups', 'sb:groups')], [$this->button($user, 'home', 'sb:start')]])); + } else { + $panel = $this->token($user, $context, 'panel'); + $request->send($user, $this->t($user, 'location_input_invalid'), $this->keyboard([[$this->button($user, 'back', 'sb:loc:' . $panel)]])); + } + } catch (PanelEditException) { + } + } + return true; + } + private function coordinatePlace(string $user, mixed $latitude, mixed $longitude, string $zone, string $name, string $source): array + { + $place = Place::validate(['latitude' => $latitude, 'longitude' => $longitude, 'timezone_id' => $zone === 'auto' ? 'UTC' : $zone, 'location_name' => $name, 'location_source' => $source]); + if ($zone === 'auto') { + if (!$this->geo->configured()) { + throw new \RuntimeException($this->t($user, 'auto_timezone_missing')); + } + $place = array_replace($place, $this->geo->timezone($place['latitude'], $place['longitude'], null, $this->userLanguage($user))); + } + return $place; + } + private function locationPreview(string $user, string $token): void + { + $context = $this->context($user, $token, 'location_choice'); + $payload = $context['_flow']['payload']; + if (isset($payload['geoname_id'])) { + $place = $this->geo->get((int) $payload['geoname_id'], null, $this->userLanguage($user)); + } elseif (isset($payload['timezone_id'])) { + $place = ['timezone_id' => $payload['timezone_id']]; + } else { + throw new \RuntimeException('Invalid server-side place selection'); + } + $this->changes($user, $context, $place, true); + } + private function changes(string $user, array $context, array $changes, bool $location = false): void + { + $candidate = array_replace($context, $changes); + $text = $this->t($user, 'changes_preview', ['title' => $this->title($user, $context), 'changes' => $this->describe($user, $changes)]); + if ($location) { + $zone = new \DateTimeZone($candidate['timezone_id']); + $text = $this->t($user, 'location_preview', ['title' => $this->title($user, $context), 'location' => $candidate['location_name'], 'country' => $candidate['country'] ?? '', 'region' => $candidate['admin_region'] ?? '', 'latitude' => $candidate['latitude'], 'longitude' => $candidate['longitude'], 'geoname' => $candidate['geoname_id'] ?? '-', 'timezone' => $candidate['timezone_id'], 'offset' => $this->clock->now()->setTimezone($zone)->format('P'), 'warning' => isset($changes['timezone_id']) && count($changes) === 1 ? $this->t($user, 'timezone_warning') : '']); + if (in_array($candidate['location_source'] ?? '', ['geonames','curated','default'], true)) { + $text .= "\n" . $this->t($user, 'location_attribution'); + } + $text .= "\n\n" . $this->renderView($user, $candidate, 'shabbat'); + } + unset($changes['admin_region']); + $token = $this->token($user, $context, 'save', ['changes' => $changes]); + $this->send($user, $text, $this->keyboard([[$this->button($user, 'confirm', 'sb:save:' . $token)], [$this->button($user, 'cancel', 'sb:cancel:' . $token)]])); + } + /** Navigation is carried in server-owned flow metadata, never interpreted as a write. */ + private function settingsNavigation(array $context): array + { + return array_intersect_key($context['_flow']['payload'] ?? [], array_flip(['return_screen', 'return_wizard', 'return_offset'])); + } + + private function settingsReturnData(array $context, string $token, string $module): string + { + $navigation = $this->settingsNavigation($context); + if (isset($navigation['return_wizard'])) { + return 'sb:wp:' . $token . ':' . ($navigation['return_offset'] ?? 0); + } + return isset($navigation['return_screen']) ? 'sb:v:' . $token . ':' . $navigation['return_screen'] : 'sb:e:' . $token . ':' . $module; + } + + private function resumeSettings(string $user, array $context, array $navigation): void + { + $this->flows->revokeAction($user, 'settings_wizard'); + unset($context['_flow']); + $context['_flow']['payload'] = array_intersect_key($navigation, array_flip(['return_screen', 'return_wizard', 'return_offset'])); + if (isset($navigation['return_wizard'])) { + $this->wizardFields($user, $context, $navigation['return_wizard'], (int)($navigation['return_offset'] ?? 0)); + } elseif (isset($navigation['return_screen'])) { + $this->screen($user, $context, $navigation['return_screen']); + } else { + $this->panel($user, $context); + } + } + + private function edit(string $user, array $context, string $module): void + { + $this->wizardFields($user, $context, $module); + } + + private function wizardFields(string $user, array $context, string $module, int $offset = 0): void + { + $fields = SettingsWizard::fields($module, isset($context['group_id'])); + $offset = max(0, min($offset, max(0, count($fields) - 1))); + $token = $this->token($user, $context, 'panel', ['wizard_module' => $module, 'return_wizard' => $module, 'return_offset' => $offset]); + $rows = $this->wizardFieldButtons($user, $context, $module, $token, $offset); + $paging = []; + if ($offset > 0) { + $paging[] = $this->button($user, 'previous', 'sb:wp:' . $token . ':' . max(0, $offset - 8)); + } + if ($offset + 8 < count($fields)) { + $paging[] = $this->button($user, 'next', 'sb:wp:' . $token . ':' . ($offset + 8)); + } + if ($paging !== []) { + $rows[] = $paging; + } + $rows[] = [$this->button($user, 'back', isset($context['group_id']) ? 'sb:v:' . $token . ':' . $module : 'sb:group:' . $token)]; + $text = $this->t($user, 'wizard_title', ['module' => $this->t($user, $module), 'title' => $this->title($user, $context)]) + . "\n\n" . $this->t($user, 'wizard_choose_field') . "\n\n" . $this->formSummary($user, $context, $module); + $this->send($user, $text, $this->keyboard($rows)); + } + + private function wizardFieldButtons(string $user, array $context, string $module, string $token, int $offset = 0, ?array $fields = null): array + { + $rows = []; + $all = SettingsWizard::fields($module, isset($context['group_id'])); + $visible = $fields === null ? array_slice($all, $offset, 8, true) : array_intersect($all, $fields); + foreach ($visible as $index => $field) { + $value = SettingsWizard::current($module, $field, $context, $this->userLanguage($user)); + $rows[] = [['text' => mb_substr($this->t($user, 'field_' . $field) . ': ' . $value, 0, 110), + 'data' => 'sb:wf:' . $token . ':' . $index]]; + } + return $rows; + } + + private function wizardField(string $user, array $context, string $module, string $field): void + { + $definition = SettingsWizard::definition($module, $field, isset($context['group_id'])); + if ($field === 'template' && isset($context['group_id'])) { + $rows = [[$this->button($user, 'messages', 'v3:templates:' . $context['group_id'] . ':' . $module)]]; + $token = $this->token($user, $context, 'panel'); + $rows[] = [$this->button($user, 'back', 'sb:v:' . $token . ':' . $module)]; + $this->send($user, $this->t($user, 'wizard_template'), $this->keyboard($rows)); + return; + } + if (($definition['kind'] ?? '') === 'flags') { + $this->screen($user, $context, 'restrictions'); + return; + } + if (($definition['kind'] ?? '') === 'days') { + $this->wizardPrompt($user, $context, $module, $field); + return; + } + $token = $this->token($user, $context, 'panel', ['wizard_module' => $module, 'wizard_field' => $field]); + $rows = []; + foreach ($definition['options'] ?? [] as $index => $option) { + $changes = SettingsWizard::changes($module, $field, $option, $context, fn(string $m, string $form): array => $this->parseForm($m, $form)); + $candidate = array_replace($context, $changes); + $label = SettingsWizard::current($module, $field, $candidate, $this->userLanguage($user)); + $rows[] = [['text' => $label, 'data' => 'sb:wo:' . $token . ':' . $index, + 'style' => ['_' => 'keyboardButtonStyle'] + ($option === false ? ['bg_danger' => true] : [])]]; + } + if ($rows !== []) { + $rows = array_chunk(array_merge(...$rows), in_array($definition['kind'], ['bool', 'enum'], true) ? 2 : 3); + } + if (!in_array($definition['kind'] ?? '', ['bool','enum'], true)) { + $rows[] = [$this->button($user, 'wizard_custom', 'sb:wi:' . $token)]; + } + $rows[] = [$this->button($user, 'back', $this->settingsReturnData($context, $token, $module))]; + $this->send($user, $this->wizardQuestion($user, $context, $module, $field, $definition), $this->keyboard($rows)); + } + + private function wizardQuestion(string $user, array $context, string $module, string $field, array $definition): string + { + $kind = $definition['kind'] ?? 'text'; + $hint = in_array($kind, ['time','times','integer','float','minutes_list','text','days'], true) + ? $this->t($user, 'wizard_hint_' . $kind) : $this->t($user, 'wizard_choose_option'); + return $this->t($user, 'wizard_input', ['field' => $this->t($user, 'field_' . $field), + 'current' => SettingsWizard::current($module, $field, $context, $this->userLanguage($user)), + 'hint' => $hint, 'title' => $this->title($user, $context)]); + } + + private function wizardPrompt(string $user, array $context, string $module, string $field): void + { + if (!$this->gateway instanceof CallbackUiGateway) { + throw new \InvalidArgumentException('An active private settings panel is required.'); + } + $definition = SettingsWizard::definition($module, $field, isset($context['group_id'])); + $this->flows->revokeAction($user, 'settings_wizard'); + $navigation = $this->settingsNavigation($context); + unset($context['_flow']); + $context['_flow']['payload'] = $navigation; + $name = $module === 'shabbat' || in_array($field, ['quiet_start','quiet_end'], true) ? $field : $module . '_' . $field; + $payload = ['module' => $module, 'field' => $field, 'message_id' => $this->gateway->messageId(), + 'selected' => ($definition['kind'] ?? '') === 'days' ? ($context[$name] ?? []) : []] + $navigation; + $token = $this->token($user, $context, 'settings_wizard', $payload); + $this->flows->updatePayload($token, $user, 'settings_wizard', $payload + ['token' => $token]); + if (($definition['kind'] ?? '') === 'days') { + $this->wizardDays($user, $this->context($user, $token, 'settings_wizard'), -1); + return; + } + $panel = $this->token($user, $context, 'panel'); + $this->send( + $user, + $this->wizardQuestion($user, $context, $module, $field, $definition), + $this->keyboard([[$this->button($user, 'cancel', $this->settingsReturnData($context, $panel, $module))]]) + ); + } + + private function wizardDays(string $user, array $context, ?int $toggle): void + { + $payload = $context['_flow']['payload']; + $token = $payload['token']; + $selected = $payload['selected']; + if ($toggle !== null && $toggle >= 0) { + $selected = in_array($toggle, $selected, true) ? array_values(array_diff($selected, [$toggle])) : [...$selected, $toggle]; + sort($selected); + $payload['selected'] = $selected; + $this->flows->updatePayload($token, $user, 'settings_wizard', $payload); + } + if ($toggle === null) { + $changes = SettingsWizard::changes($payload['module'], $payload['field'], $selected, $context, fn(string $m, string $form): array => $this->parseForm($m, $form)); + $this->changes($user, $context, $changes); + return; + } + $rows = []; + foreach (range(0, 6) as $day) { + $rows[] = [['text' => (in_array($day, $selected, true) ? 'โœ… ' : 'โฌœ ') . $this->t($user, 'times_day_name_' . $day), + 'data' => 'sb:wd:' . $token . ':' . $day]]; + } + $rows[] = [$this->button($user, 'wizard_done', 'sb:wc:' . $token)]; + $panel = $this->token($user, $context, 'panel'); + $rows[] = [$this->button($user, 'cancel', $this->settingsReturnData($context, $panel, $payload['module']))]; + $this->send($user, $this->wizardQuestion($user, $context, $payload['module'], $payload['field'], ['kind' => 'days']), $this->keyboard($rows)); + } + + private function wizardInput(string $user, array $message): bool + { + $text = trim((string)($message['text'] ?? '')); + if ($text === '' || preg_match('~^[/!.][a-z]+(?:@|\s|$)~i', $text)) { + return false; + } + $flow = $this->flows->latest($user, 'settings_wizard'); + if ($flow === null) { + return false; + } + $request = new self( + $this->settings, + $this->flows, + $this->calendar, + new CallbackUiGateway($this->gateway, $user, (int)$flow['payload']['message_id']), + $this->geo, + $this->clock, + $this->lang, + $this->authorizeOperator + ); + try { + $context = $this->context($user, $flow['payload']['token'], 'settings_wizard'); + $module = $flow['payload']['module']; + $field = $flow['payload']['field']; + $definition = SettingsWizard::definition($module, $field, isset($context['group_id'])); + if (($definition['kind'] ?? '') === 'days') { + throw new \InvalidArgumentException('Choose weekdays with the buttons.'); + } + $changes = SettingsWizard::changes($module, $field, $text, $context, fn(string $m, string $form): array => $this->parseForm($m, $form)); + $request->changes($user, $context, $changes); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + try { + $panel = isset($context) ? $this->token($user, $context, 'panel') : null; + $rows = $panel === null ? [[$this->button($user, 'home', 'sb:start')]] + : [[$this->button($user, 'cancel', $this->settingsReturnData($context, $panel, $flow['payload']['module']))]]; + $request->send($user, $this->t($user, isset($context) ? 'wizard_invalid' : 'denied'), $this->keyboard($rows)); + } catch (PanelEditException) { + } + } + return true; + } + public function parseForm(string $module, string $form): array + { + $allowed = self::FORM_FIELDS[$module] ?? throw new \InvalidArgumentException('Unsupported module'); + $changes = []; + foreach (explode(';', $form) as $pair) { + [$field, $value] = array_pad(explode('=', trim($pair), 2), 2, null); + if (!in_array($field, $allowed, true) || $value === null || array_key_exists($module === 'shabbat' || in_array($field, ['quiet_start','quiet_end'], true) ? $field : $module . '_' . $field, $changes)) { + throw new \InvalidArgumentException('Unsupported or repeated form field'); + } + $name = $module === 'shabbat' || in_array($field, ['quiet_start','quiet_end'], true) ? $field : $module . '_' . $field; + if ($field === 'lock_block_flags') { + $value = Permissions::validateBlockFlags($value === '' ? [] : array_map('trim', explode(',', $value))); + } elseif (in_array($field, ['days','fixed_times','sunset_offsets'], true)) { + $value = $value === '' ? [] : array_map('trim', explode(',', $value)); + if ($field === 'fixed_times') { + foreach ($value as $time) { + $this->hours($time); + } + } else { + $value = array_map(fn(string $v): int => $this->integer($v, $field === 'days' ? 0 : 1, $field === 'days' ? 6 : 360), $value); + } + } elseif (in_array($field, ['chol_hamoed','day_completion','times_alert_enabled','close_message_enabled','open_message_enabled','delete_previous','ack_enabled','delete_holy_user_messages','delete_holy_bot_messages'], true)) { + if (!in_array($value, ['true','false'], true)) { + throw new \InvalidArgumentException('Use true or false'); + } $value = $value === 'true'; + } elseif (($field === 'candle_minutes' && $value === 'city') || ($field === 'interval_minutes' && $value === 'off') || (in_array($field, ['quiet_start','quiet_end','target_topic_id','lock_block_flags','delete_holy_user_messages','delete_holy_bot_messages'], true) && $value === 'off')) { + $value = null; + } elseif (str_ends_with($field, '_time') || in_array($field, ['time','window_start','window_end','quiet_start','quiet_end'], true)) { + $this->hours($value); + } elseif (in_array($field, ['havdalah_value','nightfall_value'], true)) { + if (!is_numeric($value) || !is_finite((float) $value) || (float) $value <= 0 || (float) $value > 120) { + throw new \InvalidArgumentException('Invalid method value'); + } $value = (float) $value; + } elseif (in_array($field, ['candle_minutes','close_before_candles_minutes','open_after_havdalah_minutes','times_alert_before_minutes','interval_minutes','max_per_day','min_spacing_minutes','sunset_safety_minutes','after_nightfall_minutes','after_open_minutes','target_topic_id','lock_block_flags','delete_holy_user_messages','delete_holy_bot_messages'], true)) { + $value = $this->integer($value, 0, $field === 'target_topic_id' ? 2147483647 : 360); + } elseif ($field === 'ack_label') { + if (!mb_check_encoding($value, 'UTF-8') || strlen(mb_convert_encoding($value, 'UTF-16LE', 'UTF-8')) > 96 || preg_match('/[\x00-\x1F\x7F]/u', $value)) { + throw new \InvalidArgumentException('The reminder confirmation label must be bounded plain text.'); + } + $value = trim($value) === '' ? null : $value; + } elseif ($field === 'template') { + if (mb_strlen($value) > 4000) { + throw new \InvalidArgumentException('Template is too long'); + } preg_match_all('/\{\{([^}]+)\}\}/', $value, $matches); + foreach ($matches[1] as $placeholder) { + if (!in_array($placeholder, ['location','date','hdate','sunset','remaining','candles','havdalah','close','open','daf','omer','source'], true)) { + throw new \InvalidArgumentException('Unknown template placeholder'); + } + } + } else { + $enum = match ($field) { + 'havdalah_method','nightfall_method' => ['degrees','minutes'], 'format' => ['short','full'], 'mode' => ['fixed','nightfall'], 'times_alert_mode' => ['local_time','before_close'], 'nusach' => ['ashkenaz','sephard'], 'early_policy' => ['defer','skip'], 'tisha_bav_policy' => ['off','mincha'], default => [] + }; + if (!in_array($value, $enum, true)) { + throw new \InvalidArgumentException('Unsupported form choice'); + } + } + $bounds = ['candle_minutes' => [1,120], 'close_before_candles_minutes' => [0,180], 'open_after_havdalah_minutes' => [0,180], 'times_alert_before_minutes' => [0,360], 'interval_minutes' => [5,360], 'max_per_day' => [1,24], 'min_spacing_minutes' => [5,360], 'sunset_safety_minutes' => [1,120], 'after_nightfall_minutes' => [0,240], 'after_open_minutes' => [0,180]]; + if ($value !== null && isset($bounds[$field]) && ($value < $bounds[$field][0] || $value > $bounds[$field][1])) { + throw new \InvalidArgumentException('Value outside permitted range'); + } + $changes[$name] = $value; + } + return $changes; + } + private function formSummary(string $user, array $context, string $module): string + { + $lines = []; + foreach (SettingsWizard::fields($module, isset($context['group_id'])) as $field) { + $lines[] = $this->t($user, 'field_' . $field) . ': ' . SettingsWizard::current($module, $field, $context, $this->userLanguage($user)); + } + return implode("\n", $lines); + } + private function moduleDefaults(string $module, bool $group = true): array + { + if (!$group) { + if ($module !== 'shabbat') { + throw new \RuntimeException('Group module required'); + } + return ['candle_minutes' => null,'havdalah_method' => 'degrees','havdalah_value' => 8.5]; + } + return match ($module) { + 'shabbat' => ['mode' => 'lock_messages','shabbat_enabled' => true,'holidays_enabled' => true,'candle_minutes' => null,'havdalah_method' => 'degrees','havdalah_value' => 8.5,'close_before_candles_minutes' => 0,'open_after_havdalah_minutes' => 0,'lock_block_flags' => Permissions::DEFAULT_BLOCK_FLAGS,'delete_holy_user_messages' => false,'delete_holy_bot_messages' => false], + 'zmanim' => ['zmanim_enabled' => false,'zmanim_time' => '07:00','zmanim_days' => [0,1,2,3,4,5,6],'zmanim_format' => 'full'], + 'daf' => ['daf_enabled' => false,'daf_time' => '08:00','daf_days' => [0,1,2,3,4,5,6],'daf_template' => '{{daf}}'], + 'tefillin' => ['tefillin_enabled' => false,'tefillin_days' => [0,1,2,3,4,5],'tefillin_fixed_times' => ['09:00'],'tefillin_interval_minutes' => null,'tefillin_window_start' => '07:00','tefillin_window_end' => '19:00','tefillin_sunset_offsets' => [60,30,15],'tefillin_max_per_day' => 3,'tefillin_min_spacing_minutes' => 30,'tefillin_chol_hamoed' => false,'tefillin_tisha_bav_policy' => 'off','tefillin_sunset_safety_minutes' => 10,'tefillin_suspended_date' => null,'tefillin_delete_previous' => false,'tefillin_ack_enabled' => true,'tefillin_ack_label' => null], + 'omer' => ['omer_enabled' => false,'omer_time' => '21:00','omer_mode' => 'nightfall','omer_after_nightfall_minutes' => 10,'omer_nightfall_method' => 'degrees','omer_nightfall_value' => 8.5,'omer_nusach' => 'sephard','omer_format' => 'full','omer_day_completion' => false,'omer_early_policy' => 'defer','omer_after_open_minutes' => 10], + default => throw new \InvalidArgumentException('Unsupported module'), + }; + } + private function view(string $user, array $context, string $module, ?string $date = null): void + { + if (!in_array($module, ['shabbat','zmanim','daf','omer','tefillin'], true)) { + throw new \InvalidArgumentException('Unsupported view'); + } + if ($module === 'shabbat') { + $this->shabbatView($user, $context); + return; + } + $text = $this->renderView($user, $context, $module, $date); + $token = $this->token($user, $context, 'panel'); + $rows = []; + if (in_array($module, ['zmanim','daf','omer'], true)) { + $rows[] = [$this->button($user, 'today', 'sb:day:' . $token . ':' . $module . ':' . $this->localNow($context)->format('Y-m-d')), $this->button($user, 'tomorrow', 'sb:day:' . $token . ':' . $module . ':' . $this->localNow($context)->modify('+1 day')->format('Y-m-d'))]; + $rows[] = [$this->button($user, 'other_date', 'sb:date:' . $token . ':' . $module)]; + } + if ($module === 'zmanim') { + $rows[] = [$this->button($user, 'short', 'sb:f:' . $token . ':short'), $this->button($user, 'full', 'sb:f:' . $token . ':full')]; + } + if ($module !== 'tefillin') { + $shareQuery = $module; + if (isset($context['group_id'])) { + $snapshot = $context; + unset($snapshot['_flow']); + $shareToken = $this->flows->create($user, 'share', (string) $context['group_id'], (int) $context['revision'], 'share_snapshot', 600, ['settings' => $snapshot, 'module' => $module, 'date' => $date]); + $shareQuery = 'share ' . $shareToken; + } + $rows[] = [['text' => $this->t($user, 'share'), 'switch_inline' => $shareQuery, 'same_peer' => false]]; + } + $rows[] = [$this->button($user, 'back', isset($context['group_id']) ? 'sb:group:' . $token : 'sb:start')]; + $this->send($user, $text, $this->keyboard($rows)); + } + private function shabbatView(string $user, array $context, int $offset = 0): void + { + $token = $this->token($user, $context, 'panel'); + try { + $payload = (new \Shabbat\Content\ShabbatTimesFormatter($this->lang))->overview($context, $this->calendar->snapshot($context), $offset); + $text = $payload['text']; + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + $payload = ['offset' => 0, 'total' => 0]; + $text = $this->t($user, 'provider_unavailable'); + } + $rows = []; + $shareQuery = 'shabat'; + if (isset($context['group_id'])) { + $snapshot = $context; + unset($snapshot['_flow']); + $shareToken = $this->flows->create($user, 'share', (string)$context['group_id'], (int)$context['revision'], 'share_snapshot', 600, ['settings' => $snapshot, 'module' => 'shabbat', 'date' => null]); + $shareQuery = 'share ' . $shareToken; + } + $rows[] = [['text' => $this->t($user, 'share'), 'switch_inline' => $shareQuery, 'same_peer' => false]]; + $rows[] = [$this->button($user, 'back', isset($context['group_id']) ? 'sb:group:' . $token : 'sb:start')]; + $this->send($user, $text, $this->keyboard($rows)); + } + public function renderView(string $user, array $context, string $module, ?string $date = null, ?string $languageOverride = null): string + { + $translate = fn(string $key, array $values = []): string => $this->lang->text($languageOverride ?? ($context['language'] ?? 'he'), $key, $values); + try { + if ($module === 'shabbat') { + $snap = $this->calendar->snapshot($context); + $presentation = $context; + $presentation['language'] = $languageOverride ?? ($context['language'] ?? 'he'); + return (new \Shabbat\Content\ShabbatTimesFormatter($this->lang))->overview($presentation, $snap)['text']; + } + $date = $date === null ? $this->localNow($context)->format('Y-m-d') : $this->date($date); + $content = []; + if ($module === 'zmanim') { + $data = $this->calendar->zmanim($context, $date); + foreach ([42,50,72] as $minutes) { + if (isset($data['times']['sunset'])) { + $data['times']['tzeit' . $minutes . 'min'] = $data['times']['sunset'] + $minutes * 60; + } + } + $keys = ($context['zmanim_format'] ?? 'full') === 'short' ? ['sunrise','sofZmanShma','chatzot','sunset','tzeit85deg'] : ['alotHaShachar','misheyakir','sunrise','sofZmanShma','sofZmanShmaMGA','sofZmanTfilla','sofZmanTfillaMGA','chatzot','minchaGedola','minchaKetana','plagHaMincha','sunset','tzeit85deg','tzeit42min','tzeit50min','tzeit72min','chatzotNight']; + foreach ($keys as $key) { + $stamp = $data['times'][$key] ?? null; + $value = $translate('unavailable'); + if (is_int($stamp)) { + $localTime = (new \DateTimeImmutable('@' . $stamp))->setTimezone(new \DateTimeZone($context['timezone_id'])); + $value = $localTime->format($localTime->format('Y-m-d') === $date ? 'H:i' : 'Y-m-d H:i'); + } + $content[] = $translate($key) . ': ' . $value . ''; + } + } elseif ($module === 'daf') { + $data = $this->calendar->daf($context, $date); + $title = ($languageOverride ?? $context['language']) === 'he' ? ($data['hebrew'] ?? $data['title']) : $data['title']; + $content[] = '๐Ÿ“– ' . $this->escape($title) . ''; + if (is_string($data['link'] ?? null) && parse_url($data['link'], PHP_URL_SCHEME) === 'https') { + $content[] = '' . $this->escape($translate('daf_open_page')) . ''; + } + } elseif ($module === 'tefillin') { + $data = $this->calendar->zmanim($context, $date); + $state = $this->calendar->snapshot($context, new \DateTimeImmutable($date . ' 12:00', new \DateTimeZone($context['timezone_id']))); + $rule = ['enabled' => true, 'weekdays' => array_map(static fn(int $d): int => $d === 0 ? 7 : $d, $context['tefillin_days']), 'times' => $context['tefillin_fixed_times'], 'interval_minutes' => $context['tefillin_interval_minutes'] ?? 0, 'window_start' => $context['tefillin_window_start'], 'window_end' => $context['tefillin_window_end'], 'before_sunset' => $context['tefillin_sunset_offsets'], 'max_per_day' => $context['tefillin_max_per_day'], 'min_gap_minutes' => $context['tefillin_min_spacing_minutes'], 'quiet_start' => $context['quiet_start'], 'quiet_end' => $context['quiet_end'], 'pause_date' => $context['tefillin_suspended_date'], 'chol_hamoed' => $context['tefillin_chol_hamoed'], 'tisha_bav' => $context['tefillin_tisha_bav_policy'], 'sunset_safety_minutes' => $context['tefillin_sunset_safety_minutes']]; + $jobs = (new \Shabbat\Notifications\ReminderPlanner())->tefillin($context, $rule, $date, $data['times'], $state); + foreach ($jobs as $job) { + $content[] = $this->time($job['run_at'], $context) . ' ยท ' . $translate('window_end') . ': ' . $this->time($job['expires_at'], $context); + } + if ($content === []) { + $content[] = $translate('no_reminders'); + } + $content[] = $translate('preview_only'); + } elseif ($module === 'omer') { + $at = $date === $this->localNow($context)->format('Y-m-d') ? null : new \DateTimeImmutable($date . ' 23:00', new \DateTimeZone($context['timezone_id'])); + $data = $this->calendar->omer($context, $at); + if (!$data['season']) { + return $translate('omer_inactive'); + } $date = $data['date']; + $content[] = $this->escape((string)$data['count_text']); + $content[] = '๐ŸŒ™ ' . $translate('omer_nightfall_label') . ': ' . $this->time($data['nightfall'], $context) . ''; + } else { + throw new \InvalidArgumentException('Unsupported calendar view'); + } + $header = $translate('date_view', ['module' => $translate($module), 'date' => $date, 'location' => $context['location_name'], 'timezone' => $context['timezone_id'], 'hdate' => $data['hdate'] ?? '-', 'content' => '']); + return $header . implode("\n", $content); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + return $translate('provider_unavailable'); + } + } + private function context(string $user, string $token, string $action): array + { + $flow = $this->flows->peek($token, $user, $action) ?? throw new \RuntimeException('Settings context expired or belongs to another user'); + if (isset($flow['payload']['source_wizard_token'])) { + $parent = $this->flows->peek($flow['payload']['source_wizard_token'], $user, 'settings_wizard'); + if ($parent === null || $parent['scope'] !== $flow['scope'] || $parent['group_id'] !== $flow['group_id'] || $parent['revision'] !== $flow['revision']) { + throw new \RuntimeException('The original settings question expired or was replaced.'); + } + } + if (isset($flow['payload']['source_input_token'])) { + $parent = $this->flows->peek($flow['payload']['source_input_token'], $user, 'location_input'); + if ($parent === null || $parent['scope'] !== $flow['scope'] || $parent['group_id'] !== $flow['group_id'] || $parent['revision'] !== $flow['revision']) { + throw new \RuntimeException('The original location prompt has expired or was replaced.'); + } + } + $context = in_array($flow['scope'], ['group','share'], true) ? $this->group($user, (string) $flow['group_id']) : $this->settings->getUser($user); + if ((int) $context['revision'] !== (int) $flow['revision']) { + throw new \RuntimeException('Settings changed; reopen the panel'); + } $context['_flow'] = $flow; + return $context; + } + private function group(string $user, string $id): array + { + $context = $this->settings->getGroup($id) ?? throw new \RuntimeException('Unknown group. Existing DBgroups entries are imported automatically at startup.'); + if (!$this->gateway->isGroupAdmin($user, $id)) { + throw new \RuntimeException('Current administrator permission is required'); + } return $context; + } + private function token(string $user, array $context, string $action, array $payload = []): string + { + $payload += $this->settingsNavigation($context); + $wizardSource = $context['_flow']['payload']['source_wizard_token'] ?? (($context['_flow']['action'] ?? '') === 'settings_wizard' ? ($context['_flow']['payload']['token'] ?? null) : null); + if ($wizardSource !== null && $action === 'save') { + $parent = $this->flows->peek($wizardSource, $user, 'settings_wizard'); + if ($parent === null || (string)($parent['group_id'] ?? '') !== (string)($context['group_id'] ?? '') || $parent['revision'] !== (int)$context['revision']) { + throw new \RuntimeException('The settings question expired or was replaced.'); + } + $payload['source_wizard_token'] = $wizardSource; + } + $sourceInput = $context['_flow']['payload']['source_input_token'] ?? (($context['_flow']['action'] ?? '') === 'location_input' ? ($context['_flow']['payload']['token'] ?? null) : null); + if ($sourceInput !== null && in_array($action, ['location_choice','location_page','save'], true)) { + $parent = $this->flows->peek($sourceInput, $user, 'location_input'); + if ($parent === null || (string)($parent['group_id'] ?? '') !== (string)($context['group_id'] ?? '') || $parent['revision'] !== (int)$context['revision']) { + throw new \RuntimeException('The location prompt has expired or was replaced.'); + } + $payload['source_input_token'] = $sourceInput; + } + return $this->flows->create($user, isset($context['group_id']) ? 'group' : 'user', isset($context['group_id']) ? (string) $context['group_id'] : null, (int) $context['revision'], $action, 600, $payload); + } + private function capabilities(string $user, array $context): array + { + $capabilities = ['verified' => false]; + $diagnostic = $this->gateway instanceof CallbackUiGateway ? $this->gateway->underlying() : $this->gateway; + if ($diagnostic instanceof \Shabbat\Telegram\MessageMaintenanceGateway) { + try { + $candidate = $diagnostic->botCapabilities((string)$context['group_id']); + if (($candidate['verified'] ?? false) === true && ($candidate['group_id'] ?? null) === (string)$context['group_id']) { + $valid = true; + foreach (\Shabbat\Telegram\AdminRights::FLAGS as $flag) { + $valid = $valid && is_bool($candidate['rights'][$flag] ?? null); + } + foreach (['creator','is_admin','can_delete_messages'] as $field) { + $valid = $valid && is_bool($candidate[$field] ?? null); + } + if ($valid) { + $capabilities = $candidate; + } + } + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable) { + // A failed diagnostic never manufactures administrator rights. + } + } + $this->group($user, (string)$context['group_id']); + return $capabilities; + } + private function safeSnapshot(array $context): array + { + try { + return $this->calendar->snapshot($context); + } catch (\Amp\CancelledException $e) { + throw $e; + } catch (\Throwable $e) { + return ['error' => $e->getMessage()]; + } + } + private function userLanguage(string $user): string + { + return Lang::canonical($this->settings->getUser($user)['language'] ?? 'he'); + } + private function t(string $user, string $key, array $values = []): string + { + return $this->lang->text($this->settings->getUser($user)['language'] ?? 'he', $key, $values); + } + private function title(string $user, array $context): string + { + return isset($context['group_id']) ? (string) ($context['title'] ?: $context['group_id']) : $this->t($user, 'preferences'); + } + private function localNow(array $context): \DateTimeImmutable + { + return $this->clock->now()->setTimezone(new \DateTimeZone($context['timezone_id'])); + } + private function time(?int $stamp, array $context): string + { + return $stamp === null ? '-' : (new \DateTimeImmutable('@' . $stamp))->setTimezone(new \DateTimeZone($context['timezone_id']))->format('Y-m-d H:i'); + } + private function date(string $date): string + { + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, new \DateTimeZone('UTC')); + if ($parsed === false || $parsed->format('Y-m-d') !== $date) { + throw new \InvalidArgumentException('Use a real YYYY-MM-DD date'); + } return $date; + } + private function hours(string $time): void + { + if (!preg_match('/^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/D', $time)) { + throw new \InvalidArgumentException('Use HH:mm'); + } + } + private function integer(string $value, int $min, int $max): int + { + if (!preg_match('/^[0-9]+$/D', $value) || (float) $value < $min || (float) $value > $max) { + throw new \InvalidArgumentException('Value outside permitted range'); + } return (int) $value; + } + private function twoArguments(string $text): array + { + $parts = preg_split('/\s+/u', $text, 2); + if (count($parts) !== 2 || $parts[1] === '') { + throw new \InvalidArgumentException('A flow token and value are required'); + } return $parts; + } + private function describe(string $user, array $changes): string + { + $rows = []; + foreach ($changes as $field => $value) { + if (in_array($field, ['location_name','country','geoname_id','latitude','longitude','timezone_id','location_source','admin_region'], true)) { + continue; + } + $base = preg_replace('/^(?:zmanim|daf|tefillin|omer)_/', '', $field); + if ($field === 'lock_block_flags') { + $label = $this->t($user, 'field_lock_block_flags'); + $shown = $value === [] ? $this->t($user, 'moderation_none') : implode(', ', array_map(fn(string $flag): string => $this->t($user, 'permission_' . $flag), $value)); + } elseif ($field === 'mode') { + $label = $this->t($user, 'mode'); + $shown = $this->t($user, 'mode_' . $value); + } elseif ($field === 'language') { + $label = $this->t($user, 'field_language'); + $shown = Lang::NATIVE_NAMES[$value]; + } elseif ($field === 'israel') { + $label = $this->t($user, 'calendar_israel'); + $shown = $this->t($user, $value ? 'on' : 'off'); + } elseif (in_array($field, ['shabbat_enabled','holidays_enabled'], true)) { + $label = $this->t($user, $field); + $shown = $this->t($user, $value ? 'on' : 'off'); + } elseif ($base === 'enabled') { + $module = substr($field, 0, strpos($field, '_')); + $label = $this->t($user, $module) . ' ยท ' . $this->t($user, 'field_enabled'); + $shown = $this->t($user, $value ? 'on' : 'off'); + } elseif ($field === 'tefillin_suspended_date') { + $label = $this->t($user, 'tefillin'); + $shown = (string)$value; + } else { + $label = $this->t($user, 'field_' . $base); + $module = preg_match('/^(zmanim|daf|tefillin|omer)_/', $field, $match) ? $match[1] : 'shabbat'; + $shown = SettingsWizard::valueLabel($module, $base, $value, $this->userLanguage($user)); + if (in_array($base, ['havdalah_method','nightfall_method'], true)) { + $shown = $this->t($user, 'method_' . $value); + } elseif ($base === 'format' && in_array($value, ['short','full'], true)) { + $shown = $this->t($user, $value); + } + } + $rows[] = $label . ': ' . $shown; + } + return implode("\n", $rows); + } + private function escape(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + private function placeTitle(array $place): string + { + return implode(', ', array_filter([$place['location_name'], $place['country'] ?? '', $place['admin_region'] ?? ''])); + } + private function button(string $user, string $key, string $data): array + { + $color = match ($key) { + 'cancel','reset','off' => 'bg_danger', + 'confirm','on','add','preferences','groups','shabbat','edit' => 'bg_primary', + default => null, + }; + $style = ['_' => 'keyboardButtonStyle']; + if ($color !== null) { + $style[$color] = true; + } + return Presentation::button(['text' => $this->t($user, $key), 'data' => $data, 'style' => $style], true, $key); + } + private function keyboard(array $rows): array + { + $result = ['_' => 'replyInlineMarkup', 'rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $button) { + if (isset($button['switch_inline'])) { + $buttons[] = ['_' => 'keyboardButtonSwitchInline', 'text' => $button['text'], 'query' => $button['switch_inline'], 'same_peer' => $button['same_peer'] ?? true]; + } elseif (isset($button['url'])) { + if (parse_url($button['url'], PHP_URL_SCHEME) !== 'https') { + throw new \InvalidArgumentException('HTTPS links required'); + } $buttons[] = ['_' => 'keyboardButtonUrl', 'text' => $button['text'], 'url' => $button['url']]; + } else { + if (strlen($button['data']) > 64) { + throw new \LogicException('Callback exceeds Telegram limit'); + } $buttons[] = ['_' => 'keyboardButtonCallback', 'text' => $button['text'], 'data' => $button['data']]; + } + $styled = Presentation::button($button); + $buttons[array_key_last($buttons)]['style'] = $styled['style']; + if (($styled['_presentation_auto_icon'] ?? false) === true) { + $buttons[array_key_last($buttons)]['_presentation_auto_icon'] = true; + } + } + if ($buttons) { + $result['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => $buttons]; + } + } + return $result; + } + private function article(string $id, string $title, string $description, string $text, array $markup = []): array + { + $message = ['_' => 'inputBotInlineMessageText', 'message' => $text, 'parse_mode' => 'HTML']; + if (($markup['rows'] ?? []) !== []) { + $message['reply_markup'] = $markup; + } + return ['_' => 'inputBotInlineResult', 'id' => substr($id, 0, 64), 'type' => 'article', 'title' => $title, 'description' => $description, 'send_message' => $message]; + } + private function send(string $user, string $text, array $markup = []): void + { + $this->gateway->sendPrivate($user, $text, $markup); + } + private function sendError(string $user, \Throwable $error): void + { + $key = $error instanceof \Shabbat\Location\LocationException ? ($this->geo->configured() ? 'unavailable' : 'geonames_missing') : ($error instanceof \InvalidArgumentException ? (str_contains($error->getMessage(), 'YYYY-MM-DD') ? 'date_invalid' : 'hours_invalid') : 'denied'); + $this->send($user, $this->t($user, $key)); + } +} diff --git a/src/UI/BroadcastController.php b/src/UI/BroadcastController.php new file mode 100644 index 0000000..76a10ad --- /dev/null +++ b/src/UI/BroadcastController.php @@ -0,0 +1,318 @@ +authorize = \Closure::fromCallable($authorize); + } + + public function main(string $user): array + { + $this->allow($user); + $rows = []; + if ($this->drafts->state($user)['exists']) { + $rows[] = [$this->button($user, 'resume_draft', 'bc:draft', 'bg_primary')]; + } + $rows[] = [$this->button($user, 'progress', 'LastBrodDATA'), $this->button($user, 'jobs', 'bc:jobs:0')]; + if ($this->can($user, true)) { + $rows[] = [$this->button($user, 'send', 'bc:new', 'bg_primary'), $this->button($user, 'edit', 'bc:edit', 'bg_primary')]; + $rows[] = [$this->button($user, 'delete_last', 'deleteLastBroadcast', 'bg_danger'), $this->button($user, 'delete_all', 'deleteAllBroadcast', 'bg_danger')]; + $rows[] = [$this->button($user, 'unpin', 'cancelPinned')]; + } + $rows[] = [$this->button($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame('' . $this->t($user, 'broadcast') . '\n\n' . $this->t($user, 'workflow') . '\n\n' . $this->t($user, 'send_note') . '', $rows); + } + + public function draft(string $user): array + { + $this->allow($user); + $state = $this->drafts->state($user); + $text = '' . $this->t($user, 'broadcast') . '\n\n' + . $this->t($user, 'operation') . ': ' . $this->t($user, $state['operation']) . '\n' + . $this->t($user, 'audience') . ': ' . $this->t($user, $state['audience']) . '\n' + . $this->t($user, 'pin') . ': ' . $this->t($user, $state['pin'] ? 'on' : 'off') . ''; + if ($state['operation'] === 'send') { + $text .= "\n" . $this->t($user, 'self_destruct') . ': ' . ($state['self_destruct_hours'] === null ? $this->t($user, 'off') : $state['self_destruct_hours'] . ' ' . $this->t($user, 'hours')) . '' + . "\n" . $this->t($user, 'schedule') . ': ' . ($state['scheduled_at'] === null ? $this->t($user, 'now') : $this->date($user, $state['scheduled_at'])) . ''; + } + $text .= "\n\n" . $this->t($user, $state['exists'] ? 'content_ready' : 'content_missing') . ''; + if ($state['exists']) { + $preview = mb_substr($state['payload']['text'], 0, 180); + $text .= "\n
" . self::html($preview === '' ? '๐Ÿ“Ž' : $preview) + . (mb_strlen($state['payload']['text']) > 180 ? 'โ€ฆ' : '') . '
'; + $text .= "\n" . $this->t($user, 'button_count') . ': ' . array_sum(array_map('count', $state['payload']['buttons'])) . ''; + } + $text .= "\n\n" . $this->t($user, $state['operation'] === 'edit' ? 'edit_note' : 'send_note') . ''; + if (!$state['exists']) { + $text .= "\n\n" . $this->t($user, 'no_draft'); + } + $rows = []; + if ($this->can($user, true)) { + $rows[] = [$this->button($user, 'content', 'bc:content', 'bg_primary'), $this->button($user, 'buttons', 'bc:buttons')]; + $rows[] = [$this->button($user, 'pin', $state['pin'] ? 'bc:pin:off' : 'bc:pin:on', $state['pin'] ? 'bg_primary' : null), $this->button($user, 'audience', 'bc:audience')]; + if ($state['operation'] === 'send') { + $rows[] = [$this->button($user, 'self_destruct', 'bc:sd'), $this->button($user, 'schedule', 'bc:schedule')]; + } + if ($state['exists']) { + $rows[] = [$this->button($user, 'preview', 'bc:preview'), $this->button($user, 'confirm', 'bc:confirm', 'bg_primary')]; + } + } + $rows[] = [$this->button($user, 'cancel', 'bc:main', 'bg_danger')]; + return $this->frame($text, $rows); + } + + /** Returns an action envelope only for runtime-owned RPC/job operations. */ + public function callback(string $user, string $data): ?array + { + if (!str_starts_with($data, 'bc:')) { + return null; + } + $this->allow($user); + if ($data === 'bc:main') { + if ($this->can($user, true)) { + $this->drafts->cancel($user); + } + return $this->main($user); + } + if ($data === 'bc:draft') { + if ($this->can($user, true)) { + $this->drafts->cancel($user); + } + return $this->draft($user); + } + if (preg_match('/^bc:jobs:([0-9]{1,6})$/D', $data, $match)) { + return ['action' => 'jobs', 'page' => (int) $match[1]]; + } + $this->allow($user, true); + if ($data === 'bc:new' || $data === 'bc:edit') { + $this->drafts->setOperation($user, $data === 'bc:edit' ? 'edit' : 'send'); + $this->drafts->begin($user, 'broadcast1'); + return $this->prompt($user, 'content_prompt'); + } + if ($data === 'bc:buttons') { + $this->drafts->cancel($user); + return $this->buttonMenu($user); + } + if ($data === 'bc:button:add') { + $this->drafts->begin($user, 'broadcast_button_label'); + return $this->prompt($user, 'button_label_prompt', 'bc:buttons'); + } + if ($data === 'bc:button:clear') { + $this->drafts->clearButtons($user); + return $this->buttonMenu($user); + } + if ($data === 'bc:content' || $data === 'bc:buttons:advanced') { + $this->drafts->begin($user, $data === 'bc:content' ? 'broadcast1' : 'addBUTTONS'); + return $this->prompt($user, $data === 'bc:content' ? 'content_prompt' : 'advanced_buttons_prompt'); + } + if (in_array($data, ['bc:confirm', 'bc:preview'], true)) { + return ['action' => $data === 'bc:confirm' ? 'confirm' : 'preview']; + } + if (preg_match('/^bc:canceljob:([A-Za-z0-9_-]{1,40})$/D', $data, $match)) { + return ['action' => 'cancel_job', 'id' => $match[1]]; + } + if (preg_match('/^bc:canceldelete:([A-Za-z0-9_-]{1,35})$/D', $data, $match)) { + return ['action' => 'cancel_self_destruct', 'id' => $match[1]]; + } + if ($data === 'bc:audience') { + $rows = []; + foreach (SubscriberDirectory::AUDIENCES as $audience) { + $rows[] = [$this->button($user, $audience, 'bc:audience:' . $audience)]; + } + $rows = array_chunk(array_merge(...$rows), 2); + $rows[] = [$this->button($user, 'back', 'bc:draft')]; + return $this->frame('' . $this->t($user, 'audience') . '\n\n' . $this->t($user, 'audience_note'), $rows); + } + if (str_starts_with($data, 'bc:audience:')) { + $this->drafts->setAudience($user, substr($data, 12)); + return $this->draft($user); + } + if ($data === 'bc:pin:on' || $data === 'bc:pin:off') { + $this->drafts->setPin($user, $data === 'bc:pin:on'); + return $this->draft($user); + } + if ($data === 'bc:sd') { + $rows = [[$this->button($user, 'off', 'bc:sd:off')]]; + foreach ([[1, 6, 12], [24, 48]] as $hours) { + $rows[] = array_map(fn(int $hour): array => ['text' => $hour . ' ' . $this->t($user, 'hours'), 'data' => 'bc:sd:' . $hour], $hours); + } + $rows[] = [$this->button($user, 'custom', 'bc:sd:custom', 'bg_primary')]; + $rows[] = [$this->button($user, 'back', 'bc:draft')]; + return $this->frame('' . $this->t($user, 'self_destruct') . '', $rows); + } + if ($data === 'bc:schedule') { + $rows = [[$this->button($user, 'now', 'bc:schedule:off')]]; + foreach ([[10, 30, 60], [360, 1440]] as $minutes) { + $rows[] = array_map(fn(int $minute): array => ['text' => '+' . $minute . ' ' . $this->t($user, 'minutes'), 'data' => 'bc:schedule:' . $minute], $minutes); + } + $rows[] = [$this->button($user, 'custom', 'bc:schedule:custom', 'bg_primary')]; + $rows[] = [$this->button($user, 'back', 'bc:draft')]; + return $this->frame('' . $this->t($user, 'schedule') . '\n' . self::html($this->settings->getUser($user)['timezone_id']), $rows); + } + if ($data === 'bc:sd:custom' || $data === 'bc:schedule:custom') { + $isSchedule = $data === 'bc:schedule:custom'; + $this->drafts->begin($user, $isSchedule ? 'broadcast_schedule_custom' : 'broadcast_sd_custom'); + return $this->prompt($user, $isSchedule ? 'schedule_prompt' : 'sd_prompt'); + } + if (preg_match('/^bc:sd:(off|[0-9]{1,2})$/D', $data, $match)) { + $this->drafts->setSelfDestructHours($user, $match[1] === 'off' ? null : (int) $match[1]); + return $this->draft($user); + } + if (preg_match('/^bc:schedule:(off|[0-9]{1,6})$/D', $data, $match)) { + $timestamp = $this->drafts->parseSchedule($user, $match[1]); + if ($timestamp !== null && $timestamp <= $this->clock->now()->getTimestamp()) { + throw new \InvalidArgumentException('Scheduled time elapsed.'); + } + $this->drafts->setScheduledAt($user, $timestamp); + return $this->draft($user); + } + throw new \InvalidArgumentException('Unknown broadcast callback.'); + } + + public function input(string $user, string $text): ?array + { + $this->allow($user); + $button = $this->drafts->buttonInput($user, $text); + if ($button !== null) { + return $button === 'url' ? $this->prompt($user, 'button_url_prompt', 'bc:buttons') : $this->buttonMenu($user); + } + return $this->drafts->controlInput($user, $text) ? $this->draft($user) : null; + } + + /** Safe local job snapshot; runtime supplies status and enforces actor ownership on cancellation. */ + public function jobs(string $user, array $jobs, int $page = 0): array + { + $this->allow($user); + if ($page < 0) { + throw new \InvalidArgumentException('Invalid jobs page.'); + } + $pages = max(1, (int) ceil(count($jobs) / 10)); + $page = min($page, $pages - 1); + $lines = ['' . $this->t($user, 'jobs') . '', $this->t($user, 'total') . ': ' . count($jobs) . '']; + $rows = []; + foreach (array_slice(array_values($jobs), $page * 10, 10) as $job) { + $id = $job['id'] ?? null; + $kind = $job['kind'] ?? 'scheduled'; + if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,35}$/D', $id) || !in_array($kind, ['scheduled', 'self_destruct'], true)) { + throw new \InvalidArgumentException('Invalid job snapshot.'); + } + $timestamp = $job['at'] ?? $job['scheduled_at'] ?? null; + if (!is_int($timestamp) || $timestamp < 1) { + throw new \InvalidArgumentException('Invalid job time.'); + } + $lines[] = "\n" . $this->t($user, $kind === 'scheduled' ? 'schedule' : 'self_destruct') . ' ' . self::html($id) . '' + . "\n" . $this->date($user, $timestamp) + . (isset($job['targets']) && is_int($job['targets']) ? ' - ' . number_format($job['targets']) . ' ' . $this->t($user, 'subscribers') : ''); + if ($this->can($user, true)) { + $rows[] = [['text' => $this->t($user, 'cancel') . ' ' . $id, 'data' => ($kind === 'scheduled' ? 'bc:canceljob:' : 'bc:canceldelete:') . $id, 'color' => 'bg_danger']]; + } + } + if ($page > 0) { + $rows[] = [$this->button($user, 'previous', 'bc:jobs:' . ($page - 1))]; + } + if ($page + 1 < $pages) { + $rows[] = [$this->button($user, 'next', 'bc:jobs:' . ($page + 1))]; + } + $rows[] = [$this->button($user, 'refresh', 'bc:jobs:' . $page), $this->button($user, 'back', 'bc:main')]; + return $this->frame(implode("\n", $lines), $rows); + } + + public function confirmation(string $user, int $recipients, string $callback): array + { + $this->allow($user, true); + if ($recipients < 1 || preg_match('/^op:send:[A-Za-z0-9_-]{24}$/D', $callback) !== 1) { + throw new \InvalidArgumentException('Invalid broadcast confirmation.'); + } + $draft = $this->draft($user); + return $this->frame( + '' . $this->t($user, 'confirm_title') . "\n" + . $this->t($user, 'recipients') . ': ' . number_format($recipients) . "\n\n" . $draft['text'], + [[$this->button($user, 'send', $callback, 'bg_primary')], [$this->button($user, 'back', 'bc:draft')]] + ); + } + + private function buttonMenu(string $user): array + { + $payload = $this->drafts->payload($user); + $text = '' . $this->t($user, 'buttons') . "\n\n" . $this->t($user, 'buttons_note') + . "\n\n" . $this->t($user, 'button_count') . ': ' . array_sum(array_map('count', $payload['buttons'])) . ''; + foreach (array_slice($payload['buttons'], 0, 10) as $row) { + $text .= "\n" . implode(' ยท ', array_map(static fn(array $button): string => self::html(mb_substr($button['text'], 0, 32)), array_slice($row, 0, 2))); + } + return $this->frame($text, [ + [$this->button($user, 'add_button', 'bc:button:add', 'bg_primary')], + [$this->button($user, 'advanced_buttons', 'bc:buttons:advanced'), $this->button($user, 'clear_buttons', 'bc:button:clear', 'bg_danger')], + [$this->button($user, 'back', 'bc:draft')], + ]); + } + + private function prompt(string $user, string $key, string $back = 'bc:draft'): array + { + return $this->frame('' . $this->t($user, 'broadcast') . "\n\n" . $this->t($user, $key), [[$this->button($user, 'cancel', $back, 'bg_danger')]]); + } + + private function allow(string $user, bool $mutate = false): void + { + if (!$this->can($user, $mutate)) { + throw new \RuntimeException('Broadcast permission required.'); + } + } + + private function can(string $user, bool $mutate): bool + { + return (bool) ($this->authorize)($user, 'broadcast', $mutate); + } + + private function date(string $user, int $timestamp): string + { + return (new \DateTimeImmutable('@' . $timestamp))->setTimezone(new \DateTimeZone($this->settings->getUser($user)['timezone_id']))->format('Y-m-d H:i T'); + } + + private function t(string $user, string $key): string + { + return AdminDirectoryText::text($this->settings->getUser($user)['language'], $key); + } + + private function button(string $user, string $key, string $data, ?string $color = null): array + { + return ['text' => $this->t($user, $key), 'data' => $data, 'color' => $color]; + } + + private function frame(string $text, array $rows): array + { + $markup = ['_' => 'replyInlineMarkup', 'rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $button) { + if (strlen($button['data']) > 64) { + throw new \InvalidArgumentException('Oversize broadcast callback.'); + } + $native = ['_' => 'keyboardButtonCallback', 'text' => $button['text'], 'data' => $button['data']]; + if (isset($button['color'])) { + $native['style'] = ['_' => 'keyboardButtonStyle', $button['color'] => true]; + } + $buttons[] = $native; + } + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => $buttons]; + } + return ['text' => str_replace('\\n', "\n", $text), 'markup' => $markup]; + } + + private static function html(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/UI/CallbackUiGateway.php b/src/UI/CallbackUiGateway.php new file mode 100644 index 0000000..d02bab7 --- /dev/null +++ b/src/UI/CallbackUiGateway.php @@ -0,0 +1,96 @@ + 2147483647) { + throw new \InvalidArgumentException('An authenticated private panel is required.'); + } + } + + public static function fromQuery(UiGateway $gateway, array $query): self + { + $user = (string)($query['user_id'] ?? ''); + $message = $query['message_id'] ?? null; + if (($query['private'] ?? false) !== true || !is_int($message) || (isset($query['chat_id']) && (string)$query['chat_id'] !== $user)) { + throw new \InvalidArgumentException('Only the originating private bot panel can be edited.'); + } + return new self($gateway, $user, $message); + } + + public function messageId(): int + { + return $this->messageId; + } + + public function underlying(): UiGateway + { + return $this->gateway; + } + + public function isGroupAdmin(string $userId, string $groupId): bool + { + return $this->gateway->isGroupAdmin($userId, $groupId); + } + + public function sendPrivate(string $userId, string $text, array $markup = []): void + { + $this->editPrivate($userId, $this->messageId, $text, $markup); + } + + public function editPrivate(string $userId, int $messageId, string $text, array $markup = []): void + { + $this->target($userId, $messageId); + try { + $this->gateway->editPrivate($userId, $messageId, $text, $markup); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + throw new PanelEditException('The original bot panel cannot be edited.', 0, $error); + } + } + + public function preview(array $payload): void + { + if (!$this->gateway instanceof PanelPayloadGateway) { + throw new PanelEditException('This transport cannot edit rich previews.'); + } + try { + $this->gateway->editPrivatePayload($this->userId, $this->messageId, $payload); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (\Throwable $error) { + throw new PanelEditException('The original bot preview cannot be edited.', 0, $error); + } + } + + private function target(string $userId, int $messageId): void + { + if ($userId !== $this->userId || $messageId !== $this->messageId) { + throw new \LogicException('A callback cannot edit a different user or message.'); + } + } + + public function answerCallback(string $queryId, string $text): void + { + $this->gateway->answerCallback($queryId, $text); + } + + public function answerInline(string $queryId, array $results, array $options = []): void + { + $this->gateway->answerInline($queryId, $results, $options); + } + + public function botUsername(): string + { + return $this->gateway->botUsername(); + } +} diff --git a/src/UI/DiagnosticsController.php b/src/UI/DiagnosticsController.php new file mode 100644 index 0000000..dbccff0 --- /dev/null +++ b/src/UI/DiagnosticsController.php @@ -0,0 +1,331 @@ +repository = new DiagnosticsRepository($app->db, $app->clock); + $this->lang = new Lang(); + $this->capture = \Closure::fromCallable($capture ?? (new DiagnosticReport($app))->capture(...)); + } + + public function open(string $user, ?string $group = null): void + { + $this->allow($user); + if (!$this->app->gateway instanceof DiagnosticsGateway) { + throw new \RuntimeException('The diagnostics transport is unavailable.'); + } + if ($group !== null && (!preg_match('/^-[1-9][0-9]{0,19}$/D', $group) || $this->app->settings->getGroup($group) === null)) { + throw new \InvalidArgumentException('Unknown diagnostic group.'); + } + $token = $this->repository->begin($user, $group); + try { + $this->repository->saveReport($user, $token, ($this->capture)($user, $group), $token); + $this->allow($user); + $row = $this->required($user, $token); + $frame = $this->frame($user, $row, $token); + // Repeated /testzmanim also refreshes the existing acknowledged panel. + if (is_int($row['message_id']) && $row['message_id'] > 0) { + $this->app->gateway->editPrivate($user, $row['message_id'], $frame['text'], $frame['markup']); + } else { + $id = $this->app->gateway->sendDiagnosticPanel($user, $frame['text'], $frame['markup']); + $this->repository->update($user, $token, ['message_id' => $id], $token); + } + } finally { + $this->repository->release($user, $token, $token); + } + } + + public function cancelInput(string $user): void + { + $this->repository->cancelInput($user); + } + + public function callback(array $event): bool + { + $data = $event['data'] ?? ''; + if (!is_string($data) || !str_starts_with($data, 'dz:')) { + return false; + } + $user = (string)($event['user_id'] ?? ''); + $query = (string)($event['query_id'] ?? ''); + $token = null; + $claimed = false; + try { + $this->allow($user); + if ( + ($event['private'] ?? false) !== true || ($event['inline_selection'] ?? false) + || (string)($event['chat_id'] ?? '') !== $user + || !preg_match('/^dz:([a-f0-9]{24}):(list|prev|next|page|search|filters|filter|clear|refresh|export|group)(?::([a-z0-9]{1,12}))?$/D', $data, $match) + ) { + throw new \RuntimeException('A bound private diagnostic panel is required.'); + } + $token = $match[1]; + $row = $this->required($user, $token); + if ((int)$row['message_id'] < 1 || (int)$row['message_id'] !== ($event['message_id'] ?? 0)) { + throw new \RuntimeException('Diagnostic panel mismatch.'); + } + $claimed = $this->repository->claim($user, $token); + if (!$claimed) { + $this->app->gateway->answerCallback($query, $this->t($user, 'busy')); + return true; + } + $action = $match[2]; + $value = $match[3] ?? null; + if ($value !== null && !in_array($action, ['filter', 'group'], true)) { + throw new \InvalidArgumentException('Unexpected diagnostic callback argument.'); + } + $this->repository->update($user, $token, ['prompt' => null], $claimed); + if ($action === 'export') { + if (!$this->app->gateway instanceof DiagnosticsGateway || !$this->repository->claimExport($user, $token, $claimed, $query)) { + $this->app->gateway->answerCallback($query, $this->t($user, 'busy')); + return true; + } + $this->allow($user); + $json = json_encode($row['report'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $this->app->gateway->sendDiagnosticDocument($user, $json, $this->t($user, 'export_caption'), (string)random_int(1, PHP_INT_MAX)); + $this->app->gateway->answerCallback($query, $this->t($user, 'export_sent')); + return true; + } + if ($action === 'refresh' || $action === 'group') { + $group = $row['group_id']; + if ($action === 'group') { + $record = ctype_digit((string)$value) ? ($row['report']['records'][(int)$value] ?? null) : null; + $group = is_array($record) && ($record['category'] ?? '') === 'groups' ? ($record['data']['group_id'] ?? null) : null; + if (!is_string($group)) { + throw new \InvalidArgumentException('Unknown diagnostic group selection.'); + } + } + // Release the old claim, then atomically rotate its token before new I/O. + $this->repository->release($user, $token, $claimed); + $claimed = false; + $this->open($user, $group); + $this->app->gateway->answerCallback($query, ''); + return true; + } + if ($action === 'page' || $action === 'search') { + $this->repository->update($user, $token, ['prompt' => $action], $claimed); + $frame = $this->prompt($user, $token, $action); + } elseif ($action === 'filters') { + $rows = []; + foreach (self::FILTERS as $filter) { + $rows[] = [$this->button($user, $token, $filter, 'filter:' . $filter, $filter === $row['category'] ? 'bg_primary' : null)]; + } + $rows[] = [$this->button($user, $token, 'cancel', 'list')]; + $frame = $this->markup('' . $this->t($user, 'filters') . '', $rows); + } else { + $changes = match ($action) { + 'prev' => ['page' => max(0, (int)$row['page'] - 1)], + 'next' => ['page' => (int)$row['page'] + 1], + 'filter' => in_array($value, self::FILTERS, true) ? ['category' => $value, 'page' => 0] : throw new \InvalidArgumentException('Invalid filter.'), + 'clear' => ['category' => 'all', 'search' => '', 'page' => 0], + default => [], + }; + $this->repository->update($user, $token, $changes, $claimed); + $frame = $this->frame($user, $this->required($user, $token), $claimed); + } + $this->allow($user); + $this->edit($user, $row, $frame, $claimed); + $this->app->gateway->answerCallback($query, ''); + } catch (CancelledException $error) { + throw $error; + } catch (\Shabbat\Storage\DiagnosticThrottled) { + $this->app->gateway->answerCallback($query, $this->t($user, 'busy')); + } catch (\Throwable) { + $this->app->gateway->answerCallback($query, $this->t($user, 'unavailable')); + } finally { + if ($claimed && $token !== null) { + $this->repository->release($user, $token, $claimed); + } + } + return true; + } + + public function input(array $event): bool + { + $user = (string)($event['user_id'] ?? ''); + $text = trim((string)($event['text'] ?? '')); + if (($event['private'] ?? false) !== true || str_starts_with($text, '/')) { + return false; + } + $row = $this->repository->get($user); + if ($row === null || !in_array($row['prompt'], ['search', 'page'], true)) { + return false; + } + $claimed = false; + try { + $this->allow($user); + $claimed = $this->repository->claim($user, $row['token']); + if (!$claimed) { + return true; + } + $changes = ['prompt' => null, 'page' => 0]; + if ($row['prompt'] === 'search') { + if (mb_strlen($text) > 80) { + $frame = $this->prompt($user, $row['token'], 'search', true); + $this->edit($user, $row, $frame, $claimed); + return true; + } + $changes['search'] = $text; + } else { + if (!preg_match('/^[1-9][0-9]{0,2}$/D', $text) || (int)$text > $this->pages($row)) { + $frame = $this->prompt($user, $row['token'], 'page', true); + $this->edit($user, $row, $frame, $claimed); + return true; + } + $changes['page'] = (int)$text - 1; + } + $this->repository->update($user, $row['token'], $changes, $claimed); + $frame = $this->frame($user, $this->required($user, $row['token']), $claimed); + $this->allow($user); + $this->edit($user, $row, $frame, $claimed); + } catch (CancelledException $error) { + throw $error; + } catch (\Throwable) { + if (is_string($claimed)) { + try { + $this->repository->update($user, $row['token'], ['prompt' => null], $claimed); + } catch (\RuntimeException) { + // A later interaction owns the panel; do not erase its prompt. + } + } + // Deleted/expired panels and revoked roles must never produce replacement messages. + } finally { + if ($claimed) { + $this->repository->release($user, $row['token'], $claimed); + } + } + return true; + } + + private function edit(string $user, array $row, array $frame, string $owner): void + { + $this->allow($user); + $this->repository->assertOwnership($user, $row['token'], $owner); + $this->app->gateway->editPrivate($user, (int)$row['message_id'], $frame['text'], $frame['markup']); + } + + private function frame(string $user, array $row, string $owner): array + { + $records = $this->filtered($row); + $pages = max(1, (int)ceil(count($records) / self::PAGE_SIZE)); + $page = min(max(0, (int)$row['page']), $pages - 1); + $this->repository->update($user, $row['token'], ['page' => $page], $owner); + $zone = $row['report']['timezone']; + $created = (new \DateTimeImmutable('@' . $row['created_at']))->setTimezone(new \DateTimeZone($zone))->format('Y-m-d H:i'); + $text = '' . $this->t($user, 'title') . "\n" . $this->t($user, 'read_only') . "\n" + . $this->t($user, 'context') . ': ' . self::html((string)$row['report']['context']) . "\n" + . self::html($created . ' / ' . $zone) . "\n" + . $this->t($user, 'filters') . ': ' . $this->t($user, $row['category']); + if ($row['search'] !== '') { + $text .= "\n๐Ÿ”Ž " . self::html($row['search']); + } + $buttons = []; + foreach (array_slice($records, $page * self::PAGE_SIZE, self::PAGE_SIZE, true) as $index => $record) { + $icon = match ($record['status']) { + 'error' => 'โŒ', 'warning' => 'โš ๏ธ', default => 'โ€ข' + }; + $text .= "\n\n" . $icon . ' ' . $this->t($user, $record['label']) . "\n" . self::html(mb_substr($this->recordValue($user, $record), 0, 210)) . ''; + if (isset($record['data']['group_id'])) { + $buttons[] = [['_' => 'keyboardButtonCallback', 'text' => '๐Ÿ‘ฅ ' . mb_substr($record['value'], 0, 44), 'data' => 'dz:' . $row['token'] . ':group:' . $index]]; + } + } + if ($records === []) { + $text .= "\n\n" . $this->t($user, 'empty'); + } + $token = $row['token']; + $buttons[] = [$this->button($user, $token, 'previous', 'prev'), ['_' => 'keyboardButtonCallback', 'text' => ($page + 1) . ' / ' . $pages, 'data' => 'dz:' . $token . ':page'], $this->button($user, $token, 'next', 'next')]; + $buttons[] = [$this->button($user, $token, 'search', 'search', 'bg_primary'), $this->button($user, $token, 'filters', 'filters')]; + $buttons[] = [$this->button($user, $token, 'refresh', 'refresh'), $this->button($user, $token, 'clear', 'clear')]; + $buttons[] = [$this->button($user, $token, 'export', 'export', 'bg_primary')]; + $buttons[] = [['_' => 'keyboardButtonCallback', 'text' => $this->t($user, 'back'), 'data' => 'ื—ื–ืจื”ืžื ื”ืœ']]; + return $this->markup($text, $buttons); + } + + private function prompt(string $user, string $token, string $kind, bool $invalid = false): array + { + return $this->markup(($invalid ? $this->t($user, 'invalid_' . $kind) . "\n" : '') . $this->t($user, $kind . '_prompt', $kind === 'page' ? ['pages' => $this->pages($this->required($user, $token))] : []), [[$this->button($user, $token, 'cancel', 'list', 'bg_danger')]]); + } + + private function filtered(array $row): array + { + $search = mb_strtolower($row['search']); + return array_filter($row['report']['records'], function (array $record) use ($row, $search): bool { + return ($row['category'] === 'all' || ($row['category'] === 'problems' ? $record['status'] !== 'ok' : $record['category'] === $row['category'])) + && ($search === '' || str_contains(mb_strtolower($record['value'] . ' ' . $this->recordValue($row['viewer_id'], $record) . ' ' . $this->t($row['viewer_id'], $record['label'])), $search)); + }); + } + + private function recordValue(string $user, array $record): string + { + if (isset($record['data']['snapshot_provenance'])) { + return html_entity_decode($this->t($user, $record['data']['snapshot_provenance'] === 'verified_native_v1' ? 'snapshot_verified' : 'snapshot_unverified'), ENT_QUOTES, 'UTF-8'); + } + $fields = $record['data']['different_fields'] ?? null; + if (isset($record['data']['rights'])) { + $fields = array_keys(array_filter($record['data']['rights'], static fn(mixed $value): bool => $value === true)); + } + if (is_array($fields)) { + $language = $this->app->settings->getUser($user)['language']; + return $fields === [] ? '-' : implode(', ', array_map(fn(string $field): string => html_entity_decode($this->lang->text($language, 'permission_' . $field), ENT_QUOTES, 'UTF-8'), $fields)); + } + return $record['value']; + } + private function pages(array $row): int + { + return max(1, (int)ceil(count($this->filtered($row)) / self::PAGE_SIZE)); + } + + private function required(string $user, string $token): array + { + return $this->repository->get($user, $token) ?? throw new \RuntimeException('Diagnostic panel expired.'); + } + + private function allow(string $user): void + { + $this->app->adminAccess->require($user, 'statistics'); + } + + private function t(string $user, string $key, array $values = []): string + { + $this->languages[$user] ??= $this->app->settings->getUser($user)['language']; + return $this->lang->text($this->languages[$user], 'diagnostics_' . $key, $values); + } + + private function button(string $user, string $token, string $label, string $action, ?string $style = null): array + { + $button = ['_' => 'keyboardButtonCallback', 'text' => $this->t($user, $label), 'data' => 'dz:' . $token . ':' . $action]; + if ($style !== null) { + $button['style'] = ['_' => 'keyboardButtonStyle', $style => true]; + } + return $button; + } + + private function markup(string $text, array $rows): array + { + return ['text' => $text, 'markup' => ['_' => 'replyInlineMarkup', 'rows' => array_map(static fn(array $buttons): array => ['_' => 'keyboardButtonRow', 'buttons' => $buttons], $rows)]]; + } + + private static function html(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/UI/DiagnosticsGateway.php b/src/UI/DiagnosticsGateway.php new file mode 100644 index 0000000..553b98d --- /dev/null +++ b/src/UI/DiagnosticsGateway.php @@ -0,0 +1,13 @@ +cancel($user); + $offers = $this->donations->createOffers($user, $this->settings->getUser($user)['language']); + $markup = $offers['reply_markup']; + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => [$this->back($user, 'sb:start')]]; + ($target ?? $this->gateway)->sendPrivate($user, $offers['text'], $markup); + } + + public function callback(array $query): bool + { + $data = $query['data'] ?? ''; + if (!in_array($data, ['v3:donate', 'v3:donate:custom', 'v3:donate:cancel'], true)) { + return false; + } + $target = CallbackUiGateway::fromQuery($this->gateway, $query); + $user = (string)$query['user_id']; + $this->cancel($user); + if ($data === 'v3:donate:custom') { + $this->flows->revokeAction($user, 'location_input'); + $settings = $this->settings->getUser($user); + $this->flows->create($user, 'user', null, $settings['revision'], self::ACTION, 600, [ + 'panel_id' => $target->messageId(), 'nonce' => bin2hex(random_bytes(16)), + ]); + $target->sendPrivate($user, $this->text($user, 'donate_custom_prompt'), $this->promptMarkup($user)); + } else { + $this->menu($user, $target); + } + $target->answerCallback((string)$query['query_id'], ''); + return true; + } + + public function message(array $message): bool + { + if (($message['private'] ?? false) !== true) { + return false; + } + $user = (string)($message['user_id'] ?? ''); + $text = trim((string)($message['text'] ?? '')); + if (str_starts_with($text, '/')) { + $this->cancel($user); + return false; + } + $flow = $this->flows->latest($user, self::ACTION); + if ($flow === null) { + return false; + } + $panel = $flow['payload']['panel_id'] ?? null; + if (!is_int($panel) || $panel < 1) { + $this->cancel($user); + return true; + } + $target = new CallbackUiGateway($this->gateway, $user, $panel); + try { + $amount = Donations::customAmount($text); + } catch (\InvalidArgumentException) { + $target->sendPrivate($user, $this->text($user, 'donate_custom_invalid') . "\n\n" . $this->text($user, 'donate_custom_prompt'), $this->promptMarkup($user)); + return true; + } + // Claim exactly this prompt before awaiting Telegram; parallel text updates cannot export twice. + $claimed = $this->db->transaction(function () use ($user, $flow): bool { + $latest = $this->flows->latest($user, self::ACTION); + if ($latest === null || $latest['payload'] !== $flow['payload']) { + return false; + } + $now = $this->clock->now()->getTimestamp(); + $query = $this->db->pdo()->prepare('UPDATE conversation_states SET consumed_at=? WHERE user_id=? AND action=? AND payload_json=? AND scope=? AND group_id IS NULL AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?'); + $query->execute([$now, $user, self::ACTION, Database::encode($flow['payload']), 'user', $now]); + return $query->rowCount() === 1; + }); + if (!$claimed) { + return true; + } + try { + $offer = $this->donations->createCustomOffer($user, $amount, $this->settings->getUser($user)['language']); + if (!$this->current($user, $flow)) { + return true; + } + $markup = ['_' => 'replyInlineMarkup', 'rows' => [ + ['_' => 'keyboardButtonRow', 'buttons' => [$offer['button']]], + ['_' => 'keyboardButtonRow', 'buttons' => [$this->back($user, 'v3:donate')]], + ]]; + $target->sendPrivate($user, $this->text($user, 'donate_custom_ready', ['amount' => $amount]), $markup); + } catch (\Amp\CancelledException $error) { + throw $error; + } catch (PanelEditException $error) { + throw $error; + } catch (\Throwable) { + if (!$this->current($user, $flow)) { + return true; + } + $target->sendPrivate($user, $this->text($user, 'donate_invoice_error'), [ + '_' => 'replyInlineMarkup', 'rows' => [['_' => 'keyboardButtonRow', 'buttons' => [$this->back($user, 'v3:donate')]]], + ]); + } + return true; + } + + public function cancel(string $user): void + { + $this->db->pdo()->prepare('UPDATE conversation_states SET revoked_at=? WHERE user_id=? AND action=? AND revoked_at IS NULL') + ->execute([$this->clock->now()->getTimestamp(), $user, self::ACTION]); + } + + private function current(string $user, array $flow): bool + { + $query = $this->db->pdo()->prepare('SELECT 1 FROM conversation_states WHERE user_id=? AND action=? AND payload_json=? AND consumed_at IS NOT NULL AND revoked_at IS NULL AND expires_at>?'); + $query->execute([$user, self::ACTION, Database::encode($flow['payload']), $this->clock->now()->getTimestamp()]); + return $query->fetchColumn() !== false; + } + private function promptMarkup(string $user): array + { + return ['_' => 'replyInlineMarkup', 'rows' => [ + ['_' => 'keyboardButtonRow', 'buttons' => [[ + '_' => 'keyboardButtonCallback', 'text' => $this->text($user, 'donate_custom_cancel'), + 'data' => 'v3:donate:cancel', 'style' => ['_' => 'keyboardButtonStyle', 'bg_danger' => true], + ]]], + ]]; + } + + private function back(string $user, string $data): array + { + return [ + '_' => 'keyboardButtonCallback', 'text' => $this->text($user, 'back'), 'data' => $data, + 'style' => ['_' => 'keyboardButtonStyle'], + ]; + } + + private function text(string $user, string $key, array $values = []): string + { + return $this->lang->text($this->settings->getUser($user)['language'], $key, $values); + } +} diff --git a/src/UI/Lang.php b/src/UI/Lang.php new file mode 100644 index 0000000..b4246de --- /dev/null +++ b/src/UI/Lang.php @@ -0,0 +1,88 @@ + '๐Ÿ‡ฎ๐Ÿ‡ฑ ืขื‘ืจื™ืช','en' => '๐Ÿ‡ฌ๐Ÿ‡ง English','ru' => '๐Ÿ‡ท๐Ÿ‡บ ะ ัƒััะบะธะน','fr' => '๐Ÿ‡ซ๐Ÿ‡ท Franรงais','es' => '๐Ÿ‡ช๐Ÿ‡ธ Espaรฑol','pt-br' => '๐Ÿ‡ง๐Ÿ‡ท Portuguรชs (Brasil)']; + private array $locales = []; + public function __construct(?string $directory = null) + { + $directory ??= dirname(__DIR__, 2) . '/resources/locales'; + foreach (self::CODES as $language) { + $file = $directory . '/' . $language . '.json'; + if (!is_file($file)) { + throw new \RuntimeException('Missing required locale: ' . $language); + } + $data = json_decode((string)file_get_contents($file), true, 64, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new \RuntimeException('Invalid locale dictionary: ' . $language); + } + $this->locales[$language] = $data; + } + $this->validate(); + } + public static function canonical(string $language): string + { + $language = strtolower(str_replace('_', '-', trim($language))); + if ($language === 'pt') { + $language = 'pt-br'; + } + return in_array($language, self::CODES, true) ? $language : 'he'; + } + public static function telegramLanguageCode(string $language): string + { + $language = self::canonical($language); + return $language === 'pt-br' ? 'pt' : $language; + } + public function languages(): array + { + return self::CODES; + } + public function text(string $language, string $key, array $values = []): string + { + $language = self::canonical($language); + $text = $this->locales[$language][$key] ?? $this->locales['he'][$key] ?? throw new \InvalidArgumentException('Missing translation ' . $key); + preg_match_all('/\{\{([a-z_]+)\}\}/', $text, $matches); + foreach (array_unique($matches[1]) as $name) { + if (!array_key_exists($name, $values)) { + throw new \InvalidArgumentException('Missing placeholder ' . $name); + } + $text = str_replace('{{' . $name . '}}', htmlspecialchars((string)$values[$name], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), $text); + } + return $text; + } + public function validate(): void + { + $keys = array_keys($this->locales['he']); + sort($keys); + foreach (self::CODES as $language) { + $other = array_keys($this->locales[$language]); + sort($other); + if ($keys !== $other) { + throw new \RuntimeException('Translation keys differ for ' . $language); + } + foreach ($keys as $key) { + $value = $this->locales[$language][$key]; + if (!is_string($value) || trim($value) === '' || !mb_check_encoding($value, 'UTF-8')) { + throw new \RuntimeException('Empty/invalid translation: ' . $language . '/' . $key); + } + preg_match_all('/\{\{([a-z_]+)\}\}/', $this->locales['he'][$key], $a); + preg_match_all('/\{\{([a-z_]+)\}\}/', $value, $b); + sort($a[1]); + sort($b[1]); + if ($a[1] !== $b[1]) { + throw new \RuntimeException('Translation placeholders differ: ' . $language . '/' . $key); + } + } + } + } + public function keys(): array + { + return array_keys($this->locales['he']); + } +} diff --git a/src/UI/OperatorController.php b/src/UI/OperatorController.php new file mode 100644 index 0000000..313e345 --- /dev/null +++ b/src/UI/OperatorController.php @@ -0,0 +1,243 @@ +isOperator = \Closure::fromCallable($isOperator); + } + public function main(string $user): array + { + $this->authorize($user); + if ($this->access !== null) { + return $this->permissionPanel($user); + } + return $this->frame($user, 'operator_panel', [ + [$this->button($user, 'operator_statistics', 'ืกื˜ื˜ื™ืกื˜ื™ืงื•ืช')], + [$this->button($user, 'operator_subscribers', 'ืจืฉื™ืžืชืžืฉืชืžืฉื™ื')], + [$this->button($user, 'operator_broadcast', 'Broadcast')], + [$this->button($user, 'back', 'sb:start')], + ]); + } + public function broadcast(string $user, bool $active = false): array + { + $this->authorize($user, 'broadcast', false); + $text = $this->t($user, 'operator_broadcast'); + if ($active) { + $text .= "\n" . $this->t($user, 'operator_active_broadcast'); + } + return $this->result($text, [ + [$this->button($user, 'operator_progress', 'LastBrodDATA')], + [$this->button($user, 'operator_edit_text', 'setBroadcast')], + [$this->button($user, 'operator_delete_last', 'deleteLastBroadcast', 'bg_danger')], + [$this->button($user, 'operator_delete_all', 'deleteAllBroadcast', 'bg_danger')], + [$this->button($user, 'operator_unpin', 'cancelPinned')], + [$this->button($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')], + ]); + } + public function draft(string $user, array $state = []): array + { + $this->authorize($user, 'broadcast', false); + if (!($state['exists'] ?? false)) { + return $this->frame($user, 'operator_no_draft', [[$this->button($user, 'operator_edit_text', 'setBroadcast')],[$this->button($user, 'back', 'Broadcast')]]); + } + $pin = (bool)($state['pin'] ?? false); + $audience = $state['audience'] ?? 'all'; + $key = match ($audience) { + 'private','users'=>'operator_recipients_private','channels'=>'operator_recipients_channels','groups'=>'operator_recipients_groups',default=>'operator_recipients_all' + }; + $text = $this->t($user, 'operator_broadcast') . "\n" . $this->t($user, $key) . "\n" . $this->t($user, 'operator_pin') . ': ' . $this->t($user, $pin ? 'on' : 'off'); + return $this->result($text, [ + [$this->button($user, 'operator_pin', $pin ? 'ื ืขืฅื”ื•ื“ืขื”ืœืœื' : 'ื ืขืฅื”ื•ื“ืขื”', $pin ? 'bg_primary' : 'bg_danger')], + [$this->button($user, $key, 'ืžืฆื‘ืชืคื•ืฆื”')], + [$this->button($user, 'operator_buttons', 'ื”ื•ืกืคืชื›ืคืชื•ืจื™ื'),$this->button($user, 'operator_preview', 'ืฆืคื”ื‘ื›ืคืชื•ืจื™ืืžื ื”ืœ')], + [$this->button($user, 'operator_send', 'ืฉื“ืจื”ื•ื“ืขื”', 'bg_primary')], + [$this->button($user, 'operator_cancel', 'ื—ื–ืจื”ืžื ื”ืœ2', 'bg_danger')], + ]); + } + public function audience(string $user): array + { + $this->authorize($user, 'broadcast', false); + return $this->frame($user, 'operator_broadcast', [ + [$this->button($user, 'operator_recipients_private', 'ืžืฆื‘ืชืคื•ืฆื”1')], + [$this->button($user, 'operator_recipients_channels', 'ืžืฆื‘ืชืคื•ืฆื”2')], + [$this->button($user, 'operator_recipients_groups', 'ืžืฆื‘ืชืคื•ืฆื”3')], + [$this->button($user, 'operator_recipients_all', 'ืžืฆื‘ืชืคื•ืฆื”4')], + [$this->button($user, 'back', 'ื—ื–ืจื”ืชืคืจื™ื˜ืฉื™ื“ื•ืจ')], + ]); + } + public function statistics(string $user, array $counts, array $subscribers = []): array + { + $this->authorize($user, 'statistics', false); + $values = []; + foreach (['known','registered','locking','locked','recovery'] as $key) { + $values[$key] = number_format($this->count($counts[$key] ?? 0), 0, '.', ','); + } + $text = $this->t($user, 'operator_statistics') . "\n" . $this->t($user, 'operator_group_stats', $values); + if ($subscribers !== []) { + $values = []; + $detailed = isset($subscribers['chats'], $subscribers['supergroups']); + foreach ($detailed ? ['users','chats','supergroups','channels','bots','total'] : ['users','groups','channels','bots','total'] as $key) { + $values[$key] = number_format($this->count($subscribers[$key] ?? 0), 0, '.', ','); + }$text .= "\n\n" . $this->t($user, $detailed ? 'operator_subscriber_stats_full' : 'operator_subscriber_stats', $values); + } + return $this->result($text, [[$this->button($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]]); + } + public function subscribers(string $user, array $items, int $page, int $pages): array + { + $this->authorize($user, 'subscribers', false); + if ($page < 1 || $pages < 1 || $page > $pages || count($items) > 20) { + throw new \InvalidArgumentException('Invalid subscriber page'); + } + $rows = []; + foreach ($items as $item) { + $id = (string)($item['id'] ?? $item['user_id'] ?? ''); + if (!preg_match('/^-?[0-9]{1,20}$/D', $id)) { + throw new \InvalidArgumentException('Invalid subscriber identifier'); + }$name = (string)($item['name'] ?? $item['title'] ?? $id); + $rows[] = [['text' => mb_substr($name, 0, 64),'data' => 'openprofile_' . $id]]; + } + $nav = []; + if ($page > 1) { + $nav[] = $this->button($user, 'previous', 'ืจืฉื™ืžืชืžื ื•ื™ื™ืื”ืงื•ื“ื'); + }if ($page < $pages) { + $nav[] = $this->button($user, 'next', 'ืจืฉื™ืžืชืžื ื•ื™ื™ืื”ืžืฉืš'); + }if ($nav !== []) { + $rows[] = $nav; + } + $rows[] = [['text' => $this->t($user, 'operator_page', ['page' => $page,'total' => $pages]),'data' => 'var_cat']]; + $rows[] = [$this->button($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame($user, 'operator_subscribers', $rows); + } + public function progress(string $user, array $state): array + { + $this->authorize($user, 'broadcast', false); + $text = $this->t($user, 'operator_progress'); + if ($state['active'] ?? false) { + $text .= "\n" . $this->t($user, 'operator_active_broadcast'); + } + if (isset($state['page'], $state['total'])) { + $text .= "\n" . $this->t($user, 'operator_page', ['page' => $this->count($state['page']),'total' => $this->count($state['total'])]); + } + return $this->result($text, [[$this->button($user, 'back', 'Broadcast')]]); + } + public function confirmSend(string $user, int $recipients, string $confirmationCallback): array + { + $this->authorize($user, 'broadcast', true); + return $this->result($this->t($user, 'operator_confirm_send', ['count' => $this->count($recipients)]), [[$this->button($user, 'operator_send', $confirmationCallback, 'bg_primary')],[$this->button($user, 'operator_cancel', 'ื—ื–ืจื”ืžื ื”ืœ2', 'bg_danger')]]); + } + public function prompt(string $user, string $kind, ?string $token = null): array + { + $this->authorize($user, 'broadcast', true); + $key = match ($kind) { + 'buttons'=>'operator_buttons','media'=>'operator_edit_media',default=>'operator_edit_text' + }; + $text = $this->t($user, $key); + if ($token !== null) { + if (!preg_match('/^[A-Za-z0-9_-]{24}$/D', $token)) { + throw new \InvalidArgumentException('Invalid editor token'); + }$text .= "\n" . $this->t($user, $kind === 'buttons' ? 'template_buttons_prompt' : 'template_edit_prompt', ['token' => $token]); + } + return $this->result($text, [[$this->button($user, 'operator_cancel', 'ื—ื–ืจื”ืžื ื”ืœ2', 'bg_danger')]]); + } + public function notice(string $user, string $key, array $values = []): array + { + $this->authorize($user); + if (!str_starts_with($key, 'operator_')) { + throw new \InvalidArgumentException('Invalid operator notice'); + } + return $this->result($this->t($user, $key, $values), [[$this->button($user, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]]); + } + public function frame(string $user, string $key, array $rows, array $values = []): array + { + $this->authorize($user); + return $this->result($this->t($user, $key, $values), $rows); + } + private function permissionPanel(string $user): array + { + $rows = []; + foreach (['statistics' => 'sd:stats', 'subscribers' => 'sd:list:0', 'broadcast' => 'Broadcast', 'manage_admins' => 'adm:list:0', 'system_links' => 'sys:list:0', 'tracking_links' => 'trk:list:0'] as $cap => $callback) { + if ($this->access->canView($user, $cap)) { + $rows[] = [['text' => AdminText::text($this->settings->getUser($user)['language'], $cap === 'manage_admins' ? 'managers' : $cap), 'data' => $callback, 'color' => $cap === 'broadcast' ? 'bg_primary' : null]]; + } + } + if ($this->access->canMutate($user, 'add_admins')) { + $rows[] = [['text' => AdminText::text($this->settings->getUser($user)['language'], 'add'), 'data' => 'adm:add', 'color' => 'bg_primary']]; + } + $text = '' . $this->t($user, 'operator_panel') . "\n\n"; + $commands = ['admin' => null, 'adminstats' => 'statistics', 'subscribers' => 'subscribers', 'broadcast' => 'broadcast', 'managers' => 'manage_admins', 'systemlinks' => 'system_links', 'trackinglinks' => 'tracking_links', 'setcommands' => 'commands', 'testzmanim' => 'statistics', 'closegroups' => 'group_operations', 'opengroups' => 'group_operations']; + foreach ($commands as $command => $cap) { + $allowed = $cap === null || (in_array($command, ['setcommands', 'closegroups', 'opengroups'], true) + ? $this->access->canMutate($user, $cap) : $this->access->canView($user, $cap)); + if ($allowed) { + $text .= '/' . $command . '' . ($cap === null ? '' : ' - ' . AdminText::text($this->settings->getUser($user)['language'], $cap)) . "\n"; + } + } + if ($this->access->isOwner($user)) { + $text .= '/restart - ' . $this->t($user, 'command_restart') . "\n"; + } + $text .= "\n" . AdminText::text($this->settings->getUser($user)['language'], 'commands_note') . ''; + $rows[] = [$this->button($user, 'back', 'sb:start')]; + return $this->result($text, $rows); + } + private function authorize(string $user, ?string $permission = null, bool $mutation = false): void + { + if ($this->access !== null) { + if (!$this->access->isAdmin($user)) { + throw new \RuntimeException('Bot administrator access is required.'); + } + if ($permission !== null) { + $this->access->require($user, $permission, $mutation); + } + return; + } + if (!($this->isOperator)($user)) { + throw new \RuntimeException($this->lang->text($this->settings->getUser($user)['language'], 'commands_forbidden')); + } + } + private function t(string $user, string $key, array $values = []): string + { + return $this->lang->text($this->settings->getUser($user)['language'], $key, $values); + } + private function count(mixed $value): int + { + if (!is_int($value) || $value < 0) { + throw new \InvalidArgumentException('Invalid statistics count'); + }return $value; + } + private function button(string $user, string $key, string $data, ?string $color = null): array + { + $color ??= in_array($key, ['operator_broadcast','operator_edit_text','operator_buttons','operator_send'], true) ? 'bg_primary' : null; + return ['text' => $this->t($user, $key),'data' => $data,'color' => $color]; + } + private function result(string $text, array $rows): array + { + $markup = ['_' => 'replyInlineMarkup','rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $b) { + if (!isset($b['data'], $b['text']) || strlen($b['data']) > 64) { + throw new \InvalidArgumentException('Invalid operator button'); + }$button = ['_' => 'keyboardButtonCallback','text' => $b['text'],'data' => $b['data']]; + if (isset($b['color'])) { + if (!in_array($b['color'], ['bg_primary','bg_danger'], true)) { + throw new \InvalidArgumentException('Invalid operator action color.'); + } + $button['style'] = ['_' => 'keyboardButtonStyle',$b['color'] => true]; + } + $buttons[] = $button; + }$markup['rows'][] = ['_' => 'keyboardButtonRow','buttons' => $buttons]; + } + return ['text' => $text,'markup' => $markup]; + } +} diff --git a/src/UI/OperatorDraft.php b/src/UI/OperatorDraft.php new file mode 100644 index 0000000..456c2a0 --- /dev/null +++ b/src/UI/OperatorDraft.php @@ -0,0 +1,415 @@ +authorize = \Closure::fromCallable($authorize); + } + private function allowed(string $user, bool $mutate = false): void + { + if (!($this->authorize)($user, $mutate)) { + throw new \RuntimeException('Operator permission required.'); + } + $this->app->settings->getUser($user); + } + private function path(string $user, string $key): string + { + return 'data/' . $user . '/' . $key; + } + private function read(string $user, string $key, string $default = ''): string + { + if ($key === 'button_label.txt') { + return $this->app->flows->latest($user, 'broadcast_button_label')['payload']['label'] ?? $default; + } + if (in_array($key, self::DELIVERY_KEYS, true)) { + $query = $this->app->db->pdo()->prepare('SELECT value_text FROM operator_broadcast_options WHERE user_id=? AND state_key=?'); + $query->execute([$user, $key]); + $value = $query->fetchColumn(); + return $value === false ? $default : (string) $value; + } + $path = $this->path($user, $key); + return AdminStateFiles::exists($path) ? AdminStateFiles::read($path) : $default; + } + public function mode(string $user): string + { + $this->allowed($user); + return $this->read($user, 'grs1.txt'); + } + public function begin(string $user, string $mode): void + { + $this->allowed($user, true); + if (!in_array($mode, ['broadcast1','addBUTTONS','broadcast_sd_custom','broadcast_schedule_custom','broadcast_button_label','broadcast_button_url'], true)) { + throw new \InvalidArgumentException('Unknown editor mode.'); + } + AdminStateFiles::write($this->path($user, 'grs1.txt'), $mode); + } + public function cancel(string $user, bool $clear = false): void + { + $this->allowed($user, true); + foreach ($clear ? ['grs1.txt','txt.txt','ent.txt','media.txt','BUTTONS.txt','broadcast_operation.txt','selfdestructhours.txt','scheduledat.txt','button_label.txt'] : ['grs1.txt','button_label.txt'] as $key) { + if (in_array($key, self::DELIVERY_KEYS, true)) { + $this->option($user, $key, null); + } else { + AdminStateFiles::delete($this->path($user, $key)); + } + } + } + public function payload(string $user): array + { + $this->allowed($user); + $text = $this->read($user, 'txt.txt'); + $entities = json_decode($this->read($user, 'ent.txt', '[]'), true, 64, JSON_THROW_ON_ERROR); + $media = $this->read($user, 'media.txt'); + // Preserve migrated global buttons once, then keep all operator changes private to this draft. + if (!AdminStateFiles::exists($this->path($user, 'BUTTONS.txt'))) { + $legacy = ($text !== '' || $media !== '') && AdminStateFiles::exists('data/BUTTONS.txt') ? AdminStateFiles::read('data/BUTTONS.txt') : '[]'; + AdminStateFiles::write($this->path($user, 'BUTTONS.txt'), $legacy); + } + $buttons = json_decode($this->read($user, 'BUTTONS.txt', '[]'), true, 64, JSON_THROW_ON_ERROR); + if (!is_array($entities) || !is_array($buttons)) { + throw new \InvalidArgumentException('Invalid stored draft.'); + } + $buttons = \Shabbat\Storage\TemplateRepository::buttonRows($buttons); + TemplateRenderer::validateButtons($buttons); + return ['text' => $text,'entities' => $entities,'media' => $media === '' ? null : ['bot_api_file_id' => $media],'buttons' => $buttons]; + } + public function state(string $user): array + { + $payload = $this->payload($user); + if (!AdminStateFiles::exists($this->path($user, 'broadcastsend.txt'))) { + AdminStateFiles::write($this->path($user, 'broadcastsend.txt'), AdminStateFiles::exists('data/broadcastsend.txt') ? AdminStateFiles::read('data/broadcastsend.txt') : 'users'); + } + if (!AdminStateFiles::exists($this->path($user, 'pinmessage.txt'))) { + AdminStateFiles::write($this->path($user, 'pinmessage.txt'), AdminStateFiles::exists('data/pinmessage.txt') ? 'on' : 'off'); + } + $audience = match ($this->read($user, 'broadcastsend.txt', 'users')) { + 'ืžืฉืชืžืฉื™ื','users'=>'users','ืขืจื•ืฆื™ื','channels'=>'channels','ืงื‘ื•ืฆื•ืช','groups'=>'groups','ื›ื•ืœื','all'=>'all','groups_channels'=>'groups_channels','authorized_channels'=>'authorized_channels','authorized_groups'=>'authorized_groups','authorized_groups_channels'=>'authorized_groups_channels',default=>throw new \InvalidArgumentException('Invalid audience.') + }; + $operation = $this->read($user, 'broadcast_operation.txt', 'send'); + if (!in_array($operation, ['send', 'edit'], true)) { + throw new \InvalidArgumentException('Unknown stored broadcast operation.'); + } + $hours = $this->read($user, 'selfdestructhours.txt'); + $scheduled = $this->read($user, 'scheduledat.txt'); + if ( + ($hours !== '' && preg_match('/^(?:[1-9]|[1-3][0-9]|4[0-8])$/D', $hours) !== 1) + || ($scheduled !== '' && preg_match('/^[1-9][0-9]{0,11}$/D', $scheduled) !== 1) + || ($operation === 'edit' && ($hours !== '' || $scheduled !== '')) + ) { + throw new \InvalidArgumentException('Invalid stored broadcast delivery settings.'); + } + return ['operation' => $operation, 'self_destruct_hours' => $hours === '' ? null : (int) $hours, 'scheduled_at' => $scheduled === '' ? null : (int) $scheduled, 'exists' => $payload['text'] !== '' || $payload['media'] !== null,'pin' => $this->read($user, 'pinmessage.txt') === 'on','audience' => $audience,'payload' => $payload]; + } + public function capture(string $user, string $text, array $entities = [], ?string $media = null): bool + { + $this->allowed($user, true); + if (str_starts_with(ltrim($text), '/')) { + return false; + } + if ($this->mode($user) !== 'broadcast1') { + return false; + } + if (!mb_check_encoding($text, 'UTF-8') || ($text === '' && ($media === null || $media === '')) || self::utf16($text) > ($media === null ? 4096 : 1024)) { + throw new \InvalidArgumentException('Invalid draft text or caption.'); + } + if ($media !== null && ($media === '' || strlen($media) > 4096)) { + throw new \InvalidArgumentException('Invalid reusable media file identifier.'); + } + $total = self::utf16($text); + $boundaries = [0 => true]; + $position = 0; + foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $character) { + $position += self::utf16($character); + $boundaries[$position] = true; + } + foreach ($entities as $entity) { + if (!is_array($entity) || !is_int($entity['offset'] ?? null) || !is_int($entity['length'] ?? null) || $entity['offset'] < 0 || $entity['length'] < 1 || $entity['offset'] + $entity['length'] > $total || !isset($boundaries[$entity['offset']], $boundaries[$entity['offset'] + $entity['length']])) { + throw new \InvalidArgumentException('Invalid draft entity.'); + } + } + $this->app->db->transaction(function (\PDO $pdo) use ($user, $text, $entities, $media): void { + $this->write($pdo, $user, 'txt.txt', $text); + $this->write($pdo, $user, 'ent.txt', Database::encode($entities)); + if ($media === null) { + $pdo->prepare('DELETE FROM admin_state WHERE user_id=? AND state_key=?')->execute([$user,'media.txt']); + } else { + $this->write($pdo, $user, 'media.txt', $media); + } + // Content edits retain the actor's keyboard; removing buttons is an explicit editor action. + $pdo->prepare('DELETE FROM admin_state WHERE user_id=? AND state_key=?')->execute([$user,'grs1.txt']); + }); + return true; + } + public function buttons(string $user, string $input): bool + { + $this->allowed($user, true); + if (str_starts_with(ltrim($input), '/') || $this->mode($user) !== 'addBUTTONS') { + return false; + } + try { + $buttons = json_decode($input, true, 64, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $buttons = $this->app->parseCustomButtons($input); + } + if (!is_array($buttons)) { + throw new \InvalidArgumentException('Invalid button rows.'); + } + $buttons = \Shabbat\Storage\TemplateRepository::buttonRows($buttons); + TemplateRenderer::validateButtons($buttons); + $this->app->db->transaction(function (\PDO $pdo) use ($user, $buttons): void { + $this->write($pdo, $user, 'BUTTONS.txt', Database::encode($buttons)); + $pdo->prepare('DELETE FROM admin_state WHERE user_id=? AND state_key=?')->execute([$user,'grs1.txt']); + }); + return true; + } + /** A guided link button editor preserves existing rows and never sends a broadcast. */ + public function buttonInput(string $user, string $text): ?string + { + $this->allowed($user, true); + if (str_starts_with(ltrim($text), '/')) { + return null; + } + $mode = $this->mode($user); + if ($mode === 'broadcast_button_label') { + $label = trim($text); + if ($label === '' || !mb_check_encoding($label, 'UTF-8') || self::utf16($label) > 64 || preg_match('/[\x00-\x1f]/u', $label)) { + throw new \InvalidArgumentException('Button label must be one line of 1 to 64 UTF-16 units.'); + } + $this->option($user, 'button_label.txt', $label); + $this->begin($user, 'broadcast_button_url'); + return 'url'; + } + if ($mode !== 'broadcast_button_url') { + return null; + } + $url = trim($text); + $label = $this->read($user, 'button_label.txt'); + if ( + $label === '' || strlen($url) > 2048 || preg_match('/\s/u', $url) + || !in_array(parse_url($url, PHP_URL_SCHEME), ['https', 'tg'], true) + || !is_string(parse_url($url, PHP_URL_HOST)) || parse_url($url, PHP_URL_HOST) === '' + ) { + throw new \InvalidArgumentException('Enter a complete HTTPS or Telegram link.'); + } + $rows = $this->payload($user)['buttons']; + $last = count($rows) - 1; + $button = ['text' => $label, 'url' => $url]; + if ($last >= 0 && count($rows[$last]) < 2) { + $rows[$last][] = $button; + } else { + $rows[] = [$button]; + } + TemplateRenderer::validateButtons($rows); + $this->app->db->transaction(function (\PDO $pdo) use ($user, $rows): void { + $this->write($pdo, $user, 'BUTTONS.txt', Database::encode($rows)); + $pdo->prepare("DELETE FROM admin_state WHERE user_id=? AND state_key='grs1.txt'")->execute([$user]); + $this->app->flows->revokeAction($user, 'broadcast_button_label'); + }); + return 'saved'; + } + + public function clearButtons(string $user): void + { + $this->allowed($user, true); + AdminStateFiles::write($this->path($user, 'BUTTONS.txt'), '[]'); + $this->cancel($user); + $this->option($user, 'button_label.txt', null); + } + + public function setAudience(string $user, string $audience): void + { + $this->allowed($user, true); + if (!in_array($audience, \Shabbat\Storage\SubscriberDirectory::AUDIENCES, true)) { + throw new \InvalidArgumentException('Unknown audience.'); + } + AdminStateFiles::write($this->path($user, 'broadcastsend.txt'), $audience); + } + public function setPin(string $user, bool $pin): void + { + $this->allowed($user, true); + AdminStateFiles::write($this->path($user, 'pinmessage.txt'), $pin ? 'on' : 'off'); + } + public function confirmation(string $user, array $targets): string + { + $this->allowed($user, true); + $state = $this->state($user); + if ($state['scheduled_at'] !== null && $state['scheduled_at'] <= $this->app->clock->now()->getTimestamp()) { + throw new \InvalidArgumentException('Scheduled time elapsed; select a new time or immediate send.'); + } + if (!$state['exists']) { + throw new \InvalidArgumentException('Empty broadcast draft.'); + } + if ($targets === []) { + throw new \InvalidArgumentException('Empty broadcast audience.'); + } + $targets = array_values(array_unique(array_map(static fn($id): string=>(string)$id, $targets))); + foreach ($targets as $id) { + if (!preg_match('/^-?[1-9][0-9]{0,19}$/D', $id)) { + throw new \InvalidArgumentException('Invalid recipient ID.'); + } + } + $revision = $this->app->settings->getUser($user)['revision']; + return $this->app->flows->create($user, 'user', null, $revision, 'operator_broadcast', 600, ['state' => $state,'digest' => hash('sha256', Database::encode($state)),'targets' => $targets]); + } + public function claim(string $user, string $token): array + { + $this->allowed($user, true); + $state = $this->state($user); + if ($state['scheduled_at'] !== null && $state['scheduled_at'] <= $this->app->clock->now()->getTimestamp()) { + throw new \RuntimeException('Scheduled time elapsed before confirmation.'); + } + $flow = $this->app->flows->peek($token, $user, 'operator_broadcast'); + if ($flow === null || !hash_equals($flow['payload']['digest'], hash('sha256', Database::encode($this->state($user))))) { + throw new \RuntimeException('Draft confirmation is stale.'); + } + $this->app->flows->confirm($token, $user, 'operator_broadcast', static fn(): bool=>false); + return $flow['payload']; + } + public function setOperation(string $user, string $operation): void + { + $this->allowed($user, true); + if (!in_array($operation, ['send', 'edit'], true)) { + throw new \InvalidArgumentException('Unknown broadcast operation.'); + } + $this->option($user, 'broadcast_operation.txt', $operation); + if ($operation === 'edit') { + $this->option($user, 'selfdestructhours.txt', null); + $this->option($user, 'scheduledat.txt', null); + } + } + + public function setSelfDestructHours(string $user, ?int $hours): void + { + $this->allowed($user, true); + if (($hours !== null && ($hours < 1 || $hours > 48)) || $this->state($user)['operation'] !== 'send') { + throw new \InvalidArgumentException('Self-destruct requires a new send and 1 to 48 hours.'); + } + if ($hours === null) { + $this->option($user, 'selfdestructhours.txt', null); + } else { + $this->option($user, 'selfdestructhours.txt', (string) $hours); + } + } + + public function setScheduledAt(string $user, ?int $timestamp): void + { + $this->allowed($user, true); + $now = $this->app->clock->now()->getTimestamp(); + if (($timestamp !== null && ($timestamp <= $now || $timestamp > $now + 31536000)) || $this->state($user)['operation'] !== 'send') { + throw new \InvalidArgumentException('Schedule must be future, within one year, and apply to a new send.'); + } + if ($timestamp === null) { + $this->option($user, 'scheduledat.txt', null); + } else { + $this->option($user, 'scheduledat.txt', (string) $timestamp); + } + } + + /** Strict local wall-clock parsing rejects impossible or ambiguous DST times. */ + public function parseSchedule(string $user, string $input): ?int + { + $this->allowed($user); + $input = trim($input); + if (strtolower($input) === 'off') { + return null; + } + $now = $this->app->clock->now()->getTimestamp(); + if (preg_match('/^[1-9][0-9]{0,5}$/D', $input)) { + $timestamp = $now + ((int) $input * 60); + if ($timestamp > $now + 31536000) { + throw new \InvalidArgumentException('Schedule exceeds one year.'); + } + return $timestamp; + } + $zone = new \DateTimeZone($this->app->settings->getUser($user)['timezone_id']); + foreach (['Y-m-d H:i', 'd/m/Y H:i', 'd.m.Y H:i'] as $format) { + $date = \DateTimeImmutable::createFromFormat('!' . $format, $input, $zone); + $errors = \DateTimeImmutable::getLastErrors(); + if (!$date || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) || $date->format($format) !== $input) { + continue; + } + $wall = $date->format('Y-m-d H:i'); + $candidates = []; + foreach ($zone->getTransitions($date->getTimestamp() - 172800, $date->getTimestamp() + 172800) ?: [] as $transition) { + $candidate = (new \DateTimeImmutable($wall, new \DateTimeZone('UTC')))->getTimestamp() - $transition['offset']; + if ((new \DateTimeImmutable('@' . $candidate))->setTimezone($zone)->format('Y-m-d H:i') === $wall) { + $candidates[$candidate] = true; + } + } + if (count($candidates) !== 1 || $date->getTimestamp() <= $now || $date->getTimestamp() > $now + 31536000) { + throw new \InvalidArgumentException('Schedule is past, ambiguous or out of range.'); + } + return $date->getTimestamp(); + } + throw new \InvalidArgumentException('Invalid schedule date.'); + } + + public function controlInput(string $user, string $text): bool + { + if (str_starts_with(ltrim($text), '/')) { + return false; + } + $mode = $this->mode($user); + if (!in_array($mode, ['broadcast_sd_custom', 'broadcast_schedule_custom'], true)) { + return false; + } + $this->allowed($user, true); + if ($mode === 'broadcast_sd_custom') { + $input = trim($text); + if (strtolower($input) !== 'off' && !preg_match('/^(?:[1-9]|[1-3][0-9]|4[0-8])$/D', $input)) { + throw new \InvalidArgumentException('Self-destruct hours must be 1 to 48.'); + } + $this->setSelfDestructHours($user, strtolower($input) === 'off' ? null : (int) $input); + } else { + $this->setScheduledAt($user, $this->parseSchedule($user, $text)); + } + $this->cancel($user); + return true; + } + private function option(string $user, string $key, ?string $value): void + { + if (!in_array($key, self::DELIVERY_KEYS, true)) { + throw new \LogicException('Unknown broadcast delivery option.'); + } + if ($key === 'button_label.txt') { + $this->app->flows->revokeAction($user, 'broadcast_button_label'); + if ($value !== null) { + $this->app->flows->create($user, 'user', null, $this->app->settings->getUser($user)['revision'], 'broadcast_button_label', 900, ['label' => $value]); + } + return; + } + if ($value === null) { + $this->app->db->pdo()->prepare('DELETE FROM operator_broadcast_options WHERE user_id=? AND state_key=?')->execute([$user, $key]); + return; + } + $this->app->db->pdo()->prepare('INSERT INTO operator_broadcast_options(user_id,state_key,value_text,updated_at) VALUES(?,?,?,?) + ON CONFLICT(user_id,state_key) DO UPDATE SET value_text=excluded.value_text,updated_at=excluded.updated_at') + ->execute([$user, $key, $value, $this->app->clock->now()->getTimestamp()]); + } + + private function write(\PDO $pdo, string $user, string $key, string $value): void + { + $now = $this->app->clock->now()->getTimestamp(); + $kind = in_array($key, ['ent.txt','BUTTONS.txt'], true) ? 'json' : ($key === 'media.txt' ? 'media' : 'text'); + $pdo->prepare('INSERT INTO admin_directories(user_id,created_at) VALUES(?,?) ON CONFLICT(user_id) DO NOTHING')->execute([$user,$now]); + $pdo->prepare('INSERT INTO admin_state(user_id,state_key,value_kind,value_text,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(user_id,state_key) DO UPDATE SET value_kind=excluded.value_kind,value_text=excluded.value_text,updated_at=excluded.updated_at')->execute([$user,$key,$kind,$value,$now]); + } + private static function utf16(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } +} diff --git a/src/UI/PanelEditException.php b/src/UI/PanelEditException.php new file mode 100644 index 0000000..6288ffc --- /dev/null +++ b/src/UI/PanelEditException.php @@ -0,0 +1,12 @@ +groupId($context); + $frame = $this->recovery->inspect($group, $user); + if (($frame['status'] ?? '') === 'forbidden') { + throw new \RuntimeException('Current administrator permission is required.'); + } + $this->render($user, $context, $frame, $notice); + } + + public function prepare(string $user, array $context, string $action): void + { + if (!in_array($action, ['adopt', 'restore'], true)) { + throw new \InvalidArgumentException('Unknown permission recovery action.'); + } + $this->flows->revokeAction($user, 'permission_recovery_confirm'); + $frame = $this->recovery->prepare($this->groupId($context), $user, $action); + if (!is_string($frame['token'] ?? null) || $frame['token'] === '') { + $this->render($user, $context, $frame, $this->resultKey($frame)); + return; + } + $token = $this->flows->create($user, 'group', $this->groupId($context), (int)$context['revision'], 'permission_recovery_confirm', 300, [ + 'core_token' => $frame['token'], 'action' => $action, 'return_screen' => $this->parent($context), + ]); + $text = $this->summary($user, $context, $frame) . "\n\n" . $this->t($user, 'recovery_preview_' . $action) + . "\n\n" . $this->t($user, 'recovery_preview_notice'); + $rows = [ + [$this->button($user, 'recovery_confirm_' . $action, 'sb:pc:' . $token, 'bg_primary')], + [$this->button($user, 'cancel', 'sb:px:' . $token, 'bg_danger')], + ]; + $this->send($user, $text, $rows); + } + + public function confirm(string $user, array $context, string $token): void + { + $flow = $context['_flow']; + $coreToken = $flow['payload']['core_token'] ?? null; + if (!is_string($coreToken) || $coreToken === '') { + throw new \InvalidArgumentException('Missing reviewed recovery action.'); + } + // Revoking the UI wrapper prevents replay even when the external call fails. + // The durable service token independently binds actor, state and remote rights. + $this->flows->revoke($token, $user, 'permission_recovery_confirm'); + $result = $this->recovery->confirm($coreToken, $user); + $fresh = $this->settings->getGroup($this->groupId($context)); + if ($fresh === null) { + throw new \RuntimeException('Unknown recovery group.'); + } + $fresh['_flow']['payload']['return_screen'] = $this->parent($context); + if (($result['status'] ?? '') === 'forbidden') { + throw new \RuntimeException('Current administrator permission is required.'); + } + $this->show($user, $fresh, $this->resultKey($result)); + } + + public function cancel(string $user, array $context, string $token): void + { + $this->flows->revoke($token, $user, 'permission_recovery_confirm'); + $this->show($user, $context, 'recovery_cancelled'); + } + + private function render(string $user, array $context, array $frame, ?string $notice): void + { + $token = $this->flows->create($user, 'group', $this->groupId($context), (int)$context['revision'], 'panel', 600, ['return_screen' => $this->parent($context)]); + $rows = []; + if (($frame['can_adopt'] ?? false) === true) { + $rows[] = [$this->button($user, 'recovery_adopt', 'sb:pa:' . $token . ':adopt', 'bg_primary')]; + } + if (($frame['can_restore'] ?? false) === true) { + $rows[] = [$this->button($user, 'recovery_restore', 'sb:pa:' . $token . ':restore')]; + } + $parent = $this->parent($context); + $rows[] = [$this->button($user, 'recovery_refresh', 'sb:pr:' . $token), + $this->button($user, 'back', $parent === 'group' ? 'sb:group:' . $token : 'sb:v:' . $token . ':' . $parent)]; + $text = $this->summary($user, $context, $frame); + if ($notice !== null) { + $text .= "\n\n" . $this->t($user, $notice); + } + $this->send($user, $text, $rows); + } + + private function summary(string $user, array $context, array $frame): string + { + $text = $this->t($user, 'recovery_title', ['title' => mb_substr((string)($context['title'] ?: $context['group_id']), 0, 100), 'group' => $this->groupId($context)]) + . "\n\n" . $this->t($user, 'recovery_intro'); + $reason = $frame['block_reason'] ?? null; + if (is_string($reason)) { + $reason = in_array($reason, ['active_lock', 'no_problem', 'missing_snapshot', 'locked_current', 'unverified_snapshot'], true) ? $reason : 'unknown'; + $text .= "\n\n" . $this->t($user, 'recovery_block_' . $reason); + } elseif (($frame['can_adopt'] ?? false) || ($frame['can_restore'] ?? false)) { + $text .= "\n\n" . $this->t($user, 'recovery_state_problem'); + } + if ($reason === null) { + foreach (['adopt_block_reason', 'restore_block_reason'] as $key) { + $blocked = $frame[$key] ?? null; + if (in_array($blocked, ['locked_current', 'missing_snapshot', 'unverified_snapshot'], true)) { + $text .= "\n\n" . $this->t($user, 'recovery_block_' . $blocked); + } + } + } + if ($reason !== null && $reason !== 'no_problem' && is_array($frame['original'] ?? null) && ($frame['original_verified'] ?? false) === false) { + $text .= "\n\n" . $this->t($user, 'recovery_block_unverified_snapshot'); + } + $current = $frame['current'] ?? null; + $original = $frame['original'] ?? null; + if (is_array($current) && is_array($original)) { + // Compare the current state to the original, not to the expected lock mask. + $fields = Permissions::differentFields($current, $original); + $text .= "\n\n" . $this->t($user, 'recovery_compare'); + foreach (array_slice($fields, 0, 8) as $field) { + if (in_array($field, ['unknown', 'until_date'], true)) { + $text .= "\nโ€ข " . $this->t($user, 'permission_' . $field); + } else { + $text .= "\n" . $this->t($user, 'recovery_difference', [ + 'permission' => $this->t($user, 'permission_' . $field), + 'current' => $this->t($user, ($current[$field] ?? false) ? 'recovery_blocked' : 'recovery_allowed'), + 'saved' => $this->t($user, ($original[$field] ?? false) ? 'recovery_blocked' : 'recovery_allowed'), + ]); + } + } + if ($fields === []) { + $text .= "\n" . $this->t($user, 'recovery_no_differences'); + } elseif (count($fields) > 8) { + $text .= "\n" . $this->t($user, 'recovery_more', ['count' => count($fields) - 8]); + } + } + return $text; + } + + private function resultKey(array $result): string + { + $status = $result['status'] ?? 'error'; + return match ($status) { + 'adopted', 'restored', 'forbidden', 'conflict', 'retry_wait' => 'recovery_result_' . $status, + 'stale', 'expired' => 'recovery_result_stale', + 'busy' => 'recovery_result_busy', + 'active_lock', 'locked_current', 'missing_snapshot', 'unverified_snapshot' => 'recovery_block_' . $status, + default => 'recovery_result_error', + }; + } + + private function parent(array $context): string + { + $parent = $context['_flow']['payload']['return_screen'] ?? 'group'; + return in_array($parent, ['shabbat', 'moderation', 'restrictions', 'botpermissions', 'status'], true) ? $parent : 'group'; + } + + private function groupId(array $context): string + { + return isset($context['group_id']) ? (string)$context['group_id'] : throw new \InvalidArgumentException('Group recovery requires a group context.'); + } + + private function t(string $user, string $key, array $values = []): string + { + return $this->lang->text($this->settings->getUser($user)['language'], $key, $values); + } + + private function button(string $user, string $key, string $data, ?string $color = null): array + { + if (strlen($data) > 64) { + throw new \LogicException('Recovery callback exceeds Telegram limit.'); + } + $style = ['_' => 'keyboardButtonStyle']; + if ($color !== null) { + $style[$color] = true; + } + return Presentation::button(['_' => 'keyboardButtonCallback', 'text' => $this->t($user, $key), 'data' => $data, 'style' => $style]); + } + + private function send(string $user, string $text, array $rows): void + { + $this->gateway->sendPrivate($user, $text, ['_' => 'replyInlineMarkup', 'rows' => array_map(static fn(array $buttons): array => ['_' => 'keyboardButtonRow', 'buttons' => $buttons], $rows)]); + } +} diff --git a/src/UI/Presentation.php b/src/UI/Presentation.php new file mode 100644 index 0000000..2e518a8 --- /dev/null +++ b/src/UI/Presentation.php @@ -0,0 +1,239 @@ + '5944760643524500583', + 'support' => '5945186253308699040', + 'info' => '5945067566182440529', + 'source_code' => '5945275184901529100', + 'groups' => '5944900831257043691', + 'add_to_group' => '5947491022954045554', + 'shabbat_holidays' => '5944845194250690529', + 'tefillin' => '5944970388252402183', + 'daily_zmanim' => '5945067360024011010', + 'daf_yomi' => '5947481135939328427', + 'omer' => '5945033816329428634', + 'brand_coin' => '5947339492212875204', + ]; + + private const EMOJI = [ + 'โš™๏ธ' => 'settings', 'โš™' => 'settings', '๐Ÿ› ' => 'settings', + 'โญ๏ธ' => 'support', 'โญ' => 'support', '๐ŸŒŸ' => 'support', + 'โ„น๏ธ' => 'info', 'โ„น' => 'info', '๐Ÿ›ก' => 'info', '๐Ÿ“Š' => 'info', + '๐Ÿ’ป' => 'source_code', '๐Ÿ‘ฅ' => 'groups', 'โž•' => 'add_to_group', + '๐Ÿ•ฏ๏ธ' => 'shabbat_holidays', '๐Ÿ•ฏ' => 'shabbat_holidays', + '๐Ÿ””' => 'tefillin', '๐Ÿ•’' => 'daily_zmanim', '๐Ÿ“–' => 'daf_yomi', + '๐ŸŒพ' => 'omer', 'โœก๏ธ' => 'brand_coin', 'โœก' => 'brand_coin', + ]; + + public static function enabledFromEnvironment(): bool + { + $value = getenv('CUSTOM_EMOJI_ENABLED'); + if ($value === false || trim($value) === '') { + return true; + } + return match (strtolower(trim($value))) { + '1', 'true', 'on' => true, + '0', 'false', 'off' => false, + default => throw new \RuntimeException('CUSTOM_EMOJI_ENABLED must be 1 or 0.'), + }; + } + public static function iconForKey(string $key): ?string + { + $semantic = match ($key) { + 'preferences', 'settings', 'edit', 'language', 'location', 'schedule', 'status', + 'info_personal_title', 'info_operators_title', 'moderation', 'announcement_custom' => 'settings', + 'donate', 'donate_other', 'support' => 'support', + 'help', 'info', 'info_privacy_title', 'info_intro' => 'info', + 'source', 'source_code' => 'source_code', + 'groups', 'info_groups_title' => 'groups', + 'add' => 'add_to_group', + 'shabbat', 'candles', 'havdalah', 'info_calendar_title' => 'shabbat_holidays', + 'tefillin', 'info_notifications_title' => 'tefillin', + 'zmanim' => 'daily_zmanim', + 'daf' => 'daf_yomi', 'omer' => 'omer', 'welcome', 'home' => 'brand_coin', + default => null, + }; + return $semantic === null ? null : self::ICONS[$semantic]; + } + + /** Buttons keep readable Unicode fallbacks; native style.icon adds the animated asset. */ + public static function button(array $button, bool $enabled = true, ?string $key = null, bool $native = false): array + { + if (isset($button['text']) && is_string($button['text'])) { + $button['text'] = str_replace('โ€”', '-', $button['text']); + } + $style = $button['style'] ?? ['_' => 'keyboardButtonStyle']; + if (is_string($style)) { + $style = \Shabbat\Content\TemplateRenderer::nativeStyle($style); + } + if (!is_array($style)) { + throw new \InvalidArgumentException('Invalid button presentation style.'); + } + if (!$enabled) { + if (is_string($button['_presentation_fallback_text'] ?? null)) { + $button['text'] = $button['_presentation_fallback_text']; + } + unset($style['icon'], $button['icon_custom_emoji_id'], $button['_presentation_fallback_text'], $button['_presentation_auto_icon']); + } elseif (isset($button['icon_custom_emoji_id'])) { + if (isset($style['icon']) && (string)$style['icon'] !== (string)$button['icon_custom_emoji_id']) { + throw new \InvalidArgumentException('Conflicting button icons.'); + } + $style['icon'] = $button['icon_custom_emoji_id']; + unset($button['icon_custom_emoji_id']); + } elseif (!isset($style['icon'])) { + $icon = $key === null ? self::iconForText((string)($button['text'] ?? '')) : self::iconForKey($key); + if ($icon !== null) { + $style['icon'] = $icon; + $button['_presentation_auto_icon'] = true; + } + } + if ($native && $enabled && ($button['_presentation_auto_icon'] ?? false) === true && is_string($button['text'] ?? null)) { + foreach (self::EMOJI as $emoji => $semantic) { + if ((string)($style['icon'] ?? '') === self::ICONS[$semantic] && str_starts_with($button['text'], $emoji)) { + $label = ltrim(substr($button['text'], strlen($emoji))); + if ($label !== '') { + $button['_presentation_fallback_text'] = $button['text']; + $button['text'] = $label; + } + break; + } + } + if ( + !isset($button['_presentation_fallback_text']) + && preg_match('/^(\X)\s*/u', $button['text'], $prefix) + && preg_match('/[\p{Extended_Pictographic}\p{Regional_Indicator}\x{20E3}]/u', $prefix[1]) + ) { + $label = substr($button['text'], strlen($prefix[0])); + if ($label !== '') { + $button['_presentation_fallback_text'] = $button['text']; + $button['text'] = $label; + } + } + unset($button['_presentation_auto_icon']); + } + $button['style'] = $style; + return $button; + } + + public static function requiresCatalog(string $text): bool + { + return preg_match(self::pattern(), $text) === 1; + } + public static function iconForText(string $text): ?string + { + foreach (self::EMOJI as $emoji => $semantic) { + if (str_starts_with(ltrim($text), $emoji)) { + return self::ICONS[$semantic]; + } + } + return null; + } + + /** Never rewrite HTML attributes, code samples or existing custom-emoji elements. */ + public static function html(string $html, bool $enabled = true, array $alternatives = []): string + { + $parts = preg_split('/(<[^>]*>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + throw new \InvalidArgumentException('Invalid presentation UTF-8.'); + } + $protected = []; + $output = ''; + foreach ($parts as $part) { + if (str_starts_with($part, '<')) { + if (preg_match('/^<\s*(\/?)(tg-emoji|code|pre)\b/iu', $part, $match)) { + $tag = strtolower($match[2]); + if ($match[1] === '/') { + array_pop($protected); + } else { + $protected[] = $tag; + } + if ($tag === 'tg-emoji' && !$enabled) { + continue; + } + } + $output .= $part; + continue; + } + $part = str_replace('โ€”', '-', $part); + if ($enabled && $protected === []) { + $part = preg_replace_callback(self::pattern(), static function (array $match) use ($alternatives): string { + $id = self::ICONS[self::EMOJI[$match[0]]]; + $alt = $alternatives[$id] ?? null; + if (!is_string($alt) || $alt === '' || (self::EMOJI[$match[0]] === 'brand_coin' && $alt !== $match[0])) { + return $match[0]; + } + return '' . htmlspecialchars($alt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + }, $part) ?? throw new \InvalidArgumentException('Invalid presentation text.'); + } + $output .= $part; + } + return $output; + } + + /** Plain text is unchanged in length; UTF-16 offsets and existing rich entities remain valid. */ + public static function payload(array $payload, bool $enabled = true, array $alternatives = [], bool $html = true): array + { + $text = $payload['text'] ?? ''; + if (!is_string($text)) { + throw new \InvalidArgumentException('Message presentation requires text.'); + } + if (array_key_exists('entities', $payload)) { + $html = false; + } + $entities = $payload['entities'] ?? []; + if ($entities === [] && $html) { + $payload['text'] = self::html($text, $enabled, $alternatives); + return $payload; + } + $payload['text'] = str_replace('โ€”', '-', $text); + if (!$enabled) { + $payload['entities'] = array_values(array_filter($entities, static fn(array $entity): bool => + !in_array($entity['_'] ?? $entity['type'] ?? '', ['messageEntityCustomEmoji', 'custom_emoji'], true))); + if ($payload['entities'] === [] && $html) { + $payload['text'] = htmlspecialchars($payload['text'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + return $payload; + } + preg_match_all(self::pattern(), $text, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[0] as [$emoji, $byteOffset]) { + $offset = self::utf16(substr($text, 0, $byteOffset)); + $length = self::utf16($emoji); + $protected = false; + foreach ($entities as $entity) { + if ( + in_array($entity['_'] ?? $entity['type'] ?? '', ['messageEntityCustomEmoji', 'custom_emoji', 'messageEntityCode', 'code', 'messageEntityPre', 'pre'], true) + && $offset < ($entity['offset'] ?? 0) + ($entity['length'] ?? 0) + && $offset + $length > ($entity['offset'] ?? 0) + ) { + $protected = true; + break; + } + } + if (!$protected && ($alternatives[self::ICONS[self::EMOJI[$emoji]]] ?? null) === $emoji) { + $entities[] = ['_' => 'messageEntityCustomEmoji', 'offset' => $offset, 'length' => $length, + 'document_id' => (int)self::ICONS[self::EMOJI[$emoji]]]; + } + } + $payload['entities'] = $entities; + return $payload; + } + + private static function pattern(): string + { + return '/(?:' . implode('|', array_map(static fn(string $emoji): string => preg_quote($emoji, '/'), array_keys(self::EMOJI))) . ')/u'; + } + + private static function utf16(string $text): int + { + return intdiv(strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2); + } +} diff --git a/src/UI/SettingsWizard.php b/src/UI/SettingsWizard.php new file mode 100644 index 0000000..4d3610b --- /dev/null +++ b/src/UI/SettingsWizard.php @@ -0,0 +1,219 @@ + ['candle_minutes','havdalah_method','havdalah_value','close_before_candles_minutes','open_after_havdalah_minutes','close_message_enabled','open_message_enabled','times_alert_enabled','times_alert_time','times_alert_mode','times_alert_before_minutes','quiet_start','quiet_end','target_topic_id','lock_block_flags','delete_holy_user_messages','delete_holy_bot_messages'], + 'zmanim' => ['time','days','format'], + 'daf' => ['time','days','template'], + 'tefillin' => ['days','fixed_times','interval_minutes','window_start','window_end','sunset_offsets','max_per_day','min_spacing_minutes','chol_hamoed','tisha_bav_policy','sunset_safety_minutes','quiet_start','quiet_end','template','delete_previous','ack_enabled','ack_label'], + 'omer' => ['time','mode','after_nightfall_minutes','nightfall_method','nightfall_value','nusach','format','day_completion','early_policy','after_open_minutes'], + ]; + private const ENUM_LABELS = ['he' => ['degrees' => 'ืžืขืœื•ืช', 'minutes' => 'ื“ืงื•ืช ืงื‘ื•ืขื•ืช', 'short' => 'ืงืฆืจ', 'full' => 'ืžืœื', 'fixed' => 'ืฉืขื” ืงื‘ื•ืขื”', 'nightfall' => 'ืื—ืจื™ ืฆืืช ื”ื›ื•ื›ื‘ื™ื', 'local_time' => 'ืฉืขื” ืžืงื•ืžื™ืช', 'before_close' => 'ืœืคื ื™ ืกื’ื™ืจืช ื”ืงื‘ื•ืฆื”', 'ashkenaz' => 'ืืฉื›ื ื–', 'sephard' => 'ืกืคืจื“ / ืขื“ื•ืช ื”ืžื–ืจื—', 'defer' => 'ื”ืžืชื ื” ืœืžื•ืขื“ ื”ืžืชืื™ื', 'skip' => 'ื“ื™ืœื•ื’', 'off' => 'ื›ื‘ื•ื™', 'mincha' => 'ืžื ื—ื”'], 'en' => ['degrees' => 'Degrees', 'minutes' => 'Fixed minutes', 'short' => 'Short', 'full' => 'Full', 'fixed' => 'Fixed time', 'nightfall' => 'After nightfall', 'local_time' => 'Local time', 'before_close' => 'Before group closes', 'ashkenaz' => 'Ashkenaz', 'sephard' => 'Sephard', 'defer' => 'Wait until permitted', 'skip' => 'Skip', 'off' => 'Off', 'mincha' => 'Mincha'], 'ru' => ['degrees' => 'ะ“ั€ะฐะดัƒัั‹', 'minutes' => 'ะคะธะบัะธั€ะพะฒะฐะฝะฝั‹ะต ะผะธะฝัƒั‚ั‹', 'short' => 'ะšั€ะฐั‚ะบะพ', 'full' => 'ะŸะพะปะฝะพัั‚ัŒัŽ', 'fixed' => 'ะคะธะบัะธั€ะพะฒะฐะฝะฝะพะต ะฒั€ะตะผั', 'nightfall' => 'ะŸะพัะปะต ะฒั‹ั…ะพะดะฐ ะทะฒั‘ะทะด', 'local_time' => 'ะœะตัั‚ะฝะพะต ะฒั€ะตะผั', 'before_close' => 'ะ”ะพ ะทะฐะบั€ั‹ั‚ะธั ะณั€ัƒะฟะฟั‹', 'ashkenaz' => 'ะัˆะบะตะฝะฐะท', 'sephard' => 'ะกะตั„ะฐั€ะด', 'defer' => 'ะ”ะพะถะดะฐั‚ัŒัั ะฟะพะดั…ะพะดัั‰ะตะณะพ ะฒั€ะตะผะตะฝะธ', 'skip' => 'ะŸั€ะพะฟัƒัั‚ะธั‚ัŒ', 'off' => 'ะ’ั‹ะบะปัŽั‡ะตะฝะพ', 'mincha' => 'ะœะธะฝั…ะฐ'], 'fr' => ['degrees' => 'Degrรฉs', 'minutes' => 'Minutes fixes', 'short' => 'Court', 'full' => 'Complet', 'fixed' => 'Heure fixe', 'nightfall' => 'Aprรจs la nuit', 'local_time' => 'Heure locale', 'before_close' => 'Avant la fermeture du groupe', 'ashkenaz' => 'Ashkรฉnaze', 'sephard' => 'Sรฉfarade', 'defer' => 'Attendre le moment permis', 'skip' => 'Ignorer', 'off' => 'Dรฉsactivรฉ', 'mincha' => 'Minโ€™ha'], 'es' => ['degrees' => 'Grados', 'minutes' => 'Minutos fijos', 'short' => 'Breve', 'full' => 'Completo', 'fixed' => 'Hora fija', 'nightfall' => 'Despuรฉs del anochecer', 'local_time' => 'Hora local', 'before_close' => 'Antes del cierre del grupo', 'ashkenaz' => 'Ashkenaz', 'sephard' => 'Sefardรญ', 'defer' => 'Esperar el momento permitido', 'skip' => 'Omitir', 'off' => 'Desactivado', 'mincha' => 'Minjรก'], 'pt-br' => ['degrees' => 'Graus', 'minutes' => 'Minutos fixos', 'short' => 'Curto', 'full' => 'Completo', 'fixed' => 'Horรกrio fixo', 'nightfall' => 'Apรณs o anoitecer', 'local_time' => 'Horรกrio local', 'before_close' => 'Antes do fechamento do grupo', 'ashkenaz' => 'Ashkenaz', 'sephard' => 'Sefardita', 'defer' => 'Aguardar o momento permitido', 'skip' => 'Pular', 'off' => 'Desativado', 'mincha' => 'Minchรก']]; + private const DAYS = ['he' => ['ืจืืฉื•ืŸ', 'ืฉื ื™', 'ืฉืœื™ืฉื™', 'ืจื‘ื™ืขื™', 'ื—ืžื™ืฉื™', 'ืฉื™ืฉื™', 'ืฉื‘ืช'], 'en' => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], 'ru' => ['ะ’ะพัะบั€ะตัะตะฝัŒะต', 'ะŸะพะฝะตะดะตะปัŒะฝะธะบ', 'ะ’ั‚ะพั€ะฝะธะบ', 'ะกั€ะตะดะฐ', 'ะงะตั‚ะฒะตั€ะณ', 'ะŸัั‚ะฝะธั†ะฐ', 'ะกัƒะฑะฑะพั‚ะฐ'], 'fr' => ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'], 'es' => ['Domingo', 'Lunes', 'Martes', 'Miรฉrcoles', 'Jueves', 'Viernes', 'Sรกbado'], 'pt-br' => ['Domingo', 'Segunda-feira', 'Terรงa-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sรกbado']]; + + public static function fields(string $module, bool $group = true): array + { + $fields = self::FIELDS[$module] ?? throw new \InvalidArgumentException('Unknown settings module.'); + return $group ? $fields : ($module === 'shabbat' ? ['candle_minutes','havdalah_method','havdalah_value'] : []); + } + + /** Focused categories retain canonical field indexes and validation. */ + public static function sectionFields(string $section): array + { + return match ($section) { + 'shabbat_timing' => ['candle_minutes', 'havdalah_method', 'havdalah_value', 'close_before_candles_minutes', 'open_after_havdalah_minutes'], + 'shabbat_messages' => ['close_message_enabled', 'open_message_enabled', 'quiet_start', 'quiet_end', 'target_topic_id'], + 'announcement' => ['times_alert_mode', 'times_alert_time', 'times_alert_before_minutes'], + default => throw new \InvalidArgumentException('Unknown settings section.'), + }; + } + + public static function definition(string $module, string $field, bool $group = true): array + { + if (!in_array($field, self::fields($module, $group), true)) { + throw new \InvalidArgumentException('Unsupported settings field.'); + } + $base = ['kind' => 'text', 'options' => [], 'min' => null, 'max' => null, 'nullable' => false]; + if (in_array($field, ['close_message_enabled','open_message_enabled','times_alert_enabled','chol_hamoed','delete_previous','day_completion','delete_holy_user_messages','delete_holy_bot_messages','ack_enabled'], true)) { + return array_replace($base, ['kind' => 'bool','options' => [true,false]]); + } + $options = match ($field) { + 'havdalah_method','nightfall_method' => ['degrees','minutes'], + 'format' => ['short','full'], + 'mode' => ['fixed','nightfall'], + 'times_alert_mode' => ['local_time','before_close'], + 'nusach' => ['ashkenaz','sephard'], + 'early_policy' => ['defer','skip'], + 'tisha_bav_policy' => ['off','mincha'], + default => null, + }; + if ($options !== null) { + return array_replace($base, ['kind' => 'enum','options' => $options]); + } + if ($field === 'days') { + return array_replace($base, ['kind' => 'days','options' => range(0, 6),'min' => 0,'max' => 6]); + } + if ($field === 'lock_block_flags') { + return array_replace($base, ['kind' => 'flags','options' => \Shabbat\Scheduling\Permissions::BLOCK_FLAGS]); + } + if ($field === 'fixed_times') { + return array_replace($base, ['kind' => 'times','options' => [['09:00'],['09:00','13:00'],['09:00','12:00','15:00'],[]]]); + } + if ($field === 'sunset_offsets') { + return array_replace($base, ['kind' => 'minutes_list','options' => [[60,30,15],[60,30],[30],[15],[]],'min' => 1,'max' => 360]); + } + if (in_array($field, ['havdalah_value','nightfall_value'], true)) { + return array_replace($base, ['kind' => 'float','options' => [8.5,7.083,42,50,72],'min' => 0.001,'max' => 120]); + } + if (str_ends_with($field, '_time') || in_array($field, ['time','window_start','window_end','quiet_start','quiet_end'], true)) { + $nullable = in_array($field, ['quiet_start','quiet_end'], true); + $times = $field === 'times_alert_time' ? ['10:00','11:00','12:00','12:30','13:00','13:30','14:00'] + : ['06:00','07:00','08:00','09:00','10:00','12:00','13:30','16:00','18:00','20:00','21:00','22:00']; + return array_replace($base, ['kind' => 'time','options' => $nullable ? array_merge([null], $times) : $times,'nullable' => $nullable]); + } + $integers = [ + 'candle_minutes' => [1,120,[null,18,20,30,40,60],true], + 'close_before_candles_minutes' => [0,180,[0,5,10,15,30,60],false], + 'open_after_havdalah_minutes' => [0,180,[0,5,10,15,30,60],false], + 'times_alert_before_minutes' => [0,360,[0,15,30,60,90,120],false], + 'interval_minutes' => [5,360,[null,15,30,60,90,120],true], + 'max_per_day' => [1,24,[1,2,3,4,6,12,24],false], + 'min_spacing_minutes' => [5,360,[5,10,15,30,60,90,120],false], + 'sunset_safety_minutes' => [1,120,[5,10,15,20,30,60],false], + 'after_nightfall_minutes' => [0,240,[0,5,10,15,20,30,60],false], + 'after_open_minutes' => [0,180,[0,5,10,15,30,60],false], + 'target_topic_id' => [1,2147483647,[null],true], + ]; + if (isset($integers[$field])) { + [$min,$max,$presets,$nullable] = $integers[$field]; + return array_replace($base, ['kind' => 'integer','options' => $presets,'min' => $min,'max' => $max,'nullable' => $nullable]); + } + return $field === 'ack_label' ? array_replace($base, ['max' => 48,'options' => [null],'nullable' => true]) : array_replace($base, ['max' => 4000]); + } + + public static function key(string $module, string $field): string + { + self::definition($module, $field); + return $module === 'shabbat' || in_array($field, ['quiet_start','quiet_end'], true) ? $field : $module . '_' . $field; + } + + /** Typed button choices and single-question inputs pass through the existing parser exactly once. */ + public static function changes(string $module, string $field, mixed $input, array $context, callable $parseForm): array + { + $definition = self::definition($module, $field, isset($context['group_id'])); + $kind = $definition['kind']; + if ($input === null) { + if (!$definition['nullable']) { + throw new \InvalidArgumentException('This setting cannot be disabled.'); + } + $serialized = $field === 'ack_label' ? '' : ($field === 'candle_minutes' ? 'city' : 'off'); + } elseif ($kind === 'bool') { + if (!is_bool($input)) { + throw new \InvalidArgumentException('Use the on or off button.'); + } + $serialized = $input ? 'true' : 'false'; + } elseif (in_array($kind, ['days','times','minutes_list','flags'], true)) { + if (is_string($input)) { + $input = trim($input) === '' ? [] : array_map('trim', explode(',', $input)); + } + if (!is_array($input) || !array_is_list($input)) { + throw new \InvalidArgumentException('Expected a list.'); + } + foreach ($input as $item) { + if (!is_scalar($item) || str_contains((string)$item, ',') || str_contains((string)$item, ';')) { + throw new \InvalidArgumentException('Invalid list item.'); + } + } + $serialized = implode(',', $input); + } elseif (is_string($input) || is_int($input) || is_float($input)) { + $serialized = (string)$input; + } else { + throw new \InvalidArgumentException('Invalid setting input.'); + } + $form = $field . '=' . $serialized; + $methodField = $module === 'shabbat' ? 'havdalah_method' : 'nightfall_method'; + $valueField = $module === 'shabbat' ? 'havdalah_value' : 'nightfall_value'; + if (in_array($field, ['havdalah_method','nightfall_method'], true)) { + $valueKey = self::key($module, $valueField); + $value = $context[$valueKey] ?? 8.5; + if ($input === 'degrees' && !in_array((float)$value, [8.5,7.083], true)) { + $form .= ';' . $valueField . '=8.5'; + } elseif ($input === 'minutes' && (!is_numeric($value) || floor((float)$value) !== (float)$value)) { + $form .= ';' . $valueField . '=42'; + } + } elseif (in_array($field, ['havdalah_value','nightfall_value'], true)) { + if (!is_numeric($input) || !is_finite((float)$input)) { + throw new \InvalidArgumentException('Invalid nightfall value.'); + } + $value = (float)$input; + $method = in_array($value, [8.5,7.083], true) ? 'degrees' : 'minutes'; + if ($method === 'minutes' && floor($value) !== $value) { + throw new \InvalidArgumentException('Fixed nightfall minutes must be whole.'); + } + $form .= ';' . $methodField . '=' . $method; + } + $result = $parseForm($module, $form); + if (!is_array($result)) { + throw new \LogicException('Settings parser must return normalized changes.'); + } + return $result; + } + + public static function current(string $module, string $field, array $context, string $language): string + { + $value = $context[self::key($module, $field)] ?? null; + return self::valueLabel($module, $field, $value, $language); + } + + public static function valueLabel(string $module, string $field, mixed $value, string $language): string + { + $language = Lang::canonical($language); + $lang = self::$lang ??= new Lang(); + if ($field === 'ack_label' && ($value === null || $value === '')) { + return $lang->text($language, 'tefillin_ack'); + } + if ($value === null) { + return $lang->text($language, $field === 'candle_minutes' ? 'city_custom' : 'off'); + } + if (is_bool($value)) { + return $lang->text($language, $value ? 'on' : 'off'); + } + if ($field === 'template') { + return $lang->text($language, 'messages'); + } + if ($field === 'lock_block_flags' && is_array($value)) { + return $value === [] ? $lang->text($language, 'off') + : implode(', ', array_map(static fn(string $flag): string => $lang->text($language, 'permission_' . $flag), $value)); + } + if ($field === 'days' && is_array($value)) { + return $value === [] ? $lang->text($language, 'off') : implode(', ', array_map(static fn(int $day): string => self::DAYS[$language][$day] ?? throw new \InvalidArgumentException('Invalid weekday.'), $value)); + } + if (is_array($value)) { + return $value === [] ? $lang->text($language, 'off') : implode(', ', $value); + } + if (in_array($field, ['havdalah_value', 'nightfall_value'], true) && is_numeric($value)) { + $number = (string) $value; + if (in_array($language, ['ru', 'fr', 'es', 'pt-br'], true)) { + $number = str_replace('.', ',', $number); + } + return $number . ' ' . self::ENUM_LABELS[$language][in_array((float) $value, [8.5, 7.083], true) ? 'degrees' : 'minutes']; + } + if (is_string($value) && isset(self::ENUM_LABELS[$language][$value])) { + return self::ENUM_LABELS[$language][$value]; + } + return (string)$value; + } + + public static function dayLabel(int $day, string $language): string + { + return self::DAYS[Lang::canonical($language)][$day] ?? throw new \InvalidArgumentException('Invalid weekday.'); + } +} diff --git a/src/UI/StatisticsController.php b/src/UI/StatisticsController.php new file mode 100644 index 0000000..db7d18b --- /dev/null +++ b/src/UI/StatisticsController.php @@ -0,0 +1,77 @@ + AdminDirectoryText::text($language, $key); + $counts = $this->counts(); + $lines = ['' . $t('stats_overview') . '', '']; + if ($tab === 'overview') { + $lines[] = '' . $t('stats_groups') . ''; + foreach (['known' => 'registered_groups', 'active' => 'active_groups', 'locking' => 'locking', 'locked' => 'locked'] as $key => $label) { + $lines[] = $t($label) . ': ' . number_format($counts[$key]) . ''; + } + $lines[] = ''; + foreach (['off', 'messages', 'lock', 'lock_messages'] as $mode) { + $lines[] = $t('mode_' . $mode) . ': ' . number_format($counts[$mode]) . ''; + } + $lines[] = "\n" . $t('stats_public_note') . ''; + } else { + $lines[] = '' . $t('stats_modules') . ''; + foreach (['times_alert' => 'shabbat', 'zmanim' => 'zmanim', 'daf' => 'daf', 'tefillin' => 'tefillin', 'omer' => 'omer'] as $key => $label) { + $lines[] = $this->lang->text($language, $label) . ': ' . number_format($counts[$key]) . ''; + } + $lines[] = "\n" . $t('stats_public_note') . ''; + } + $lines[] = "\n" . $t('stats_refresh_at') . ': ' + . $this->clock->now()->setTimezone(new \DateTimeZone($timezone))->format('Y-m-d H:i T') . ''; + $buttons = []; + foreach (['overview' => 'back_summary', 'modules' => 'stats_modules'] as $destination => $label) { + $buttons[] = ['_' => 'keyboardButtonCallback', 'text' => $t($label), 'data' => 'ps:' . $destination, + 'style' => ['_' => 'keyboardButtonStyle'] + ($tab === $destination ? ['bg_primary' => true] : [])]; + } + return ['text' => implode("\n", $lines), 'markup' => ['_' => 'replyInlineMarkup', 'rows' => [ + ['_' => 'keyboardButtonRow', 'buttons' => $buttons], + ['_' => 'keyboardButtonRow', 'buttons' => [ + ['_' => 'keyboardButtonCallback', 'text' => $t('refresh'), 'data' => 'ps:' . $tab], + ['_' => 'keyboardButtonCallback', 'text' => $this->lang->text($language, 'back'), 'data' => 'sb:start'], + ]], + ]]]; + } + + public function counts(): array + { + $row = $this->db->pdo()->query("SELECT COUNT(*) known, + COALESCE(SUM(g.active),0) active, + COALESCE(SUM(g.active=1 AND s.mode IN ('lock','lock_messages')),0) locking, + COALESCE(SUM(g.active=1 AND s.mode='off'),0) off, + COALESCE(SUM(g.active=1 AND s.mode='messages'),0) messages, + COALESCE(SUM(g.active=1 AND s.mode='lock'),0) lock, + COALESCE(SUM(g.active=1 AND s.mode='lock_messages'),0) lock_messages, + COALESCE(SUM(g.active=1 AND s.times_alert_enabled=1 AND s.mode IN ('messages','lock_messages')),0) times_alert, + COALESCE(SUM(g.active=1 AND s.zmanim_enabled=1),0) zmanim, + COALESCE(SUM(g.active=1 AND s.daf_enabled=1),0) daf, + COALESCE(SUM(g.active=1 AND s.tefillin_enabled=1),0) tefillin, + COALESCE(SUM(g.active=1 AND s.omer_enabled=1),0) omer, + (SELECT COUNT(*) FROM group_lock_state WHERE status='locked') locked + FROM groups g JOIN group_settings s USING(group_id)")->fetch(); + return array_map(static fn(mixed $value): int => (int)$value, $row); + } +} diff --git a/src/UI/SubscriberController.php b/src/UI/SubscriberController.php new file mode 100644 index 0000000..d742707 --- /dev/null +++ b/src/UI/SubscriberController.php @@ -0,0 +1,410 @@ +authorize = \Closure::fromCallable($authorize); + } + + public function callback(string $viewer, string $data): ?array + { + if (!str_starts_with($data, 'sd:')) { + return null; + } + $this->allow($viewer, str_starts_with($data, 'sd:stats') ? 'statistics' : 'subscribers'); + if (preg_match('/^sd:auth:(-?[1-9][0-9]{0,19}):(0|1)$/D', $data, $match)) { + if (!(($this->authorize)($viewer, 'broadcast', true))) { + throw new \RuntimeException('Broadcast mutation permission required.'); + } + return ['action' => 'authorize_peer', 'id' => $match[1], 'authorized' => $match[2] === '1']; + } + if (preg_match('/^sd:stats(?::(audience|activity|groups|delivery))?$/D', $data, $match)) { + return $this->statistics($viewer, $match[1] ?? 'overview'); + } + if (preg_match('/^sd:list:([0-9]{1,6})$/D', $data, $match)) { + $this->directory->saveView($viewer, ['page' => (int) $match[1], 'prompt' => null]); + return $this->listing($viewer); + } + if (preg_match('/^sd:view:(-?[1-9][0-9]{0,19}):([0-9]{1,6})$/D', $data, $match)) { + $this->directory->saveView($viewer, ['page' => (int) $match[2], 'prompt' => null]); + return $this->profile($viewer, $match[1]); + } + if (preg_match('/^sd:value:(-?[1-9][0-9]{0,19}):([0-9]{1,6}):([0-9]{1,6})$/D', $data, $match)) { + return $this->valuePage($viewer, $match[1], (int) $match[2], (int) $match[3]); + } + if (preg_match('/^sd:data:(-?[1-9][0-9]{0,19}):([0-9]{1,6})$/D', $data, $match)) { + return $this->settingsPage($viewer, $match[1], (int) $match[2]); + } + if (preg_match('/^sd:roles:(-?[1-9][0-9]{0,19}):([0-9]{1,6})$/D', $data, $match)) { + return $this->rolesPage($viewer, $match[1], (int) $match[2]); + } + if ($data === 'sd:search' || $data === 'sd:page') { + $prompt = $data === 'sd:search' ? 'search' : 'page'; + $this->directory->saveView($viewer, ['prompt' => $prompt]); + return $this->frame($this->t($viewer, $prompt . '_prompt'), [[$this->button($viewer, 'cancel', 'sd:list:' . $this->directory->view($viewer)['page'], 'bg_danger')]]); + } + if ($data === 'sd:sort') { + $view = $this->directory->view($viewer); + $index = array_search($view['sort'], SubscriberDirectory::SORTS, true); + $this->directory->saveView($viewer, ['sort' => SubscriberDirectory::SORTS[((int) $index + 1) % count(SubscriberDirectory::SORTS)], 'page' => 0, 'prompt' => null]); + return $this->listing($viewer); + } + if ($data === 'sd:clear') { + $this->directory->saveView($viewer, ['search' => '', 'page' => 0, 'prompt' => null]); + return $this->listing($viewer); + } + if ($data === 'sd:filters') { + $rows = []; + foreach (SubscriberDirectory::FILTERS as $filter) { + $rows[] = [$this->button($viewer, $filter, 'sd:filter:' . $filter)]; + } + $rows[] = [$this->button($viewer, 'back', 'sd:list:' . $this->directory->view($viewer)['page'])]; + return $this->frame('' . $this->t($viewer, 'filter') . '', $rows); + } + if (preg_match('/^sd:filter:(all|users|groups|channels|bots)$/D', $data, $match)) { + $this->directory->saveView($viewer, ['filter' => $match[1], 'page' => 0, 'prompt' => null]); + return $this->listing($viewer); + } + throw new \InvalidArgumentException('Unknown subscriber callback.'); + } + + /** Commands always retain their own routes instead of being captured by an old prompt. */ + public function input(string $viewer, string $text): ?array + { + if (str_starts_with(ltrim($text), '/')) { + return null; + } + $view = $this->directory->view($viewer); + if ($view['prompt'] === null) { + return null; + } + $this->allow($viewer, 'subscribers'); + if ($view['prompt'] === 'search') { + $this->directory->saveView($viewer, ['search' => trim($text) === '-' ? '' : trim($text), 'page' => 0, 'prompt' => null]); + } else { + if (!preg_match('/^[1-9][0-9]{0,5}$/D', trim($text))) { + throw new \InvalidArgumentException('Invalid directory page number.'); + } + $this->directory->saveView($viewer, ['page' => (int) trim($text) - 1, 'prompt' => null]); + } + return $this->listing($viewer); + } + + public function listing(string $viewer): array + { + $this->allow($viewer, 'subscribers'); + $view = $this->directory->view($viewer); + $page = $this->directory->page((int) $view['page'], 10, $view['search'], $view['sort'], $view['filter']); + $this->directory->saveView($viewer, ['page' => $page['page']]); + $text = '' . $this->t($viewer, 'directory') . ' +' + . $this->t($viewer, 'total') . ': ' . number_format($page['total']) . ' +' + . $this->t($viewer, 'filter') . ': ' . $this->t($viewer, $view['filter']) . ' +' + . $this->t($viewer, 'sort') . ': ' . $this->t($viewer, $view['sort']) . ''; + if ($view['search'] !== '') { + $text .= " +" . $this->t($viewer, 'search') . ': ' . self::html($view['search']) . ''; + } + $rows = []; + foreach ($page['items'] as $item) { + $icon = match ($item['type']) { + 'user' => '๐Ÿ‘ค', 'bot' => '๐Ÿค–', 'channel' => '๐Ÿ“ข', 'chat', 'supergroup', 'group' => '๐Ÿ‘ฅ', default => 'โ”' + }; + $rows[] = [['text' => $icon . ' ' . mb_substr($item['name'], 0, 55), 'data' => 'sd:view:' . $item['id'] . ':' . $page['page']]]; + } + if ($rows === []) { + $text .= " + +" . $this->t($viewer, 'empty'); + } + $nav = []; + if ($page['page'] > 0) { + $nav[] = $this->button($viewer, 'previous', 'sd:list:' . ($page['page'] - 1)); + } + $nav[] = ['text' => ($page['page'] + 1) . '/' . $page['pages'], 'data' => 'sd:page']; + if ($page['page'] + 1 < $page['pages']) { + $nav[] = $this->button($viewer, 'next', 'sd:list:' . ($page['page'] + 1)); + } + $rows[] = $nav; + $rows[] = [$this->button($viewer, 'search', 'sd:search', 'bg_primary'), $this->button($viewer, 'sort', 'sd:sort')]; + $rows[] = [$this->button($viewer, 'filter', 'sd:filters'), $this->button($viewer, 'clear', 'sd:clear')]; + $rows[] = [$this->button($viewer, 'refresh', 'sd:list:' . $page['page']), $this->button($viewer, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame(str_replace('\ +', " +", $text), $rows); + } + + public function profile(string $viewer, string $id): array + { + $this->allow($viewer, 'subscribers'); + $item = $this->directory->detail($id); + if ($item === null) { + return $this->listing($viewer); + } + $p = $item['profile']; + $lines = ['' . $this->t($viewer, 'profile') . '', '' . self::html(mb_substr($item['name'], 0, 64)) . '']; + foreach ( + ['id' => $item['id'], 'type' => $item['type'], 'username' => $p['username'] ?? null, + 'first_name' => $p['first_name'] ?? null, 'last_name' => $p['last_name'] ?? null, + 'language' => $p['language_code'] ?? null] as $key => $value + ) { + $lines[] = $this->field($viewer, $key, $value); + } + foreach ( + ['joined' => $item['joined_at'], 'first_activity' => $p['first_activity_at'] ?? null, 'last_activity' => $p['last_activity_at'] ?? null, + 'last_start' => $p['last_start_at'] ?? null] as $key => $value + ) { + $lines[] = $this->field($viewer, $key, $this->date($viewer, $value)); + } + foreach (['activities' => $p['activity_count'] ?? 0, 'starts' => $p['start_count'] ?? 0] as $key => $value) { + $lines[] = $this->field($viewer, $key, number_format((int) $value)); + } + foreach (['premium' => 'is_premium', 'deleted' => 'is_deleted', 'contact' => 'is_contact'] as $key => $column) { + $lines[] = $this->field($viewer, $key, $this->yesNo($viewer, $p[$column] ?? null)); + } + $lines[] = $this->field($viewer, 'group_admin', $this->t($viewer, $item['known_group_admin'] ? 'yes' : 'unknown')); + if ($item['preferences'] !== null) { + foreach (['language' => 'language', 'location' => 'location_name', 'timezone' => 'timezone_id'] as $key => $column) { + $lines[] = $this->field($viewer, $key, $item['preferences'][$column]); + } + } + if ($item['group'] !== null) { + $lines[] = $this->field($viewer, 'registered', $this->t($viewer, 'yes')); + $lines[] = $this->field($viewer, 'enabled', $this->yesNo($viewer, $item['group']['active'])); + $lines[] = $this->field($viewer, 'mode', $item['group']['mode']); + } + if (in_array($item['type'], ['chat', 'supergroup', 'group', 'channel'], true)) { + $lines[] = $this->field($viewer, 'authorized', $this->yesNo($viewer, $item['broadcast_authorized'])); + } + $lines[] = " +" . $this->t($viewer, 'historical') . ''; + $rows = []; + if ($item['profile'] !== [] || $item['preferences'] !== null || $item['group'] !== null) { + $rows[] = [$this->button($viewer, 'profile', 'sd:data:' . $id . ':0')]; + } + if ($item['group_roles'] !== []) { + $rows[] = [$this->button($viewer, 'role_observation', 'sd:roles:' . $id . ':0')]; + } + if (in_array($item['type'], ['chat', 'supergroup', 'group', 'channel'], true) && (($this->authorize)($viewer, 'broadcast', true))) { + $rows[] = [$this->button($viewer, $item['broadcast_authorized'] ? 'unauthorize' : 'authorize', 'sd:auth:' . $id . ':' . ($item['broadcast_authorized'] ? '0' : '1'), $item['broadcast_authorized'] ? 'bg_danger' : 'bg_primary')]; + } + $rows[] = [$this->button($viewer, 'refresh', 'sd:view:' . $id . ':' . $this->directory->view($viewer)['page'])]; + $rows[] = [$this->button($viewer, 'back', 'sd:list:' . $this->directory->view($viewer)['page'])]; + return $this->frame(implode(" +", $lines), $rows); + } + + public function statistics(string $viewer, string $tab = 'overview'): array + { + $this->allow($viewer, 'statistics'); + $sections = [ + 'audience' => ['users','bots','chats','supergroups','groups','channels','unknown','total'], + 'activity' => ['active_24h','active_7d','active_30d','starts','activities'], + 'groups' => ['registered_groups','active_groups','locking','locked','recovery'], + 'delivery' => ['pending_jobs','failed_jobs'], + ]; + if ($tab !== 'overview' && !isset($sections[$tab])) { + throw new \InvalidArgumentException('Unknown statistics section.'); + } + $stats = $this->directory->stats(); + $lines = ['' . $this->t($viewer, 'stats') . '', '']; + $display = $tab === 'overview' ? [ + 'audience' => ['total','users','channels'], + 'activity' => ['active_24h','active_7d'], + 'groups' => ['active_groups','locking','locked'], + 'delivery' => ['pending_jobs','failed_jobs'], + ] : [$tab => $sections[$tab]]; + foreach ($display as $section => $keys) { + $lines[] = '' . $this->t($viewer, 'stats_' . $section) . ''; + foreach ($keys as $key) { + $label = match ($key) { + 'chats' => 'basic_groups', 'supergroups' => 'supergroups', 'groups' => 'other_groups', + default => $key, + }; + $lines[] = $this->t($viewer, $label) . ': ' . number_format($stats[$key === 'activities' ? 'activity' : $key]) . ''; + } + $lines[] = ''; + } + $lines[] = '' . $this->t($viewer, 'stats_note') . ''; + $lines[] = '' . $this->t($viewer, 'stats_health_note') . ''; + $rows = []; + foreach (array_keys($sections) as $section) { + $rows[] = $this->button($viewer, 'stats_' . $section, 'sd:stats:' . $section, $tab === $section ? 'bg_primary' : null); + } + $rows = array_chunk($rows, 2); + if ($tab !== 'overview') { + $rows[] = [$this->button($viewer, 'back_summary', 'sd:stats')]; + } + $rows[] = [$this->button($viewer, 'refresh', 'sd:stats' . ($tab === 'overview' ? '' : ':' . $tab)), $this->button($viewer, 'back', 'ื—ื–ืจื”ืžื ื”ืœ')]; + return $this->frame(implode("\n", $lines), $rows); + } + + private function settingsPage(string $viewer, string $id, int $page): array + { + $item = $this->directory->detail($id); + if ($item === null) { + return $this->listing($viewer); + } + $data = $this->profileData($item); + $pages = max(1, (int) ceil(count($data) / 12)); + $page = min($page, $pages - 1); + $lines = ['' . self::html(mb_substr($item['name'], 0, 64)) . '', $this->t($viewer, 'page') . ': ' . ($page + 1) . '/' . $pages, '']; + $rows = []; + $index = $page * 12; + foreach (array_slice($data, $page * 12, 12, true) as $key => $value) { + $value = $value === null ? $this->t($viewer, 'unknown') : (string) $value; + $lines[] = '' . self::html($key) . ': ' . self::html(mb_substr($value, 0, 32)) . (mb_strlen($value) > 32 ? 'โ€ฆ' : '') . ''; + if (mb_strlen($value) > 32) { + $rows[] = [['text' => '๐Ÿ”Ž ' . $key, 'data' => 'sd:value:' . $id . ':' . $index . ':0']]; + } + $index++; + } + if ($page > 0) { + $rows[] = [$this->button($viewer, 'previous', 'sd:data:' . $id . ':' . ($page - 1))]; + } + if ($page + 1 < $pages) { + $rows[] = [$this->button($viewer, 'next', 'sd:data:' . $id . ':' . ($page + 1))]; + } + $rows[] = [$this->button($viewer, 'back', 'sd:view:' . $id . ':' . $this->directory->view($viewer)['page'])]; + return $this->frame(implode(" +", $lines), $rows); + } + + private function rolesPage(string $viewer, string $id, int $page): array + { + $item = $this->directory->detail($id); + if ($item === null) { + return $this->listing($viewer); + } + $roles = $this->directory->roles($id, $page, 5); + $pages = $roles['pages']; + $page = min($page, $pages - 1); + $lines = ['' . $this->t($viewer, 'role_observation') . '', '' . $this->t($viewer, 'historical') . '']; + foreach ($roles['items'] as $role) { + $lines[] = " +" . self::html(mb_substr($role['title'] ?: $role['group_id'], 0, 60)) . ' ' . self::html($role['group_id']) . '' + . " +" . self::html($role['role']) . ' - ' . $this->yesNo($viewer, $role['is_admin']) . ' - ' . $this->date($viewer, $role['checked_at']); + } + $rows = []; + if ($page > 0) { + $rows[] = [$this->button($viewer, 'previous', 'sd:roles:' . $id . ':' . ($page - 1))]; + } + if ($page + 1 < $pages) { + $rows[] = [$this->button($viewer, 'next', 'sd:roles:' . $id . ':' . ($page + 1))]; + } + $rows[] = [$this->button($viewer, 'back', 'sd:view:' . $id . ':' . $this->directory->view($viewer)['page'])]; + return $this->frame(implode(" +", $lines), $rows); + } + + private function profileData(array $item): array + { + $data = ['name' => $item['name'], 'type' => $item['type'], 'joined_at' => $item['joined_at']]; + foreach (['profile' => $item['profile'], 'preferences' => $item['preferences'] ?? [], 'group' => $item['group'] ?? []] as $prefix => $fields) { + foreach ($fields as $key => $value) { + $data[$prefix . '.' . $key] = $value; + } + } + return $data; + } + + private function valuePage(string $viewer, string $id, int $index, int $page): array + { + $item = $this->directory->detail($id); + if ($item === null) { + return $this->listing($viewer); + } + $data = $this->profileData($item); + $keys = array_keys($data); + if (!isset($keys[$index])) { + throw new \InvalidArgumentException('Unknown subscriber detail field.'); + } + $key = $keys[$index]; + $value = $data[$key] === null ? $this->t($viewer, 'unknown') : (string) $data[$key]; + $pages = max(1, (int) ceil(mb_strlen($value) / 400)); + $page = min($page, $pages - 1); + $text = '' . self::html($key) . '\n' . $this->t($viewer, 'page') . ': ' . ($page + 1) . '/' . $pages + . "\n\n" . self::html(mb_substr($value, $page * 400, 400)) . ''; + $rows = []; + if ($page > 0) { + $rows[] = [$this->button($viewer, 'previous', 'sd:value:' . $id . ':' . $index . ':' . ($page - 1))]; + } + if ($page + 1 < $pages) { + $rows[] = [$this->button($viewer, 'next', 'sd:value:' . $id . ':' . $index . ':' . ($page + 1))]; + } + $rows[] = [$this->button($viewer, 'back', 'sd:data:' . $id . ':' . (int) floor($index / 12))]; + return $this->frame(str_replace('\\n', "\n", $text), $rows); + } + + private function allow(string $viewer, string $capability): void + { + if (!(($this->authorize)($viewer, $capability))) { + throw new \RuntimeException('Administration permission required.'); + } + } + + private function t(string $viewer, string $key): string + { + return AdminDirectoryText::text($this->settings->getUser($viewer)['language'], $key); + } + + private function field(string $viewer, string $key, mixed $value): string + { + return $this->t($viewer, $key) . ': ' . self::html($value === null || $value === '' ? $this->t($viewer, 'unknown') : mb_substr((string) $value, 0, 80) . (mb_strlen((string) $value) > 80 ? 'โ€ฆ' : '')) . ''; + } + + private function yesNo(string $viewer, mixed $value): string + { + return $this->t($viewer, $value === null ? 'unknown' : ((bool) $value ? 'yes' : 'no')); + } + + private function date(string $viewer, mixed $value): ?string + { + return is_numeric($value) && (int) $value > 0 ? (new \DateTimeImmutable('@' . (int) $value))->setTimezone(new \DateTimeZone($this->settings->getUser($viewer)['timezone_id']))->format('Y-m-d H:i T') : null; + } + + private function button(string $viewer, string $key, string $data, ?string $color = null): array + { + return ['text' => $this->t($viewer, $key), 'data' => $data, 'color' => $color]; + } + + private function frame(string $text, array $rows): array + { + $markup = ['_' => 'replyInlineMarkup', 'rows' => []]; + foreach ($rows as $row) { + $buttons = []; + foreach ($row as $button) { + if (strlen($button['data']) > 64) { + throw new \InvalidArgumentException('Oversize directory callback.'); + } + $native = ['_' => 'keyboardButtonCallback', 'text' => $button['text'], 'data' => $button['data']]; + if (isset($button['color'])) { + $native['style'] = ['_' => 'keyboardButtonStyle', $button['color'] => true]; + } + $buttons[] = $native; + } + $markup['rows'][] = ['_' => 'keyboardButtonRow', 'buttons' => $buttons]; + } + return ['text' => $text, 'markup' => $markup]; + } + + private static function html(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/src/UI/SystemLinks.php b/src/UI/SystemLinks.php new file mode 100644 index 0000000..5d168ec --- /dev/null +++ b/src/UI/SystemLinks.php @@ -0,0 +1,69 @@ + ['action' => 'start', 'label' => 'home'], + 'preferences' => ['action' => 'preferences', 'label' => 'preferences'], + 'language' => ['action' => 'language', 'label' => 'language'], + 'groups' => ['action' => 'groups', 'label' => 'groups'], + 'shabbat' => ['action' => 'shabbat', 'label' => 'shabbat'], + 'zmanim' => ['action' => 'zmanim', 'label' => 'zmanim'], + 'daf' => ['action' => 'daf', 'label' => 'daf'], + 'omer' => ['action' => 'omer', 'label' => 'omer'], + 'help' => ['action' => 'help', 'label' => 'help'], + 'support' => ['action' => 'donate', 'label' => 'support'], + 'source' => ['action' => 'source', 'label' => 'source'], + 'license' => ['action' => 'license', 'label' => 'license'], + 'version' => ['action' => 'version', 'label' => 'version'], + 'admin' => ['action' => 'admin', 'label' => 'panel', 'permission' => 'panel'], + 'admin_stats' => ['action' => 'statistics', 'label' => 'statistics', 'permission' => 'statistics'], + 'admin_subscribers' => ['action' => 'subscribers', 'label' => 'subscribers', 'permission' => 'subscribers'], + 'admin_broadcast' => ['action' => 'broadcast', 'label' => 'broadcast', 'permission' => 'broadcast'], + 'admin_managers' => ['action' => 'managers', 'label' => 'managers', 'permission' => 'manage_admins'], + 'admin_links' => ['action' => 'system_links', 'label' => 'system_links', 'permission' => 'system_links'], + 'admin_tracking' => ['action' => 'tracking_links', 'label' => 'tracking_links', 'permission' => 'tracking_links'], + 'admin_commands' => ['action' => 'commands', 'label' => 'commands', 'permission' => 'commands'], + ]; + public static function url(string $username, string $parameter): string + { + if (!preg_match('/^[a-zA-Z0-9_]{5,32}$/D', $username) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $parameter)) { + throw new \InvalidArgumentException('Invalid bot deep link.'); + } + return 'https://t.me/' . $username . '?start=' . rawurlencode($parameter); + } + public static function isReserved(string $parameter): bool + { + $parameter = strtolower($parameter); + return isset(self::DEFINITIONS[$parameter]) || in_array($parameter, ['start','settings','donate','shabat','tefillin','adminpanel'], true) + || preg_match('/^(?:g_|loc_|sb_|tpl_|v3_|adm:|trk:)/D', $parameter) === 1; + } + public static function resolve(string $parameter, string $user, bool $private, AdminAccessRepository $access): ?string + { + if (!$private) { + return null; + } + $definition = self::DEFINITIONS[strtolower($parameter)] ?? null; + if ($definition === null) { + return null; + } + if (isset($definition['permission'])) { + if ($definition['permission'] === 'panel') { + if (!$access->isAdmin($user)) { + throw new \RuntimeException('Bot administrator access required.'); + } + } else { + $access->require($user, $definition['permission']); + } + } + return $definition['action']; + } +} diff --git a/src/UI/UiGateway.php b/src/UI/UiGateway.php new file mode 100644 index 0000000..d4aad02 --- /dev/null +++ b/src/UI/UiGateway.php @@ -0,0 +1,16 @@ +migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $this->gateway = new GuidedUiGateway(); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('No navigation HTTP.'); + } + }; + $this->app = new Application( + $this->gateway, + $clock, + $db, + new CalendarService(new HebcalProvider($http), $clock), + new GeoNames($http, $clock, ''), + rpc:static fn(string $method): mixed => $method === 'payments.exportInvoice' ? ['url' => 'https://t.me/invoice-fixture'] : true, + configuredOwners:fn(): array => $this->owner ? ['1'] : [] + ); + $this->app->settings->ensureGroup('-1001', 'Fixture group'); + $this->bridge = new AdminNavigationFixture($this->app); + } + + public function testPublicStatisticsAreEditableWithoutManagerRoleAndDoNotLeakGroupIdentity(): void + { + $this->owner = false; + self::assertTrue($this->bridge->callback('ps:overview', user: 2)); + self::assertTrue($this->bridge->callback('ps:modules', user: 2)); + self::assertSame([], $this->gateway->sent); + self::assertCount(2, $this->gateway->edited); + foreach ($this->gateway->edited as $edit) { + self::assertSame(200, $edit['id']); + self::assertStringNotContainsString('Fixture group', $edit['text']); + self::assertStringNotContainsString('-1001', $edit['text']); + } + self::assertTrue($this->bridge->callback('ps:modules', user: 2, chat: -1001)); + self::assertCount(2, $this->gateway->edited, 'Group callbacks cannot edit a private statistics panel.'); + } + + private function competingInputs(): void + { + $draft = $this->bridge->draft(); + $draft->begin('1', 'broadcast1'); + $draft->capture('1', 'Keep this draft'); + $draft->begin('1', 'broadcast1'); + $this->app->subscribers->saveView('1', ['prompt' => 'search']); + $this->bridge->adminPrompt(); + } + + public function testDonationEarlyReturnClearsAllOtherActorInputsButKeepsDraftContent(): void + { + $this->competingInputs(); + $revision = $this->app->settings->getUser('1')['revision']; + $this->app->flows->create('1', 'user', null, $revision, 'settings_wizard', 600, ['panel_id' => 200]); + self::assertTrue($this->bridge->callback('v3:donate:custom')); + self::assertSame('', $this->bridge->draft()->mode('1')); + self::assertSame('Keep this draft', $this->bridge->draft()->payload('1')['text']); + self::assertNull($this->app->subscribers->view('1')['prompt']); + self::assertSame(0, (int)$this->app->db->pdo()->query("SELECT COUNT(*) FROM admin_panel_flows WHERE actor_id='1'")->fetchColumn()); + self::assertNull($this->app->flows->latest('1', 'settings_wizard')); + self::assertNotNull($this->app->flows->latest('1', 'donation_input')); + self::assertTrue($this->bridge->message('5')); + self::assertFalse($this->bridge->message('No accidental broadcast capture')); + self::assertSame([], $this->gateway->sent); + foreach ($this->gateway->edited as $edit) { + self::assertSame(200, $edit['id']); + } + } + + public function testBroadcastQuestionKeepsOnlyItsOwnInputAndEditsOriginalPanel(): void + { + $this->competingInputs(); + self::assertTrue($this->bridge->callback('bc:sd:custom')); + self::assertSame('broadcast_sd_custom', $this->bridge->draft()->mode('1')); + self::assertNull($this->app->subscribers->view('1')['prompt']); + self::assertSame(0, (int)$this->app->db->pdo()->query("SELECT COUNT(*) FROM admin_panel_flows WHERE actor_id='1'")->fetchColumn()); + self::assertTrue($this->bridge->message('12')); + self::assertSame(12, $this->bridge->draft()->state('1')['self_destruct_hours']); + self::assertSame('Keep this draft', $this->bridge->draft()->payload('1')['text']); + self::assertSame([], $this->gateway->sent); + self::assertSame(200, end($this->gateway->edited)['id']); + } + + public function testSubscriberQuestionCancelsBroadcastAndAdminCapture(): void + { + $this->competingInputs(); + self::assertTrue($this->bridge->callback('sd:search')); + self::assertSame('', $this->bridge->draft()->mode('1')); + self::assertSame('search', $this->app->subscribers->view('1')['prompt']); + self::assertTrue($this->bridge->message('Alice')); + self::assertSame('Alice', $this->app->subscribers->view('1')['search']); + self::assertSame('Keep this draft', $this->bridge->draft()->payload('1')['text']); + self::assertSame([], $this->gateway->sent); + } + + public function testTypedOperatorDestinationCancelsOldInputBeforeReturningTheNewMenu(): void + { + $this->competingInputs(); + self::assertTrue($this->bridge->message('/subscribers')); + self::assertSame('', $this->bridge->draft()->mode('1')); + self::assertNull($this->app->subscribers->view('1')['prompt']); + self::assertFalse($this->bridge->message('An ordinary message')); + self::assertCount(1, $this->gateway->sent, 'An explicit command opens one menu; later ordinary text does not send another.'); + } + + public function testForeignGroupCallbackDoesNotCancelPrivateInputAndActorsRemainIsolated(): void + { + $this->competingInputs(); + self::assertFalse($this->bridge->callback('sb:start', chat:-1001, frontend:false)); + self::assertSame('broadcast1', $this->bridge->draft()->mode('1')); + self::assertSame('search', $this->app->subscribers->view('1')['prompt']); + $this->app->settings->getUser('2'); + AdminStateFiles::write('data/2/grs1.txt', 'broadcast1'); + $this->app->subscribers->saveView('2', ['prompt' => 'search']); + self::assertFalse($this->bridge->callback('sb:start', user:2, frontend:false)); + self::assertFalse(AdminStateFiles::exists('data/2/grs1.txt')); + self::assertNull($this->app->subscribers->view('2')['prompt']); + self::assertSame('broadcast1', $this->bridge->draft()->mode('1')); + self::assertSame('search', $this->app->subscribers->view('1')['prompt']); + } + + public function testRevokedOperatorCanStillClearOnlyTheirOldPendingInput(): void + { + $this->competingInputs(); + $this->owner = false; + self::assertFalse($this->bridge->callback('sb:start', frontend:false)); + self::assertFalse(AdminStateFiles::exists('data/1/grs1.txt')); + self::assertNull($this->app->subscribers->view('1')['prompt']); + $this->owner = true; + self::assertSame('', $this->bridge->draft()->mode('1')); + self::assertSame('Keep this draft', $this->bridge->draft()->payload('1')['text']); + } + + public function testWizardConfirmationSurvivesAdminBridgeAndAppliesItsParentBoundSave(): void + { + $g = $this->app->settings->getGroup('-1001'); + $parent = $this->app->flows->create('1', 'group', '-1001', $g['revision'], 'settings_wizard', 600, ['module' => 'tefillin','field' => 'max_per_day','panel_id' => 200]); + $save = $this->app->flows->create('1', 'group', '-1001', $g['revision'], 'save', 600, ['changes' => ['tefillin_max_per_day' => 4],'source_wizard_token' => $parent]); + self::assertTrue($this->bridge->callback('sb:save:' . $save)); + self::assertSame(4, $this->app->settings->getGroup('-1001')['tefillin_max_per_day']); + self::assertSame(2, $this->app->settings->getGroup('-1001')['revision']); + self::assertNull($this->app->flows->peek($save, '1', 'save')); + self::assertSame([], $this->gateway->sent); + self::assertSame(200, end($this->gateway->edited)['id']); + } +} diff --git a/tests/AdminNavigationFixture.php b/tests/AdminNavigationFixture.php new file mode 100644 index 0000000..f13098a --- /dev/null +++ b/tests/AdminNavigationFixture.php @@ -0,0 +1,86 @@ +app; + } + + private function v3Text(string $user, string $key, array $values = []): string + { + return (new Lang())->text($this->app->settings->getUser($user)['language'], $key, $values); + } + + private function v3OperatorDraft(): OperatorDraft + { + return new OperatorDraft($this->app, fn(string $user, bool $mutate = false): bool => $mutate + ? $this->app->adminAccess->canMutate($user, 'broadcast') : $this->app->adminAccess->canView($user, 'broadcast')); + } + + private function v3OperatorUi(): OperatorController + { + return new OperatorController($this->app->settings, new Lang(), $this->app->adminAccess->isAdmin(...), $this->app->adminAccess); + } + + public function getSelf(): array + { + return ['id' => 999]; + } + + public function getInfo(int $id): array + { + return ['type' => 'user','User' => ['first_name' => 'Fixture user']]; + } + + public function draft(): OperatorDraft + { + return $this->v3OperatorDraft(); + } + + public function adminPrompt(): void + { + $this->v4Admin()->callback('1', 'adm:add'); + } + + public function callback(string $data, int $user = 1, ?int $chat = null, int $panel = 200, bool $frontend = true): bool + { + $chat ??= $user; + $class = new \ReflectionClass(ChatButtonQuery::class); + $query = $class->newInstanceWithoutConstructor(); + foreach (['data' => $data,'userId' => $user,'chatId' => $chat,'messageId' => $panel,'queryId' => 99,'chatInstance' => 777] as $key => $value) { + $class->getProperty($key)->getDeclaringClass()->getProperty($key)->setValue($query, $value); + } + $event = ['user_id' => (string)$user,'chat_id' => (string)$chat,'private' => $chat === $user,'message_id' => $panel,'data' => $data,'query_id' => '99']; + $handled = $this->v4HandleCallback($query, $event); + return $handled || ($frontend && $this->app->ui->handleCallback($event)); + } + + public function message(string $text, int $user = 1): bool + { + $class = new \ReflectionClass(PrivateMessage::class); + $message = $class->newInstanceWithoutConstructor(); + foreach (['message' => $text,'senderId' => $user,'chatId' => $user,'id' => 600] as $key => $value) { + $class->getProperty($key)->getDeclaringClass()->getProperty($key)->setValue($message, $value); + } + return $this->v4HandleMessage($message, ['user_id' => (string)$user,'private' => true,'text' => $text,'message_id' => 600,'entities' => []]); + } +} diff --git a/tests/AdminRightsTest.php b/tests/AdminRightsTest.php new file mode 100644 index 0000000..c750087 --- /dev/null +++ b/tests/AdminRightsTest.php @@ -0,0 +1,38 @@ + 'chatAdminRights', $flag => true]); + self::assertTrue($normalized[$flag]); + self::assertCount(17, $normalized); + } + } + + public function testUnknownNativeFlagIsNotSilentlyDropped(): void + { + $this->expectException(\RuntimeException::class); + AdminRights::normalize(['_' => 'chatAdminRights', 'future_unreviewed_right' => true]); + } + + public function testDiagnosticDoesNotAcceptStringsAsGrantedRights(): void + { + $this->expectException(\RuntimeException::class); + AdminRights::normalize(['_' => 'chatAdminRights', 'delete_messages' => 'true']); + } +} diff --git a/tests/AdminRolesLinksTest.php b/tests/AdminRolesLinksTest.php new file mode 100644 index 0000000..7c5cab0 --- /dev/null +++ b/tests/AdminRolesLinksTest.php @@ -0,0 +1,312 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->access = new AdminAccessRepository($this->db, $this->clock, static fn(): array => ['1']); + $this->tracking = new TrackingLinkRepository($this->db, $this->clock, $this->access); + $this->ui = new AdminController($this->db, $this->clock, $this->settings, $this->access, $this->tracking, 'FixtureBot'); + } + public function testImmutableOwnersAndOneTimeLegacyImport(): void + { + $this->access->importLegacyOnce(['1', '2', '-100123', 'invalid']); + self::assertSame(['1', '2'], $this->access->adminIds()); + self::assertTrue($this->access->canMutate('2', 'broadcast')); + $this->access->remove('1', '2', 1); + $this->access->importLegacyOnce(['2', '3']); + self::assertSame(['1'], $this->access->adminIds()); + foreach (['1', '0', '-1001'] as $id) { + try { + $this->access->add('1', $id, 'full', false, []); + self::fail('Protected or invalid ID added'); + } catch (\RuntimeException) { + self::assertNull($id === '1' ? null : $this->access->profile($id)); + } + } + self::assertSame('environment', $this->access->profile('1')['source']); + } + public function testViewOnlyAndUnknownPermissionsFailClosed(): void + { + $this->access->add('1', '2', 'limited', true, ['statistics' => true, 'broadcast' => true]); + self::assertTrue($this->access->canView('2', 'statistics')); + self::assertFalse($this->access->canMutate('2', 'broadcast')); + self::assertFalse($this->access->canView('2', 'manage_admins')); + self::assertFalse($this->access->canView('1', 'unknown')); + self::assertFalse($this->access->isAdmin('99')); + $this->expectException(\RuntimeException::class); + $this->access->add('2', '3', 'limited', false, []); + } + public function testLimitedManagerCannotGrantOrManageWiderAuthority(): void + { + $this->access->add('1', '2', 'limited', false, ['statistics' => true, 'add_admins' => true, 'manage_admins' => true]); + $this->access->add('2', '3', 'limited', true, ['statistics' => true]); + self::assertTrue($this->access->canView('3', 'statistics')); + foreach (['full', 'limited'] as $role) { + try { + $this->access->add('2', '4', $role, false, ['broadcast' => true]); + self::fail('Privilege escalation accepted'); + } catch (\RuntimeException) { + self::assertNull($this->access->profile('4')); + } + } + $this->access->add('1', '4', 'full', false, []); + $this->expectException(\RuntimeException::class); + $this->access->remove('2', '4', 1); + } + public function testTargetRevisionAndCurrentActorRoleAreCheckedAtMutation(): void + { + $this->access->add('1', '2', 'full', false, []); + $this->access->add('1', '3', 'limited', false, ['statistics' => true]); + $this->access->update('1', '3', 'limited', true, ['statistics' => true], 1); + try { + $this->access->update('2', '3', 'full', false, [], 1); + self::fail('Stale administrator save accepted'); + } catch (\RuntimeException) { + self::assertSame(2, $this->access->profile('3')['revision']); + } + $this->access->remove('1', '2', 1); + $this->expectException(\RuntimeException::class); + $this->access->update('2', '3', 'full', false, [], 2); + } + public function testManagerCannotChangeSelfEvenWithFullRole(): void + { + $this->access->add('1', '2', 'full', false, []); + $this->expectException(\RuntimeException::class); + $this->access->remove('2', '2', 1); + } + public function testAclAndAuditRollBackTogetherOnFailure(): void + { + $this->db->pdo()->exec("CREATE TRIGGER reject_admin_audit BEFORE INSERT ON audit_log WHEN NEW.action='operator.admin.add' BEGIN SELECT RAISE(ABORT,'fixture'); END"); + try { + $this->access->add('1', '2', 'full', false, []); + self::fail('Failed transaction succeeded'); + } catch (\PDOException) { + self::assertNull($this->access->profile('2')); + } + } + public function testAdministratorEditorIsPrivateBoundExpiringAndOneUse(): void + { + $this->ui->callback('1', 'adm:add'); + $editor = $this->ui->capture('1', '2'); + $save = $this->find($editor, 'adm:save:'); + self::assertLessThanOrEqual(64, strlen($save)); + self::assertFalse($this->access->isAdmin('2')); + try { + $this->ui->callback('2', $save); + self::fail('Other actor used editor'); + } catch (\RuntimeException) { + self::assertFalse($this->access->isAdmin('2')); + } + $this->ui->callback('1', $save); + self::assertTrue($this->access->profile('2')['view_only']); + try { + $this->ui->callback('1', $save); + self::fail('Editor replay accepted'); + } catch (\RuntimeException) { + self::assertSame(1, $this->access->profile('2')['revision']); + } + $this->ui->callback('1', 'adm:add'); + $editor = $this->ui->capture('1', '', '3'); + $this->clock->set(new \DateTimeImmutable('2026-10-15T12:11:00Z')); + $this->expectException(\RuntimeException::class); + $this->ui->callback('1', $this->find($editor, 'adm:save:')); + } + public function testPrivateGroupBoundaryAndCommandsDoNotBecomeAdminInput(): void + { + $this->ui->callback('1', 'adm:add'); + self::assertNull($this->ui->capture('1', '/help')); + self::assertNull($this->ui->capture('1', '2', null, false)); + self::assertFalse($this->access->isAdmin('2')); + $this->expectException(\RuntimeException::class); + $this->ui->callback('1', 'adm:list:0', false); + } + public function testReplacingDraftRevokesOldButtonsAndRevokingRolePreventsSave(): void + { + $this->access->add('1', '2', 'full', false, []); + $this->ui->callback('2', 'adm:add'); + $editor = $this->ui->capture('2', '3'); + $this->ui->callback('2', 'adm:add'); + try { + $this->ui->callback('2', $this->find($editor, 'adm:save:')); + self::fail('Replaced draft accepted'); + } catch (\RuntimeException) { + self::assertNull($this->access->profile('3')); + } + $editor = $this->ui->capture('2', '3'); + $this->access->remove('1', '2', 1); + $this->expectException(\RuntimeException::class); + $this->ui->callback('2', $this->find($editor, 'adm:save:')); + } + public function testTrackingCountsUniqueRepeatsDaysAndDeduplicatesRedelivery(): void + { + $p = $this->tracking->create('1', 'Community', 'Neighborhood', 'shabbat'); + $id = (int) $p['id']; + self::assertSame('shabbat', $this->tracking->recordStart('community', '123456789', 10)); + self::assertSame('shabbat', $this->tracking->recordStart('COMMUNITY', '123456789', 10)); + $this->tracking->recordStart('community', '123456789', 11); + $this->tracking->recordStart('community', '987654321', 12); + $p = $this->tracking->get('1', $id); + self::assertSame(3, (int) $p['total_clicks']); + self::assertSame(2, (int) $p['unique_users']); + self::assertSame(1, $p['repeat_clicks']); + self::assertSame(3, (int) $p['days'][0]['clicks']); + $raw = $this->db->pdo()->query('SELECT * FROM tracking_link_users')->fetchAll(); + self::assertCount(2, $raw); + self::assertStringNotContainsString('123456789', Database::encode($raw)); + self::assertStringNotContainsString('987654321', Database::encode($raw)); + self::assertSame(64, strlen($raw[0]['user_hash'])); + } + public function testUnknownMalformedGroupAndMissingMessageStartsAreNotCounted(): void + { + $p = $this->tracking->create('1', 'campaign'); + foreach ([['campaign', '2', 0, true], ['campaign', '-1001', 1, true], ['campaign', '2', 1, false], ['unknown', '2', 1, true], ['bad!', '2', 1, true], ['admin', '2', 1, true]] as $args) { + self::assertNull($this->tracking->recordStart(...$args)); + } + self::assertSame(0, (int) $this->tracking->get('1', (int) $p['id'])['total_clicks']); + } + public function testTrackersCannotShadowRoutesOrTargetPrivilegedActions(): void + { + foreach (['admin','g_123','loc_abc','preferences','admin_broadcast','a!','-wrong','bad_'] as $param) { + try { + $this->tracking->create('1', $param); + self::fail('Reserved or malformed tracking link created'); + } catch (\InvalidArgumentException) { + self::assertSame(0, $this->tracking->page('1')['total']); + } + } + $this->expectException(\InvalidArgumentException::class); + $this->tracking->create('1', 'campaign', '', 'admin_broadcast'); + } + public function testTrackingManagementHonorsViewOnlyAndResetRevision(): void + { + $p = $this->tracking->create('1', 'campaign'); + $id = (int) $p['id']; + $this->tracking->recordStart('campaign', '2', 1); + $this->access->add('1', '3', 'limited', true, ['tracking_links' => true]); + self::assertSame(1, $this->tracking->page('3')['total']); + try { + $this->tracking->reset('3', $id, 1); + self::fail('View-only reset accepted'); + } catch (\RuntimeException) { + self::assertSame(1, (int) $this->tracking->get('1', $id)['total_clicks']); + } + $this->tracking->reset('1', $id, 1); + self::assertSame(0, (int) $this->tracking->get('1', $id)['total_clicks']); + self::assertSame([], $this->tracking->get('1', $id)['days']); + try { + $this->tracking->delete('1', $id, 1); + self::fail('Stale tracker deletion accepted'); + } catch (\RuntimeException) { + self::assertNotNull($this->tracking->get('1', $id)); + } + $this->tracking->delete('1', $id, 2); + self::assertNull($this->tracking->get('1', $id)); + } + public function testTrackingCreationPreviewDestinationAndExplicitSave(): void + { + $this->ui->callback('1', 'trk:add'); + $frame = $this->ui->capture('1', 'community '); + self::assertStringContainsString('<Neighborhood>', $frame['text']); + self::assertSame(0, $this->tracking->page('1')['total']); + $destination = $this->find($frame, 'trk:destination:', ':shabbat'); + $frame = $this->ui->callback('1', $destination); + $this->ui->callback('1', $this->find($frame, 'trk:save:')); + $p = $this->tracking->page('1')['items'][0]; + self::assertSame('shabbat', $p['destination']); + $ask = $this->ui->callback('1', 'trk:delete:' . $p['id']); + self::assertSame(1, $this->tracking->page('1')['total']); + $this->ui->callback('1', $this->find($ask, 'trk:save:')); + self::assertSame(0, $this->tracking->page('1')['total']); + } + public function testSystemLinksNeverBypassPrivateAdminCapabilities(): void + { + self::assertSame('shabbat', SystemLinks::resolve('shabbat', '99', true, $this->access)); + self::assertNull(SystemLinks::resolve('admin', '1', false, $this->access)); + $this->access->add('1', '2', 'limited', true, ['statistics' => true]); + self::assertSame('statistics', SystemLinks::resolve('admin_stats', '2', true, $this->access)); + $this->expectException(\RuntimeException::class); + SystemLinks::resolve('admin_broadcast', '2', true, $this->access); + } + public function testAllMenusHaveCompleteSixLanguageTextAndBoundedNativeButtons(): void + { + foreach (Lang::CODES as $language) { + $u = $this->settings->getUser('1'); + $this->settings->updateUser('1', $u['revision'], ['language' => $language]); + foreach (AdminText::keys() as $key) { + self::assertNotSame('', AdminText::text($language, $key)); + self::assertStringNotContainsString('โ€”', AdminText::text($language, $key)); + } + foreach ([$this->ui->managers('1'), $this->ui->systemLinks('1'), $this->ui->trackers('1')] as $frame) { + self::assertStringNotContainsString('\\n', $frame['text']); + self::assertLessThan(4096, mb_strlen($frame['text'])); + foreach ($frame['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + self::assertLessThanOrEqual(64, strlen($button['data'] ?? '')); + } + } + } + } + } + public function testOperatorMenuOnlyExposesCurrentCapabilities(): void + { + $this->access->add('1', '2', 'limited', true, ['statistics' => true]); + $ui = new OperatorController($this->settings, new Lang(), static fn(): bool => true, $this->access); + $frame = $ui->main('2'); + self::assertSame('sd:stats', $this->find($frame, 'sd:')); + self::assertStringNotContainsString('/restart', $frame['text']); + self::assertStringNotContainsString('Broadcast', Database::encode($frame)); + $this->expectException(\RuntimeException::class); + $ui->broadcast('2'); + } + + public function testSelfBotCannotBeAddedAndAddOnlyManagerCanSaveWithoutListingOtherManagers(): void + { + $this->access->add('1', '2', 'limited', false, ['add_admins' => true]); + $ui = new AdminController($this->db, $this->clock, $this->settings, $this->access, $this->tracking, 'FixtureBot', '999'); + $ui->callback('2', 'adm:add'); + try { + $ui->capture('2', '999'); + self::fail('Bot itself accepted as manager'); + } catch (\InvalidArgumentException) { + self::assertFalse($this->access->isAdmin('999')); + } + $editor = $ui->capture('2', '3'); + self::assertSame('ื—ื–ืจื”ืžื ื”ืœ', $this->find($editor, 'ื—ื–ืจื”ืžื ื”ืœ')); + $frame = $ui->callback('2', $this->find($editor, 'adm:save:')); + self::assertTrue($this->access->isAdmin('3')); + self::assertStringNotContainsString('adm:view:1', Database::encode($frame)); + self::assertSame('ื—ื–ืจื”ืžื ื”ืœ', $this->find($frame, 'ื—ื–ืจื”ืžื ื”ืœ')); + } + private function find(array $frame, string $prefix, string $suffix = ''): string + { + foreach ($frame['markup']['rows'] as $row) { + foreach ($row['buttons'] as $b) { + if (str_starts_with($b['data'] ?? '', $prefix) && ($suffix === '' || str_ends_with($b['data'], $suffix))) { + return $b['data']; + } + } + } + self::fail('Expected callback not found: ' . $prefix); + } +} diff --git a/tests/AdminStateFilesTest.php b/tests/AdminStateFilesTest.php new file mode 100644 index 0000000..b679624 --- /dev/null +++ b/tests/AdminStateFilesTest.php @@ -0,0 +1,112 @@ +root = dirname(__DIR__); + $this->database = Database::open(':memory:'); + $this->database->migrate(); + AdminStateFiles::configure($this->database, new FrozenClock(new DateTimeImmutable('2026-10-13T00:00:00Z')), $this->root); + } + public function testDraftReadWriteDeleteWithoutCreatingFiles(): void + { + $path = $this->root . '/data/7001/txt.txt'; + self::assertFalse(AdminStateFiles::exists($path)); + self::assertSame(5, AdminStateFiles::write($path, 'draft')); + self::assertTrue(AdminStateFiles::exists($path)); + self::assertSame('draft', AdminStateFiles::read($path)); + self::assertFalse(file_exists($path)); + AdminStateFiles::delete($path); + self::assertFalse(AdminStateFiles::exists($path)); + AdminStateFiles::delete($path); + } + public function testUserStatesRemainSeparate(): void + { + AdminStateFiles::write('data/7001/grs1.txt', 'broadcast1'); + AdminStateFiles::write('data/7002/grs1.txt', 'addBUTTONS'); + self::assertSame('broadcast1', AdminStateFiles::read('data/7001/grs1.txt')); + self::assertSame('addBUTTONS', AdminStateFiles::read('data/7002/grs1.txt')); + } + public function testOperatorDraftOptionsAreIsolatedFromEachOtherAndLegacyGlobalRows(): void + { + foreach (['BUTTONS.txt', 'broadcastsend.txt', 'pinmessage.txt', 'page_var.txt'] as $key) { + AdminStateFiles::write('data/' . $key, 'legacy-global'); + AdminStateFiles::write('data/7001/' . $key, 'operator-one'); + AdminStateFiles::write('data/7002/' . $key, 'operator-two'); + self::assertSame('legacy-global', AdminStateFiles::read('data/' . $key)); + self::assertSame('operator-one', AdminStateFiles::read('data/7001/' . $key)); + self::assertSame('operator-two', AdminStateFiles::read('data/7002/' . $key)); + AdminStateFiles::delete('data/7001/' . $key); + self::assertFalse(AdminStateFiles::exists('data/7001/' . $key)); + self::assertSame('operator-two', AdminStateFiles::read('data/7002/' . $key)); + } + } + + public function testMediaEntitiesAndButtonsPreserveExactBytes(): void + { + $values = ['data/7001/media.txt' => 'fixture-file-id', 'data/7001/ent.txt' => '[{"_":"messageEntityBold","offset":0,"length":7}]', 'data/BUTTONS.txt' => '[[{"text":"ืฉืœื•ื","callback_data":"fixture","style":"primary"}]]']; + foreach ($values as $path => $value) { + AdminStateFiles::write($path, $value); + self::assertSame($value, AdminStateFiles::read($path)); + } + self::assertSame('json', $this->database->pdo()->query("SELECT value_kind FROM admin_state WHERE state_key='BUTTONS.txt'")->fetchColumn()); + } + public function testVirtualDirectoriesAndLegacyLineReading(): void + { + self::assertTrue(AdminStateFiles::mkdir('data/7001')); + self::assertTrue(AdminStateFiles::isDirectory('data/7001')); + AdminStateFiles::write('ids.txt', "7\n8\n9"); + self::assertSame(["7\n","8\n","9"], AdminStateFiles::lines('ids.txt')); + } + public function testUnknownPathsFailClosed(): void + { + $this->expectException(InvalidArgumentException::class); + AdminStateFiles::write('data/7001/BOT_TOKEN.txt', 'fixture'); + } + public function testTraversalFailsClosed(): void + { + $this->expectException(InvalidArgumentException::class); + AdminStateFiles::read($this->root . '/data/7001/../../.env'); + } + public function testSiblingRootPrefixCannotEscapeContainment(): void + { + $this->expectException(InvalidArgumentException::class); + AdminStateFiles::read($this->root . '-other/data/7001/txt.txt'); + } + public function testTypedRegisteredGroupsViewIsReadonly(): void + { + $query = $this->database->pdo()->prepare('INSERT INTO groups VALUES(?,?,?,?,?)'); + $query->execute(['-100123','fixture',1,1,1]); + $query->execute(['-100999','removed',0,1,1]); + self::assertSame('-100123', AdminStateFiles::read('data/DBgroups.txt')); + $this->expectException(InvalidArgumentException::class); + AdminStateFiles::write('data/DBgroups.txt', '-100777'); + } + public function testMissingDraftReadThrows(): void + { + $this->expectException(RuntimeException::class); + AdminStateFiles::read('data/7001/media.txt'); + } +} diff --git a/tests/BroadcastAdvancedTest.php b/tests/BroadcastAdvancedTest.php new file mode 100644 index 0000000..03f43c4 --- /dev/null +++ b/tests/BroadcastAdvancedTest.php @@ -0,0 +1,381 @@ +migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $gateway = new class implements UiGateway, TelegramGateway { + public function isGroupAdmin(string $user, string $group): bool + { + return false; + } + public function sendPrivate(string $user, string $text, array $markup = []): void + { + throw new \RuntimeException('No live messages.'); + } + public function editPrivate(string $user, int $id, string $text, array $markup = []): void + { + throw new \RuntimeException('No live messages.'); + } + public function answerCallback(string $id, string $text): void + { + throw new \RuntimeException('No live callbacks.'); + } + public function answerInline(string $id, array $results, array $options = []): void + { + throw new \RuntimeException('No live inline.'); + } + public function botUsername(): string + { + return 'FixtureBot'; + } + public function getPermissions(string $group): array + { + throw new \RuntimeException('No live permissions.'); + } + public function setPermissions(string $group, array $permissions): void + { + throw new \RuntimeException('No live permissions.'); + } + public function send(string $peer, array $payload, string $randomId): array + { + throw new \RuntimeException('No live messages.'); + } + }; + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('No network.'); + } + }; + $this->app = new Application($gateway, $this->clock, $db, new CalendarService(new HebcalProvider($http), $this->clock), new GeoNames($http, $this->clock, '')); + $this->draft = new OperatorDraft($this->app, fn(string $user, bool $mutate = false): bool => $this->view && (!$mutate || $this->mutate)); + $this->ui = new BroadcastController($this->app->settings, $this->draft, $this->clock, fn(string $user, string $cap, bool $mutate = false): bool => $this->view && (!$mutate || $this->mutate)); + } + + public function testGuidedLinkButtonsKeepRichContentAreScopedAndConfirmationBecomesStale(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Rich text', [['_' => 'messageEntityBold', 'offset' => 0, 'length' => 4]]); + $this->ui->callback('1', 'bc:buttons'); + $this->ui->callback('1', 'bc:button:add'); + self::assertNotNull($this->ui->input('1', 'Open website')); + self::assertSame('broadcast_button_url', $this->draft->mode('1')); + try { + $this->ui->input('1', 'javascript:alert(1)'); + self::fail('Unsafe URL accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame('broadcast_button_url', $this->draft->mode('1')); + } + $this->ui->input('1', 'https://example.com'); + $payload = $this->draft->payload('1'); + self::assertSame('Rich text', $payload['text']); + self::assertSame('messageEntityBold', $payload['entities'][0]['_']); + self::assertSame('https://example.com', $payload['buttons'][0][0]['url']); + self::assertSame([], $this->draft->payload('2')['buttons']); + $token = $this->draft->confirmation('1', ['2']); + $frame = $this->ui->confirmation('1', 1, 'op:send:' . $token); + self::assertStringContainsString('Rich text', $frame['text']); + self::assertContains('bc:draft', $this->callbacks($frame)); + $this->ui->callback('1', 'bc:button:add'); + $this->ui->input('1', 'Second'); + $this->ui->input('1', 'tg://resolve?domain=example'); + self::assertCount(2, $this->draft->payload('1')['buttons'][0]); + $this->ui->callback('1', 'bc:content'); + $this->draft->capture('1', 'Updated rich text', $payload['entities']); + self::assertCount(2, $this->draft->payload('1')['buttons'][0], 'Editing content preserves the designed keyboard.'); + try { + $this->draft->claim('1', $token); + self::fail('Changed keyboard accepted stale confirmation.'); + } catch (\RuntimeException) { + self::assertSame('', $this->draft->mode('1')); + } + $this->ui->callback('1', 'bc:button:clear'); + self::assertSame([], $this->draft->payload('1')['buttons']); + self::assertSame($payload['entities'], $this->draft->payload('1')['entities']); + } + + public function testLinkWizardExpiresAndNavigationCancelsInputWithoutChangingDraft(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Keep me'); + $this->ui->callback('1', 'bc:button:add'); + $this->ui->input('1', 'Expired'); + $this->clock->set($this->clock->now()->modify('+16 minutes')); + try { + $this->ui->input('1', 'https://example.com'); + self::fail('Expired button label accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame([], $this->draft->payload('1')['buttons']); + } + $this->ui->callback('1', 'bc:draft'); + self::assertNull($this->ui->input('1', 'https://example.com')); + self::assertSame('Keep me', $this->draft->payload('1')['text']); + } + + public function testReferenceOptionsAreAvailableInAllSixLanguagesWithBoundedRealCallbacks(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Hello'); + foreach (Lang::CODES as $language) { + $user = $this->app->settings->getUser('1'); + $this->app->settings->updateUser('1', $user['revision'], ['language' => $language]); + $main = $this->ui->main('1'); + self::assertStringContainsString(AdminDirectoryText::text($language, 'broadcast'), $main['text']); + foreach (['bc:new','bc:edit','bc:jobs:0','deleteLastBroadcast','deleteAllBroadcast','cancelPinned','LastBrodDATA'] as $callback) { + self::assertContains($callback, $this->callbacks($main)); + } + $draft = $this->ui->draft('1'); + foreach (['bc:content','bc:buttons','bc:pin:on','bc:audience','bc:sd','bc:schedule','bc:preview','bc:confirm'] as $callback) { + self::assertContains($callback, $this->callbacks($draft)); + } + $audience = $this->ui->callback('1', 'bc:audience'); + foreach (SubscriberDirectory::AUDIENCES as $name) { + self::assertContains('bc:audience:' . $name, $this->callbacks($audience)); + } + foreach ([$main,$draft,$audience,$this->ui->callback('1', 'bc:sd'),$this->ui->callback('1', 'bc:schedule')] as $frame) { + self::assertStringNotContainsString('โ€”', $frame['text']); + foreach ($this->callbacks($frame) as $callback) { + self::assertLessThanOrEqual(64, strlen($callback)); + } + } + } + } + + public function testSelfDestructPresetsCustomOffAndEditModeAreStrictAndOperatorScoped(): void + { + $this->ui->callback('1', 'bc:sd:48'); + self::assertSame(48, $this->draft->state('1')['self_destruct_hours']); + self::assertNull($this->draft->state('2')['self_destruct_hours']); + $this->ui->callback('1', 'bc:sd:custom'); + self::assertNull($this->ui->input('1', '/start')); + self::assertNotNull($this->ui->input('1', '17')); + self::assertSame(17, $this->draft->state('1')['self_destruct_hours']); + $this->ui->callback('1', 'bc:sd:off'); + self::assertNull($this->draft->state('1')['self_destruct_hours']); + foreach ([0,49,-1] as $hours) { + try { + $this->draft->setSelfDestructHours('1', $hours); + self::fail('Invalid self-destruct accepted.'); + } catch (\InvalidArgumentException) { + self::assertNull($this->draft->state('1')['self_destruct_hours']); + } + } + $this->draft->setSelfDestructHours('1', 6); + $this->draft->setScheduledAt('1', $this->clock->now()->getTimestamp() + 600); + $this->ui->callback('1', 'bc:edit'); + self::assertSame('edit', $this->draft->state('1')['operation']); + self::assertNull($this->draft->state('1')['self_destruct_hours']); + self::assertNull($this->draft->state('1')['scheduled_at']); + self::assertNotContains('bc:sd', $this->callbacks($this->ui->draft('1'))); + $this->expectException(\InvalidArgumentException::class); + $this->draft->setSelfDestructHours('1', 1); + } + + public function testCustomInputValidationRetainsPromptUntilValidAndCancelStopsCapture(): void + { + $this->ui->callback('1', 'bc:sd:custom'); + try { + $this->ui->input('1', '1000'); + self::fail('Invalid custom hours accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame('broadcast_sd_custom', $this->draft->mode('1')); + } + $this->ui->callback('1', 'bc:draft'); + self::assertSame('', $this->draft->mode('1')); + self::assertNull($this->ui->input('1', '12')); + $this->ui->callback('1', 'bc:schedule:custom'); + self::assertNotNull($this->ui->input('1', 'off')); + self::assertNull($this->draft->state('1')['scheduled_at']); + } + + public function testSchedulingUsesTheUserTimeZoneAndStrictDatesInsteadOfFreeFormParsing(): void + { + $expected = new \DateTimeImmutable('2026-10-16 13:45', new \DateTimeZone('Asia/Jerusalem')); + foreach (['2026-10-16 13:45','16/10/2026 13:45','16.10.2026 13:45'] as $input) { + self::assertSame($expected->getTimestamp(), $this->draft->parseSchedule('1', $input)); + } + self::assertSame($this->clock->now()->getTimestamp() + 1800, $this->draft->parseSchedule('1', '30')); + self::assertNull($this->draft->parseSchedule('1', 'off')); + foreach (['0','-1','tomorrow','2026-02-30 13:00','2026-10-14 13:00','2028-10-16 13:45','2026-10-25 01:30','2027-03-26 02:30'] as $input) { + try { + $this->draft->parseSchedule('1', $input); + self::fail('Invalid or ambiguous time accepted: ' . $input); + } catch (\InvalidArgumentException) { + self::assertNull($this->draft->state('1')['scheduled_at']); + } + } + $user = $this->app->settings->getUser('1'); + $this->app->settings->updateUser('1', $user['revision'], ['timezone_id' => 'America/New_York']); + self::assertSame((new \DateTimeImmutable('2026-10-16 13:45', new \DateTimeZone('America/New_York')))->getTimestamp(), $this->draft->parseSchedule('1', '2026-10-16 13:45')); + } + + public function testScheduledPresetsSnapshotAndConfirmationCoverEveryDeliverySetting(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Original ๐Ÿš€', [['_' => 'messageEntityBold','offset' => 0,'length' => 8]], 'fileMedia'); + $this->ui->callback('1', 'bc:audience:authorized_groups_channels'); + $this->ui->callback('1', 'bc:pin:on'); + $this->ui->callback('1', 'bc:sd:12'); + $this->ui->callback('1', 'bc:schedule:30'); + $state = $this->draft->state('1'); + self::assertSame('authorized_groups_channels', $state['audience']); + self::assertSame(12, $state['self_destruct_hours']); + self::assertTrue($state['pin']); + self::assertSame($this->clock->now()->getTimestamp() + 1800, $state['scheduled_at']); + $token = $this->draft->confirmation('1', ['1','-1001']); + $this->ui->callback('1', 'bc:sd:24'); + try { + $this->draft->claim('1', $token); + self::fail('Changed delivery settings accepted.'); + } catch (\RuntimeException) { + self::assertNotNull($this->app->flows->peek($token, '1', 'operator_broadcast')); + } + $token = $this->draft->confirmation('1', ['1','-1001']); + $snapshot = $this->draft->claim('1', $token); + self::assertSame(24, $snapshot['state']['self_destruct_hours']); + self::assertSame('fileMedia', $snapshot['state']['payload']['media']['bot_api_file_id']); + self::assertSame('Original ๐Ÿš€', $snapshot['state']['payload']['text']); + self::assertNull($this->app->flows->peek($token, '1', 'operator_broadcast')); + } + + public function testExpiredScheduleCannotSilentlyTurnIntoImmediateSend(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Hello'); + $this->draft->setScheduledAt('1', $this->clock->now()->getTimestamp() + 60); + $this->clock->set($this->clock->now()->modify('+2 minutes')); + $this->expectException(\InvalidArgumentException::class); + $this->draft->confirmation('1', ['1']); + } + + public function testReadOnlyRoleCanViewJobsAndDraftButCannotMutateOrReturnSendActions(): void + { + $this->mutate = false; + $main = $this->ui->main('1'); + self::assertNotContains('bc:new', $this->callbacks($main)); + self::assertContains('bc:jobs:0', $this->callbacks($main)); + self::assertSame(['action' => 'jobs','page' => 0], $this->ui->callback('1', 'bc:jobs:0')); + self::assertNotContains('bc:confirm', $this->callbacks($this->ui->draft('1'))); + foreach (['bc:new','bc:edit','bc:pin:on','bc:sd:1','bc:schedule:30','bc:confirm','bc:preview','bc:canceljob:test'] as $callback) { + try { + $this->ui->callback('1', $callback); + self::fail('View-only action accepted: ' . $callback); + } catch (\RuntimeException) { + self::assertFalse($this->draft->state('1')['pin']); + } + } + $this->view = false; + $this->expectException(\RuntimeException::class); + $this->ui->callback('1', 'bc:jobs:0'); + } + + public function testJobsArePagedAndCancelActionsNeverExecuteRemoteOperations(): void + { + $jobs = []; + foreach (range(1, 12) as $id) { + $jobs[] = ['id' => 'job_' . $id,'kind' => $id % 2 === 0 ? 'self_destruct' : 'scheduled','at' => $this->clock->now()->getTimestamp() + $id * 3600,'targets' => 50]; + } + $first = $this->ui->jobs('1', $jobs); + self::assertContains('bc:jobs:1', $this->callbacks($first)); + self::assertContains('bc:canceljob:job_1', $this->callbacks($first)); + self::assertContains('bc:canceldelete:job_2', $this->callbacks($first)); + self::assertSame(['action' => 'cancel_job','id' => 'job_1'], $this->ui->callback('1', 'bc:canceljob:job_1')); + self::assertSame(['action' => 'cancel_self_destruct','id' => 'job_2'], $this->ui->callback('1', 'bc:canceldelete:job_2')); + self::assertSame(['action' => 'confirm'], $this->ui->callback('1', 'bc:confirm')); + self::assertSame(['action' => 'preview'], $this->ui->callback('1', 'bc:preview')); + $this->mutate = false; + self::assertNotContains('bc:canceljob:job_1', $this->callbacks($this->ui->jobs('1', $jobs))); + } + + public function testMalformedPersistedDeliverySettingsFailClosedAndClearRemovesAllOptions(): void + { + $this->app->settings->getUser('1'); + $this->app->db->pdo()->exec("INSERT INTO operator_broadcast_options VALUES('1','selfdestructhours.txt','999',0)"); + try { + $this->draft->state('1'); + self::fail('Invalid stored settings accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame('999', $this->app->db->pdo()->query("SELECT value_text FROM operator_broadcast_options WHERE user_id='1'")->fetchColumn()); + } + $this->draft->cancel('1', true); + self::assertNull($this->draft->state('1')['self_destruct_hours']); + self::assertNull($this->draft->state('1')['scheduled_at']); + self::assertSame('send', $this->draft->state('1')['operation']); + } + + public function testScheduleExpiringDuringConfirmationCannotBeClaimed(): void + { + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Hello'); + $this->draft->setScheduledAt('1', $this->clock->now()->getTimestamp() + 60); + $token = $this->draft->confirmation('1', ['1']); + $this->clock->set($this->clock->now()->modify('+2 minutes')); + try { + $this->draft->claim('1', $token); + self::fail('Expired schedule claim accepted.'); + } catch (\RuntimeException) { + self::assertNotNull($this->app->flows->peek($token, '1', 'operator_broadcast')); + } + } + + public function testLegacyFullKeyboardWrappersAreReadWithoutRewritingTheirStoredBytes(): void + { + $native = ['_' => 'replyInlineMarkup','rows' => [['_' => 'keyboardButtonRow','buttons' => [ + ['_' => 'keyboardButtonUrl','text' => 'Native','url' => 'https://example.org','style' => ['_' => 'keyboardButtonStyle','bg_primary' => true,'icon' => 5944760643524500583]], + ]]]]; + $botApi = ['inline_keyboard' => [[ + ['text' => 'Bot API','url' => 'https://example.org','style' => 'success'], + ]]]; + $this->draft->begin('1', 'broadcast1'); + $this->draft->capture('1', 'Preserved legacy draft'); + foreach ([$native,$botApi] as $wrapper) { + $raw = json_encode($wrapper, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + AdminStateFiles::write('data/1/BUTTONS.txt', $raw); + $state = $this->draft->state('1'); + $expected = \Shabbat\Storage\TemplateRepository::buttonRows($wrapper); + self::assertSame($expected, $state['payload']['buttons']); + self::assertSame($raw, AdminStateFiles::read('data/1/BUTTONS.txt')); + $this->draft->begin('1', 'addBUTTONS'); + self::assertTrue($this->draft->buttons('1', $raw)); + self::assertSame($expected, $this->draft->state('1')['payload']['buttons']); + } + } + + private function callbacks(array $frame): array + { + $result = []; + foreach ($frame['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + $result[] = $button['data']; + } + } + return $result; + } +} diff --git a/tests/BroadcastDuePreflightTest.php b/tests/BroadcastDuePreflightTest.php new file mode 100644 index 0000000..bf44945 --- /dev/null +++ b/tests/BroadcastDuePreflightTest.php @@ -0,0 +1,160 @@ +migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T12:00:00Z')); + $this->acl = new AdminAccessRepository($db, $this->clock, static fn(): array => ['1']); + $this->jobs = new BroadcastJobs($db, $this->clock, $this->acl); + } + private function runJobs(array $schedules, array $deletions, array &$events): array + { + return $this->jobs->runDue( + static fn(): array => $schedules, + static fn(): bool => false, + static function () use (&$events): void { + $events[] = 'send'; + }, + static function () use (&$events): void { + $events[] = 'delete'; + }, + static function () use (&$events): bool { + $events[] = 'priority'; + return true; + }, + static fn(): bool => false, + static fn(): array => $deletions, + ); + } + public function testThousandIdleAndFutureTicksPerformZeroPermissionScansOrNativeRuns(): void + { + $this->jobs->recordScheduled('1', self::ID); + $events = []; + $future = $this->clock->now()->getTimestamp() + 3600; + for ($i = 0; $i < 1000; $i++) { + $scheduled = $i % 2 ? [self::ID => ['id' => self::ID, 'status' => 'scheduled', 'scheduledAt' => $future]] : []; + $destruction = $i % 2 ? [self::DELETE_ID => ['id' => self::DELETE_ID, 'status' => 'scheduled', 'deleteAt' => $future]] : []; + self::assertSame('idle', $this->runJobs($scheduled, $destruction, $events)['reason']); + } + self::assertSame([], $events); + } + public function testDueSendHasOnePriorityPassAndNoEmptyDeletionRunner(): void + { + $this->jobs->recordScheduled('1', self::ID); + $events = []; + $result = $this->runJobs([self::ID => ['id' => self::ID, 'status' => 'scheduled', 'scheduledAt' => $this->clock->now()->getTimestamp()]], [], $events); + self::assertTrue($result['ran']); + self::assertSame(['priority', 'send'], $events); + } + public function testDueDeletionHasOnePriorityPassAndNoEmptySendRunner(): void + { + $events = []; + $result = $this->runJobs([], [self::DELETE_ID => ['id' => self::DELETE_ID, 'status' => 'scheduled', 'deleteAt' => $this->clock->now()->getTimestamp()]], $events); + self::assertTrue($result['ran']); + self::assertSame(['priority', 'delete'], $events); + } + public function testShutdownTokenAndGuardArePermanentAndStopAllNewWork(): void + { + $token = $this->jobs->cancellation(); + self::assertFalse($token->isRequested()); + self::assertTrue($this->jobs->canContinue()); + $this->jobs->stop(); + self::assertTrue($token->isRequested()); + self::assertFalse($this->jobs->canContinue()); + self::assertTrue($this->jobs->cancellation()->isRequested()); + $events = []; + self::assertSame('shutdown', $this->runJobs([], [], $events)['reason']); + self::assertSame([], $events); + } + public function testSharedAdmissionIsFifoAndNeverMultipliesNativeOperations(): void + { + $active = 0; + $maximum = 0; + $order = []; + $work = []; + for ($i = 0; $i < 40; $i++) { + $work[] = \Amp\async(function () use ($i, &$active, &$maximum, &$order): int { + return $this->jobs->runExclusive(static function () use ($i, &$active, &$maximum, &$order): int { + $maximum = max($maximum, ++$active); + $order[] = $i; + \Amp\delay(0.001); + $active--; + return $i; + }); + }); + } + self::assertSame(range(0, 39), \Amp\Future\await($work)); + self::assertSame(range(0, 39), $order); + self::assertSame(1, $maximum); + self::assertSame(0, $active); + self::assertSame([], (new \ReflectionProperty($this->jobs, 'admissionQueue'))->getValue($this->jobs)); + } + public function testShutdownReleasesWaitingAdmissionWithoutStartingQueuedOperations(): void + { + $entered = new \Amp\DeferredFuture(); + $started = 0; + $futures = [\Amp\async(function () use ($entered, &$started): void { + $this->jobs->runExclusive(function () use ($entered, &$started): void { + $started++; + $entered->complete(); + \Amp\delay(60, cancellation: $this->jobs->cancellation()); + }); + })]; + $entered->getFuture()->await(); + for ($i = 0; $i < 40; $i++) { + $futures[] = \Amp\async(function () use (&$started): void { + $this->jobs->runExclusive(static function () use (&$started): void { + $started++; + }); + }); + } + \Amp\delay(0.001); + $this->jobs->stop(); + $cancelled = 0; + foreach ($futures as $future) { + try { + $future->await(); + } catch (\Amp\CancelledException) { + $cancelled++; + } + } + self::assertSame(41, $cancelled); + self::assertSame(1, $started); + self::assertSame([], (new \ReflectionProperty($this->jobs, 'admissionQueue'))->getValue($this->jobs)); + self::assertNull((new \ReflectionProperty($this->jobs, 'admitted'))->getValue($this->jobs)); + } + public function testCreatorRevocationDuringNativeWorkIsVisibleToEveryWorkerGuard(): void + { + $this->acl->add('1', '2', 'full', false, []); + $this->jobs->recordScheduled('2', self::ID); + $list = fn(): array => [self::ID => ['id' => self::ID, 'status' => 'scheduled', 'scheduledAt' => $this->clock->now()->getTimestamp()]]; + $this->jobs->runDue($list, static fn(): bool => false, function (): void { + self::assertTrue($this->jobs->canContinue()); + self::assertTrue($this->jobs->isRunning()); + $this->acl->remove('1', '2', 1); + self::assertFalse($this->jobs->canContinue()); + }, static function (): void { + self::fail('No deletion due'); + }, static fn(): bool => true, static fn(): bool => false, static fn(): array => []); + self::assertFalse($this->jobs->isRunning()); + } +} diff --git a/tests/BroadcastJobsTest.php b/tests/BroadcastJobsTest.php new file mode 100644 index 0000000..d7d7448 --- /dev/null +++ b/tests/BroadcastJobsTest.php @@ -0,0 +1,245 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $this->acl = new AdminAccessRepository($this->db, $this->clock, static fn(): array => ['1']); + $this->jobs = new BroadcastJobs($this->db, $this->clock, $this->acl); + } + private function scheduled(string $id = self::ID, int $offset = -1): array + { + return ['id' => $id, 'status' => 'scheduled', 'scheduledAt' => $this->clock->now()->getTimestamp() + $offset]; + } + public function testCreatorIsPersistentIdempotentAndCannotBeReassigned(): void + { + $this->jobs->recordScheduled('1', self::ID); + $this->jobs->recordScheduled('1', self::ID); + self::assertSame('1', $this->jobs->actorForSchedule(self::ID)); + self::assertSame(1, (int) $this->db->pdo()->query("SELECT COUNT(*) FROM audit_log WHERE action='operator.broadcast.scheduled'")->fetchColumn()); + $this->acl->add('1', '2', 'full', false, []); + $this->expectException(\RuntimeException::class); + $this->jobs->recordScheduled('2', self::ID); + } + public function testRecordingRequiresCurrentMutationCapability(): void + { + $this->acl->add('1', '2', 'limited', true, ['broadcast' => true]); + $this->expectException(\RuntimeException::class); + $this->jobs->recordScheduled('2', self::ID); + } + public function testAuthorizedSchedulesAndDestructionRunAfterPriority(): void + { + $this->jobs->recordScheduled('1', self::ID); + $events = []; + $result = $this->jobs->runDue( + fn(): array => [self::ID => $this->scheduled()], + static fn(): bool => false, + function () use (&$events): void { + $events[] = 'send'; + }, + function () use (&$events): void { + $events[] = 'destroy'; + }, + function () use (&$events): bool { + $events[] = 'priority'; + return true; + }, + static fn(): bool => false, + ); + self::assertSame(['priority', 'send', 'priority', 'destroy'], $events); + self::assertSame(['ran' => true, 'cancelled' => [], 'reason' => 'ok'], $result); + } + public function testUnattributedDueJobsAreCancelledBeforeAnyRunner(): void + { + $native = [self::ID => $this->scheduled()]; + $ran = 0; + $result = $this->jobs->runDue( + static function () use (&$native): array { + return $native; + }, + static function (string $id) use (&$native): bool { + $native[$id]['status'] = 'cancelled'; + return true; + }, + static function () use (&$ran): void { + $ran++; + }, + static function () use (&$ran): void { + $ran++; + }, + static fn(): bool => true, + static fn(): bool => false, + ); + self::assertSame([self::ID], $result['cancelled']); + self::assertSame(1, $ran); + } + public function testRevokedAndViewOnlyCreatorsAreCancelled(): void + { + foreach ([false, true] as $viewOnly) { + $this->acl->add('1', '2', 'limited', false, ['broadcast' => true]); + $id = $viewOnly ? self::OTHER : self::ID; + $this->jobs->recordScheduled('2', $id); + if ($viewOnly) { + $this->acl->update('1', '2', 'limited', true, ['broadcast' => true], 1); + } else { + $this->acl->remove('1', '2', 1); + } + $native = [$id => $this->scheduled($id)]; + $result = $this->jobs->runDue( + static function () use (&$native): array { + return $native; + }, + static function (string $key) use (&$native): bool { + $native[$key]['status'] = 'cancelled'; + return true; + }, + static function (): void { + }, + static function (): void { + }, + static fn(): bool => true, + static fn(): bool => false, + ); + self::assertSame([$id], $result['cancelled']); + if ($viewOnly) { + $this->acl->remove('1', '2', 2); + } + } + } + public function testUnconfirmedCancellationBlocksBothNativeRunners(): void + { + $ran = 0; + $r = $this->jobs->runDue(fn(): array => [self::ID => $this->scheduled()], static fn(): bool => false, static function () use (&$ran): void { + $ran++; + }, static function () use (&$ran): void { + $ran++; + }, static fn(): bool => true, static fn(): bool => false); + self::assertSame('cancellation_unconfirmed', $r['reason']); + self::assertSame(0, $ran); + } + public function testCancellationAckMustMatchFreshNativeState(): void + { + $ran = 0; + $r = $this->jobs->runDue(fn(): array => [self::ID => $this->scheduled()], static fn(): bool => true, static function () use (&$ran): void { + $ran++; + }, static function () use (&$ran): void { + $ran++; + }, static fn(): bool => true, static fn(): bool => false); + self::assertSame('authorization_changed', $r['reason']); + self::assertSame(0, $ran); + } + public function testUnknownFutureScheduleCannotCrossNativeBulkDeadline(): void + { + $ran = 0; + $r = $this->jobs->runDue(fn(): array => [self::ID => $this->scheduled(self::ID, 1)], static fn(): bool => false, static function () use (&$ran): void { + $ran++; + }, static function () use (&$ran): void { + $ran++; + }, static fn(): bool => true, static fn(): bool => false); + self::assertSame('unattributed_future', $r['reason']); + self::assertSame(0, $ran); + } + public function testPriorityAndShutdownSkipAllNativeSideEffects(): void + { + foreach ([true, false] as $shutdown) { + $r = $this->jobs->runDue(static function (): array { + return []; + }, static fn(): bool => false, static function (): void { + self::fail('Sent during stop'); + }, static function (): void { + self::fail('Destroyed during stop'); + }, static fn(): bool => $shutdown, static fn(): bool => $shutdown); + self::assertFalse($r['ran']); + self::assertSame($shutdown ? 'shutdown' : 'permission_priority', $r['reason']); + } + } + public function testRoleRevokedWhilePriorityRunsIsRecheckedBeforeSending(): void + { + $this->acl->add('1', '2', 'full', false, []); + $this->jobs->recordScheduled('2', self::ID); + $priorities = 0; + $r = $this->jobs->runDue( + fn(): array => [self::ID => $this->scheduled()], + static fn(): bool => false, + static function (): void { + self::fail('Sent after revocation'); + }, + static function (): void { + self::fail('Destroyed after revocation'); + }, + function () use (&$priorities): bool { + if (++$priorities === 1) { + $this->acl->remove('1', '2', 1); + } return true; + }, + static fn(): bool => false, + ); + self::assertSame('authorization_changed', $r['reason']); + } + public function testReentrantTickCannotDuplicateNativeRuns(): void + { + $this->jobs->recordScheduled('1', self::ID); + $nested = null; + $r = $this->jobs->runDue( + fn(): array => [self::ID => $this->scheduled()], + static fn(): bool => false, + function () use (&$nested): void { + $nested = $this->jobs->runDue(static fn(): array => [], static fn(): bool => false, static function (): void { + self::fail('Nested run'); + }, static function (): void { + }, static fn(): bool => true, static fn(): bool => false); + }, + static function (): void { + }, + static fn(): bool => true, + static fn(): bool => false, + ); + self::assertTrue($r['ran']); + self::assertSame('running', $nested['reason']); + } + public function testIntentionalCancellationPropagatesAndReleasesReentrancyGuard(): void + { + $error = new CancelledException(new \RuntimeException('fixture')); + try { + $this->jobs->runDue(static function () use ($error): array { + throw $error; + }, static fn(): bool => false, static function (): void { + }, static function (): void { + }, static fn(): bool => true, static fn(): bool => false); + self::fail('Cancellation swallowed'); + } catch (CancelledException $caught) { + self::assertSame($error, $caught); + } + self::assertTrue($this->jobs->runDue(static fn(): array => [], static fn(): bool => false, static function (): void { + }, static function (): void { + }, static fn(): bool => true, static fn(): bool => false)['ran']); + } + public function testMalformedMetadataAndIdentityFailClosed(): void + { + $this->expectException(\RuntimeException::class); + $this->jobs->runDue(fn(): array => [self::OTHER => $this->scheduled()], static fn(): bool => false, static function (): void { + self::fail('Malformed schedule ran'); + }, static function (): void { + }, static fn(): bool => true, static fn(): bool => false); + } +} diff --git a/tests/BroadcastLifecycleTest.php b/tests/BroadcastLifecycleTest.php new file mode 100644 index 0000000..865980d --- /dev/null +++ b/tests/BroadcastLifecycleTest.php @@ -0,0 +1,223 @@ +newInstanceWithoutConstructor(); + $manager->setRuntimeGuard(static fn(): bool => true); + return $manager; + } + private function state(BroadcastManager $manager, int $count): array + { + $state = (new \ReflectionMethod($manager, 'createState'))->invoke($manager, 'test', 'send', $count); + for ($i = 0; $i < $count; $i++) { + $state['queue']->enqueue(['peer' => (string)$i, 'attempts' => 0, 'availableAt' => 0]); + } + return $state; + } + private function start(BroadcastManager $manager, array &$state, callable $handler, int $concurrency = 4): void + { + (new \ReflectionMethod($manager, 'startQueueWorkers'))->invokeArgs($manager, [&$state, $concurrency, $handler]); + } + private function wait(BroadcastManager $manager, array &$state): void + { + (new \ReflectionMethod($manager, 'waitForCompletion'))->invokeArgs($manager, [&$state]); + } + public function testSuspendedNativeBroadcastDoesNotBlockIndependentPermissionClose(): void + { + $manager = $this->manager(); + $state = $this->state($manager, 1); + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $this->start($manager, $state, static function () use ($entered, $release): void { + $entered->complete(); + $release->getFuture()->await(); + }, 1); + $broadcast = \Amp\async(function () use ($manager, &$state): void { + $this->wait($manager, $state); + }); + $entered->getFuture()->await(); + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T14:20:00Z')); + (new SettingsRepository($db, $clock))->ensureGroup('-1'); + $gateway = new PermissionRecoveryGateway(); + $locks = new LockCoordinator($db, $clock, $gateway); + try { + self::assertFalse($broadcast->isComplete()); + self::assertSame('locked', $locks->reconcile('-1', 'shabbat-boundary', true)['status']); + self::assertFalse($broadcast->isComplete()); + } finally { + $release->complete(); + $broadcast->await(); + } + self::assertTrue($state['done']); + self::assertArrayNotHasKey('runtimeWorkers', $state); + } + public function testShutdownCancelsThousandQueuedTargetsAndDrainsEveryWorker(): void + { + $manager = $this->manager(); + $shutdown = new \Amp\DeferredCancellation(); + $manager->setRuntimeGuard(static fn(): bool => true, $shutdown->getCancellation()); + $state = $this->state($manager, 1000); + $sent = 0; + $reason = new \RuntimeException('test shutdown'); + $this->start($manager, $state, static function () use (&$sent, $shutdown, $reason): void { + if (++$sent === 3) { + $shutdown->cancel($reason); + } + }); + try { + $this->wait($manager, $state); + self::fail('Shutdown cancellation was swallowed.'); + } catch (\Amp\CancelledException $error) { + self::assertSame($reason, $error->getPrevious()); + } + self::assertSame(3, $sent); + self::assertTrue($state['done']); + self::assertTrue($state['cancel']); + self::assertTrue($state['queue']->isEmpty()); + self::assertSame([], $state['inFlight']); + self::assertArrayNotHasKey('runtimeWorkers', $state); + } + public function testIntentionalCancellationFromWorkerPropagatesAfterAllChildrenJoin(): void + { + $manager = $this->manager(); + $state = $this->state($manager, 40); + $error = new \Amp\CancelledException(new \RuntimeException('intentional')); + $this->start($manager, $state, static fn() => throw $error); + try { + $this->wait($manager, $state); + self::fail('Intentional cancellation swallowed.'); + } catch (\Amp\CancelledException $caught) { + self::assertSame($error, $caught); + } + self::assertTrue($state['cancel']); + self::assertSame([], $state['inFlight']); + self::assertArrayNotHasKey('runtimeWorkers', $state); + } + public function testRevokedAuthorizationStopsRemainingTargets(): void + { + $manager = $this->manager(); + $allowed = true; + $manager->setRuntimeGuard(static function () use (&$allowed): bool { + return $allowed; + }); + $state = $this->state($manager, 100); + $calls = 0; + $this->start($manager, $state, static function () use (&$allowed, &$calls): void { + $calls++; + $allowed = false; + }, 1); + $this->wait($manager, $state); + self::assertSame(1, $calls); + self::assertTrue($state['cancel']); + self::assertTrue($state['queue']->isEmpty()); + } + public function testLongProgressSleepIsWokenAndJoinedOnCompletion(): void + { + $manager = $this->manager(); + $state = $this->state($manager, 0); + $edits = 0; + $manager->setMessageTransport(static function () use (&$edits): array { + $edits++; + return []; + }); + (new \ReflectionMethod($manager, 'startProgressLoop'))->invokeArgs($manager, [123, 10, &$state, 'Test']); + \Amp\delay(0.001); + self::assertSame(1, $edits); + $started = hrtime(true); + $this->wait($manager, $state); + self::assertLessThan(1.0, (hrtime(true) - $started) / 1_000_000_000); + self::assertSame(1, $edits); + self::assertArrayNotHasKey('runtimeWorkers', $state); + } + public function testConcurrentAuthorityLossCannotMaskAnIndependentWorkerCancellation(): void + { + $manager = $this->manager(); + $allowed = true; + $manager->setRuntimeGuard(static function () use (&$allowed): bool { + return $allowed; + }); + $state = $this->state($manager, 2); + $error = new \Amp\CancelledException(new \RuntimeException('independent cancellation')); + $this->start($manager, $state, static function () use (&$allowed, $error): void { + $allowed = false; + throw $error; + }, 1); + try { + $this->wait($manager, $state); + self::fail('Independent cancellation hidden by the authority guard.'); + } catch (\Amp\CancelledException $caught) { + self::assertSame($error, $caught); + } + self::assertArrayNotHasKey('runtimeWorkers', $state); + } + public function testEveryActualNativeOperationUsesOptionalAdmissionAndNullRestoresDefault(): void + { + $manager = $this->manager(); + $count = 0; + $result = 'admitted'; + $manager->setRuntimeAdmission(static function (callable $work) use (&$count, &$result): mixed { + $count++; + return $result; + }); + self::assertSame('admitted', $manager->broadcastWithProgress([], [])); + self::assertSame('admitted', $manager->editLastBroadcastForAll([], 'test')); + self::assertSame('admitted', $manager->deleteLastBroadcastForAll([])); + self::assertSame('admitted', $manager->deleteAllBroadcastsForAll([])); + self::assertSame('admitted', $manager->unpinAllMessagesForAll([])); + $result = []; + self::assertSame([], (new \ReflectionMethod($manager, 'runSelfDestructJob'))->invoke($manager, 'selfdestruct_0123456789abcdef')); + self::assertSame(6, $count); + $manager->setRuntimeAdmission(null); + self::assertSame('plain', (new \ReflectionMethod($manager, 'withAdmission'))->invoke($manager, static fn(): string => 'plain')); + } + public function testOnlyFullyDrainedCompletedProgressIsPrunedBeyondLatest128(): void + { + $manager = $this->manager(); + $shared = new \ReflectionProperty(BroadcastManager::class, 'sharedBroadcastState'); + $old = $shared->getValue(); + $states = []; + for ($i = 0; $i < 250; $i++) { + $states['done-' . $i] = ['done' => true, 'inFlight' => []]; + } + $states['active'] = ['done' => false, 'inFlight' => []]; + $states['in-flight'] = ['done' => true, 'inFlight' => ['peer']]; + $states['joining'] = ['done' => true, 'inFlight' => [], 'runtimeWorkers' => []]; + try { + $shared->setValue(null, $states); + (new \ReflectionProperty($manager, 'currentBroadcastState'))->setValue($manager, $states); + (new \ReflectionMethod($manager, 'pruneCompletedRuntimeStates'))->invoke($manager); + $kept = $shared->getValue(); + self::assertCount(131, $kept); + foreach (['active', 'in-flight', 'joining', 'done-249', 'done-122'] as $key) { + self::assertArrayHasKey($key, $kept); + } + self::assertArrayNotHasKey('done-121', $kept); + self::assertCount(131, (new \ReflectionProperty($manager, 'currentBroadcastState'))->getValue($manager)); + } finally { + $shared->setValue(null, $old); + } + } + public function testCronIntegrationSeparatesPermissionNotificationAndBroadcastWork(): void + { + $source = file_get_contents(dirname(__DIR__) . '/bot.php'); + self::assertMatchesRegularExpression('/#\[Cron\(period: 15\.0\)\]\s+public function shabatCronClose\(\): void \{\s*\$this->v3App\(\)->permissionTick\(\);\s*return;/', $source); + self::assertMatchesRegularExpression('/#\[Cron\(period: 1\.0\)\]\s+public function shabatNotifications\(\): void \{\s*\$this->v3App\(\)->notificationTick\(\);/', $source); + self::assertMatchesRegularExpression('/#\[Cron\(period: 15\.0\)\]\s+public function shabbatBroadcastCron\(\): void \{\s*\$this->v4RunBroadcastJobs\(\);\s*\}/', $source); + } +} diff --git a/tests/BroadcastPatchTest.php b/tests/BroadcastPatchTest.php new file mode 100644 index 0000000..d056175 --- /dev/null +++ b/tests/BroadcastPatchTest.php @@ -0,0 +1,382 @@ +root = sys_get_temp_dir() . '/shabbat-broadcast-patch-' . bin2hex(random_bytes(8)); + $this->vendor = $this->root . '/vendor'; + $this->target = $this->vendor . '/wizardloop/broadcastmanager/src/BroadcastManager.php'; + $this->manifest = $this->root . '/patches/broadcastmanager/manifest.json'; + $this->writer = $this->root . '/application.writer.lock'; + mkdir(dirname($this->target), 0700, true); + mkdir($this->vendor . '/composer', 0700, true); + mkdir(dirname($this->manifest) . '/baseline', 0700, true); + $source = dirname(__DIR__) . '/patches/broadcastmanager/'; + foreach (['manifest.json', 'status-localization-hooks.patch', 'baseline/BroadcastManager.php', 'previous/dev3/BroadcastManager.php', 'dev3-to-transport-hooks.patch', 'previous/dev8/BroadcastManager.php', 'dev8-to-runtime-lifecycle.patch'] as $file) { + $destination = dirname($this->manifest) . '/' . $file; + if (!is_dir(dirname($destination))) { + mkdir(dirname($destination), 0700, true); + } + copy($source . $file, $destination); + } + copy($source . 'baseline/BroadcastManager.php', $this->target); + $this->installed('3.2.2', 'f2792a383416b66bb68e55f09414563e540538d6'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->root); + } + + private function removeDirectory(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $child = $path . '/' . $entry; + if (is_dir($child) && !is_link($child)) { + $this->removeDirectory($child); + } else { + unlink($child); + } + } + rmdir($path); + } + + private function installed(string $version, string $commit): void + { + file_put_contents($this->vendor . '/composer/installed.json', json_encode(['packages' => [ + ['name' => 'wizardloop/broadcastmanager', 'version' => $version, 'source' => ['reference' => $commit]], + ]], JSON_THROW_ON_ERROR)); + } + + private function installer(): PatchManager + { + return new PatchManager($this->root, $this->vendor, $this->manifest, $this->writer); + } + + private function rejected(callable $operation): void + { + $error = null; + try { + $operation(); + } catch (RuntimeException $caught) { + $error = $caught; + } + self::assertInstanceOf(RuntimeException::class, $error); + } + + public function testCleanInstallRepeatedApplyAndVerifyMatchApprovedOutput(): void + { + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame('patched', $this->installer()->process(true)); + $metadata = json_decode(file_get_contents($this->manifest), true, 64, JSON_THROW_ON_ERROR)['approved'][0]; + self::assertSame($metadata['patched_sha256'], hash_file('sha256', $this->target)); + $before = file_get_contents($this->target); + self::assertSame('already-patched', $this->installer()->process(true)); + self::assertSame('already-patched', $this->installer()->process(false)); + self::assertSame($before, file_get_contents($this->target)); + } + + public function testWrongVersionAndCommitNeverMutatePristineSource(): void + { + $before = file_get_contents($this->target); + foreach ([['3.2.1', 'f2792a383416b66bb68e55f09414563e540538d6'], ['3.2.2', str_repeat('0', 40)]] as [$version, $commit]) { + $this->installed($version, $commit); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } + } + + public function testUnknownAndPartiallyPatchedFilesFailClosedWithoutChanges(): void + { + $base = file_get_contents($this->target); + $patch = file_get_contents(dirname($this->manifest) . '/status-localization-hooks.patch'); + $partial = str_replace(' private ?\\Closure $statusFormatter = null;' . "\n", '', PatchManager::applyDiff($base, $patch)); + foreach ([$base . "\n// unreviewed change\n", $partial] as $changed) { + file_put_contents($this->target, $changed); + $this->rejected(fn (): string => $this->installer()->process(true)); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame($changed, file_get_contents($this->target)); + } + } + + public function testActiveWriterPreventsApplyingButDoesNotSilentlySkipThePatch(): void + { + $before = file_get_contents($this->target); + $writer = SingleInstance::acquire($this->writer); + try { + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } finally { + $writer->release(); + } + self::assertSame('patched', $this->installer()->process(true)); + } + + private function manager(): BroadcastManager + { + return (new ReflectionClass(BroadcastManager::class))->newInstanceWithoutConstructor(); + } + + private function invoke(BroadcastManager $manager, string $method, array $arguments): mixed + { + return (new ReflectionMethod($manager, $method))->invokeArgs($manager, $arguments); + } + + private function state(string $id = 'abcdef0123456789'): array + { + return ['id' => $id, 'type' => 'send', 'sent' => 4, 'failed' => 1, 'flood' => 2, 'total' => 10, 'startedAt' => null, 'done' => false]; + } + + public function testDisabledHooksPreserveExactUpstreamProgressAndControls(): void + { + $manager = $this->manager(); + $expected = "Broadcast Progress\n\n##########---------- 50%\n\nProcessed: 5 / 10\nSent: 4\nFailed: 1\nFLOOD_WAIT: 2\nPending: 5\nTPS: 0/s"; + self::assertSame($expected, $this->invoke($manager, 'buildProgressText', [$this->state(), 'Broadcast Progress'])); + $controls = ['inline_keyboard' => [[['text' => 'Pause', 'callback_data' => 'bm:pause:abcdef0123456789'], ['text' => 'Cancel', 'callback_data' => 'bm:cancel:abcdef0123456789']]]]; + self::assertSame($controls, $this->invoke($manager, 'buildStatusControls', [$this->state()])); + $manager->setStatusFormatter(static fn (): string => 'Custom'); + $manager->setStatusFormatter(null); + $manager->setStatusControlsFormatter(null); + self::assertSame($expected, $this->invoke($manager, 'buildProgressText', [$this->state(), 'Broadcast Progress'])); + self::assertSame($controls, $this->invoke($manager, 'buildStatusControls', [$this->state()])); + } + + public function testSixLocalizedPatchedFormattersPreserveCountersAndNativeCallbackBindings(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $manager = $this->manager(); + $formatter = new BroadcastStatusFormatter($lang, $language); + $manager->setStatusFormatter($formatter); + $manager->setStatusControlsFormatter($formatter->controls(...)); + $starting = $this->invoke($manager, 'formatStatus', ['starting', 'Gathering peers...', ['type' => 'send', 'total' => 10]]); + self::assertStringContainsString($lang->text($language, 'operator_progress'), $starting); + self::assertNotSame('Gathering peers...', $starting); + $state = $this->state() + ['paused' => true, 'cancel' => true]; + $text = $this->invoke($manager, 'buildProgressText', [$state, 'Broadcast Progress', true]); + $values = ['processed' => 5, 'total' => 10, 'sent' => 4, 'edited' => 0, 'unchanged' => 0, 'deleted' => 0, 'unpin' => 0, 'failed' => 1, 'flood' => 2, 'pending' => 5, 'tps' => in_array($language, ['he', 'en'], true) ? '0.00' : '0,00']; + self::assertStringContainsString($lang->text($language, 'operator_bc_counts', $values), $text); + self::assertStringContainsString($lang->text($language, 'operator_bc_paused'), $text); + self::assertStringContainsString($lang->text($language, 'operator_bc_cancelled'), $text); + $controls = $this->invoke($manager, 'buildStatusControls', [$state]); + self::assertSame('replyInlineMarkup', $controls['_']); + self::assertSame('bm:resume:abcdef0123456789', $controls['rows'][0]['buttons'][0]['data']); + self::assertSame($lang->text($language, 'operator_resume'), $controls['rows'][0]['buttons'][0]['text']); + self::assertTrue($controls['rows'][0]['buttons'][0]['style']['bg_primary']); + self::assertSame('bm:cancel:abcdef0123456789', $controls['rows'][0]['buttons'][1]['data']); + self::assertTrue($controls['rows'][0]['buttons'][1]['style']['bg_danger']); + self::assertNull($this->invoke($manager, 'buildStatusControls', [array_replace($state, ['done' => true])])); + } + } + + public function testFormatterIsPerInstanceAndInvalidTextNeverBecomesAnEnglishFallback(): void + { + $one = $this->manager(); + $two = $this->manager(); + $one->setStatusFormatter(new BroadcastStatusFormatter(new Lang(), 'fr')); + self::assertSame('Cancel', $this->invoke($two, 'formatStatus', ['cancel', 'Cancel', []])); + self::assertSame((new Lang())->text('fr', 'operator_cancel'), $this->invoke($one, 'formatStatus', ['cancel', 'Cancel', []])); + $one->setStatusFormatter(static fn (): array => ['wrong']); + $this->expectException(\InvalidArgumentException::class); + $this->invoke($one, 'formatStatus', ['starting', 'Gathering peers...', []]); + } + + public function testLastSummaryLocalizesAvailableNumericIdStateAndPreservesLegacyFile(): void + { + $stateProperty = new ReflectionProperty(BroadcastManager::class, 'sharedBroadcastState'); + $dirProperty = new ReflectionProperty(BroadcastManager::class, 'dataDir'); + $oldState = $stateProperty->getValue(); + $oldDirectory = $dirProperty->getValue(); + file_put_contents($this->root . '/LastBrodDATA.txt', 'Original English summary'); + BroadcastManager::setDataDir($this->root); + $manager = $this->manager(); + $formatter = new BroadcastStatusFormatter(new Lang(), 'es'); + $manager->setStatusFormatter($formatter); + try { + $stateProperty->setValue(null, []); + self::assertSame((new Lang())->text('es', 'operator_bc_legacy_summary'), $manager->lastBroadcastData()); + $stateProperty->setValue(null, ['1234567890123456' => $this->state('1234567890123456')]); + $text = $manager->lastBroadcastData(); + self::assertStringContainsString('5 / 10', $text); + self::assertStringNotContainsString('Original English summary', $text); + self::assertSame('Original English summary', file_get_contents($this->root . '/LastBrodDATA.txt')); + } finally { + $stateProperty->setValue(null, $oldState); + BroadcastManager::setDataDir($oldDirectory); + } + } + + public function testPreviousDev3PatchRequiresExplicitOfflineUpgradeThenVerifiesIdempotently(): void + { + copy(dirname($this->manifest) . '/previous/dev3/BroadcastManager.php', $this->target); + $before = file_get_contents($this->target); + self::assertSame('3d9425f7911b12c69faae5e87954db134dc3fb7c2f7d654a72f972ebeb84e424', hash('sha256', $before)); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame($before, file_get_contents($this->target)); + self::assertSame('patched', $this->installer()->process(true)); + self::assertSame('already-patched', $this->installer()->process(false)); + self::assertSame('already-patched', $this->installer()->process(true)); + self::assertSame(hash_file('sha256', dirname(__DIR__) . '/patches/broadcastmanager/reviewed/BroadcastManager.php'), hash_file('sha256', $this->target)); + } + + public function testPreviousDev8RuntimeUpgradeIsExplicitAndIdempotent(): void + { + copy(dirname($this->manifest) . '/previous/dev8/BroadcastManager.php', $this->target); + self::assertSame('08db35c271f671041257ebe79d1010cdd7c33c4d5868e4553c78612f9e8bf0f3', hash_file('sha256', $this->target)); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame('patched', $this->installer()->process(true)); + self::assertSame('already-patched', $this->installer()->process(false)); + self::assertSame('already-patched', $this->installer()->process(true)); + self::assertSame(hash_file('sha256', dirname(__DIR__) . '/patches/broadcastmanager/reviewed/BroadcastManager.php'), hash_file('sha256', $this->target)); + } + public function testPreviousPatchWriterLockAndModifiedTransitionFailClosed(): void + { + copy(dirname($this->manifest) . '/previous/dev3/BroadcastManager.php', $this->target); + $before = file_get_contents($this->target); + $lock = SingleInstance::acquire($this->writer); + try { + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } finally { + $lock->release(); + } + file_put_contents(dirname($this->manifest) . '/dev3-to-transport-hooks.patch', "\n// unknown", FILE_APPEND); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } + + public function testAllApprovedTransitionArtifactsAreVerifiedEvenWhenAlreadyPatched(): void + { + $this->installer()->process(true); + $before = file_get_contents($this->target); + file_put_contents(dirname($this->manifest) . '/previous/dev3/BroadcastManager.php', "\n// unknown", FILE_APPEND); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame($before, file_get_contents($this->target)); + } + + public function testTransportHookPreservesNativeMethodsPayloadsResultsAndPerInstanceIsolation(): void + { + $one = $this->manager(); + $two = $this->manager(); + $calls = []; + $one->setMessageTransport(static function (string $method, array $parameters) use (&$calls): array { + $calls[] = [$method, $parameters]; + return ['_' => 'updates', 'fixture' => count($calls)]; + }); + $params = ['peer' => '-100123', 'id' => 91, 'random_id' => 1234567890, 'message' => 'Bold ๐Ÿš€', 'entities' => [['_' => 'messageEntityBold', 'offset' => 0, 'length' => 7]], 'reply_markup' => ['_' => 'replyInlineMarkup', 'rows' => []], 'media' => ['_' => 'inputMediaPhoto', 'id' => 'fixture'], 'multi_media' => [['_' => 'inputSingleMedia', 'random_id' => 9191, 'message' => 'caption']], 'floodWaitLimit' => 172800]; + foreach (['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'] as $index => $method) { + self::assertSame(['_' => 'updates', 'fixture' => $index + 1], $this->invoke($one, 'callMessage', [$method, $params])); + self::assertSame([$method, $params], $calls[$index]); + } + $property = new ReflectionProperty(BroadcastManager::class, 'messageTransport'); + self::assertNull($property->getValue($two)); + $one->setMessageTransport(null); + self::assertNull($property->getValue($one)); + $source = file_get_contents(dirname(__DIR__) . '/patches/broadcastmanager/reviewed/BroadcastManager.php'); + self::assertSame(1, substr_count($source, '$this->api->messages->{$method}($parameters)')); + self::assertStringNotContainsString('$this->api->messages->sendMessage(', $source); + self::assertStringNotContainsString('$this->api->messages->sendMultiMedia(', $source); + self::assertStringNotContainsString('$this->api->messages->editMessage(', $source); + self::assertSame(3, substr_count($source, "\$this->callMessage('editMessage', \$payload)")); + } + + public function testTransportHookDoesNotSwallowCancellationOrPermitOtherNativeMethods(): void + { + $manager = $this->manager(); + $error = new \Amp\CancelledException(new RuntimeException('fixture')); + $manager->setMessageTransport(static function () use ($error): never { + throw $error; + }); + try { + $this->invoke($manager, 'callMessage', ['sendMessage', ['message' => 'fixture']]); + self::fail('Transport cancellation swallowed'); + } catch (\Amp\CancelledException $caught) { + self::assertSame($error, $caught); + } + $this->expectException(\InvalidArgumentException::class); + $this->invoke($manager, 'callMessage', ['deleteMessages', []]); + } + + public function testDisabledTransportUsesUnchangedNativeDynamicDispatchInIsolatedOfflineProcess(): void + { + $source = var_export(dirname(__DIR__) . '/patches/broadcastmanager/reviewed/BroadcastManager.php', true); + $script = <<<'PHP' +namespace danog\MadelineProto { + final class API { + public object $messages; + } +} +namespace { + require SOURCE_FILE; + $probe = new class { + public array $calls = []; + public function __call(string $method, array $arguments): array { + $this->calls[] = [$method, $arguments]; + return ['method' => $method, 'parameters' => $arguments[0]]; + } + }; + $api = new \danog\MadelineProto\API(); + $api->messages = $probe; + $manager = (new \ReflectionClass(\BroadcastTool\BroadcastManager::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty($manager, 'api'))->setValue($manager, $api); + $method = new \ReflectionMethod($manager, 'callMessage'); + $payload = ['peer' => '-1001', 'id' => 92, 'random_id' => 818181, 'message' => '๐Ÿš€ unchanged', 'entities' => [], 'reply_markup' => ['inline_keyboard' => []]]; + $results = []; + foreach (['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'] as $name) { + $results[] = $method->invoke($manager, $name, $payload); + } + $manager->setMessageTransport(static fn(): string => 'hooked'); + $hooked = $method->invoke($manager, 'sendMessage', $payload); + $manager->setMessageTransport(null); + $unhooked = $method->invoke($manager, 'editMessage', $payload); + echo json_encode(['calls' => $probe->calls, 'results' => $results, 'hooked' => $hooked, 'unhooked' => $unhooked], JSON_THROW_ON_ERROR); +} +PHP; + $script = str_replace('SOURCE_FILE', $source, $script); + $process = proc_open([PHP_BINARY, '-r', $script], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__)); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + $proof = json_decode($output, true, 64, JSON_THROW_ON_ERROR); + self::assertCount(5, $proof['calls']); + self::assertSame('hooked', $proof['hooked']); + foreach (['sendMessage', 'sendMedia', 'sendMultiMedia', 'editMessage'] as $index => $name) { + self::assertSame($name, $proof['calls'][$index][0]); + self::assertCount(1, $proof['calls'][$index][1]); + self::assertSame($proof['calls'][$index][1][0], $proof['results'][$index]['parameters']); + } + self::assertSame('editMessage', $proof['unhooked']['method']); + self::assertSame($proof['results'][0]['parameters'], $proof['unhooked']['parameters']); + } +} diff --git a/tests/BuildMetadataTest.php b/tests/BuildMetadataTest.php new file mode 100644 index 0000000..e98d020 --- /dev/null +++ b/tests/BuildMetadataTest.php @@ -0,0 +1,108 @@ +fixture = sys_get_temp_dir() . '/shabbat-metadata-' . bin2hex(random_bytes(8)); + foreach (ReleaseInputs::REQUIRED as $relative) { + $path = $this->fixture . '/' . $relative; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0700, true); + } + file_put_contents($path, $relative === 'VERSION' ? "3.0.0-dev\n" : "fixture public source\n"); + } + } + + protected function tearDown(): void + { + $resolved = realpath($this->fixture); + if ($resolved === false || !str_starts_with($resolved, realpath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . 'shabbat-metadata-')) { + throw new RuntimeException('Metadata test cleanup target escaped its fixture.'); + } + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($resolved, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($resolved); + } + + private function archiveMetadata(): array + { + $metadata = ['commit' => self::COMMIT, 'application_source_sha256' => ReleaseInputs::fingerprint(ReleaseInputs::application($this->fixture)), 'local_changes' => true, 'origin' => 'verified local archive']; + file_put_contents($this->fixture . '/build-info.json', json_encode($metadata, JSON_THROW_ON_ERROR)); + return $metadata; + } + + public function testArchiveFallbackPreservesSourceFingerprintAndLocalChangeEvidence(): void + { + $before = $this->archiveMetadata(); + $after = BuildMetadata::write($this->fixture); + self::assertSame($before['commit'], $after['commit']); + self::assertSame($before['application_source_sha256'], $after['application_source_sha256']); + self::assertSame($before['local_changes'], $after['local_changes']); + self::assertSame($before['origin'], $after['origin']); + self::assertSame('3.0.0-dev', $after['version']); + } + + public function testExplicitCheckoutCommitIsCanonicalAndSourceFingerprintIsActual(): void + { + $metadata = BuildMetadata::write($this->fixture, strtoupper(self::COMMIT)); + self::assertSame(self::COMMIT, $metadata['commit']); + self::assertSame(ReleaseInputs::fingerprint(ReleaseInputs::application($this->fixture)), $metadata['application_source_sha256']); + self::assertTrue($metadata['local_changes']); + } + + public function testCheckoutWithoutExplicitCommitFailsBeforeWriting(): void + { + try { + BuildMetadata::write($this->fixture); + self::fail('Unknown checkout commit was accepted.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('explicit 40-hex', $error->getMessage()); + self::assertFileDoesNotExist($this->fixture . '/build-info.json'); + } + } + + public function testInvalidCommitCannotOverwriteRetainedMetadata(): void + { + $this->archiveMetadata(); + $before = file_get_contents($this->fixture . '/build-info.json'); + try { + BuildMetadata::write($this->fixture, self::COMMIT . "\n"); + self::fail('Invalid commit was accepted.'); + } catch (RuntimeException) { + self::assertSame($before, file_get_contents($this->fixture . '/build-info.json')); + } + } + + public function testModifiedArchiveSourceCannotKeepAnOldFingerprint(): void + { + $this->archiveMetadata(); + $before = file_get_contents($this->fixture . '/build-info.json'); + file_put_contents($this->fixture . '/bot.php', "source changed\n", FILE_APPEND); + try { + BuildMetadata::write($this->fixture); + self::fail('Changed source with stale fingerprint was accepted.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('differs from retained archive', $error->getMessage()); + self::assertSame($before, file_get_contents($this->fixture . '/build-info.json')); + } + } +} diff --git a/tests/CalendarDiagnosticsTest.php b/tests/CalendarDiagnosticsTest.php new file mode 100644 index 0000000..8171ac2 --- /dev/null +++ b/tests/CalendarDiagnosticsTest.php @@ -0,0 +1,299 @@ +migrate(); + $actual = (new SettingsRepository($database, $this->clock()))->ensureGroup('-1'); + $settings = CalendarDiagnostics::settings(); + foreach ($settings as $field => $value) { + self::assertSame($actual[$field], $value, $field); + } + self::assertSame(40, CalendarEngine::candleMinutes($settings)); + self::assertSame(31.76904, $settings['latitude']); + self::assertSame(35.21633, $settings['longitude']); + } + + public function testFullUntouchedPublicResponsesExerciseAllServiceViewsWithOnlyThreeRequests(): void + { + $solar = json_decode((string) file_get_contents(__DIR__ . '/Fixtures/hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json'), true, 64, JSON_THROW_ON_ERROR); + $calendar = json_decode((string) file_get_contents(__DIR__ . '/Fixtures/hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json'), true, 64, JSON_THROW_ON_ERROR); + $http = new CalendarFixtureClient(); + $http->override = static function (string $url, array $query) use ($solar, $calendar): array { + if (str_ends_with($url, '/zmanim')) { + self::assertSame(31.76904, $query['latitude']); + self::assertSame(35.21633, $query['longitude']); + return $solar; + } + if ($query['start'] === $query['end']) { + $calendar['range'] = ['start' => $query['start'], 'end' => $query['end']]; + $calendar['items'] = array_values(array_filter($calendar['items'], static fn(array $item): bool => $item['date'] === $query['start'])); + } + return $calendar; + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(['date' => '2026-10-15']); + self::assertTrue($report['ok']); + self::assertSame(['start' => '2026-09-23', 'end' => '2026-12-10'], $report['window']); + self::assertSame(79, $report['stages']['solar']['day_count']); + self::assertSame('valid_inactive', $report['stages']['omer']['state']); + self::assertNull($report['stages']['omer']['day']); + self::assertNotNull($report['stages']['shabbat_holidays']['next_close_at']); + self::assertSame($solar['times']['sunset']['2026-10-15'], $report['stages']['zmanim']['sunset']); + foreach ($report['stages'] as $stage) { + self::assertSame('ok', $stage['status']); + } + self::assertCount(3, $http->calls); + self::assertTrue($report['read_only']); + self::assertFalse($report['credentials_required']); + self::assertFalse($report['persisted_data_used']); + } + + public function testOmerInSeasonIsSuccessfulAndDistinctFromIneligibleDaytime(): void + { + $http = new CalendarFixtureClient(); + $report = (new CalendarDiagnostics($this->clock('2026-04-12T17:00:00Z'), $http))->run(); + self::assertTrue($report['ok']); + self::assertSame('active_season', $report['stages']['omer']['state']); + self::assertSame(11, $report['stages']['omer']['day']); + self::assertSame('night', $report['stages']['omer']['phase']); + self::assertTrue($report['stages']['omer']['eligible']); + $noon = (new CalendarDiagnostics($this->clock(), new CalendarFixtureClient()))->run(['date' => '2026-04-12']); + self::assertSame('active_season', $noon['stages']['omer']['state']); + self::assertFalse($noon['stages']['omer']['eligible']); + self::assertSame('2026-04-12T12:00:00+03:00', $noon['input']['instant']); + } + + public function testMalformedSolarDataFailsSolarStageBlocksDependentViewsAndLeavesDafWorking(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (string $url, array $query): array { + $data = (new CalendarFixtureClient())->get($url, $query); + if (str_ends_with($url, '/zmanim')) { + unset($data['times']['sunset'][$query['start']]); + } + return $data; + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(); + self::assertFalse($report['ok']); + self::assertSame('ok', $report['stages']['calendar']['status']); + self::assertSame(['status' => 'failed', 'kind' => 'provider', 'reason' => 'solar_daily_coverage_incomplete'], $report['stages']['solar']); + foreach (['shabbat_holidays', 'zmanim', 'omer'] as $stage) { + self::assertSame(['status' => 'blocked', 'reason' => 'provider_stage_failed'], $report['stages'][$stage]); + } + self::assertSame('ok', $report['stages']['daf']['status']); + self::assertCount(3, $http->calls); + } + + public function testMalformedCalendarDataHasIndependentSolarResultAndNoRetries(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (string $url, array $query): array { + $data = (new CalendarFixtureClient())->get($url, $query); + if (str_ends_with($url, '/hebcal') && $query['start'] !== $query['end']) { + $data['items'] = 'malformed'; + } + return $data; + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(); + self::assertSame('calendar_schema_invalid', $report['stages']['calendar']['reason']); + self::assertSame('ok', $report['stages']['solar']['status']); + self::assertSame('ok', $report['stages']['daf']['status']); + self::assertCount(3, $http->calls); + } + + public function testUnavailableRequiredTimesAreCalculationFailuresRatherThanTransportFailures(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (string $url, array $query): array { + $data = (new CalendarFixtureClient())->get($url, $query); + if (str_ends_with($url, '/zmanim')) { + foreach ($data['times']['sunset'] as &$value) { + $value = null; + } + unset($value); + } + return $data; + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(); + self::assertSame('ok', $report['stages']['solar']['status']); + self::assertSame(['status' => 'failed', 'kind' => 'calculation', 'reason' => 'calculation_required_times_unavailable'], $report['stages']['shabbat_holidays']); + self::assertSame('calculation', $report['stages']['omer']['kind']); + self::assertSame('ok', $report['stages']['daf']['status']); + self::assertCount(3, $http->calls); + } + + public function testUnknownErrorTextQueryAndTraceNeverEscapeReport(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (): array { + throw new ProviderException('BOT_TOKEN=DO_NOT_PRINT_SECRET https://provider.invalid/?secret=DO_NOT_PRINT_SECRET'); + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(); + self::assertFalse($report['ok']); + $json = json_encode($report, JSON_THROW_ON_ERROR); + foreach (['DO_NOT_PRINT_SECRET', 'BOT_TOKEN', 'provider.invalid', 'trace', 'https://'] as $private) { + self::assertStringNotContainsString($private, $json); + } + self::assertSame('provider_validation_failed', $report['stages']['calendar']['reason']); + self::assertSame('provider_validation_failed', $report['stages']['solar']['reason']); + self::assertCount(3, $http->calls); + } + + public function testTransportTimeoutHasSafeDistinctReasonAndNoTightRetry(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (): array { + throw new TimeoutException('DO_NOT_PRINT_SECRET'); + }; + $report = (new CalendarDiagnostics($this->clock(), $http))->run(); + self::assertSame('provider_transport_timeout', $report['stages']['solar']['reason']); + self::assertStringNotContainsString('DO_NOT_PRINT_SECRET', json_encode($report, JSON_THROW_ON_ERROR)); + self::assertCount(3, $http->calls); + } + + public function testIntentionalCancellationPropagatesWithoutContinuingOtherQueries(): void + { + $http = new CalendarFixtureClient(); + $http->override = static function (): array { + throw new CancelledException(); + }; + try { + (new CalendarDiagnostics($this->clock(), $http))->run(); + self::fail('Cancellation was converted into a diagnostic result.'); + } catch (CancelledException) { + self::assertCount(1, $http->calls); + } + } + + public function testValidatedOverridesPreserveIndependentGeographyLanguageAndIsraelScheme(): void + { + $arguments = ['--date=2026-05-23', '--latitude', '40.7', '--longitude=-74', '--timezone=America/New_York', '--israel=off', '--language=pt-br']; + $options = CalendarDiagnostics::parseArguments($arguments); + $settings = CalendarDiagnostics::settings($options); + self::assertNull($settings['geoname_id']); + self::assertSame(18, CalendarEngine::candleMinutes($settings)); + $http = new CalendarFixtureClient(); + $report = (new CalendarDiagnostics($this->clock(), $http))->run($options); + self::assertTrue($report['ok']); + self::assertSame('America/New_York', $report['input']['timezone_id']); + self::assertFalse($report['input']['israel']); + self::assertSame('pt-br', $report['input']['language']); + self::assertSame('off', $http->calls[0][1]['i']); + self::assertSame('pt', $http->calls[0][1]['lg']); + self::assertTrue($report['stages']['shabbat_holidays']['is_yom_tov']); + } + + #[DataProvider('invalidArguments')] + public function testInvalidOptionsCannotReachProvider(array $arguments): void + { + $this->expectException(\InvalidArgumentException::class); + CalendarDiagnostics::parseArguments($arguments); + } + + public static function invalidArguments(): array + { + return [ + 'invalid day' => [['--date=2026-02-30']], + 'missing value' => [['--date']], + 'empty value' => [['--date=']], + 'duplicate value' => [['--date=2026-10-15', '--date=2026-10-16']], + 'unknown option' => [['--env=private.env']], + 'bare argument' => [['2026-10-15']], + 'partial coordinates' => [['--latitude=31']], + 'out of range' => [['--latitude=91', '--longitude=35']], + 'non-finite coordinate' => [['--latitude=INF', '--longitude=35']], + 'wrong zone' => [['--timezone=+03:00']], + 'wrong Israel scheme' => [['--israel=sometimes']], + 'wrong language' => [['--language=zz']], + 'option used as value' => [['--date', '--language=he']], + ]; + } + + public function testCliWithoutArgumentArrayStopsBeforeAnyNetworkOrBootstrap(): void + { + $process = proc_open( + [PHP_BINARY, '-r', 'unset($argv); require $_SERVER["argv"][1];', dirname(__DIR__) . '/bin/check-calendar.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + dirname(__DIR__), + null, + ['bypass_shell' => true], + ); + self::assertIsResource($process); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(2, proc_close($process)); + self::assertSame('', $stderr); + self::assertSame('cli_arguments_unavailable', json_decode($stdout, true, 64, JSON_THROW_ON_ERROR)['reason']); + } + + public function testCliInvalidOptionsDoNotReadEnvCreateStorageOrPrintSecrets(): void + { + $fixture = sys_get_temp_dir() . '/shabbat-calendar-diagnostic-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($fixture, 0700)); + $env = $fixture . '/.env'; + file_put_contents($env, 'BOT_TOKEN=DO_NOT_PRINT_SECRET'); + try { + $environment = getenv(); + self::assertIsArray($environment); + $environment = array_replace($environment, [ + 'BOT_TOKEN' => 'DO_NOT_PRINT_SECRET', 'API_HASH' => 'DO_NOT_PRINT_SECRET', + 'APP_DB_PATH' => $fixture . '/never-created.sqlite', + 'DATA_DIR' => $fixture . '/never-created-data', + 'SESSION_PATH' => $fixture . '/never-created-session', + ]); + $process = proc_open( + [PHP_BINARY, dirname(__DIR__) . '/bin/check-calendar.php', '--date=DO_NOT_PRINT_SECRET'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $fixture, + $environment, + ['bypass_shell' => true], + ); + self::assertIsResource($process); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(2, proc_close($process)); + self::assertSame('', $stderr); + self::assertStringNotContainsString('DO_NOT_PRINT_SECRET', $stdout); + self::assertSame('invalid_options', json_decode($stdout, true, 64, JSON_THROW_ON_ERROR)['reason']); + self::assertSame(['.env'], array_values(array_diff(scandir($fixture), ['.', '..']))); + self::assertSame('BOT_TOKEN=DO_NOT_PRINT_SECRET', file_get_contents($env)); + } finally { + unlink($env); + rmdir($fixture); + } + } +} diff --git a/tests/CalendarFullResponseIntegrationTest.php b/tests/CalendarFullResponseIntegrationTest.php new file mode 100644 index 0000000..2d86bf6 --- /dev/null +++ b/tests/CalendarFullResponseIntegrationTest.php @@ -0,0 +1,297 @@ +setValue(null, false); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->gateway = new FullCalendarGateway(); + $this->http = new FullHebcalFixtureClient(); + $this->calendar = new CalendarService(new HebcalProvider($this->http), $this->clock); + $this->ui = new BotController( + $this->settings, + new FlowTokenRepository($this->db, $this->clock), + $this->calendar, + $this->gateway, + new GeoNames($this->http, $this->clock, ''), + $this->clock + ); + $this->previousHeartbeat = getenv('HEARTBEAT_PATH'); + $this->heartbeat = sys_get_temp_dir() . '/shabbat-full-calendar-' . bin2hex(random_bytes(8)) . '.json'; + putenv('HEARTBEAT_PATH=' . $this->heartbeat); + } + + protected function tearDown(): void + { + if (is_file($this->heartbeat)) { + unlink($this->heartbeat); + } + putenv($this->previousHeartbeat === false ? 'HEARTBEAT_PATH' : 'HEARTBEAT_PATH=' . $this->previousHeartbeat); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + + public function testCapturedFixtureProvenanceMatchesUnmodifiedCompleteResponseBytes(): void + { + $provenance = FullHebcalFixtureClient::read('hebcal-full-runtime-provenance.json'); + self::assertSame('CC-BY-4.0', $provenance['license']); + self::assertCount(15, $provenance['fixtures']); + foreach ($provenance['fixtures'] as $fixture) { + self::assertSame($fixture['sha256'], hash_file('sha256', __DIR__ . '/Fixtures/' . $fixture['file'])); + self::assertSame('www.hebcal.com', parse_url($fixture['url'], PHP_URL_HOST)); + $raw = FullHebcalFixtureClient::read($fixture['file']); + self::assertArrayHasKey('date', $raw); + self::assertArrayHasKey('location', $raw); + self::assertArrayHasKey('version', $raw); + } + } + + public function testDefaultPrivateCommandsAndGroupTefillinUseFullResponsesInAllSixLanguages(): void + { + $user = $this->settings->getUser('42'); + self::assertSame(31.76904, $user['latitude']); + self::assertSame(35.21633, $user['longitude']); + self::assertSame('Asia/Jerusalem', $user['timezone_id']); + self::assertTrue($user['israel']); + $group = $this->settings->ensureGroup('-10042', 'Offline calendar integration'); + $lang = new Lang(); + $raw = FullHebcalFixtureClient::read('hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json'); + $sunset = (new \DateTimeImmutable($raw['times']['sunset']['2026-10-15']))->format('H:i'); + foreach (Lang::CODES as $code) { + $user = $this->settings->updateUser('42', $user['revision'], ['language' => $code]); + $group = $this->settings->updateGroup('-10042', $group['revision'], ['language' => $code], '42'); + foreach (['zmanim','shabbat','omer'] as $module) { + self::assertTrue($this->ui->handleMessage(['user_id' => '42','private' => true,'text' => '/' . $module])); + $text = $this->gateway->private[array_key_last($this->gateway->private)]['text']; + self::assertStringNotContainsString($lang->text($code, 'provider_unavailable'), $text, $code . '/' . $module); + if ($module === 'zmanim') { + self::assertStringContainsString($lang->text($code, 'sunset'), $text); + self::assertStringContainsString($sunset, $text); + } elseif ($module === 'shabbat') { + self::assertStringContainsString($lang->text($code, 'times_heading'), $text); + self::assertStringContainsString('2026-10-17', $text); + self::assertStringNotContainsString('Hebcal.com', $text); + self::assertStringContainsString('Hebcal.com', $lang->text($code, 'info_calendar_text')); + } else { + self::assertSame($lang->text($code, 'omer_inactive'), $text); + } + } + $preview = $this->ui->renderView('42', $group, 'tefillin', '2026-10-15'); + self::assertStringNotContainsString($lang->text($code, 'provider_unavailable'), $preview); + self::assertStringContainsString($lang->text($code, 'preview_only'), $preview); + self::assertStringContainsString('09:00', $preview); + } + self::assertSame(0, $this->gateway->permissionChanges); + self::assertSame([], $this->gateway->sent); + self::assertCount(6, array_filter($this->http->calls, static fn(array $call): bool => str_ends_with($call[0], '/hebcal'))); + } + + public function testFullRawResponsesRemainUsableThroughPersistentAndSharedCacheAcrossRestart(): void + { + $group = $this->settings->ensureGroup('-10042'); + $makeCalendar = fn(): CalendarService => new CalendarService( + new HebcalProvider(new SharedJsonClient(new SqliteJsonClient($this->http, $this->db, $this->clock), $this->clock)), + $this->clock + ); + $first = $makeCalendar()->zmanim($group, '2026-10-15'); + self::assertCount(2, $this->http->calls); + $cached = $makeCalendar()->zmanim($group, '2026-10-15'); + self::assertSame($first, $cached); + self::assertCount(2, $this->http->calls); + $solarPayload = $this->db->pdo()->query("SELECT payload_json FROM calendar_cache WHERE payload_json LIKE '%seaLevelSunrise%'")->fetchColumn(); + self::assertIsString($solarPayload); + $raw = json_decode($solarPayload, true, 64, JSON_THROW_ON_ERROR); + self::assertCount(42, $raw['times']); + self::assertSame([], $raw['times']['seaLevelSunrise']); + self::assertArrayHasKey('molad', $raw['times']); + self::assertArrayNotHasKey('molad', $cached['times']); + self::assertArrayNotHasKey('seaLevelSunrise', $cached['times']); + self::assertIsInt($cached['times']['sunset']); + } + + public function testActiveOmerUsesGenuineLocaleCountsAndChangesAfterSunsetInAllSixLanguages(): void + { + $this->clock->set(new \DateTimeImmutable('2026-04-14T12:00:00+03:00')); + $group = $this->settings->ensureGroup('-10042'); + $lang = new Lang(); + foreach (Lang::CODES as $code) { + $group = $this->settings->updateGroup('-10042', $group['revision'], ['language' => $code], '42'); + $day = $this->calendar->omer($group); + self::assertTrue($day['season'], $code); + self::assertSame(12, $day['day'], $code); + self::assertSame('2026-04-14', $day['date']); + self::assertSame('day', $day['phase']); + self::assertFalse($day['eligible']); + $night = $this->calendar->omer($group, new \DateTimeImmutable('2026-04-14T21:00:00+03:00')); + self::assertTrue($night['season']); + self::assertSame(13, $night['day'], $code); + self::assertSame('2026-04-15', $night['date']); + self::assertSame('night', $night['phase']); + self::assertTrue($night['eligible']); + self::assertFalse($night['quiet_now']); + self::assertNotEmpty($night['count_text']); + $view = $this->ui->renderView('42', $group, 'omer', '2026-04-14'); + self::assertStringNotContainsString($lang->text($code, 'provider_unavailable'), $view); + self::assertStringNotContainsString($lang->text($code, 'omer_inactive'), $view); + self::assertStringContainsString($lang->text($code, 'omer'), $view); + } + foreach ($this->http->calls as [$url, $query]) { + if (str_ends_with($url, '/hebcal')) { + self::assertContains($query['lg'], ['he','s','ru','fr','es','pt']); + } + } + } + + public function testOmerTwilightPreviousNightDawnAndQuietPeriodUseActualSolarBoundaries(): void + { + $this->clock->set(new \DateTimeImmutable('2026-04-14T12:00:00+03:00')); + $group = $this->settings->ensureGroup('-10042'); + $sun = $this->calendar->zmanim($group, '2026-04-14')['times']; + $at = static fn(int $stamp): \DateTimeImmutable => new \DateTimeImmutable('@' . $stamp); + $twilight = $this->calendar->omer($group, $at($sun['sunset'])); + self::assertSame(13, $twilight['day']); + self::assertSame('twilight', $twilight['phase']); + self::assertFalse($twilight['eligible']); + $night = $this->calendar->omer($group, $at($sun['tzeit85deg'])); + self::assertSame('night', $night['phase']); + self::assertTrue($night['eligible']); + $tomorrow = $this->calendar->zmanim($group, '2026-04-15')['times']; + $beforeDawn = $this->calendar->omer($group, $at($tomorrow['alotHaShachar'] - 1)); + self::assertSame(13, $beforeDawn['day']); + self::assertSame('2026-04-14', $beforeDawn['night_date']); + self::assertTrue($beforeDawn['eligible']); + $afterDawn = $this->calendar->omer($group, $at($tomorrow['alotHaShachar'])); + self::assertSame('day', $afterDawn['phase']); + self::assertFalse($afterDawn['eligible']); + $group = $this->settings->updateGroup('-10042', $group['revision'], ['omer_day_completion' => true], '42'); + self::assertTrue($this->calendar->omer($group, new \DateTimeImmutable('2026-04-15T12:00:00+03:00'))['day_completion']); + $shabbat = $this->calendar->omer($group, new \DateTimeImmutable('2026-04-17T21:00:00+03:00')); + self::assertSame(16, $shabbat['day']); + self::assertTrue($shabbat['quiet_now']); + self::assertFalse($shabbat['eligible']); + $last = $this->calendar->omer($group, new \DateTimeImmutable('2026-04-02T21:00:00+03:00')); + self::assertSame(1, $last['day']); + $this->clock->set(new \DateTimeImmutable('2026-05-20T21:00:00+03:00')); + $last = $this->calendar->omer($group); + self::assertSame(49, $last['day']); + $ended = $this->calendar->omer($group, new \DateTimeImmutable('2026-05-21T21:00:00+03:00')); + self::assertFalse($ended['season']); + self::assertNull($ended['day']); + self::assertFalse($ended['eligible']); + } + + public function testChosenDegreeAndFixedMinuteMethodsUseVerifiedFullSolarValues(): void + { + $this->clock->set(new \DateTimeImmutable('2026-04-14T21:00:00+03:00')); + $group = $this->settings->ensureGroup('-10042'); + $sun = $this->calendar->zmanim($group, '2026-04-14')['times']; + foreach ([['degrees',8.5,'tzeit85deg'],['degrees',7.083,'tzeit7083deg'],['minutes',42,null],['minutes',50,null],['minutes',72,null]] as [$method,$value,$field]) { + $group = $this->settings->updateGroup('-10042', $group['revision'], [ + 'havdalah_method' => $method,'havdalah_value' => $value, + 'omer_nightfall_method' => $method,'omer_nightfall_value' => $value, + ], '42'); + $expected = $field === null ? $sun['sunset'] + $value * 60 : $sun[$field]; + $omer = $this->calendar->omer($group); + self::assertSame($expected, $omer['nightfall']); + self::assertSame(13, $omer['day']); + self::assertTrue($omer['eligible']); + $state = $this->calendar->snapshot($group); + $lastDate = $state['next_event']['days'][array_key_last($state['next_event']['days'])]; + $exitSun = $this->calendar->zmanim($group, $lastDate)['times']; + $exit = $field === null ? $exitSun['sunset'] + $value * 60 : $exitSun[$field]; + self::assertSame($exit, $state['next_event']['havdalah_at']); + self::assertSame($exit, $state['next_event']['open_at']); + } + } + + + public function testShavuotFollowedByShabbatStaysClosedUntilFinalExitInIsraelAndDiaspora(): void + { + $this->clock->set(new \DateTimeImmutable('2026-05-21T12:00:00+03:00')); + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $israel = $app->settings->ensureGroup('-10042', 'Israel'); + $israel = $app->settings->updateGroup('-10042', $israel['revision'], [ + 'close_before_candles_minutes' => 10,'open_after_havdalah_minutes' => 15, + ], '42'); + $diaspora = $app->settings->ensureGroup('-10043', 'Diaspora'); + $diaspora = $app->settings->updateGroup('-10043', $diaspora['revision'], [ + 'israel' => false,'close_before_candles_minutes' => 10,'open_after_havdalah_minutes' => 15, + ], '42'); + $state = $this->calendar->snapshot($israel); + self::assertSame(['2026-05-22','2026-05-23'], $state['next_event']['days']); + $sun = $this->calendar->zmanim($israel, '2026-05-21')['times']; + self::assertSame($sun['sunset'] - 40 * 60, $state['next_event']['candles_at']); + self::assertSame($sun['sunset'] - 50 * 60, $state['next_event']['close_at']); + $closing = $state['next_event']['close_at']; + $opening = $state['next_event']['open_at']; + $this->clock->set(new \DateTimeImmutable('@' . ($closing - 1))); + $app->tick(); + self::assertSame(0, $this->gateway->permissionChanges); + $this->gateway->getPermissions('-10042'); + $this->gateway->getPermissions('-10043'); + $original = $this->gateway->rights; + $this->clock->set(new \DateTimeImmutable('@' . $closing)); + $app->tick(); + self::assertSame(2, $this->gateway->permissionChanges); + self::assertSame('locked', $app->locks->state('-10042')['status']); + self::assertSame('locked', $app->locks->state('-10043')['status']); + $friday = $this->calendar->snapshot($israel, new \DateTimeImmutable('2026-05-22T12:00:00+03:00')); + self::assertTrue($friday['is_yom_tov']); + self::assertFalse($friday['is_shabbat']); + $between = $this->calendar->zmanim($israel, '2026-05-22')['times']['tzeit85deg'] + 60; + $this->clock->set(new \DateTimeImmutable('@' . $between)); + $app->tick(); + self::assertSame(2, $this->gateway->permissionChanges); + $saturday = $this->calendar->snapshot($israel, new \DateTimeImmutable('2026-05-23T12:00:00+03:00')); + self::assertTrue($saturday['is_shabbat']); + self::assertFalse($saturday['is_yom_tov']); + $second = $this->calendar->snapshot($diaspora, new \DateTimeImmutable('2026-05-23T12:00:00+03:00')); + self::assertTrue($second['is_shabbat']); + self::assertTrue($second['is_yom_tov']); + self::assertSame($opening, $second['next_open_at']); + $this->clock->set(new \DateTimeImmutable('@' . ($opening - 1))); + $app->tick(); + self::assertSame(2, $this->gateway->permissionChanges); + $this->clock->set(new \DateTimeImmutable('@' . $opening)); + $app->tick(); + self::assertSame(4, $this->gateway->permissionChanges); + self::assertSame($original, $this->gateway->rights); + self::assertSame('restored', $app->locks->state('-10042')['status']); + self::assertSame('restored', $app->locks->state('-10043')['status']); + self::assertSame([], $this->gateway->sent); + } +} diff --git a/tests/CalendarProviderTest.php b/tests/CalendarProviderTest.php new file mode 100644 index 0000000..01c0d67 --- /dev/null +++ b/tests/CalendarProviderTest.php @@ -0,0 +1,219 @@ +clock = new FrozenClock(new \DateTimeImmutable('2026-04-12T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $this->settings = (new SettingsRepository($db, $this->clock))->ensureGroup('-1'); + } + public function testCoordinatesAndIsraelSchemeRemainIndependentAcrossGroups(): void + { + $http = new CalendarFixtureClient(); + $service = new CalendarService(new HebcalProvider($http), $this->clock); + $zone = date_default_timezone_get(); + $jerusalem = $service->zmanim($this->settings, '2026-04-12'); + $other = $this->settings; + $other['latitude'] = 32.8; + $other['longitude'] = 34.98; + $other['location_name'] = 'Haifa'; + $haifa = $service->zmanim($other, '2026-04-12'); + self::assertSame(600, $haifa['times']['sunset'] - $jerusalem['times']['sunset']); + $other['timezone_id'] = 'America/New_York'; + $other['israel'] = false; + $service->snapshot($other, new \DateTimeImmutable('2026-05-23T12:00:00-04:00')); + self::assertSame($zone, date_default_timezone_get()); + $calendarCalls = array_filter($http->calls, static fn(array $call): bool=>str_ends_with($call[0], '/hebcal')); + self::assertContains('off', array_column(array_column($calendarCalls, 1), 'i')); + self::assertContains('on', array_column(array_column($calendarCalls, 1), 'i')); + } + public function testMergedShavuotSequenceKeepsAccurateCurrentDayFlagsInIsraelAndDiaspora(): void + { + $service = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $this->clock); + $friday = $service->snapshot($this->settings, new \DateTimeImmutable('2026-05-22T10:00:00+03:00')); + self::assertTrue($friday['is_yom_tov']); + self::assertFalse($friday['is_shabbat']); + self::assertTrue($friday['should_group_be_locked']); + $saturday = $service->snapshot($this->settings, new \DateTimeImmutable('2026-05-23T10:00:00+03:00')); + self::assertTrue($saturday['is_shabbat']); + self::assertFalse($saturday['is_yom_tov']); + self::assertSame(['2026-05-22','2026-05-23'], $saturday['current_event']['days']); + $diaspora = $this->settings; + $diaspora['israel'] = false; + $secondDay = $service->snapshot($diaspora, new \DateTimeImmutable('2026-05-23T10:00:00+03:00')); + self::assertTrue($secondDay['is_shabbat']); + self::assertTrue($secondDay['is_yom_tov']); + self::assertSame($saturday['next_open_at'], $secondDay['next_open_at']); + } + public function testDafYomiUsesExplicitCivilDateAndReturnsPageAndTrustedLink(): void + { + $service = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $this->clock); + $today = $service->daf($this->settings, '2026-04-12'); + $tomorrow = $service->daf($this->settings, '2026-04-13'); + self::assertSame(14, $today['page']); + self::assertSame(15, $tomorrow['page']); + self::assertSame('Berakhot', $today['tractate']); + self::assertSame('2026-04-12', $today['date']); + } + public function testInvalidProviderDateRangeIsRejected(): void + { + $http = new CalendarFixtureClient(); + $http->override = static fn(): array=>['range' => ['start' => '2026-02-30','end' => '2026-04-30'],'items' => []]; + $this->expectException(ProviderException::class); + (new HebcalProvider($http))->calendar('2026-04-12', '2026-04-13', true); + } + public function testStaleCoverageIsRejectedEvenIfProviderTimestampIsCurrent(): void + { + $http = new CalendarFixtureClient(); + $http->override = static fn(): array=>['date' => '2026-04-12T12:00:00Z','range' => ['start' => '2026-04-01','end' => '2026-04-05'],'items' => []]; + $this->expectException(ProviderException::class); + (new HebcalProvider($http))->calendar('2026-04-12', '2026-04-13', true); + } + public function testMalformedSolarTimestampAndWrongTimezoneAreRejected(): void + { + foreach (['2026-04-12T18:00:00+01:00','2026-04-31T18:00:00+03:00','yesterday'] as $bad) { + $http = new CalendarFixtureClient(); + $http->override = static fn(): array=>['date' => ['start' => '2026-04-12','end' => '2026-04-12'],'times' => ['sunset' => ['2026-04-12' => $bad]]]; + try { + (new HebcalProvider($http))->solar($this->settings, '2026-04-12', '2026-04-12'); + self::fail('Invalid solar value accepted.'); + } catch (ProviderException) { + self::assertTrue(true); + } + } + } + public function testNullPolarSunsetIsPreservedButIncompleteDateMapFails(): void + { + $http = new CalendarFixtureClient(); + $http->override = static fn(): array=>['date' => ['start' => '2026-04-12','end' => '2026-04-12'],'times' => ['sunset' => ['2026-04-12' => null]]]; + self::assertNull((new HebcalProvider($http))->solar($this->settings, '2026-04-12', '2026-04-12')['2026-04-12']['sunset']); + $http->override = static fn(): array=>['date' => ['start' => '2026-04-12','end' => '2026-04-12'],'times' => ['sunset' => []]]; + $this->expectException(ProviderException::class); + (new HebcalProvider($http))->solar($this->settings, '2026-04-12', '2026-04-12'); + } + public function testPersistentRawCacheShowsOriginalFetchTimeAndVisibleStaleFallback(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $http = new CalendarFixtureClient(); + $query = ['start' => '2026-04-12','end' => '2026-04-13','i' => 'on']; + $one = new SqliteJsonClient($http, $db, $this->clock, 30); + $first = $one->get('https://www.hebcal.com/hebcal', $query); + $two = new SqliteJsonClient($http, $db, $this->clock, 30); + $cached = $two->get('https://www.hebcal.com/hebcal', $query); + self::assertCount(1, $http->calls); + self::assertSame($first, $cached); + $this->clock->set(new \DateTimeImmutable('2026-04-12T12:01:00Z')); + $http->override = static function (): array { + throw new ProviderException('offline'); + }; + $stale = $two->get('https://www.hebcal.com/hebcal', $query); + self::assertTrue($stale['__shabbat_cache']['stale']); + self::assertSame($first['__shabbat_cache']['fetched_at'], $stale['__shabbat_cache']['fetched_at']); + self::assertArrayHasKey('__shabbat_refresh_error', $stale); + self::assertSame('2026-04-12', $db->pdo()->query('SELECT coverage_start FROM calendar_cache')->fetchColumn()); + } + public function testCancellationIsNotConvertedToCachedSuccess(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $http = new CalendarFixtureClient(); + $client = new SqliteJsonClient($http, $db, $this->clock, 30); + $client->get('https://www.hebcal.com/hebcal', ['start' => '2026-04-12','end' => '2026-04-13','i' => 'on']); + $cancel = new DeferredCancellation(); + $cancel->cancel(); + $this->expectException(\Amp\CancelledException::class); + $client->get('https://www.hebcal.com/hebcal', ['start' => '2026-04-12','end' => '2026-04-13','i' => 'on'], $cancel->getCancellation()); + } + public function testConcurrentIdenticalRequestsCoalesceAndOneCancelledWaiterDoesNotCancelOthers(): void + { + $inner = new class implements JsonClient { + public int $calls = 0; + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + $this->calls++; + delay(0.02); + return ['value' => 'fixture']; + } + }; + $shared = new SharedJsonClient($inner, $this->clock); + $cancel = new DeferredCancellation(); + $one = async(fn(): array=>$shared->get('https://fixture.invalid', ['q' => 'city'], $cancel->getCancellation())); + $two = async(fn(): array=>$shared->get('https://fixture.invalid', ['q' => 'city'])); + delay(0.005); + $cancel->cancel(); + try { + $one->await(); + self::fail('Cancellation did not propagate.'); + } catch (\Amp\CancelledException) { + } + self::assertSame(['value' => 'fixture'], $two->await()); + self::assertSame(1, $inner->calls); + self::assertSame(['value' => 'fixture'], $shared->get('https://fixture.invalid', ['q' => 'city'])); + self::assertSame(1, $inner->calls); + } + public function testEveryDisplayLanguageMapsExplicitlyAndCannotChangeRestrictions(): void + { + foreach (['he' => 'he','en' => 's','ru' => 'ru','fr' => 'fr','es' => 'es','pt-br' => 'pt'] as $internal => $providerCode) { + $http = new CalendarFixtureClient(); + $provider = new HebcalProvider($http); + $response = $provider->calendar('2026-05-22', '2026-05-23', false, $internal); + self::assertSame($providerCode, $http->calls[0][1]['lg']); + self::assertSame($internal, $response['requested_language']); + self::assertSame($providerCode, $response['provider_language']); + $group = $this->settings; + $group['language'] = $internal; + $group['israel'] = false; + $state = (new CalendarService($provider, $this->clock))->snapshot($group, new \DateTimeImmutable('2026-05-23T10:00:00+03:00')); + self::assertTrue($state['is_shabbat']); + self::assertTrue($state['is_yom_tov']); + self::assertTrue($state['should_group_be_locked']); + } + } + public function testOriginalEnglishTitleWorksWhenProviderOmitsRedundantTitleOriginal(): void + { + $fixture = new CalendarFixtureClient(); + $fixture->override = static function (string $url, array $query): array { + $original = (new CalendarFixtureClient())->get($url, $query); + if (str_ends_with($url, '/hebcal')) { + foreach ($original['items'] as &$item) { + if ($item['category'] === 'holiday') { + unset($item['title_orig']); + } + } unset($item); + } + return $original; + }; + $group = $this->settings; + $group['language'] = 'en'; + $state = (new CalendarService(new HebcalProvider($fixture), $this->clock))->snapshot($group, new \DateTimeImmutable('2019-08-11T10:00:00+03:00')); + self::assertTrue($state['is_tisha_bav']); + self::assertFalse($state['is_yom_tov']); + } +} diff --git a/tests/CalendarTest.php b/tests/CalendarTest.php new file mode 100644 index 0000000..0ac916b --- /dev/null +++ b/tests/CalendarTest.php @@ -0,0 +1,131 @@ + 'Asia/Jerusalem','geoname_id' => 281184,'candle_minutes' => 18,'havdalah_method' => 'degrees','havdalah_value' => 8.5,'mode' => 'lock_messages','shabbat_enabled' => true,'holidays_enabled' => true,'close_before_candles_minutes' => 10,'open_after_havdalah_minutes' => 5,'language' => 'he']; + } + private function fixture(string $start = '2026-10-08', string $end = '2026-10-29'): array + { + $solar = []; + for ($day = new \DateTimeImmutable($start); $day->format('Y-m-d') <= $end; $day = $day->modify('+1 day')) { + $key = $day->format('Y-m-d'); + $solar[$key] = ['sunset' => LocalTime::resolve($key, '18:00', 'Asia/Jerusalem'),'tzeit85deg' => LocalTime::resolve($key, '18:40', 'Asia/Jerusalem'),'misheyakir' => LocalTime::resolve($key, '05:45', 'Asia/Jerusalem'),'minchaGedola' => LocalTime::resolve($key, '12:30', 'Asia/Jerusalem')]; + } + return [['range' => ['start' => $start,'end' => $end],'items' => []],$solar]; + } + public function testBeforeDuringAfterAndSundayDoNotReusePreviousWeek(): void + { + [$cal,$sun] = $this->fixture(); + $engine = new CalendarEngine(); + $settings = $this->settings(); + $before = $engine->evaluate($settings, $cal, $sun, new \DateTimeImmutable('2026-10-16 17:00', new \DateTimeZone('Asia/Jerusalem'))); + self::assertFalse($before['should_group_be_locked']); + self::assertSame('2026-10-17', $before['next_event']['event_key']); + $during = $engine->evaluate($settings, $cal, $sun, new \DateTimeImmutable('2026-10-17 10:00', new \DateTimeZone('Asia/Jerusalem'))); + self::assertTrue($during['is_work_prohibited_now']); + self::assertTrue($during['should_group_be_locked']); + $after = $engine->evaluate($settings, $cal, $sun, new \DateTimeImmutable('2026-10-18 10:00', new \DateTimeZone('Asia/Jerusalem'))); + self::assertNull($after['current_event']); + self::assertSame('2026-10-24', $after['next_event']['event_key'] ?? null); + } + public function testYomTovAdjacentToShabbatNeverOpensBetween(): void + { + [$cal,$sun] = $this->fixture(); + $cal['items'] = [['date' => '2026-10-16','category' => 'holiday','yomtov' => true,'hebrew' => 'ื—ื’','title' => 'Festival']]; + $state = (new CalendarEngine())->evaluate($this->settings(), $cal, $sun, new \DateTimeImmutable('2026-10-16 19:00', new \DateTimeZone('Asia/Jerusalem'))); + self::assertTrue($state['should_group_be_locked']); + self::assertSame(['2026-10-16','2026-10-17'], $state['current_event']['days']); + self::assertSame($sun['2026-10-17']['tzeit85deg'] + 300, $state['next_open_at']); + } + public function testFestivalAfterShabbatLightsAtNightfallAndDoesNotClaimEarlyFestivalDay(): void + { + [$cal,$sun] = $this->fixture(); + $cal['items'] = [['date' => '2026-10-18','category' => 'holiday','yomtov' => true,'hebrew' => 'ื—ื’','title' => 'Festival']]; + $engine = new CalendarEngine(); + $settings = $this->settings(); + $before = $engine->evaluate($settings, $cal, $sun, new \DateTimeImmutable('2026-10-17T17:30:00+03:00')); + self::assertTrue($before['is_shabbat']); + self::assertFalse($before['is_yom_tov']); + self::assertSame($sun['2026-10-17']['tzeit85deg'], $before['current_event']['intermediate'][0]['candles_at']); + $twilight = $engine->evaluate($settings, $cal, $sun, new \DateTimeImmutable('2026-10-17T18:10:00+03:00')); + self::assertTrue($twilight['is_shabbat']); + self::assertTrue($twilight['is_yom_tov']); + self::assertTrue($twilight['should_group_be_locked']); + } + public function testHolidayCategoryAloneDoesNotProhibitWork(): void + { + [$cal,$sun] = $this->fixture(); + $cal['items'] = [['date' => '2026-10-15','category' => 'holiday','title_orig' => 'Minor holiday']]; + $state = (new CalendarEngine())->evaluate($this->settings(), $cal, $sun, new \DateTimeImmutable('2026-10-15 12:00', new \DateTimeZone('Asia/Jerusalem'))); + self::assertFalse($state['is_yom_tov']); + self::assertFalse($state['is_work_prohibited_now']); + } + public function testAllCandleOptionsAndDegreesRemainExact(): void + { + foreach ([10,15,18,20,22,30,40] as $minutes) { + self::assertSame($minutes, CalendarEngine::candleMinutes(['candle_minutes' => $minutes])); + } + self::assertSame(40, CalendarEngine::candleMinutes(['geoname_id' => 281184])); + self::assertSame(18, CalendarEngine::candleMinutes(['geoname_id' => 293397])); + self::assertSame(200, CalendarEngine::nightfall(['sunset' => 100,'tzeit85deg' => 200], ['havdalah_method' => 'degrees','havdalah_value' => 8.5])); + self::assertSame(4420, CalendarEngine::nightfall(['sunset' => 100], ['havdalah_method' => 'minutes','havdalah_value' => 72])); + } + public function testMissingSunDoesNotGuess(): void + { + [$cal,$sun] = $this->fixture(); + $sun['2026-10-17']['tzeit85deg'] = null; + $this->expectException(\Shabbat\Http\ProviderException::class); + (new CalendarEngine())->evaluate($this->settings(), $cal, $sun, new \DateTimeImmutable('2026-10-17T10:00:00+03:00')); + } + public function testDstMissingTimeSkippedRepeatedTimeOnce(): void + { + self::assertNull(LocalTime::resolve('2026-03-08', '02:30', 'America/New_York')); + self::assertSame(strtotime('2026-11-01T01:30:00-04:00'), LocalTime::resolve('2026-11-01', '01:30', 'America/New_York')); + self::assertNotSame(LocalTime::resolve('2026-10-16', '12:00', 'Asia/Jerusalem'), LocalTime::resolve('2026-10-16', '12:00', 'America/New_York')); + } + public function testAll49CountsAndWeekTransitionsAreBundled(): void + { + for ($day = 1; $day <= 49; $day++) { + self::assertNotSame('', OmerText::count($day)); + self::assertStringContainsString((string)$day, OmerText::count($day, 'sephardi', 'en')); + } + self::assertStringContainsString('1 week', OmerText::count(7, 'sephardi', 'en')); + self::assertStringContainsString('1 day', OmerText::count(8, 'sephardi', 'en')); + self::assertStringContainsString('2 weeks', OmerText::count(14, 'sephardi', 'en')); + self::assertStringContainsString('7 weeks', OmerText::count(49, 'sephardi', 'en')); + self::assertStringNotContainsString('ื‘ืจื•ืš', OmerText::render(7, 'sephardi', true, true)); + } + public function testTefillinCombinesModesDeduplicatesAndCutsOffOnFriday(): void + { + [$cal,$sun] = $this->fixture(); + $state = (new CalendarEngine())->evaluate($this->settings(), $cal, $sun, new \DateTimeImmutable('2026-10-16T10:00:00+03:00')); + $jobs = (new ReminderPlanner())->tefillin($this->settings(), ['enabled' => true,'times' => ['17:00','17:45'],'before_sunset' => [60,30,15],'max_per_day' => 10,'min_gap_minutes' => 15], '2026-10-16', $sun['2026-10-16'], $state); + self::assertCount(2, $jobs); + self::assertSame(array_unique(array_column($jobs, 'run_at')), array_column($jobs, 'run_at')); + foreach ($jobs as $job) { + self::assertLessThan($state['next_close_at'], $job['run_at']); + } + } + public function testTefillinDisabledOnYomTovCholHamoedAndTishaBav(): void + { + $p = new ReminderPlanner(); + $sun = ['misheyakir' => 100,'sunset' => 1000]; + $rule = ['enabled' => true,'times' => ['12:00']]; + foreach ([['is_work_prohibited_now' => true],['is_chol_hamoed' => true],['is_tisha_bav' => true]] as $state) { + self::assertSame([], $p->tefillin($this->settings(), $rule, '2026-10-15', $sun, $state)); + } + } +} diff --git a/tests/CallbackRoutingContractTest.php b/tests/CallbackRoutingContractTest.php new file mode 100644 index 0000000..27c2096 --- /dev/null +++ b/tests/CallbackRoutingContractTest.php @@ -0,0 +1,253 @@ +getFileName(), $method->getFileName()); + self::assertSame($trait->getStartLine(), $method->getStartLine()); + self::assertCount(1, $method->getAttributes(Handler::class)); + $type = $method->getParameters()[0]->getType(); + self::assertNotNull($type); + $this->filter = Filter::fromReflectionType($type); + + $gateway = $this->createMock(CallbackRoutingGateway::class); + $gateway->method('botUsername')->willReturn('FixtureBot'); + $gateway->method('isGroupAdmin')->willReturn(false); + foreach (['getPermissions', 'setPermissions', 'send', 'answerInline'] as $name) { + $gateway->expects(self::never())->method($name); + } + $gateway->method('sendPrivate')->willReturnCallback(function (string $user, string $text, array $markup = []): void { + $this->sent[] = compact('user', 'text', 'markup'); + }); + $gateway->method('editPrivate')->willReturnCallback(function (string $user, int $id, string $text, array $markup = []): void { + if ($this->editFailure !== null) { + throw $this->editFailure; + } + $this->edited[] = compact('user', 'id', 'text', 'markup'); + }); + $gateway->method('answerCallback')->willReturnCallback(function (string $id, string $text): void { + $this->answers[] = compact('id', 'text'); + }); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \LogicException('Callback navigation must not request provider data.'); + } + }; + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $this->app = new Application( + $gateway, + $clock, + $db, + new CalendarService(new HebcalProvider($http), $clock), + new GeoNames($http, $clock, ''), + rpc: static fn (): never => throw new \LogicException('No RPC during callback navigation.'), + configuredOwners: static fn (): array => [] + ); + $this->app->settings->ensureGroup('-1001', 'Private group identity'); + $this->bridge = new CallbackRoutingFixture($this->app); + } + + public static function languages(): array + { + return array_map(static fn (string $code): array => [$code], Lang::CODES); + } + + #[DataProvider('languages')] + public function testGeneratedHomeStatisticsTabsRefreshAndBackUseActualSdkHandler(string $language): void + { + $user = $this->app->settings->getUser('2'); + $this->app->settings->updateUser('2', $user['revision'], ['language' => $language]); + self::assertFalse($this->app->adminAccess->isAdmin('2')); + $this->app->ui->start('2'); + $home = $this->sent[0]; + $this->sent = []; + self::assertContains('ps:overview', $this->callbacks($home['markup'])); + $this->dispatch('ps:overview'); + $overview = $this->edited[0]; + self::assertStringContainsString(AdminDirectoryText::text($language, 'stats_overview'), $overview['text']); + self::assertContains('ps:modules', $this->callbacks($overview['markup'])); + $this->dispatch('ps:modules'); + $modules = $this->edited[1]; + self::assertStringContainsString(AdminDirectoryText::text($language, 'stats_modules'), $modules['text']); + self::assertContains('ps:overview', $this->callbacks($modules['markup'])); + self::assertContains('ps:modules', $this->callbacks($modules['markup']), 'Refresh retains the current tab.'); + $this->dispatch('ps:modules'); + $this->dispatch('ps:overview'); + self::assertSame($overview['text'], $this->edited[3]['text']); + self::assertContains('sb:start', $this->callbacks($this->edited[3]['markup'])); + $this->dispatch('sb:start'); + self::assertSame($home['text'], $this->edited[4]['text']); + self::assertCount(5, $this->edited); + self::assertCount(5, $this->answers); + self::assertSame([], $this->sent, 'Each click must edit its own existing panel.'); + foreach ($this->edited as $edit) { + self::assertSame('2', $edit['user']); + self::assertSame(200, $edit['id']); + self::assertStringNotContainsString('Private group identity', $edit['text']); + self::assertStringNotContainsString('-1001', $edit['text']); + } + foreach ($this->answers as $answer) { + self::assertSame(['id' => '99', 'text' => ''], $answer); + } + } + + public static function invalidStatistics(): array + { + return [['ps:'], ['ps:admin'], ['ps:overview:extra'], ["ps:modules\0"], ['ps:OVERVIEW']]; + } + + #[DataProvider('invalidStatistics')] + public function testMalformedStatisticsCallbackIsDeniedWithoutEditing(string $data): void + { + $this->dispatch($data); + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([['id' => '99', 'text' => (new Lang())->text('he', 'denied')]], $this->answers); + } + + public static function tabs(): array + { + return [['ps:overview'], ['ps:modules']]; + } + + #[DataProvider('tabs')] + public function testGroupStatisticsCallbackCannotEditOrSendPrivatePanels(string $data): void + { + $this->dispatch($data, chat: -1001); + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([['id' => '99', 'text' => (new Lang())->text('he', 'denied')]], $this->answers); + } + + #[DataProvider('tabs')] + public function testInlineStatisticsCallbackIsRejectedBeforePrivateRouting(string $data): void + { + $query = $this->query($data, inline: true); + self::assertTrue($this->filter->apply($query)); + $this->bridge->upgradedCallbacks($query); + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([], $this->answers); + } + + #[DataProvider('tabs')] + public function testDeletedStatisticsPanelGetsOneErrorAnswerWithoutReplacement(string $data): void + { + $this->editFailure = new \RuntimeException('MESSAGE_ID_INVALID'); + $this->dispatch($data); + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([['id' => '99', 'text' => (new Lang())->text('he', 'callback_panel_unavailable')]], $this->answers); + } + + public function testStatisticsCancellationIsNotSwallowedOrRetried(): void + { + $this->editFailure = new CancelledException(); + try { + $this->dispatch('ps:overview'); + self::fail('Intentional cancellation must propagate to the event loop.'); + } catch (CancelledException $error) { + self::assertSame($this->editFailure, $error); + } + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([], $this->answers); + } + + public static function publicDestinations(): array + { + return [['sb:personal'], ['sb:help'], ['v3:source'], ['v3:donate']]; + } + + #[DataProvider('publicDestinations')] + public function testExistingPublicCallbackFamiliesStillReachTheirRealHandlers(string $data): void + { + $this->dispatch(data: $data, user: 3, panel: 701); + self::assertSame([], $this->sent); + self::assertCount(1, $this->edited); + self::assertSame('3', $this->edited[0]['user']); + self::assertSame(701, $this->edited[0]['id']); + self::assertSame([['id' => '99', 'text' => '']], $this->answers); + } + + public function testSdkParameterFilterAndOuterGuardIgnoreOtherUpdates(): void + { + $message = (new \ReflectionClass(PrivateMessage::class))->newInstanceWithoutConstructor(); + self::assertFalse($this->filter->apply($message)); + $this->dispatch('unrecognized:overview'); + self::assertSame([], $this->edited); + self::assertSame([], $this->sent); + self::assertSame([], $this->answers); + } + + private function dispatch(string $data, int $user = 2, ?int $chat = null, int $panel = 200): void + { + $query = $this->query($data, $user, $chat, $panel); + self::assertTrue($this->filter->apply($query), 'The real SDK parameter filter must accept the callback.'); + $this->bridge->upgradedCallbacks($query); + } + + private function query(string $data, int $user = 2, ?int $chat = null, int $panel = 200, bool $inline = false): ButtonQuery + { + $class = new \ReflectionClass($inline ? InlineButtonQuery::class : ChatButtonQuery::class); + $query = $class->newInstanceWithoutConstructor(); + $fields = ['data' => $data, 'userId' => $user, 'queryId' => 99, 'chatInstance' => 777]; + if (!$inline) { + $fields += ['chatId' => $chat ?? $user, 'messageId' => $panel]; + } + foreach ($fields as $key => $value) { + $class->getProperty($key)->getDeclaringClass()->getProperty($key)->setValue($query, $value); + } + return $query; + } + + private function callbacks(array $markup): array + { + $callbacks = []; + foreach ($markup['rows'] ?? [] as $row) { + foreach ($row['buttons'] as $button) { + if (isset($button['data'])) { + $callbacks[] = $button['data']; + } + } + } + return $callbacks; + } +} diff --git a/tests/CallbackRoutingFixture.php b/tests/CallbackRoutingFixture.php new file mode 100644 index 0000000..a88ec01 --- /dev/null +++ b/tests/CallbackRoutingFixture.php @@ -0,0 +1,28 @@ +app; + } + + public function getSelf(): array + { + return ['id' => 999]; + } +} diff --git a/tests/CallbackRoutingGateway.php b/tests/CallbackRoutingGateway.php new file mode 100644 index 0000000..cecbd58 --- /dev/null +++ b/tests/CallbackRoutingGateway.php @@ -0,0 +1,14 @@ +rights = $fixture['original']; + } + public function call(string $method, array $parameters): mixed + { + if ($method === 'messages.editChatDefaultBannedRights') { + $this->writes[] = $parameters['banned_rights']; + $this->rights = self::readback($parameters['banned_rights']); + ($this->afterWrite)?->__invoke($this); + } + return ['_' => 'updates', 'updates' => []]; + } + public static function readback(array $request): array + { + $request['send_messages'] = false; + $request['send_media'] = false; + if (($request['until_date'] ?? 0) === 0) { + $request['until_date'] = 2147483647; + } + return $request; + } + public function info(string $peer): array + { + return ['type' => $this->type, 'Chat' => ['_' => $this->type === 'chat' ? 'chat' : 'channel', 'id' => 1]]; + } + public function fullInfo(string $peer): array + { + return FreshPeerFixture::info($peer, $this->rights, $this->type); + } + public function fileInfo(string $fileId): array + { + throw new \LogicException('No files in permission fixture.'); + } + public function self(): array + { + return ['id' => 777, 'username' => 'PermissionFixtureBot', 'bot' => true]; + } +} diff --git a/tests/CholHamoedClassificationTest.php b/tests/CholHamoedClassificationTest.php new file mode 100644 index 0000000..f329eda --- /dev/null +++ b/tests/CholHamoedClassificationTest.php @@ -0,0 +1,130 @@ + ['2026-04-03', "Pesach II (CH''M)"]; + yield 'Sukkot Israel' => ['2026-09-27', "Sukkot II (CH''M)"]; + } + + #[DataProvider('capturedWeekdays')] + public function testCapturedProviderClassificationControlsReminderOptIn(string $date, string $original): void + { + [$settings, $state, $sun] = $this->calendar($date); + $matches = array_values(array_filter($state['calendar_items'], static fn(array $item): bool => + $item['date'] === $date && ($item['title_orig'] ?? null) === $original)); + self::assertCount(1, $matches, 'Unmodified captured provider response contains the exact double-apostrophe spelling.'); + self::assertTrue($state['is_chol_hamoed']); + self::assertFalse($state['is_yom_tov']); + self::assertFalse($state['is_work_prohibited_now']); + self::assertFalse($state['should_group_be_locked']); + self::assertNull($state['lock_event']); + self::assertFalse((bool)$settings['tefillin_chol_hamoed']); + $planner = new ReminderPlanner(); + $rule = ['enabled' => true, 'times' => ['11:00']]; + self::assertSame([], $planner->tefillin($settings, $rule, $date, $sun, $state)); + self::assertSame([], $planner->tefillin($settings, $rule + ['chol_hamoed' => false], $date, $sun, $state)); + $enabled = $planner->tefillin($settings, $rule + ['chol_hamoed' => true], $date, $sun, $state); + self::assertCount(1, $enabled); + self::assertSame(LocalTime::resolve($date, '11:00', 'Asia/Jerusalem'), $enabled[0]['run_at']); + } + + public function testCholHamoedOnShabbatStillLocksAndExplicitReminderOptInCannotOverrideIt(): void + { + [$settings, $state, $sun] = $this->calendar('2026-04-04'); + self::assertTrue($state['is_chol_hamoed']); + self::assertTrue($state['is_shabbat']); + self::assertFalse($state['is_yom_tov']); + self::assertTrue($state['should_group_be_locked']); + self::assertSame([], (new ReminderPlanner())->tefillin($settings, [ + 'enabled' => true, 'times' => ['11:00'], 'chol_hamoed' => true, + ], '2026-04-04', $sun, $state)); + } + + public function testActualYomTovRemainsSeparateAndCannotBeEnabledThroughCholHamoedPolicy(): void + { + foreach (['2026-04-02', '2026-09-26'] as $date) { + [$settings, $state, $sun] = $this->calendar($date); + self::assertFalse($state['is_chol_hamoed']); + self::assertTrue($state['is_yom_tov']); + self::assertTrue($state['should_group_be_locked']); + self::assertSame([], (new ReminderPlanner())->tefillin($settings, [ + 'enabled' => true, 'times' => ['11:00'], 'chol_hamoed' => true, + ], $date, $sun, $state)); + } + } + + public static function explicitMarkerVariants(): iterable + { + foreach ([true, false] as $israel) { + foreach (["(CH'M)", "(CH''M)"] as $marker) { + yield ($israel ? 'Israel' : 'diaspora') . ' Pesach ' . $marker => ['2026-04-05', 'Pesach IV ' . $marker, $israel]; + yield ($israel ? 'Israel' : 'diaspora') . ' Sukkot ' . $marker => ['2026-09-28', 'Sukkot III ' . $marker, $israel]; + } + } + } + + #[DataProvider('explicitMarkerVariants')] + public function testBothExplicitSpellingsWorkWithoutCreatingHolyPeriods(string $date, string $original, bool $israel): void + { + // Diaspora cases are modeled contract fixtures, not claimed captured responses. + [$settings] = $this->calendar($date); + $settings['israel'] = $israel; + $zone = new \DateTimeZone($settings['timezone_id']); + $now = new \DateTimeImmutable($date . ' 12:00', $zone); + $start = $now->modify('-2 days')->format('Y-m-d'); + $end = $now->modify('+2 days')->format('Y-m-d'); + $solar = []; + for ($day = new \DateTimeImmutable($start, $zone); $day->format('Y-m-d') <= $end; $day = $day->modify('+1 day')) { + $solar[$day->format('Y-m-d')] = [ + 'sunset' => $day->setTime(18, 0)->getTimestamp(), + 'tzeit85deg' => $day->setTime(18, 40)->getTimestamp(), + ]; + } + $calendar = ['range' => ['start' => $start, 'end' => $end], 'items' => [[ + 'date' => $date, 'category' => 'holiday', 'title' => 'Localized label', 'title_orig' => $original, + ]]]; + $engine = new CalendarEngine(); + $state = $engine->evaluate($settings, $calendar, $solar, $now); + self::assertTrue($state['is_chol_hamoed']); + self::assertFalse($state['is_yom_tov']); + self::assertFalse($state['is_work_prohibited_now']); + self::assertFalse($state['should_group_be_locked']); + $calendar['items'][0]['title_orig'] = str_replace(["(CH'M)", "(CH''M)"], '(CHM)', $original); + $unmarked = $engine->evaluate($settings, $calendar, $solar, $now); + self::assertFalse($unmarked['is_chol_hamoed'], 'Only the two reviewed explicit markers are recognized.'); + self::assertSame($unmarked['events'], $state['events'], 'Chol Hamoed classification never changes religious periods.'); + self::assertSame($unmarked['operational_events'], $state['operational_events'], 'Classification never changes lock intervals.'); + } + + private function calendar(string $date): array + { + $clock = new FrozenClock(new \DateTimeImmutable($date . 'T10:00:00+03:00')); + $db = Database::open(':memory:'); + $db->migrate(); + $settings = (new SettingsRepository($db, $clock))->ensureGroup('-1001', 'Holiday fixture', ['mode' => 'lock_messages']); + // The October capture begins on September 23 and includes the actual Sukkot dates. + [$start, $end] = str_starts_with($date, '2026-04') + ? ['2026-03-24', '2026-06-09'] : ['2026-09-23', '2026-12-10']; + $provider = new HebcalProvider(new FullHebcalFixtureClient()); + $calendar = $provider->calendar($start, $end, true, 'he'); + $solar = $provider->solar($settings, $start, $end); + $state = (new CalendarEngine())->evaluate($settings, $calendar, $solar, $clock->now()) + + ['calendar_items' => $calendar['items']]; + return [$settings, $state, $solar[$date]]; + } +} diff --git a/tests/CleanupFakeGateway.php b/tests/CleanupFakeGateway.php new file mode 100644 index 0000000..ed3a7e8 --- /dev/null +++ b/tests/CleanupFakeGateway.php @@ -0,0 +1,95 @@ + false, '43' => true]; + public bool $canDelete = true; + public ?\Closure $beforeCapabilities = null; + public ?\Closure $beforePermissions = null; + public ?\Throwable $deleteError = null; + public ?\Throwable $sendError = null; + public int $nextId = 100; + public int $classifications = 0; + public function getPermissions(string $group): array + { + if ($this->beforePermissions !== null) { + ($this->beforePermissions)(); + } + return $this->rights[$group] ?? ['_' => 'chatBannedRights', 'until_date' => 0, 'send_messages' => false]; + } + public function setPermissions(string $group, array $rights): void + { + $this->rights[$group] = $rights; + } + public function send(string $peer, array $payload, string $randomId): array + { + if ($this->sendError !== null) { + throw $this->sendError; + } + $id = $this->nextId++; + $this->sent[] = [$peer, $payload, $randomId, $id]; + return ['_' => 'updates', 'updates' => [['_' => 'updateMessageID','random_id' => (int)$randomId,'id' => $id]]]; + } + public function deleteMessages(string $peer, array $messageIds): void + { + if ($this->deleteError !== null) { + throw $this->deleteError; + } + $this->deleted[] = [$peer, $messageIds]; + } + public function senderIsBot(string $group, string $senderId): ?bool + { + $this->classifications++; + return $this->bots[$senderId] ?? null; + } + public function botCapabilities(string $group): array + { + if ($this->beforeCapabilities !== null) { + ($this->beforeCapabilities)(); + } + return ['can_delete_messages' => $this->canDelete]; + } + public function botCanDeleteMessages(string $group): bool + { + return $this->botCapabilities($group)['can_delete_messages']; + } + public function selfUserId(): string + { + return '9000'; + } + public function extractSentMessageIds(array $response, ?string $expectedRandomId = null, ?string $expectedPeer = null): array + { + return SentMessageIds::extract($response, $expectedRandomId, $expectedPeer); + } + public function isGroupAdmin(string $user, string $group): bool + { + return $user === '42'; + } + public function sendPrivate(string $user, string $text, array $markup = []): void + { + } + public function editPrivate(string $user, int $id, string $text, array $markup = []): void + { + } + public function answerCallback(string $id, string $text): void + { + } + public function answerInline(string $id, array $results, array $options = []): void + { + } + public function botUsername(): string + { + return 'CleanupFixtureBot'; + } +} diff --git a/tests/CommandMenusTest.php b/tests/CommandMenusTest.php new file mode 100644 index 0000000..6a3b89a --- /dev/null +++ b/tests/CommandMenusTest.php @@ -0,0 +1,169 @@ +calls[] = [$method,$parameters]; + return true; + }, $authorize ?? static fn(string $user): bool=>$user === '42'); + } + public function testAllSixLanguagesHaveCorrectPrivateOperatorAndGroupScopes(): void + { + $plans = $this->service()->plan(['42','43']); + self::assertCount(35, $plans); + foreach ($plans as $plan) { + $ids = array_column($plan['commands'], 'command'); + $scope = $plan['scope']['_']; + if ($scope === 'botCommandScopeUsers') { + self::assertContains('preferences', $ids); + self::assertNotContains('setcommands', $ids); + self::assertNotContains('add', $ids); + } + if ($scope === 'botCommandScopeChats') { + self::assertNotContains('admin', $ids); + self::assertSame(['settings'], $ids); + } + if ($scope === 'botCommandScopeChatAdmins') { + self::assertContains('settings', $ids); + self::assertSame(['settings'], $ids); + self::assertNotContains('setcommands', $ids); + } + if ($scope === 'botCommandScopePeer') { + self::assertContains('setcommands', $ids); + self::assertContains($plan['scope']['peer'], ['42','43']); + } + if ($plan['locale'] === 'pt-br') { + self::assertSame('pt', $plan['lang_code']); + } + foreach ($plan['commands'] as $command) { + self::assertNotSame('', $command['description']); + self::assertLessThanOrEqual(256, mb_strlen($command['description'])); + } + } + self::assertSame([], $this->calls); + } + public function testEveryLocalizedDescriptionHasTheCommandAppropriateEmoji(): void + { + $prefixes = [ + 'start' => '๐Ÿ ', 'preferences' => 'โš™๏ธ', 'lang' => '๐ŸŒ', 'language' => '๐ŸŒ', 'groups' => '๐Ÿ‘ฅ', + 'help' => 'โ„น๏ธ', 'shabbat' => '๐Ÿ•ฏ', 'zmanim' => '๐Ÿ•’', 'daf' => '๐Ÿ“–', + 'omer' => '๐ŸŒพ', 'source' => '๐Ÿ’ป', 'license' => '๐Ÿ“œ', 'version' => '๐Ÿท', + 'donate' => 'โญ', 'stats' => '๐Ÿ“Š', 'add' => 'โž•', 'remove' => 'โž–', + 'settings' => 'โš™๏ธ', 'admin' => '๐Ÿ› ', 'adminstats' => '๐Ÿ“Š', 'subscribers' => '๐Ÿ‘ฅ', 'broadcast' => '๐Ÿ“ฃ', 'managers' => '๐Ÿ‘ฅ', 'systemlinks' => 'โš™๏ธ', 'trackinglinks' => 'โ„น๏ธ', 'testzmanim' => '๐Ÿ”Ž', + 'closegroups' => '๐Ÿ”’', 'opengroups' => '๐Ÿ”“', 'setcommands' => '๐Ÿ“‹', 'restart' => '๐Ÿ”„', + ]; + foreach ($this->service()->plan(['42']) as $menu) { + foreach ($menu['commands'] as $command) { + self::assertMatchesRegularExpression('/^[a-z0-9_]{1,32}$/D', $command['command']); + self::assertStringStartsWith($prefixes[$command['command']] . ' ', $command['description'], $menu['locale'] . ': ' . $command['command']); + self::assertGreaterThan(2, mb_strlen($command['description'])); + } + } + self::assertSame([], $this->calls); + } + public function testPreviewHasNoRpcAndUnauthorizedOperatorCannotWrite(): void + { + $service = $this->service(); + self::assertTrue($service->apply('42', ['42'], 'ru', 'group_admins', true)['preview']); + self::assertSame([], $this->calls); + $this->expectException(\RuntimeException::class); + $service->apply('99', ['42']); + } + public function testNativeApiUsesLangCodeAndNoGroupReceivesOperatorCommands(): void + { + $result = $this->service()->apply('42', ['42'], 'pt-br', 'all'); + self::assertSame(4, $result['applied']); + foreach ($this->calls as [$method,$parameters]) { + self::assertSame('bots.setBotCommands', $method); + self::assertSame('pt', $parameters['lang_code']); + self::assertArrayNotHasKey('language_code', $parameters); + } + } + public function testInvalidPeerAndLanguageFailBeforeRpc(): void + { + $service = $this->service(); + try { + $service->plan(['-10042']); + self::fail(); + } catch (\InvalidArgumentException) { + self::assertSame([], $this->calls); + } + $this->expectException(\InvalidArgumentException::class); + $service->plan(['42'], 'pt'); + } + public function testOperatorRevocationStopsRemainingMenuWrites(): void + { + $allowed = true; + $calls = 0; + $service = new CommandMenus(new Lang(), function () use (&$allowed, &$calls): bool { + $calls++; + $allowed = false; + return true; + }, function () use (&$allowed): bool { + return $allowed; + }); + $result = $service->apply('42', ['42']); + self::assertSame(1, $calls); + self::assertSame(1, $result['applied']); + self::assertSame(27, $result['skipped']); + } + public function testPerOperatorCapabilitiesFilterOnlyOperatorCommandsAndNeverGrantAuthority(): void + { + $allowed = [ + '42' => ['admin','adminstats','subscribers','broadcast'], + '43' => ['admin','adminstats','systemlinks'], + '44' => [], + ]; + $service = new CommandMenus(new Lang(), function (string $method, array $parameters): bool { + $this->calls[] = [$method,$parameters]; + return true; + }, static fn(string $user): bool => $user === '1', + static fn(string $target, string $command): bool => in_array($command, $allowed[$target] ?? [], true)); + $plans = $service->plan(['42','43','44'], 'all', 'all'); + foreach ($plans as $plan) { + $commands = array_column($plan['commands'], 'command'); + if ($plan['scope']['_'] === 'botCommandScopePeer') { + $target = (string)$plan['scope']['peer']; + self::assertContains('preferences', $commands); + self::assertContains('donate', $commands); + self::assertNotContains('setcommands', $commands); + self::assertNotContains('closegroups', $commands); + self::assertNotContains('opengroups', $commands); + foreach (['admin','adminstats','subscribers','broadcast','managers','systemlinks','trackinglinks','testzmanim'] as $command) { + self::assertSame(in_array($command, $allowed[$target], true), in_array($command, $commands, true), $target . ':' . $command); + } + } elseif ($plan['scope']['_'] === 'botCommandScopeUsers') { + self::assertContains('preferences', $commands); + self::assertNotContains('admin', $commands); + } elseif ($plan['scope']['_'] === 'botCommandScopeChatAdmins') { + self::assertContains('settings', $commands); + self::assertNotContains('setcommands', $commands); + } + } + self::assertSame([], $this->calls, 'Building a menu plan must not call Telegram.'); + self::assertTrue($service->apply('1', ['42'], 'en', 'operators', true)['preview']); + self::assertSame([], $this->calls); + $this->expectException(\RuntimeException::class); + $service->apply('42', ['42'], 'en', 'operators'); + } + + public function testFalseAcknowledgementIsReportedAsFailure(): void + { + $service = new CommandMenus(new Lang(), static fn()=>false, static fn()=>true); + $result = $service->apply('42', ['42'], 'en', 'private'); + self::assertSame(0, $result['applied']); + self::assertSame(1, $result['failed']); + } +} diff --git a/tests/ConfiguredOwnersTest.php b/tests/ConfiguredOwnersTest.php new file mode 100644 index 0000000..3e56040 --- /dev/null +++ b/tests/ConfiguredOwnersTest.php @@ -0,0 +1,115 @@ +reportAdminIds; + } + }; + $bridge = new class ($sdk) { + use BotIntegration; + + public function __construct(private object $sdk) + { + } + + public function getAdminIds(): array + { + return $this->sdk->getAdminIds(); + } + + public function getReportPeers(): array + { + throw new \LogicException('Raw configured usernames are not authorization IDs.'); + } + + public function configuredOwners(): array + { + return $this->v3ConfiguredOwners(); + } + }; + $db = Database::open(':memory:'); + $db->migrate(); + $access = new AdminAccessRepository($db, new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')), $bridge->configuredOwners(...)); + $access->importLegacyOnce([123]); + self::assertSame(['123'], $access->ownerIds()); + self::assertSame('environment', $access->profile('123')['source']); + self::assertSame(0, (int)$db->pdo()->query('SELECT COUNT(*) FROM bot_admins')->fetchColumn()); + $access->add('123', '456', 'full', false, []); + self::assertSame(['123'], $access->ownerIds()); + self::assertFalse($access->isOwner('456')); + $access->remove('123', '456', 1); + self::assertNull($access->profile('456')); + try { + $access->remove('123', '123', 0); + self::fail('Configured recovery owner became removable.'); + } catch (\RuntimeException) { + self::assertTrue($access->isOwner('123')); + } + // Read the current configured SDK list, rather than a cached role union. + $sdk->reportAdminIds = []; + self::assertSame([], $access->ownerIds()); + self::assertNull($access->profile('123')); + } + + public function testRestartMenusAndPanelMatchConfiguredOwnersWhileViewOnlyHidesMutationCommands(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $access = new AdminAccessRepository($db, $clock, static fn(): array => ['1']); + $access->add('1', '2', 'full', false, []); + $access->add('1', '3', 'limited', true, ['statistics' => true, 'commands' => true, 'group_operations' => true]); + $settings = new SettingsRepository($db, $clock); + $panel = new OperatorController($settings, new Lang(), $access->isAdmin(...), $access); + $menus = new CommandMenus( + new Lang(), + static fn(): bool => true, + static fn(): bool => true, + static function (string $user, string $command) use ($access): bool { + if ($command === 'restart') { + return $access->isOwner($user); + } + $capability = match ($command) { + 'setcommands' => 'commands', 'closegroups', 'opengroups' => 'group_operations', default => null, + }; + return $capability === null || $access->canMutate($user, $capability); + } + ); + foreach (['he', 'en', 'ru', 'fr', 'es', 'pt-br'] as $language) { + foreach (['1', '2', '3'] as $id) { + $current = $settings->getUser($id); + $settings->updateUser($id, $current['revision'], ['language' => $language]); + $text = $panel->main($id)['text']; + self::assertSame($id === '1', str_contains($text, '/restart')); + foreach (['setcommands', 'closegroups', 'opengroups'] as $command) { + self::assertSame($id !== '3', str_contains($text, '/' . $command . '')); + } + $commands = array_column($menus->plan([$id], $language, 'operators')[0]['commands'], 'command'); + self::assertSame($id === '1', in_array('restart', $commands, true)); + foreach (['setcommands', 'closegroups', 'opengroups'] as $command) { + self::assertSame($id !== '3', in_array($command, $commands, true)); + } + self::assertContains('preferences', $commands); + } + } + } +} diff --git a/tests/CustomEmojiFallbackTest.php b/tests/CustomEmojiFallbackTest.php new file mode 100644 index 0000000..7406078 --- /dev/null +++ b/tests/CustomEmojiFallbackTest.php @@ -0,0 +1,207 @@ +newInstanceWithoutConstructor(); + (new \ReflectionProperty(\danog\MadelineProto\RPCErrorException::class, 'rpc'))->setValue($error, $name); + return $error; + } + + public static function operations(): array + { + return ['private' => ['private','messages.sendMessage'], 'scheduled' => ['scheduled','messages.sendMessage'], + 'media' => ['media','messages.sendMedia'], 'edit' => ['edit','messages.editMessage'], + 'media edit' => ['media-edit','messages.editMessage'], 'inline' => ['inline','messages.setInlineBotResults']]; + } + + private function operation(MadelineGateway $gateway, string $kind): array + { + $payload = ['text' => '๐Ÿ˜€ โญ formatted', + 'entities' => [['_' => 'messageEntityBold','offset' => 0,'length' => 14], + ['_' => 'messageEntityCustomEmoji','offset' => 3,'length' => 1,'document_id' => 42]], + 'buttons' => [[['text' => 'โญ Support','url' => 'https://example.org','style' => 'primary']]]]; + $original = $payload; + if ($kind === 'private') { + $gateway->sendPrivate('1', 'โญ Support', ['inline_keyboard' => $payload['buttons']]); + } elseif (in_array($kind, ['edit','media-edit'], true)) { + if ($kind === 'media-edit') { + $payload['media'] = 'fixture-file-id'; + } + $gateway->editPrivatePayload('1', 42, $payload); + } elseif ($kind === 'inline') { + $gateway->answerInline('456', [['_' => 'inputBotInlineResult','id' => 'i','type' => 'article', + 'send_message' => ['_' => 'inputBotInlineMessageText','message' => $payload['text'],'entities' => $payload['entities'], + 'reply_markup' => MadelineGateway::normalizeMarkup(['inline_keyboard' => $payload['buttons']])]]]); + } else { + if ($kind === 'media') { + $payload['media'] = 'fixture-file-id'; + } + $gateway->send('-1001', $payload, '123'); + } + return [$original,$payload]; + } + + #[DataProvider('operations')] + public function testEntitlementRejectionRetriesOnceWithIdenticalTargetAndLatchesBasicMode(string $kind, string $method): void + { + $transport = new EmojiFixtureTransport(); + $transport->failures[$method] = [$this->rpc('PREMIUM_ACCOUNT_REQUIRED')]; + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + [$original,$payload] = $this->operation($gateway, $kind); + self::assertCount(2, $transport->calls); + [$firstMethod,$first] = $transport->calls[0]; + [$secondMethod,$second] = $transport->calls[1]; + self::assertSame($method, $firstMethod); + self::assertSame($firstMethod, $secondMethod); + foreach (['peer','random_id','id','query_id','media'] as $identity) { + self::assertSame($first[$identity] ?? null, $second[$identity] ?? null, $identity); + } + $firstBody = $kind === 'inline' ? $first['results'][0]['send_message'] : $first; + $secondBody = $kind === 'inline' ? $second['results'][0]['send_message'] : $second; + if ($kind !== 'private') { + self::assertSame($firstBody['message'], $secondBody['message']); + self::assertSame([['_' => 'messageEntityBold','offset' => 0,'length' => 14]], $secondBody['entities']); + } else { + self::assertStringNotContainsString('tg-emoji', $secondBody['message']); + self::assertStringContainsString('', $secondBody['message']); + } + $button = $secondBody['reply_markup']['rows'][0]['buttons'][0]; + self::assertArrayNotHasKey('icon', $button['style']); + self::assertTrue($button['style']['bg_primary']); + self::assertSame('https://example.org', $button['url']); + self::assertSame($original['text'], $payload['text']); + self::assertSame($original['entities'], $payload['entities']); + $this->operation($gateway, $kind); + self::assertCount(3, $transport->calls, 'The session must not repeat a known unsupported animation request.'); + $third = $transport->calls[2][1]; + $thirdBody = $kind === 'inline' ? $third['results'][0]['send_message'] : $third; + self::assertArrayNotHasKey('icon', $thirdBody['reply_markup']['rows'][0]['buttons'][0]['style']); + } + + public function testUnknownMarkupAndNetworkErrorsNeverRetryOrBecomeSilentSuccess(): void + { + foreach ([$this->rpc('REPLY_MARKUP_INVALID'), new \RuntimeException('Network failed'), new CancelledException()] as $error) { + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.sendMessage'] = [$error]; + try { + $this->operation(new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()), 'scheduled'); + self::fail('An unrelated failure was swallowed.'); + } catch (\Throwable $caught) { + self::assertSame($error, $caught); + self::assertCount(1, $transport->calls); + } + } + } + + public function testFailedFallbackIsNotRetriedAgain(): void + { + $transport = new EmojiFixtureTransport(); + $second = $this->rpc('CHAT_WRITE_FORBIDDEN'); + $transport->failures['messages.sendMessage'] = [$this->rpc('PREMIUM_ACCOUNT_REQUIRED'), $second]; + try { + $this->operation(new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()), 'scheduled'); + self::fail('Second failure must surface.'); + } catch (\Throwable $caught) { + self::assertSame($second, $caught); + self::assertCount(2, $transport->calls); + } + } + + public function testBroadcastAlbumsKeepEveryItemIdentityAndRichTextThroughOneFallback(): void + { + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.sendMultiMedia'] = [$this->rpc('PREMIUM_ACCOUNT_REQUIRED')]; + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $single = ['_' => 'inputSingleMedia','random_id' => 123,'media' => ['_' => 'inputMediaPhotoExternal','url' => 'https://example.org/photo.jpg'], + 'message' => '๐Ÿ˜€ โญ ','entities' => [['_' => 'messageEntityBold','offset' => 0,'length' => 4], + ['_' => 'messageEntityCustomEmoji','offset' => 3,'length' => 1,'document_id' => 42]]]; + $parameters = ['peer' => '-1001','silent' => true,'multi_media' => [$single, array_replace($single, ['random_id' => 456])]]; + $original = $parameters; + $gateway->broadcastCall('sendMultiMedia', $parameters); + self::assertCount(2, $transport->calls); + self::assertSame($original, $parameters); + foreach ($transport->calls[1][1]['multi_media'] as $index => $item) { + self::assertSame($original['multi_media'][$index]['message'], $item['message']); + self::assertSame($original['multi_media'][$index]['random_id'], $item['random_id']); + self::assertSame($original['multi_media'][$index]['media'], $item['media']); + self::assertSame([['_' => 'messageEntityBold','offset' => 0,'length' => 4]], $item['entities']); + } + self::assertTrue($transport->calls[1][1]['silent']); + } + + public function testBroadcastPlainTextNeverBecomesHtmlAndUnsupportedMethodsFailBeforeRpc(): void + { + $transport = new EmojiFixtureTransport(); + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $gateway->broadcastCall('sendMessage', ['peer' => '-1001','random_id' => 123,'message' => 'โญ & text','entities' => []]); + self::assertSame('โญ & text', $transport->calls[0][1]['message']); + self::assertArrayNotHasKey('parse_mode', $transport->calls[0][1]); + self::assertSame('messageEntityCustomEmoji', $transport->calls[0][1]['entities'][0]['_']); + try { + $gateway->broadcastCall('deleteMessages', ['id' => [1]]); + self::fail('Unrelated RPCs must not use this message hook.'); + } catch (\InvalidArgumentException) { + self::assertCount(1, $transport->calls); + } + } + public function testDocumentLookupIsOnceReadOnlyValidatesKnownIdsAndExactGreetingWinsOverAltMismatch(): void + { + $transport = new EmojiFixtureTransport(); + foreach ($transport->documents as &$document) { + if ((string)$document['id'] === Presentation::ICONS['brand_coin']) { + $document['attributes'][0]['alt'] = 'โญ'; + } + } + unset($document); + $gateway = new MadelineGateway($transport); + $welcome = (new Lang())->text('he', 'welcome'); + $gateway->sendPrivate('1', $welcome); + $gateway->sendPrivate('1', 'โš™๏ธ Settings'); + self::assertSame(['messages.getCustomEmojiDocuments','messages.sendMessage','messages.sendMessage'], array_column($transport->calls, 0)); + self::assertSame(array_map('intval', array_values(Presentation::ICONS)), $transport->calls[0][1]['document_id']); + self::assertSame($welcome, $transport->calls[1][1]['message']); + self::assertStringContainsString('5944760643524500583', $transport->calls[2][1]['message']); + } + + public function testFailedCatalogUsesBasicPanelAndIntentionalCancellationPropagates(): void + { + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.getCustomEmojiDocuments'] = [new \RuntimeException('Unavailable')]; + $gateway = new MadelineGateway($transport); + $gateway->sendPrivate('1', 'โš™๏ธ Settings'); + $gateway->sendPrivate('1', 'โš™๏ธ Settings'); + self::assertCount(3, $transport->calls); + self::assertSame('โš™๏ธ Settings', $transport->calls[1][1]['message']); + $cancelled = new CancelledException(); + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.getCustomEmojiDocuments'] = [$cancelled]; + try { + (new MadelineGateway($transport))->sendPrivate('1', 'โš™๏ธ Settings'); + self::fail('Intentional cancellation must propagate.'); + } catch (CancelledException $caught) { + self::assertSame($cancelled, $caught); + self::assertCount(1, $transport->calls); + } + } +} diff --git a/tests/CustomEmojiSerializationTest.php b/tests/CustomEmojiSerializationTest.php new file mode 100644 index 0000000..34f35d7 --- /dev/null +++ b/tests/CustomEmojiSerializationTest.php @@ -0,0 +1,77 @@ +newInstanceWithoutConstructor(); + $adapter->settings = new \danog\MadelineProto\Settings(); + $adapter->logger = new Logger((new \danog\MadelineProto\Settings\Logger())->setType(Logger::CALLABLE_LOGGER)->setExtra(static function (): void { + })); + $tl = new TL($adapter); + $tl->init(new TLSchema()); + $alternatives = EmojiFixtureTransport::alternatives(); + $buttons = []; + foreach (array_values(Presentation::ICONS) as $index => $id) { + $style = ['_' => 'keyboardButtonStyle','icon' => $id]; + if ($index % 4 < 3) { + $style[['bg_primary','bg_danger','bg_success'][$index % 4]] = true; + } + $buttons[] = [['text' => 'Fixture ' . $index,'callback_data' => 'fixture:' . $index,'style' => $style]]; + } + $transport = new EmojiFixtureTransport(); + $gateway = new MadelineGateway($transport, true, $alternatives); + $text = implode(' ', $alternatives); + $gateway->answerInline('123', [['_' => 'inputBotInlineResult','id' => 'all-twelve','type' => 'article', + 'send_message' => ['_' => 'inputBotInlineMessageText','message' => '' . $text . '','parse_mode' => 'HTML', + 'reply_markup' => ['inline_keyboard' => $buttons]]]]); + $wire = $tl->serializeMethod('messages.setInlineBotResults', $transport->calls[0][1]); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $wire); + rewind($stream); + $decoded = $tl->deserialize($stream, ['type' => '','connection' => null,'encrypted' => false]); + self::assertSame(strlen($wire), ftell($stream)); + fclose($stream); + $message = $decoded['results'][0]['send_message']; + self::assertSame($text, $message['message']); + $custom = array_values(array_filter($message['entities'], static fn(array $e): bool => $e['_'] === 'messageEntityCustomEmoji')); + self::assertCount(12, $custom); + self::assertSame(array_map('intval', array_values(Presentation::ICONS)), array_column($custom, 'document_id')); + $offset = 0; + foreach (array_values($alternatives) as $index => $alt) { + $length = intdiv(strlen(mb_convert_encoding($alt, 'UTF-16LE', 'UTF-8')), 2); + self::assertSame($offset, $custom[$index]['offset']); + self::assertSame($length, $custom[$index]['length']); + $offset += $length + 1; + $style = $message['reply_markup']['rows'][$index]['buttons'][0]['style']; + self::assertSame((int)array_values(Presentation::ICONS)[$index], $style['icon']); + self::assertSame($index % 4 < 3, ($style['bg_primary'] ?? false) || ($style['bg_danger'] ?? false) || ($style['bg_success'] ?? false)); + } + } finally { + $tl = null; + $adapter = null; + Logger::$default = $previousLogger; + } + } +} diff --git a/tests/CustomStarsMigrationTest.php b/tests/CustomStarsMigrationTest.php new file mode 100644 index 0000000..756db26 --- /dev/null +++ b/tests/CustomStarsMigrationTest.php @@ -0,0 +1,163 @@ +directory = sys_get_temp_dir() . '/shabbat-star-migration-' . bin2hex(random_bytes(8)); + if (!mkdir($this->directory, 0700)) { + throw new \RuntimeException('Cannot create fixture migration directory.'); + } + foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $file) { + if (strcmp(basename($file), '011_custom_star_amounts.sql') < 0) { + $target = $this->directory . '/' . basename($file); + if (!copy($file, $target)) { + throw new \RuntimeException('Cannot copy fixture migration.'); + } + $this->files[] = $target; + } + } + $this->db = Database::open(':memory:'); + $this->db->migrate($this->directory); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $now = $this->clock->now()->getTimestamp(); + $this->db->pdo()->prepare('INSERT INTO users(user_id,created_at,updated_at) VALUES(?,?,?)')->execute(['17',$now - 3600,$now]); + foreach (['paid','precheckout_approved','exported','created','expired','failed'] as $index => $status) { + $id = 7 + $index; + $this->db->pdo()->prepare('INSERT INTO payment_intents(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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute([$id,'17','legacy-payload-' . $id,'legacy-nonce-' . $id,'XTR',$index === 0 ? 5 : 25,['ru','fr','pt-br','he','en','es'][$index],$status,$now - 100,$now + 3600,'https://t.me/invoice-fixture-' . $id,$index < 2 ? (string)(1000 + $id) : null,$index < 2 ? $now - 80 : null,$index === 0 ? $now - 60 : null,$status === 'failed' ? 'legacy failure' : null]); + } + $this->db->pdo()->prepare('INSERT INTO donations(donation_id,intent_id,user_id,currency,amount,charge_uid,received_at,service_message_id) VALUES(?,?,?,?,?,?,?,?)') + ->execute([23,7,'17','XTR',5,'legacy-charge',$now - 60,777]); + } + + protected function tearDown(): void + { + foreach ($this->files as $file) { + if (dirname($file) !== $this->directory) { + throw new \RuntimeException('Unexpected fixture cleanup path.'); + } + if (is_file($file)) { + unlink($file); + } + } + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + private function addMigration(string $suffix = ''): void + { + $path = $this->directory . '/011_custom_star_amounts.sql'; + $this->files[] = $path; + $source = (string)file_get_contents(dirname(__DIR__) . '/migrations/011_custom_star_amounts.sql'); + if (file_put_contents($path, $source . $suffix) === false) { + throw new \RuntimeException('Cannot write fixture migration.'); + } + } + + public function testUpgradePreservesEveryExistingRecordAndAllBindingsWithForeignKeysEnabled(): void + { + $intents = $this->db->pdo()->query('SELECT * FROM payment_intents ORDER BY intent_id')->fetchAll(); + $donations = $this->db->pdo()->query('SELECT * FROM donations ORDER BY donation_id')->fetchAll(); + $this->addMigration(); + self::assertSame(['011_custom_star_amounts.sql'], $this->db->migrate($this->directory)); + self::assertSame($intents, $this->db->pdo()->query('SELECT * FROM payment_intents ORDER BY intent_id')->fetchAll()); + self::assertSame($donations, $this->db->pdo()->query('SELECT * FROM donations ORDER BY donation_id')->fetchAll()); + self::assertSame(1, (int)$this->db->pdo()->query('PRAGMA foreign_keys')->fetchColumn()); + self::assertSame(['ok'], $this->db->integrity()['integrity_check']); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + self::assertNotFalse($this->db->pdo()->query("SELECT name FROM sqlite_master WHERE type='index' AND name='payment_intent_expiry'")->fetchColumn()); + $foreignKeys = $this->db->pdo()->query('PRAGMA foreign_key_list(donations)')->fetchAll(); + $intentKey = array_values(array_filter($foreignKeys, static fn(array $key): bool => $key['from'] === 'intent_id')); + self::assertSame('payment_intents', $intentKey[0]['table']); + self::assertSame([], $this->db->migrate($this->directory)); + } + + public function testPendingApprovalStillWorksAndNewCustomPaymentsContinuePrimaryKeys(): void + { + $this->addMigration(); + $this->db->migrate($this->directory); + $service = new Donations($this->db, $this->clock, static fn(string $method): mixed => $method === 'payments.exportInvoice' ? ['url' => 'https://t.me/invoice-fixture-custom'] : true); + // Reserved IDs are arbitrary signed-decimal Telegram query identifiers; preserve legacy binding byte for byte. + $old = $this->db->pdo()->query('SELECT precheckout_query_id FROM payment_intents WHERE intent_id=8')->fetchColumn(); + self::assertSame('1008', $old); + self::assertSame('precheckout_approved', $this->db->pdo()->query('SELECT status FROM payment_intents WHERE intent_id=8')->fetchColumn()); + self::assertTrue($service->validatePrecheckout(['query_id' => '1008','user_id' => '17','currency' => 'XTR','total_amount' => 25,'payload' => 'legacy-payload-8'])['ok']); + $one = $service->createCustomOffer('17', 1, 'ru'); + $max = $service->createCustomOffer('17', 10000, 'pt-br'); + self::assertSame(13, $one['intent_id']); + self::assertSame(14, $max['intent_id']); + $query = ['query_id' => '1234','user_id' => '17','currency' => 'XTR','total_amount' => 1,'payload' => $one['payload']]; + self::assertTrue($service->answerPrecheckout($query)['ok']); + $payment = ['user_id' => '17','payload' => $one['payload'],'currency' => 'XTR','total_amount' => 1,'charge_uid' => 'new-custom-charge']; + self::assertSame('recorded', $service->recordPayment($payment)['status']); + self::assertSame(24, (int)$this->db->pdo()->query("SELECT donation_id FROM donations WHERE charge_uid='new-custom-charge'")->fetchColumn()); + self::assertSame(['donations' => 2,'stars' => 6], $service->stats()); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } + + public function testDatabaseRetainsIntegerBoundsUniqueBindingsAndForeignKeys(): void + { + $this->addMigration(); + $this->db->migrate($this->directory); + foreach ([0,10001,1.5] as $amount) { + try { + $this->db->pdo()->prepare("INSERT INTO payment_intents(user_id,payload,nonce,currency,amount,language,status,created_at,expires_at) VALUES('17',?,?,'XTR',?,'en','created',1,2)") + ->execute(['invalid-' . (string)$amount,'invalid-nonce-' . (string)$amount,$amount]); + self::fail('Out-of-bound or non-integer database amount accepted.'); + } catch (\PDOException) { + self::assertSame(6, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM payment_intents')->fetchColumn()); + } + } + foreach ( + [ + "INSERT INTO payment_intents(user_id,payload,nonce,currency,amount,language,created_at,expires_at) VALUES('17','legacy-payload-7','unique','XTR',1,'en',1,2)", + "INSERT INTO payment_intents(user_id,payload,nonce,currency,amount,language,created_at,expires_at) VALUES('17','unique','legacy-nonce-7','XTR',1,'en',1,2)", + "INSERT INTO donations(intent_id,user_id,currency,amount,charge_uid,received_at) VALUES(999,'17','XTR',1,'new',1)", + "INSERT INTO donations(intent_id,user_id,currency,amount,charge_uid,received_at) VALUES(8,'17','XTR',1,'legacy-charge',1)", + "INSERT INTO donations(intent_id,user_id,currency,amount,charge_uid,received_at) VALUES(7,'17','XTR',1,'new',1)", + ] as $sql + ) { + try { + $this->db->pdo()->exec($sql); + self::fail('Payment uniqueness or FK guard lost.'); + } catch (\PDOException) { + self::assertSame(1, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM donations')->fetchColumn()); + } + } + } + + public function testFailedMigrationRollsBackCopiesDropsRenamesDataAndHistory(): void + { + $before = $this->db->pdo()->query('SELECT * FROM payment_intents ORDER BY intent_id')->fetchAll(); + $history = $this->db->pdo()->query('SELECT * FROM schema_migrations ORDER BY version')->fetchAll(); + $this->addMigration("\nSELECT deliberately_missing_migration_function();\n"); + try { + $this->db->migrate($this->directory); + self::fail('Broken migration reported success.'); + } catch (\PDOException) { + self::assertSame($before, $this->db->pdo()->query('SELECT * FROM payment_intents ORDER BY intent_id')->fetchAll()); + self::assertSame($history, $this->db->pdo()->query('SELECT * FROM schema_migrations ORDER BY version')->fetchAll()); + self::assertSame(1, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM donations')->fetchColumn()); + self::assertSame(0, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM sqlite_master WHERE name LIKE '%_v11'")->fetchColumn()); + self::assertSame(1, (int)$this->db->pdo()->query('PRAGMA foreign_keys')->fetchColumn()); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } + } +} diff --git a/tests/DatabaseTransactionTest.php b/tests/DatabaseTransactionTest.php new file mode 100644 index 0000000..564cf5b --- /dev/null +++ b/tests/DatabaseTransactionTest.php @@ -0,0 +1,161 @@ +db = new Database($connection); + $this->db->migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-14T09:00:00Z')); + (new SettingsRepository($this->db, $clock))->ensureGroup('-1', '', ['tefillin_delete_previous' => true]); + $this->deletions = new DeletionRepository($this->db, $clock); + } + + public function testOwnedImmediateTransactionCommitsWhenPdoReportsFalse(): void + { + $this->db->transaction(function (PDO $pdo): void { + self::assertFalse($pdo->inTransaction()); + self::assertTrue($this->db->ownsTransaction($pdo)); + $result = $this->deletions->recordModuleDeliveryInTransaction($pdo, '-1', 'tefillin', [100], 'first'); + self::assertSame('recorded', $result['status']); + }); + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [101], 'second', true); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([100], array_column($this->deletions->list(), 'message_id')); + } + + public function testBookkeepingRejectsTheSameConnectionOutsideAnOwnedTransaction(): void + { + foreach ([false, true] as $externalTransaction) { + if ($externalTransaction) { + $this->db->pdo()->beginTransaction(); + } + try { + $this->deletions->recordModuleDeliveryInTransaction($this->db->pdo(), '-1', 'tefillin', [100], 'outside'); + self::fail('Unowned bookkeeping was accepted.'); + } catch (LogicException $error) { + self::assertSame('Module delivery bookkeeping requires this database transaction.', $error->getMessage()); + } finally { + if ($externalTransaction) { + $this->db->pdo()->rollBack(); + } + } + self::assertSame([], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + } + } + + public function testBookkeepingRejectsAnotherActiveConnection(): void + { + $other = new PDO('sqlite::memory:'); + $other->beginTransaction(); + try { + $this->db->transaction(function (PDO $owned) use ($other): void { + self::assertTrue($other->inTransaction()); + self::assertTrue($this->db->ownsTransaction($owned)); + self::assertFalse($this->db->ownsTransaction($other)); + $this->deletions->recordModuleDeliveryInTransaction($other, '-1', 'tefillin', [100], 'wrong-connection'); + }); + self::fail('A different active connection was accepted.'); + } catch (LogicException $error) { + self::assertSame('Module delivery bookkeeping requires this database transaction.', $error->getMessage()); + } finally { + $other->rollBack(); + } + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + self::assertSame([], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + } + + public function testCleanupFailureRollsBackPointerAndEarlierQueuedDeletionThenAllowsRetry(): void + { + $this->deletions->recordModuleDelivery('-1', 'tefillin', [100,101], 'first', true); + $this->db->pdo()->exec("CREATE TRIGGER reject_second_cleanup BEFORE INSERT ON message_deletions WHEN NEW.message_id=101 BEGIN SELECT RAISE(FAIL,'second cleanup failed'); END"); + try { + $this->deletions->recordModuleDelivery('-1', 'tefillin', [102], 'replacement', true); + self::fail('Partial deletion bookkeeping was committed.'); + } catch (PDOException $error) { + self::assertStringContainsString('second cleanup failed', $error->getMessage()); + } + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + self::assertSame([100,101], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([], $this->deletions->list()); + $this->db->pdo()->exec('DROP TRIGGER reject_second_cleanup'); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [102], 'replacement', true); + self::assertSame([102], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([100,101], array_column($this->deletions->list(), 'message_id')); + } + + public function testFailedBeginDoesNotClaimAnExternalTransaction(): void + { + $this->db->pdo()->exec('BEGIN IMMEDIATE'); + try { + $this->db->transaction(static fn (): bool => true); + self::fail('A second transaction began on the same connection.'); + } catch (PDOException) { + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + } finally { + $this->db->pdo()->exec('ROLLBACK'); + } + self::assertSame('recorded', $this->deletions->recordModuleDelivery('-1', 'tefillin', [100], 'after-begin-failure')['status']); + } + + public function testRejectedNestedTransactionLeavesTheOuterOwnerIntact(): void + { + $this->db->transaction(function (PDO $pdo): void { + try { + $this->db->transaction(static fn (): bool => true); + self::fail('A nested transaction was accepted.'); + } catch (LogicException $error) { + self::assertSame('Nested/shared transactions are forbidden.', $error->getMessage()); + } + self::assertTrue($this->db->ownsTransaction($pdo)); + $this->deletions->recordModuleDeliveryInTransaction($pdo, '-1', 'tefillin', [100], 'outer'); + }); + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + self::assertSame([100], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + } + + public function testFailedCommitRollsBackAndClearsOwnership(): void + { + $this->db->pdo()->exec('CREATE TABLE transaction_parent(id INTEGER PRIMARY KEY)'); + $this->db->pdo()->exec('CREATE TABLE transaction_child(parent_id INTEGER REFERENCES transaction_parent(id) DEFERRABLE INITIALLY DEFERRED)'); + try { + $this->db->transaction(function (PDO $pdo): void { + $pdo->exec('INSERT INTO transaction_child VALUES(99)'); + $this->deletions->recordModuleDeliveryInTransaction($pdo, '-1', 'tefillin', [100], 'uncommitted'); + }); + self::fail('A deferred foreign-key violation committed.'); + } catch (PDOException $error) { + self::assertStringContainsString('FOREIGN KEY constraint failed', $error->getMessage()); + } + self::assertFalse($this->db->ownsTransaction($this->db->pdo())); + self::assertSame(0, (int) $this->db->pdo()->query('SELECT COUNT(*) FROM transaction_child')->fetchColumn()); + self::assertSame([], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame('recorded', $this->deletions->recordModuleDelivery('-1', 'tefillin', [100], 'after-commit-failure')['status']); + } +} diff --git a/tests/DeletionRepositoryTest.php b/tests/DeletionRepositoryTest.php new file mode 100644 index 0000000..9122ba2 --- /dev/null +++ b/tests/DeletionRepositoryTest.php @@ -0,0 +1,245 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-14T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1', '', ['tefillin_delete_previous' => true]); + $this->deletions = new DeletionRepository($this->db, $this->clock); + $this->jobs = new JobRepository($this->db, $this->clock); + } + private function now(): int + { + return $this->clock->now()->getTimestamp(); + } + private function metadata(): array + { + return ['original_timestamp' => $this->now() - 60,'sender_id' => '42','sender_is_bot' => false,'settings_revision' => 1]; + } + private function sentJob(int $messageId, string $event, string $module = 'tefillin'): int + { + $id = $this->jobs->enqueue('-1', $module, $event, $this->now(), $this->now() + 3600, 1, ['text' => 'Fixture']); + $leased = $this->jobs->lease('sender'); + self::assertSame($id, $leased['job_id']); + $this->jobs->sent($id, 'sender', ['_' => 'updateShortSentMessage','id' => $messageId]); + return $id; + } + public function testOnlyReplacedStoredIdsAreQueuedAfterConfirmedReplacement(): void + { + $first = $this->deletions->recordModuleDelivery('-1', 'tefillin', [100,101], 'first', true); + self::assertSame([], $first['queued_deletions']); + $second = $this->deletions->recordModuleDelivery('-1', 'tefillin', [102,103], 'second', true); + self::assertCount(2, $second['queued_deletions']); + self::assertSame([102,103], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame('duplicate', $this->deletions->recordModuleDelivery('-1', 'tefillin', [102,103], 'second', true)['status']); + self::assertCount(2, $this->deletions->list()); + $row = $this->deletions->claim('cleanup'); + self::assertSame(100, $row['message_id']); + self::assertTrue($row['sender_is_bot']); + self::assertTrue($row['sender_is_self']); + self::assertSame('first', $row['origin_delivery_key']); + self::assertNull($row['expires_at']); + $this->deletions->complete($row['deletion_id'], 'cleanup'); + self::assertSame('deleted', $this->deletions->get($row['deletion_id'])['status']); + } + public function testDisabledConsentRetainsPointerWithoutRetrospectiveBulkCleanup(): void + { + $this->settings->updateGroup('-1', 1, ['tefillin_delete_previous' => false], '42'); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [100], 'first', true); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [101], 'second', true); + self::assertSame([], $this->deletions->list()); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + $this->settings->updateGroup('-1', 2, ['tefillin_delete_previous' => true], '42'); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [102], 'third', true); + self::assertSame([101], array_column($this->deletions->list(), 'message_id')); + } + public function testHolyDeadlineIsMandatoryAndReplayDoesNotExtendIt(): void + { + $id = $this->deletions->enqueue('-1', 30, 'holy_message', $this->metadata(), $this->now(), $this->now() + 10); + self::assertSame($id, $this->deletions->enqueue('-1', 30, 'holy_message', $this->metadata(), $this->now(), $this->now() + 100)); + self::assertSame($this->now() + 10, $this->deletions->get($id)['expires_at']); + $row = $this->deletions->claim('a'); + self::assertTrue($this->deletions->owns($row['deletion_id'], 'a')); + $this->clock->set($this->clock->now()->modify('+10 seconds')); + self::assertFalse($this->deletions->owns($id, 'a')); + self::assertNull($this->deletions->claim('b')); + self::assertSame('expired', $this->deletions->get($id)['status']); + $this->expectException(InvalidArgumentException::class); + $this->deletions->enqueue('-1', 31, 'holy_message', $this->metadata(), $this->now()); + } + public function testLeasesAreFencedAndRetryWaitIsPersistentAndBounded(): void + { + $id = $this->deletions->enqueue('-1', 30, 'holy_message', $this->metadata(), $this->now(), $this->now() + 3600); + self::assertNotNull($this->deletions->claim('a', 10)); + self::assertNull($this->deletions->claim('b')); + $this->clock->set($this->clock->now()->modify('+11 seconds')); + self::assertNotNull($this->deletions->claim('b')); + self::assertFalse($this->deletions->owns($id, 'a')); + $this->deletions->retry($id, 'b', 'FLOOD_WAIT_30', 30); + self::assertNull($this->deletions->claim('c')); + $this->clock->set($this->clock->now()->modify('+31 seconds')); + self::assertNotNull($this->deletions->claim('c')); + $this->deletions->renew($id, 'c', 200); + self::assertTrue($this->deletions->owns($id, 'c')); + $this->deletions->retry($id, 'c', 'quota wait exceeds cleanup window', 172800); + self::assertSame('failed', $this->deletions->get($id)['status']); + } + public function testCurrentReminderIsProtectedEvenIfAnOldQueueRowExists(): void + { + $this->deletions->recordModuleDelivery('-1', 'tefillin', [100], 'first', true); + $this->deletions->recordModuleDelivery('-1', 'tefillin', [101], 'second', true); + $this->db->pdo()->exec("UPDATE module_last_messages SET message_ids_json='[100]',max_message_id=100 WHERE group_id='-1'"); + self::assertNull($this->deletions->claim('worker')); + self::assertSame('cancelled', $this->deletions->get(1)['status']); + } + public function testFailedBookkeepingRollsBackPointerAndMarkerWithoutChangingSentJob(): void + { + $first = $this->sentJob(100, 'first'); + $this->deletions->recordSentJobDelivery($first, [100], true); + $second = $this->sentJob(101, 'second'); + $this->db->pdo()->exec("CREATE TRIGGER reject_cleanup BEFORE INSERT ON message_deletions BEGIN SELECT RAISE(FAIL,'disk failure'); END"); + try { + $this->deletions->recordSentJobDelivery($second, [101], true); + self::fail('Cleanup persistence failure was swallowed.'); + } catch (PDOException $error) { + self::assertStringContainsString('disk failure', $error->getMessage()); + } + self::assertSame('sent', $this->jobs->get($second)['status']); + self::assertNull($this->jobs->get($second)['module_delivery_processed_at']); + self::assertSame([100], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([$second], array_column($this->jobs->pendingDeliveryBookkeeping(), 'job_id')); + self::assertSame(101, $this->jobs->pendingDeliveryBookkeeping()[0]['response']['id']); + $this->db->pdo()->exec('DROP TRIGGER reject_cleanup'); + self::assertSame('recorded', $this->deletions->recordSentJobDelivery($second, [101], true)['status']); + self::assertSame('already_processed', $this->deletions->recordSentJobDelivery($second, [101], true)['status']); + self::assertSame([], $this->jobs->pendingDeliveryBookkeeping()); + self::assertSame('sent', $this->jobs->get($second)['status']); + } + public function testReplayedOlderAcknowledgementCannotMovePointerBackwards(): void + { + $old = $this->sentJob(100, 'old'); + $this->clock->set($this->clock->now()->modify('+1 second')); + $new = $this->sentJob(101, 'new'); + $this->deletions->recordSentJobDelivery($new, [101], true); + self::assertSame('stale', $this->deletions->recordSentJobDelivery($old, [100], true)['status']); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([], $this->deletions->list()); + self::assertSame([], $this->jobs->pendingDeliveryBookkeeping()); + } + public function testOnlyPersistedTefillinResponseIdsCanEnterReplacementBookkeeping(): void + { + $id = $this->sentJob(100, 'first'); + try { + $this->deletions->recordSentJobDelivery($id, [999], true); + self::fail('An unrelated message identifier was accepted.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('absent', $error->getMessage()); + } + self::assertNull($this->jobs->get($id)['module_delivery_processed_at']); + $other = $this->sentJob(101, 'other', 'daf'); + self::assertSame('ignored', $this->deletions->recordSentJobDelivery($other, [], true)['status']); + self::assertSame([], $this->deletions->getLastModuleMessages('-1', 'daf')); + $this->jobs->markDeliveryProcessed($id); + self::assertSame([], $this->jobs->pendingDeliveryBookkeeping()); + $this->expectException(InvalidArgumentException::class); + $this->deletions->enqueue('-1', 200, 'tefillin_previous', ['original_timestamp' => $this->now(),'sender_is_bot' => true,'sender_is_self' => true,'origin_delivery_key' => 'forged'], $this->now()); + } + public function testUntrustedResponseShapesCannotAuthorizeDeletionOfUnrelatedIds(): void + { + $job = $this->sentJob(100, 'strict-ack'); + foreach ( + [ + ['_' => 'updates','message_id' => 100], ['_' => 'updates','message_ids' => [100]], ['id' => 100], + ['_' => 'updates','updates' => [['_' => 'updateNewScheduledMessage','message' => ['_' => 'message','out' => true,'id' => 100]]]], + ['_' => 'updates','updates' => [['_' => 'updateNewMessage','message' => ['_' => 'message','out' => false,'id' => 100]]]], + ] as $response + ) { + $this->db->pdo()->prepare('UPDATE delivery_log SET response_json=? WHERE job_id=?')->execute([Database::encode($response),$job]); + try { + $this->deletions->recordSentJobDelivery($job, [100], true); + self::fail('An untrusted acknowledgement entered deletion bookkeeping.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('absent', $error->getMessage()); + } + self::assertNull($this->jobs->get($job)['module_delivery_processed_at']); + self::assertSame([], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + } + } + public function testConsentRevisionChangesFenceAnAlreadyClaimedHolyDeletion(): void + { + $id = $this->deletions->enqueue('-1', 30, 'holy_message', $this->metadata(), $this->now(), $this->now() + 3600); + $this->deletions->claim('a'); + $this->settings->updateGroup('-1', 1, ['delete_holy_user_messages' => true], '42'); + self::assertFalse($this->deletions->owns($id, 'a')); + $this->deletions->cancel($id, 'a', 'obsolete settings revision'); + self::assertSame('cancelled', $this->deletions->get($id)['status']); + } + public function testFiveAttemptsExhaustCleanupBudgetWithoutSixthClaim(): void + { + $id = $this->deletions->enqueue('-1', 30, 'holy_message', $this->metadata(), $this->now(), $this->now() + 3600); + for ($attempt = 1; $attempt <= 5; $attempt++) { + $row = $this->deletions->claim('worker'); + self::assertSame($attempt, $row['attempts']); + $this->deletions->retry($id, 'worker', 'temporary failure', 1); + $this->clock->set($this->clock->now()->modify('+2 seconds')); + } + self::assertNull($this->deletions->claim('worker')); + self::assertSame('failed', $this->deletions->get($id)['status']); + } + public function testPersistedAcknowledgementsAreBoundToTheSentJobRandomIdAndPeer(): void + { + $first = $this->sentJob(100, 'first-context'); + $this->deletions->recordSentJobDelivery($first, [100], true); + $second = $this->sentJob(101, 'second-context'); + $random = $this->jobs->get($second)['random_id']; + $differentRandom = $random === '1' ? 2 : 1; + foreach ( + [ + ['_' => 'updates','updates' => [['_' => 'updateMessageID','id' => 999,'random_id' => $differentRandom]]], + ['_' => 'updates','updates' => [['_' => 'updateNewChannelMessage','message' => ['_' => 'message','out' => true,'id' => 999,'peer_id' => -2]]]], + ] as $response + ) { + $this->db->pdo()->prepare('UPDATE delivery_log SET response_json=? WHERE job_id=?')->execute([Database::encode($response),$second]); + try { + $this->deletions->recordSentJobDelivery($second, [999], true); + self::fail('An unrelated acknowledgement replaced the bot-owned reminder pointer.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('absent', $error->getMessage()); + } + self::assertSame([100], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([], $this->deletions->list()); + self::assertSame('sent', $this->jobs->get($second)['status']); + self::assertNull($this->jobs->get($second)['module_delivery_processed_at']); + } + $matched = ['_' => 'updates','updates' => [['_' => 'updateMessageID','id' => 101,'random_id' => (int)$random]]]; + $this->db->pdo()->prepare('UPDATE delivery_log SET response_json=? WHERE job_id=?')->execute([Database::encode($matched),$second]); + self::assertSame('recorded', $this->deletions->recordSentJobDelivery($second, [101], true)['status']); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1', 'tefillin')); + self::assertSame([100], array_column($this->deletions->list(), 'message_id')); + } +} diff --git a/tests/DeliveryDeferralTest.php b/tests/DeliveryDeferralTest.php new file mode 100644 index 0000000..34b3591 --- /dev/null +++ b/tests/DeliveryDeferralTest.php @@ -0,0 +1,77 @@ +migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-15T09:00:00Z')); + $settings = new SettingsRepository($database, $clock); + $group = $settings->ensureGroup('-1001', 'Fixture'); + $group = $settings->updateGroup('-1001', $group['revision'], ['zmanim_enabled' => true], '42'); + $jobs = new JobRepository($database, $clock); + $id = $jobs->enqueue('-1001', 'zmanim', 'fixture-event', $clock->now()->getTimestamp(), $clock->now()->getTimestamp() + 3600, $group['revision'], ['text' => 'fixture']); + $gateway = new SchedulerFakeGateway(); + $defer = true; + $worker = new OutboxWorker($jobs, $settings, $gateway, $clock, static function () use (&$defer, $clock): bool { + if ($defer) { + throw new DeliveryDeferredException('fixture restoration debt', $clock->now()->getTimestamp() + 60); + } + return true; + }); + for ($count = 0; $count < 6; $count++) { + self::assertSame('deferred', $worker->runOnce('fixture-owner')['status']); + self::assertSame('pending', $jobs->get($id)['status']); + self::assertSame([], $gateway->sends); + $clock->set($clock->now()->modify('+60 seconds')); + } + self::assertSame(6, $jobs->get($id)['deferrals']); + $defer = false; + $gateway->onSend = static function (): array { + throw new RetryableTelegramException('fixture timeout', 1); + }; + self::assertSame('retry', $worker->runOnce('fixture-owner')['status']); + self::assertSame('pending', $jobs->get($id)['status']); + $clock->set($clock->now()->modify('+1 second')); + $gateway->onSend = null; + self::assertSame('sent', $worker->runOnce('fixture-owner')['status']); + self::assertSame('sent', $jobs->get($id)['status']); + self::assertSame($gateway->sends[0][2], $gateway->sends[1][2]); + } + + public function testDeferredDeadlineBeyondCutoffExpiresWithoutTelegramRequest(): void + { + $database = Database::open(':memory:'); + $database->migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-15T09:00:00Z')); + $settings = new SettingsRepository($database, $clock); + $group = $settings->ensureGroup('-1001', 'Fixture'); + $group = $settings->updateGroup('-1001', $group['revision'], ['zmanim_enabled' => true], '42'); + $jobs = new JobRepository($database, $clock); + $id = $jobs->enqueue('-1001', 'zmanim', 'fixture-cutoff', $clock->now()->getTimestamp(), $clock->now()->getTimestamp() + 30, $group['revision'], ['text' => 'fixture']); + $gateway = new SchedulerFakeGateway(); + $worker = new OutboxWorker($jobs, $settings, $gateway, $clock, static function () use ($clock): bool { + throw new DeliveryDeferredException('fixture after cutoff', $clock->now()->getTimestamp() + 60); + }); + self::assertSame('deferred', $worker->runOnce('fixture-owner')['status']); + self::assertSame('expired', $jobs->get($id)['status']); + self::assertSame([], $gateway->sends); + } +} diff --git a/tests/DeliverySafetyTest.php b/tests/DeliverySafetyTest.php new file mode 100644 index 0000000..849c946 --- /dev/null +++ b/tests/DeliverySafetyTest.php @@ -0,0 +1,231 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-15T08:30:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1'); + $this->jobs = new JobRepository($this->db, $this->clock); + } + private function add(string $event, int $ago = 0, string $group = '-1', string $module = 'tefillin'): int + { + $now = $this->clock->now()->getTimestamp(); + return $this->jobs->enqueue($group, $module, $event, $now - $ago, $now + 14400, 1, ['text' => $event]); + } + private function reserve(array $job, string $owner = 'worker', array $changes = []): bool + { + return $this->jobs->guardDelivery($job, array_replace($this->settings->getGroup($job['group_id']), $changes), $owner); + } + public function testPersistedOverdueRemindersCollapseToOnlyLatestAfterRestart(): void + { + $a = $this->add('09:00', 9000); + $b = $this->add('10:00', 5400); + $c = $this->add('11:00', 1800); + $restarted = new JobRepository($this->db, $this->clock); + $job = $restarted->lease('worker'); + self::assertSame($c, $job['job_id']); + self::assertSame('cancelled', $restarted->get($a)['status']); + self::assertSame('cancelled', $restarted->get($b)['status']); + self::assertTrue($this->reserve($job)); + $restarted->sent($c, 'worker', ['id' => 12]); + self::assertNull($restarted->lease('worker')); + } + public function testActualDeliverySpacingSurvivesRestartAndUsesCurrentSettings(): void + { + $id = $this->add('first', 3600); + $job = $this->jobs->lease('worker'); + self::assertTrue($this->reserve($job)); + $this->jobs->sent($id, 'worker', ['id' => 1]); + $this->clock->set($this->clock->now()->modify('+10 minutes')); + $id2 = $this->add('second', 3500); + $job2 = (new JobRepository($this->db, $this->clock))->lease('worker'); + try { + $this->reserve($job2, changes: ['tefillin_min_spacing_minutes' => 45]); + self::fail('Should defer actual delivery spacing.'); + } catch (DeliveryDeferredException $error) { + self::assertSame($this->clock->now()->getTimestamp() + 35 * 60, $error->retryAt); + $this->jobs->defer($id2, 'worker', $error->getMessage(), $error->retryAt); + } + self::assertNull($this->jobs->get($id2)['dispatch_started_at']); + $this->clock->set($this->clock->now()->modify('+35 minutes')); + self::assertTrue($this->reserve($this->jobs->lease('worker'), changes: ['tefillin_min_spacing_minutes' => 45])); + } + public function testDailyMaximumCountsSuccessfulDeliveryAcrossRevisionsAndLocalMidnight(): void + { + $this->clock->set(new DateTimeImmutable('2026-10-15T20:55:00Z')); + $id = $this->add('one'); + self::assertTrue($this->reserve($this->jobs->lease('worker'), changes: ['tefillin_max_per_day' => 1])); + $this->jobs->sent($id, 'worker', ['id' => 1]); + $this->clock->set($this->clock->now()->modify('+1 minute')); + $this->add('two'); + $job = $this->jobs->lease('worker'); + self::assertFalse($this->reserve($job, changes: ['tefillin_max_per_day' => 1])); + $this->jobs->cancel($job['job_id'], 'worker', 'daily limit'); + $this->clock->set(new DateTimeImmutable('2026-10-15T21:30:00Z')); + $this->add('next-local-day'); + self::assertTrue($this->reserve($this->jobs->lease('worker'), changes: ['tefillin_max_per_day' => 1])); + } + public function testUncertainRetryPreservesPayloadRandomIdAndBlocksNewReminder(): void + { + $id = $this->add('uncertain'); + $job = $this->jobs->lease('first'); + $this->jobs->preparePayload($id, 'first', ['text' => 'frozen']); + self::assertTrue($this->reserve($job, 'first')); + $this->jobs->markDispatch($id, 'first'); + $this->jobs->retry($id, 'first', 'lost response', 120); + $this->clock->set($this->clock->now()->modify('+1 second')); + $newId = $this->add('later'); + $new = $this->jobs->lease('second'); + self::assertSame($newId, $new['job_id']); + try { + $this->reserve($new, 'second'); + self::fail('Uncertain predecessor should defer fresh logical reminder.'); + } catch (DeliveryDeferredException $error) { + $this->jobs->defer($newId, 'second', $error->getMessage(), $error->retryAt); + } + $this->clock->set($this->clock->now()->modify('+120 seconds')); + $retry = $this->jobs->lease('first'); + self::assertSame($id, $retry['job_id']); + self::assertSame($job['random_id'], $retry['random_id']); + self::assertSame(['text' => 'frozen'], $retry['payload']); + self::assertTrue($this->reserve($retry, 'first', ['tefillin_max_per_day' => 1])); + $this->jobs->sent($id, 'first', ['id' => 1]); + self::assertSame('pending', $this->jobs->get($newId)['status']); + } + public function testParallelOwnersCannotReserveTwoSlotsAndUnrelatedGroupCanSend(): void + { + $this->settings->ensureGroup('-2'); + $first = $this->add('first'); + $a = $this->jobs->lease('a'); + self::assertTrue($this->reserve($a, 'a')); + $this->add('second'); + $b = $this->jobs->lease('b'); + try { + $this->reserve($b, 'b'); + self::fail('Parallel logical reminder must not pass an unresolved reservation.'); + } catch (DeliveryDeferredException $error) { + $this->jobs->defer($b['job_id'], 'b', $error->getMessage(), $error->retryAt); + } + $other = $this->add('other', group: '-2'); + $c = $this->jobs->lease('c'); + self::assertSame($other, $c['job_id']); + self::assertTrue($this->reserve($c, 'c')); + self::assertSame('leased', $this->jobs->get($first)['status']); + } + public function testAlreadyDispatchedExpiredIdentityCannotBeRewrittenBySettingsUpdate(): void + { + $id = $this->add('identity'); + $job = $this->jobs->lease('worker'); + $this->jobs->preparePayload($id, 'worker', ['text' => 'immutable']); + self::assertTrue($this->reserve($job)); + $this->jobs->fail($id, 'worker', 'unknown response'); + $before = $this->jobs->get($id); + $now = $this->clock->now()->getTimestamp(); + self::assertSame($id, $this->jobs->enqueue('-1', 'tefillin', 'identity', $now, $now + 15000, 2, ['text' => 'new'])); + self::assertSame($before, $this->jobs->get($id)); + } + public function testAlreadyDispatchedRawLegacyPayloadCannotBeRegenerated(): void + { + $id = $this->add('frozen-original'); + $job = $this->jobs->lease('worker'); + self::assertTrue($this->reserve($job)); + $this->jobs->preparePayload($id, 'worker', ['text' => 'different']); + self::assertSame(['text' => 'frozen-original'], $this->jobs->get($id)['payload']); + self::assertSame($job['random_id'], $this->jobs->get($id)['random_id']); + } + public function testDeadlineOwnershipAndCancelledJobsCannotReserve(): void + { + $id = $this->add('owner'); + $job = $this->jobs->lease('worker', 1); + self::assertFalse($this->reserve($job, 'wrong')); + $this->clock->set($this->clock->now()->modify('+1 second')); + self::assertFalse($this->reserve($job)); + self::assertNull($this->jobs->get($id)['dispatch_started_at']); + } + public function testUpgradeBackfillsOldUncertainHistoryConservativelyWithoutChangingPayload(): void + { + $db = Database::open(':memory:'); + $pdo = $db->pdo(); + $files = glob(dirname(__DIR__) . '/migrations/*.sql'); + sort($files); + foreach ($files as $file) { + if (basename($file) === '016_delivery_safety.sql') { + break; + } + $pdo->exec(file_get_contents($file)); + } + $pdo->exec("INSERT INTO groups(group_id,created_at,updated_at) VALUES('-1',10,10)"); + $pdo->exec("INSERT INTO scheduled_jobs(group_id,module,event_key,run_at,expires_at,settings_revision,payload_json,random_id,status,attempts,created_at) VALUES('-1','tefillin','old',10,1000,1,'{\"text\":\"original\"}','123','pending',1,10)"); + $pdo->exec("INSERT INTO delivery_log(job_id,attempt,random_id,started_at,outcome) VALUES(1,1,'123',100,'retry')"); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/migrations/016_delivery_safety.sql')); + $row = $pdo->query('SELECT * FROM scheduled_jobs')->fetch(); + self::assertSame(100, $row['dispatch_started_at']); + self::assertSame(100, $row['last_dispatch_at']); + self::assertSame('123', $row['random_id']); + self::assertSame('{"text":"original"}', $row['payload_json']); + } + public function testMultipleLegacyUncertainRetriesReconcileInOrderWithoutBurst(): void + { + $first = $this->add('first-old', 60); + $second = $this->add('second-old', 30); + $now = $this->clock->now()->getTimestamp(); + $this->db->pdo()->exec("UPDATE scheduled_jobs SET dispatch_started_at=$now-3600,last_dispatch_at=$now-3600"); + $job = $this->jobs->lease('worker'); + self::assertSame($first, $job['job_id']); + self::assertTrue($this->reserve($job)); + $this->jobs->sent($first, 'worker', ['id' => 1]); + $retry = $this->jobs->lease('worker'); + self::assertSame($second, $retry['job_id']); + try { + $this->reserve($retry); + self::fail('Multiple old uncertain identities must not replay in a burst.'); + } catch (DeliveryDeferredException $error) { + self::assertSame($now + 1800, $error->retryAt); + } + self::assertNotNull($this->jobs->get($second)['dispatch_started_at']); + } + public function testOneThousandGroupsWithThreeDueJobsEachSendOnlyLatestPerGroup(): void + { + for ($i = 1; $i <= 1000; $i++) { + $group = (string)(-$i); + $this->settings->ensureGroup($group); + $this->add('old', 7200, $group); + $this->add('middle', 3600, $group); + $this->add('latest', 1, $group); + } + $seen = []; + while (($job = $this->jobs->lease('worker')) !== null) { + self::assertSame('latest', $job['event_key']); + self::assertArrayNotHasKey($job['group_id'], $seen); + self::assertTrue($this->reserve($job)); + $this->jobs->sent($job['job_id'], 'worker', ['id' => 1]); + $seen[$job['group_id']] = true; + } + self::assertCount(1000, $seen); + self::assertSame(['cancelled' => 2000,'sent' => 1000], $this->jobs->counts()); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } +} diff --git a/tests/Dev7UpgradeTest.php b/tests/Dev7UpgradeTest.php new file mode 100644 index 0000000..2f4f8e3 --- /dev/null +++ b/tests/Dev7UpgradeTest.php @@ -0,0 +1,70 @@ +migrate($directory)); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00Z')); + $settings = new SettingsRepository($db, $clock); + $settings->ensureGroup('-1001', 'Preserved group', ['language' => 'pt-br', 'candle_minutes' => 40]); + $settings->getUser('42'); + $original = '{"_":"chatBannedRights","send_plain":false,"send_photos":true,"until_date":0}'; + $requested = '{"_":"chatBannedRights","send_plain":true,"send_photos":true,"until_date":0}'; + $db->pdo()->prepare('INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,applied_json,previous_applied_json,snapshot_sha256,prepared_at,last_error) VALUES(?,?,?,?,?,?,?,?,?)') + ->execute(['-1001', 'retained-event', 'conflict', $original, $requested, $requested, hash('sha256', $original), 1792054800, 'Retained conflict evidence']); + $before = []; + foreach (['groups', 'group_settings', 'users', 'user_preferences', 'group_lock_state', 'schema_migrations'] as $table) { + $before[$table] = $db->pdo()->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(); + } + self::assertSame(['013_permission_recovery.sql', '014_diagnostic_panels.sql', '015_permission_snapshot_provenance.sql', '016_delivery_safety.sql'], $db->migrate()); + self::assertSame([], $db->migrate()); + $db->assertCurrentSchema(); + foreach ($before as $table => $rows) { + $after = $db->pdo()->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(); + foreach ($rows as $index => $row) { + self::assertSame($row, array_intersect_key($after[$index], $row), $table); + } + } + $state = $db->pdo()->query('SELECT * FROM group_lock_state')->fetch(); + self::assertSame('legacy_unverified', $state['snapshot_provenance']); + self::assertNull($state['snapshot_evidence_json']); + self::assertNull($state['recovery_baseline_json']); + self::assertNull($state['recovery_baseline_sha256']); + foreach (['permission_operations', 'permission_recovery_confirmations', 'diagnostic_panels'] as $table) { + self::assertSame(0, (int)$db->pdo()->query('SELECT count(*) FROM ' . $table)->fetchColumn()); + } + self::assertSame(['ok'], $db->integrity()['integrity_check']); + self::assertSame([], $db->integrity()['foreign_key_check']); + } finally { + foreach ($copies as $copy) { + if (is_file($copy)) { + unlink($copy); + } + } + rmdir($directory); + } + } +} diff --git a/tests/Dev8PermissionsPresentationTest.php b/tests/Dev8PermissionsPresentationTest.php new file mode 100644 index 0000000..25df326 --- /dev/null +++ b/tests/Dev8PermissionsPresentationTest.php @@ -0,0 +1,37 @@ + [$code], Lang::CODES); + } + + #[DataProvider('languages')] + public function testManualResultsDistinguishOldSnapshotFromPostWriteFailure(string $language): void + { + $base = ['action' => 'open', 'changed' => 0, 'failed' => 1, 'expires_at' => 1792054800]; + $row = ['group_id' => '-1001', 'title' => 'Test group', 'status' => 'recovery_required', 'changed' => false, + 'error_code' => 'SNAPSHOT_UNVERIFIED', 'recovery_reason' => 'unverified_snapshot']; + $lang = new Lang(); + $legacy = ManualGroupReport::format($base + ['groups' => [$row]], $language, 'Asia/Jerusalem'); + self::assertStringContainsString($lang->text($language, 'permission_error_unverified'), $legacy); + $row = ['group_id' => '-1001', 'title' => 'Test group', 'status' => 'conflict', 'changed' => false, + 'mutation_attempted' => true, 'verification_failed' => true, 'different_fields' => ['manage_topics']]; + $readback = ManualGroupReport::format($base + ['groups' => [$row]], $language, 'Asia/Jerusalem'); + self::assertStringContainsString($lang->text($language, 'permission_error_readback'), $readback); + self::assertStringContainsString($lang->text($language, 'permission_manage_topics'), $readback); + self::assertStringNotContainsString('manage_topics', $readback); + } +} diff --git a/tests/DiagnosticsControllerTest.php b/tests/DiagnosticsControllerTest.php new file mode 100644 index 0000000..6db18fa --- /dev/null +++ b/tests/DiagnosticsControllerTest.php @@ -0,0 +1,452 @@ +createMock(DiagnosticsGatewayFixture::class); + $gateway->method('botUsername')->willReturn('FixtureBot'); + $gateway->method('isGroupAdmin')->willReturn(true); + $gateway->expects(self::never())->method('setPermissions'); + $gateway->expects(self::never())->method('send'); + $gateway->method('getPermissions')->willReturnCallback(function (): array { + $this->permissionReads++; + if ($this->permissionError !== null) { + throw $this->permissionError; + } + return ['_' => 'chatBannedRights', 'until_date' => 0, 'send_media' => true]; + }); + $gateway->method('sendPrivate')->willReturnCallback(function (string $user, string $text): void { + $this->sent[] = compact('user', 'text'); + }); + $gateway->method('sendDiagnosticPanel')->willReturnCallback(function (string $user, string $text, array $markup): int { + $this->sent[] = compact('user', 'text', 'markup'); + return 800; + }); + $gateway->method('editPrivate')->willReturnCallback(function (string $user, int $id, string $text, array $markup): void { + if ($this->editError !== null) { + throw $this->editError; + } + $this->edits[] = compact('user', 'id', 'text', 'markup'); + }); + $gateway->method('sendDiagnosticDocument')->willReturnCallback(function (string $user, string $json, string $caption, string $randomId): void { + $this->documents[] = compact('user', 'json', 'caption', 'randomId'); + if ($this->documentError !== null) { + throw $this->documentError; + } + }); + $gateway->method('answerCallback')->willReturnCallback(function (string $query, string $text): void { + $this->answers[] = compact('query', 'text'); + }); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('private-secret-exception-message'); + } + }; + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $this->app = new Application($gateway, $this->clock, $db, new CalendarService(new HebcalProvider($http), $this->clock), new GeoNames($http, $this->clock, ''), configuredOwners: fn(): array => $this->owner ? [2] : []); + $this->app->settings->getUser('2'); + $this->app->settings->ensureGroup('-1001', 'Example group'); + $this->repo = new DiagnosticsRepository($db, $this->clock); + $this->ui = new DiagnosticsController($this->app, function (string $user, ?string $group): array { + $this->captures++; + $records = []; + for ($n = 0; $n < 30; $n++) { + $records[] = ['category' => 'calendar', 'label' => 'period', 'value' => 'period-' . $n, 'status' => $n === 10 ? 'warning' : 'ok', 'data' => []]; + } + $records[] = ['category' => 'groups', 'label' => 'group', 'value' => 'Example -1001', 'status' => 'ok', 'data' => ['group_id' => '-1001']]; + return ['schema' => 'shabbat-diagnostics-1', 'read_only' => true, 'created_at' => $this->clock->now()->getTimestamp(), 'context' => $group ?? 'personal', 'timezone' => 'Asia/Jerusalem', 'records' => $records]; + }); + } + + public static function languages(): array + { + return array_map(static fn(string $language): array => [$language], Lang::CODES); + } + + #[DataProvider('languages')] + public function testNavigationSearchFiltersAndJumpEditOnlyOnePanel(string $language): void + { + $settings = $this->app->settings->getUser('2'); + $this->app->settings->updateUser('2', $settings['revision'], ['language' => $language]); + $this->ui->open('2'); + self::assertCount(1, $this->sent); + self::assertLessThan(2000, mb_strlen($this->sent[0]['text'])); + $this->action('next'); + self::assertStringContainsString('period-4', $this->last()['text']); + $this->action('page'); + self::assertTrue($this->ui->input(['user_id' => '2', 'private' => true, 'text' => '4'])); + self::assertStringContainsString('period-12', $this->last()['text']); + $this->action('page'); + $this->ui->input(['user_id' => '2', 'private' => true, 'text' => '999']); + self::assertStringContainsString((new Lang())->text($language, 'diagnostics_invalid_page'), $this->last()['text']); + $this->action('list'); + $this->action('search'); + $this->ui->input(['user_id' => '2', 'private' => true, 'text' => 'period-10']); + self::assertStringContainsString('period-10', $this->last()['text']); + self::assertStringNotContainsString('period-11', $this->last()['text']); + $this->action('clear'); + $this->action('filters'); + $this->action('filter:problems'); + self::assertStringContainsString('period-10', $this->last()['text']); + self::assertCount(1, $this->sent); + self::assertSame(1, $this->captures); + self::assertSame(0, $this->permissionReads); + foreach ($this->edits as $edit) { + self::assertSame(800, $edit['id']); + self::assertLessThan(3000, mb_strlen($edit['text'])); + foreach ($edit['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + self::assertLessThanOrEqual(64, strlen($button['data'])); + } + } + } + } + + public function testExportIsExplicitOneFileAndDebouncedAcrossRefresh(): void + { + $this->ui->open('2'); + self::assertSame([], $this->documents); + $this->action('export'); + $this->action('export'); + self::assertCount(1, $this->documents); + self::assertSame('shabbat-diagnostics-1', json_decode($this->documents[0]['json'], true)['schema']); + $this->advance(31); + $this->action('refresh'); + $this->action('export'); + self::assertCount(1, $this->documents); + self::assertCount(1, $this->sent); + self::assertSame(2, $this->captures); + $this->advance(30); + $this->action('export'); + self::assertCount(2, $this->documents); + self::assertNotSame($this->documents[0]['randomId'], $this->documents[1]['randomId']); + } + + public function testUncertainExportIsNotRepeatedByDuplicateClick(): void + { + $this->ui->open('2'); + $this->documentError = new \RuntimeException('uncertain transport'); + $this->action('export'); + $this->action('export'); + self::assertCount(1, $this->documents); + self::assertCount(1, $this->sent); + } + + public function testForgeryStaleTokenWrongPanelAndRevokedRoleCannotReadOrExport(): void + { + $this->ui->open('2'); + $event = $this->event('export'); + foreach ( + [ + ['private' => false, 'chat_id' => '-1001'], + ['inline_selection' => true], + ['message_id' => 801], + ['user_id' => '3', 'chat_id' => '3'], + ['data' => 'dz:' . str_repeat('a', 24) . ':export'], + ] as $change + ) { + self::assertTrue($this->ui->callback(array_replace($event, $change))); + } + self::assertSame([], $this->documents); + self::assertSame([], $this->edits); + $this->action('page'); + $edits = count($this->edits); + $this->owner = false; + $this->ui->input(['user_id' => '2', 'private' => true, 'text' => '2']); + $this->action('export'); + self::assertCount($edits, $this->edits); + self::assertSame([], $this->documents); + $this->owner = true; + $this->advance(901); + $this->ui->callback($event); + self::assertSame([], $this->documents); + } + + public function testCancelledAndFailedEditNeverSendReplacementMessages(): void + { + $this->ui->open('2'); + $this->editError = new \RuntimeException('message missing'); + $this->action('next'); + self::assertCount(1, $this->sent); + $this->editError = new CancelledException(); + $this->expectException(CancelledException::class); + $this->action('next'); + } + + public function testCurrentRightsOnlyReadForSelectedGroupAndErrorsRemainPerSection(): void + { + $report = new DiagnosticReport($this->app); + $personal = $report->capture('2', null); + self::assertSame(0, $this->permissionReads); + self::assertStringNotContainsString('private-secret', json_encode($personal)); + $this->permissionError = new \RuntimeException('secret-token-must-not-leak'); + $group = $report->capture('2', '-1001'); + self::assertSame(1, $this->permissionReads); + self::assertStringNotContainsString('secret-token', json_encode($group)); + $errors = array_values(array_filter($group['records'], static fn(array $r): bool => $r['status'] === 'error')); + self::assertSame(['provider_error', 'current'], array_column($errors, 'label')); + self::assertTrue($group['read_only']); + } + + public function testActualNativeCallbackAndPrivateInputBridgeReachPanel(): void + { + $this->ui->open('2'); + require_once dirname(__DIR__) . '/bot.php'; + $bridge = new CallbackRoutingFixture($this->app); + $event = $this->event('next'); + $reflection = new \ReflectionClass(ChatButtonQuery::class); + $query = $reflection->newInstanceWithoutConstructor(); + foreach (['data' => $event['data'], 'userId' => 2, 'chatId' => 2, 'messageId' => 800, 'queryId' => 99, 'chatInstance' => 1] as $field => $value) { + $reflection->getProperty($field)->getDeclaringClass()->getProperty($field)->setValue($query, $value); + } + $bridge->upgradedCallbacks($query); + self::assertStringContainsString('period-4', $this->last()['text']); + $this->action('search'); + $reflection = new \ReflectionClass(PrivateMessage::class); + $message = $reflection->newInstanceWithoutConstructor(); + foreach (['senderId' => 2, 'chatId' => 2, 'id' => 901, 'message' => 'period-10'] as $field => $value) { + $reflection->getProperty($field)->getDeclaringClass()->getProperty($field)->setValue($message, $value); + } + $method = new \ReflectionMethod($bridge, 'v4HandleMessage'); + self::assertTrue($method->invoke($bridge, $message, ['user_id' => '2', 'private' => true, 'text' => 'period-10'])); + self::assertStringContainsString('period-10', $this->last()['text']); + self::assertCount(1, $this->sent); + } + + public function testDuplicateExportQueryNeverReplaysAfterCooldownOrRefresh(): void + { + $this->ui->open('2'); + $event = $this->event('export'); + $this->ui->callback($event); + $this->advance(61); + $this->ui->callback($event); + self::assertCount(1, $this->documents); + $this->action('export'); + self::assertCount(2, $this->documents); + } + + public function testExpiredClaimCannotOverwriteReleaseOrExportForNewOwner(): void + { + $this->ui->open('2'); + $row = $this->repo->get('2'); + $old = $this->repo->claim('2', $row['token']); + $this->advance(121); + $new = $this->repo->claim('2', $row['token']); + self::assertIsString($old); + self::assertIsString($new); + self::assertNotSame($old, $new); + $this->repo->release('2', $row['token'], $old); + self::assertSame($new, $this->repo->get('2')['busy_owner']); + foreach ( + [ + fn() => $this->repo->update('2', $row['token'], ['page' => 9], $old), + fn() => $this->repo->saveReport('2', $row['token'], [], $old), + fn() => $this->repo->claimExport('2', $row['token'], $old, '22'), + ] as $operation + ) { + try { + $operation(); + self::fail('An expired owner must not mutate diagnostic state.'); + } catch (\RuntimeException) { + self::assertSame($new, $this->repo->get('2')['busy_owner']); + } + } + $this->repo->update('2', $row['token'], ['page' => 2], $new); + self::assertSame(2, $this->repo->get('2')['page']); + $this->repo->release('2', $row['token'], $new); + } + + public function testGroupSelectionWorksWithoutWaitingForRefreshCooldownAndRotatesToken(): void + { + $this->ui->open('2'); + $old = $this->event('next'); + $this->advance(2); + $this->action('group:30'); + self::assertSame('-1001', $this->repo->get('2')['group_id']); + self::assertCount(1, $this->sent); + self::assertSame(2, $this->captures); + $edits = count($this->edits); + $this->ui->callback($old); + self::assertCount($edits, $this->edits); + $this->action('refresh'); + self::assertSame(2, $this->captures); + } + + public function testPermissionSnapshotsIncludeVerifiedBaselineIntermediateAndJournal(): void + { + $original = Database::encode(['_' => 'chatBannedRights', 'until_date' => 0]); + $applied = Database::encode(['_' => 'chatBannedRights', 'until_date' => 0, 'send_plain' => true]); + $baseline = Database::encode(['_' => 'chatBannedRights', 'until_date' => 0, 'send_media' => true]); + $this->app->db->pdo()->prepare("INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,snapshot_sha256,applied_json,previous_applied_json,recovery_baseline_json,recovery_baseline_sha256,prepared_at) VALUES('-1001','test','conflict',?,?,?,?,?,?,1)") + ->execute([$original, hash('sha256', $original), $applied, $applied, $baseline, hash('sha256', $baseline)]); + $journal = new \Shabbat\Scheduling\PermissionRecoveryJournal($this->app->db, $this->clock); + $id = $journal->begin('-1001', 'lock', json_decode($original, true), json_decode($applied, true), ['snapshot_json' => $original, 'snapshot_sha256' => hash('sha256', $original)]); + $journal->update($id, 'verified', 'conflict', json_decode($baseline, true)); + $report = (new DiagnosticReport($this->app))->capture('2', '-1001'); + $rows = array_column($report['records'], null, 'label'); + self::assertArrayHasKey('previous_rights', $rows); + self::assertArrayHasKey('baseline', $rows); + self::assertSame([], $rows['compare_baseline']['data']['different_fields']); + self::assertSame('verified', $rows['operation']['data']['stage']); + self::assertSame('conflict', $rows['operation']['data']['outcome']); + self::assertTrue($rows['operation']['data']['observed']['send_media']); + $this->app->db->pdo()->exec("UPDATE group_lock_state SET snapshot_sha256='corrupted'"); + $report = (new DiagnosticReport($this->app))->capture('2', '-1001'); + $rows = array_column($report['records'], null, 'label'); + self::assertSame('error', $rows['original']['status']); + self::assertSame(['integrity' => 'checksum_mismatch'], $rows['original']['data']); + self::assertArrayNotHasKey('compare_original', $rows); + } + public function testNativePrivateCommandCannotExecuteFromGroupAndRepeatDoesNotFlood(): void + { + require_once dirname(__DIR__) . '/bot.php'; + $method = new \ReflectionMethod(\Shabbat::class, 'testzmanim'); + self::assertCount(1, $method->getAttributes(\danog\MadelineProto\EventHandler\Filter\FilterCommandCaseInsensitive::class)); + $filter = \danog\MadelineProto\EventHandler\Filter\Filter::fromReflectionType($method->getParameters()[0]->getType()); + $bridge = new CallbackRoutingFixture($this->app); + $handler = new \ReflectionMethod($bridge, 'v3TestZmanim'); + foreach ([\danog\MadelineProto\EventHandler\Message\GroupMessage::class, PrivateMessage::class] as $className) { + $reflection = new \ReflectionClass($className); + $message = $reflection->newInstanceWithoutConstructor(); + foreach (['senderId' => 2, 'chatId' => $className === PrivateMessage::class ? 2 : -1001, 'id' => 910, 'message' => '/testzmanim', 'out' => false, 'editDate' => null] as $field => $value) { + $reflection->getProperty($field)->getDeclaringClass()->getProperty($field)->setValue($message, $value); + } + self::assertSame($className === PrivateMessage::class, $filter->apply($message)); + $handler->invoke($bridge, $message); + if ($className !== PrivateMessage::class) { + self::assertSame([], $this->sent); + } + } + self::assertCount(1, $this->sent); + $handler->invoke($bridge, $message); + self::assertCount(1, $this->sent); + self::assertStringNotContainsString('private-secret', $this->sent[0]['text']); + self::assertLessThan(3000, mb_strlen($this->sent[0]['text'])); + } + + public function testNativeCallbackForgeriesCannotExportAndMissingPanelDoesNotSend(): void + { + $this->ui->open('2'); + $bridge = new CallbackRoutingFixture($this->app); + foreach ( + [ + [ChatButtonQuery::class, 2, -1001, 800], + [ChatButtonQuery::class, 3, 3, 800], + [ChatButtonQuery::class, 2, 2, 801], + [\danog\MadelineProto\EventHandler\Query\InlineButtonQuery::class, 2, null, null], + ] as [$className, $actor, $chat, $panel] + ) { + $reflection = new \ReflectionClass($className); + $query = $reflection->newInstanceWithoutConstructor(); + $fields = ['data' => $this->event('export')['data'], 'userId' => $actor, 'queryId' => 99, 'chatInstance' => 1]; + if ($chat !== null) { + $fields += ['chatId' => $chat, 'messageId' => $panel]; + } + foreach ($fields as $field => $value) { + $reflection->getProperty($field)->getDeclaringClass()->getProperty($field)->setValue($query, $value); + } + $bridge->upgradedCallbacks($query); + } + self::assertSame([], $this->documents); + self::assertSame([], $this->edits); + self::assertCount(1, $this->sent); + $reflection = new \ReflectionClass(ChatButtonQuery::class); + $query = $reflection->newInstanceWithoutConstructor(); + foreach (['data' => $this->event('next')['data'], 'userId' => 2, 'chatId' => 2, 'messageId' => 800, 'queryId' => 100, 'chatInstance' => 1] as $field => $value) { + $reflection->getProperty($field)->getDeclaringClass()->getProperty($field)->setValue($query, $value); + } + $this->editError = new \RuntimeException('MESSAGE_ID_INVALID'); + $bridge->upgradedCallbacks($query); + self::assertCount(1, $this->sent); + self::assertSame([], $this->edits); + self::assertSame((new Lang())->text('he', 'diagnostics_unavailable'), $this->answers[array_key_last($this->answers)]['text']); + } + public function testLongSearchRetainsPromptAndPageJumpShowsRange(): void + { + $this->ui->open('2'); + $this->action('page'); + self::assertStringContainsString('8', $this->last()['text']); + $this->action('search'); + $this->ui->input(['user_id' => '2', 'private' => true, 'text' => str_repeat('x', 81)]); + self::assertSame('search', $this->repo->get('2')['prompt']); + self::assertStringContainsString((new Lang())->text('he', 'diagnostics_invalid_search'), $this->last()['text']); + $this->ui->input(['user_id' => '2', 'private' => true, 'text' => 'period-2']); + self::assertNull($this->repo->get('2')['prompt']); + self::assertCount(1, $this->sent); + } + public function testSnapshotVerificationRequiresOriginalBytesAndEvidenceNotOnlyLabel(): void + { + $raw = '{"_":"chatBannedRights","until_date":0,"invite_users":true}'; + $now = $this->clock->now()->getTimestamp(); + $evidence = \Shabbat\Scheduling\PermissionRecoveryProvenance::capture('-1001', $raw, $now); + $this->app->db->pdo()->prepare('INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,snapshot_sha256,prepared_at,snapshot_provenance,snapshot_evidence_json) VALUES(?,?,?,?,?,?,?,?)') + ->execute(['-1001', 'diagnostic-fixture', 'conflict', $raw, hash('sha256', $raw), $now, 'verified_native_v1', $evidence]); + $report = new DiagnosticReport($this->app); + $verification = static fn(array $data): array => array_values(array_filter($data['records'], static fn(array $record): bool => $record['label'] === 'snapshot_verification'))[0]; + self::assertSame('ok', $verification($report->capture('2', '-1001'))['status']); + $this->app->db->pdo()->exec("UPDATE group_lock_state SET snapshot_json='{}'"); + self::assertSame('warning', $verification($report->capture('2', '-1001'))['status']); + $this->app->db->pdo()->prepare("UPDATE group_lock_state SET snapshot_json=?,snapshot_evidence_json=NULL,snapshot_provenance='legacy_unverified'")->execute([$raw]); + $legacy = $verification($report->capture('2', '-1001')); + self::assertSame('warning', $legacy['status']); + self::assertSame('legacy_unverified', $legacy['data']['snapshot_provenance']); + } + private function action(string $action): void + { + self::assertTrue($this->ui->callback($this->event($action))); + } + + private function event(string $action): array + { + $row = $this->repo->get('2'); + return ['user_id' => '2', 'chat_id' => '2', 'query_id' => (string)++$this->queryId, 'message_id' => 800, 'private' => true, 'inline_selection' => false, 'data' => 'dz:' . $row['token'] . ':' . $action]; + } + + private function last(): array + { + return $this->edits[array_key_last($this->edits)]; + } + + private function advance(int $seconds): void + { + $this->clock->set($this->clock->now()->modify('+' . $seconds . ' seconds')); + } +} diff --git a/tests/DiagnosticsGatewayFixture.php b/tests/DiagnosticsGatewayFixture.php new file mode 100644 index 0000000..abe651c --- /dev/null +++ b/tests/DiagnosticsGatewayFixture.php @@ -0,0 +1,13 @@ + 'shabbat-diagnostics-1', 'read_only' => true, 'records' => []], JSON_THROW_ON_ERROR); + $gateway->sendDiagnosticDocument('2', $json, 'Diagnostic snapshot', '12345'); + self::assertCount(1, $transport->calls); + [$method, $payload] = $transport->calls[0]; + self::assertSame('messages.sendMedia', $method); + self::assertSame(12345, $payload['random_id']); + self::assertSame('2', $payload['peer']); + self::assertSame(0, $payload['floodWaitLimit']); + self::assertSame('inputMediaUploadedDocument', $payload['media']['_']); + self::assertSame('application/json', $payload['media']['mime_type']); + self::assertTrue($payload['media']['force_file']); + self::assertInstanceOf(ReadableStream::class, $payload['media']['file']); + self::assertSame($json, $payload['media']['file']->read()); + self::assertNull($payload['media']['file']->read()); + self::assertSame('shabbat-diagnostics.json', $payload['media']['attributes'][0]['file_name']); + self::assertArrayNotHasKey('parse_mode', $payload); + } + + public function testAcknowledgedPanelIdentityUsesNativeSendResponse(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['messages.sendMessage'] = ['_' => 'updateShortSentMessage', 'id' => 500]; + $gateway = new MadelineGateway($transport, false); + self::assertSame(500, $gateway->sendDiagnosticPanel('2', 'Test', ['inline_keyboard' => [[['text' => 'Next', 'callback_data' => 'dz:012345678901234567890123:next']]]])); + self::assertSame('keyboardButtonCallback', $transport->calls[0][1]['reply_markup']['rows'][0]['buttons'][0]['_']); + $transport->responses['messages.sendMessage'] = ['_' => 'updates', 'updates' => []]; + $this->expectException(\RuntimeException::class); + $gateway->sendDiagnosticPanel('2', 'Test', []); + } + + public function testInvalidExportsNeverReachTransport(): void + { + $transport = new GatewayFixtureTransport(); + $gateway = new MadelineGateway($transport, false); + foreach ( + [ + ['-1001', '{}', 'ok', '1'], + ['2', '/etc/passwd', 'ok', '1'], + ['2', '{}', 'ok', '1'], + ['2', str_repeat('x', 1048577), 'ok', '1'], + ] as $args + ) { + try { + $gateway->sendDiagnosticDocument(...$args); + self::fail('Invalid export must be rejected.'); + } catch (\InvalidArgumentException | \JsonException) { + self::assertSame([], $transport->calls); + } + } + } + + public function testPinnedSerializerAcceptsReadableStreamsBeforeFilePathFallback(): void + { + // Verify the exact locked SDK path that the mock transport cannot execute. + $base = dirname(__DIR__) . '/vendor/danog/madelineproto/src/'; + $tl = file_get_contents($base . 'TL/TL.php'); + $logic = file_get_contents($base . 'MTProtoTools/FilesLogic.php'); + self::assertStringContainsString('$value = $this->API->upload($value, cancellation:', $tl); + self::assertStringContainsString('if ($file instanceof ReadableStream || \is_resource($file))', $logic); + self::assertStringContainsString('return $this->uploadFromStream($file, 0,', $logic); + } +} diff --git a/tests/DispatchFenceTest.php b/tests/DispatchFenceTest.php new file mode 100644 index 0000000..dbd9528 --- /dev/null +++ b/tests/DispatchFenceTest.php @@ -0,0 +1,354 @@ + ['topic'], 'emoji catalog' => ['emoji'], 'reusable media' => ['media']]; + } + + private function clock(): FrozenClock + { + return new FrozenClock(new DateTimeImmutable('2026-10-16T14:19:59Z')); + } + + private function payload(string $preflight): array + { + return match ($preflight) { + 'topic' => ['text' => 'Reminder', 'topic_id' => 7], + 'emoji' => ['text' => '๐Ÿ”” Reminder'], + 'media' => ['text' => 'Reminder', 'media' => 'fixture-file'], + }; + } + + private function advanceOnPreflight(DispatchFenceTransport $transport, FrozenClock $clock, string $preflight): void + { + $advance = static fn() => $clock->set($clock->now()->modify('+2 seconds')); + if ($preflight === 'media') { + $transport->onFile = $advance; + } else { + $transport->onCall = static function (string $method) use ($advance): void { + if (in_array($method, ['messages.getForumTopicsByID', 'messages.getCustomEmojiDocuments'], true)) { + $advance(); + } + }; + } + } + + private function sends(DispatchFenceTransport $transport): array + { + return array_values(array_filter($transport->calls, static fn(array $call): bool => in_array($call[0], ['messages.sendMessage', 'messages.sendMedia'], true))); + } + + #[DataProvider('preflights')] + public function testNoNativeSendCanCrossBoundaryDuringPreflight(string $preflight): void + { + $clock = $this->clock(); + $cutoff = $clock->now()->getTimestamp() + 1; + $transport = new DispatchFenceTransport(); + $this->advanceOnPreflight($transport, $clock, $preflight); + $guardCalls = 0; + try { + (new MadelineGateway($transport))->sendGuarded('-100123', $this->payload($preflight), '42', static function () use ($clock, $cutoff, &$guardCalls): void { + $guardCalls++; + if ($clock->now()->getTimestamp() >= $cutoff) { + throw new DispatchRejectedException('holy period began'); + } + }); + self::fail('The native send passed its cutoff.'); + } catch (DispatchRejectedException) { + self::assertSame(1, $guardCalls); + self::assertSame([], $this->sends($transport)); + } + } + + private function rpc(string $name): \danog\MadelineProto\RPCErrorException + { + $error = (new \ReflectionClass(\danog\MadelineProto\RPCErrorException::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty(\danog\MadelineProto\RPCErrorException::class, 'rpc'))->setValue($error, $name); + return $error; + } + + public function testEmojiFallbackHasItsOwnGuardAndCannotCrossCutoff(): void + { + $clock = $this->clock(); + $cutoff = $clock->now()->getTimestamp() + 1; + $transport = new DispatchFenceTransport(); + $error = $this->rpc('PREMIUM_ACCOUNT_REQUIRED'); + $transport->onCall = static function (string $method) use ($clock, $error): void { + if ($method === 'messages.sendMessage') { + $clock->set($clock->now()->modify('+2 seconds')); + throw $error; + } + }; + $guardCalls = 0; + try { + (new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()))->sendGuarded('-100123', ['text' => '๐Ÿ”” Reminder'], '42', static function () use ($clock, $cutoff, &$guardCalls): void { + $guardCalls++; + if ($clock->now()->getTimestamp() >= $cutoff) { + throw new DispatchRejectedException('fallback expired'); + } + }); + self::fail('Fallback crossed cutoff.'); + } catch (DispatchRejectedException) { + self::assertSame(2, $guardCalls); + self::assertCount(1, $this->sends($transport)); + } + } + + public function testAllowedFallbackPreservesIdentityAndPayloadWithoutGuardMetadata(): void + { + $transport = new DispatchFenceTransport(); + $error = $this->rpc('CUSTOM_EMOJI_INVALID'); + $attempt = 0; + $transport->onCall = static function (string $method) use ($error, &$attempt): void { + if ($method === 'messages.sendMessage' && ++$attempt === 1) { + throw $error; + } + }; + $payload = ['text' => 'โญ Reminder', 'entities' => [['_' => 'messageEntityCustomEmoji', 'offset' => 0, 'length' => 1, 'document_id' => 42]]]; + $original = $payload; + $guardCalls = 0; + (new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()))->sendGuarded('-100123', $payload, '12345678', static function () use (&$guardCalls): void { + $guardCalls++; + }); + $sends = $this->sends($transport); + self::assertSame(2, $guardCalls); + self::assertCount(2, $sends); + self::assertSame($original, $payload); + self::assertSame($sends[0][1]['random_id'], $sends[1][1]['random_id']); + self::assertSame($sends[0][1]['message'], $sends[1][1]['message']); + self::assertSame([], $sends[1][1]['entities']); + self::assertArrayNotHasKey('guard', $sends[0][1]); + } + + public function testPrivateNavigationDuringScheduledPreflightNeverInheritsScheduledGuard(): void + { + $transport = new DispatchFenceTransport(); + $gateway = new MadelineGateway($transport, false); + $transport->onCall = static function (string $method) use ($gateway): void { + if ($method === 'messages.getForumTopicsByID') { + $gateway->sendPrivate('42', 'Private menu'); + } + }; + try { + $gateway->sendGuarded('-100123', ['text' => 'Reminder', 'topic_id' => 7], '123', static function (): void { + throw new DispatchRejectedException('scheduled blocked'); + }); + self::fail('Scheduled guard was not applied.'); + } catch (DispatchRejectedException) { + $sends = $this->sends($transport); + self::assertCount(1, $sends); + self::assertSame('42', $sends[0][1]['peer']); + } + } + + private function context(array $payload, int $ttl = 3600): array + { + $db = class_exists(\Pdo\Sqlite::class) ? new Database(new \Pdo\Sqlite('sqlite::memory:')) : Database::open(':memory:'); + $db->migrate(); + $clock = $this->clock(); + $settings = new SettingsRepository($db, $clock); + $group = $settings->ensureGroup('-100123'); + $group = $settings->updateGroup('-100123', $group['revision'], ['zmanim_enabled' => true], '42'); + $jobs = new JobRepository($db, $clock); + $id = $jobs->enqueue('-100123', 'zmanim', 'fixture', $clock->now()->getTimestamp(), $clock->now()->getTimestamp() + $ttl, $group['revision'], $payload); + return [$db, $clock, $settings, $jobs, $id]; + } + + #[DataProvider('preflights')] + public function testWorkerPassesFinalFenceThroughNativePreflight(string $preflight): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context($this->payload($preflight)); + $transport = new DispatchFenceTransport(); + $this->advanceOnPreflight($transport, $clock, $preflight); + $cutoff = $clock->now()->getTimestamp() + 1; + $worker = new OutboxWorker( + $jobs, + $settings, + new MadelineGateway($transport), + $clock, + static fn(): bool => true, + null, + null, + static fn(array $job, array $group, DateTimeImmutable $now): bool => $now->getTimestamp() < $cutoff + ); + $result = $worker->runOnce('fence'); + self::assertSame('cancelled', $result['status']); + self::assertTrue($result['dispatch_rejected']); + self::assertSame('cancelled', $jobs->get($id)['status']); + self::assertSame([], $this->sends($transport)); + } + + public function testWorkerDeadlineIsCheckedAgainAfterNativePreflightEvenWithoutEvidenceCallback(): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => 'Reminder', 'topic_id' => 7], 1); + $transport = new DispatchFenceTransport(); + $this->advanceOnPreflight($transport, $clock, 'topic'); + $worker = new OutboxWorker($jobs, $settings, new MadelineGateway($transport), $clock, static fn(): bool => true); + self::assertSame('cancelled', $worker->runOnce('fence')['status']); + self::assertSame([], $this->sends($transport)); + } + + public function testStaleEligibilityResultCannotOverrideSynchronousEvidenceDeadline(): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => 'Reminder']); + $cutoff = $clock->now()->getTimestamp() + 1; + $calls = 0; + $transport = new DispatchFenceTransport(); + $worker = new OutboxWorker($jobs, $settings, new MadelineGateway($transport), $clock, static function () use ($clock, &$calls): bool { + if (++$calls === 2) { + $clock->set($clock->now()->modify('+2 seconds')); + } + return true; // A provider's pre-await observation is stale. + }, null, null, static fn(array $job, array $group, DateTimeImmutable $now): bool => $now->getTimestamp() < $cutoff); + self::assertSame('cancelled', $worker->runOnce('fence')['status']); + self::assertSame([], $this->sends($transport)); + } + + public function testFinalDeferralKeepsPayloadRandomIdentityAndDoesNotConsumeTransportBudget(): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => 'Initial']); + $transport = new DispatchFenceTransport(); + $preparations = 0; + $defer = true; + $worker = new OutboxWorker( + $jobs, + $settings, + new MadelineGateway($transport), + $clock, + static fn(): bool => true, + static function () use (&$preparations): array { + $preparations++; + return ['text' => 'Prepared']; + }, + null, + static function () use (&$defer, $clock): bool { + if ($defer) { + throw new DeliveryDeferredException('refresh evidence', $clock->now()->getTimestamp() + 1); + } + return true; + } + ); + $random = $jobs->get($id)['random_id']; + for ($i = 0; $i < 6; $i++) { + self::assertSame('deferred', $worker->runOnce('fence')['status']); + $clock->set($clock->now()->modify('+1 second')); + } + self::assertSame([], $this->sends($transport)); + self::assertSame(6, $jobs->get($id)['deferrals']); + $defer = false; + self::assertSame('sent', $worker->runOnce('fence')['status']); + self::assertSame(1, $preparations); + self::assertSame((int)$random, $this->sends($transport)[0][1]['random_id']); + self::assertSame('Prepared', $this->sends($transport)[0][1]['message']); + } + + public static function localMutations(): array + { + return ['settings revision' => ['settings'], 'lease stolen' => ['lease'], 'worker stop' => ['stop']]; + } + + #[DataProvider('localMutations')] + public function testLiveLocalStateIsRecheckedAfterPreflight(string $mutation): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => 'Reminder', 'topic_id' => 7]); + $transport = new DispatchFenceTransport(); + $worker = new OutboxWorker($jobs, $settings, new MadelineGateway($transport), $clock, static fn(): bool => true); + $transport->onCall = static function (string $method) use ($db, $settings, $worker, $mutation): void { + if ($method !== 'messages.getForumTopicsByID') { + return; + } + if ($mutation === 'settings') { + $group = $settings->getGroup('-100123'); + $settings->updateGroup('-100123', $group['revision'], ['zmanim_enabled' => false], '42'); + } elseif ($mutation === 'lease') { + $db->pdo()->exec("UPDATE scheduled_jobs SET lease_owner='other'"); + } else { + $worker->stop(); + } + }; + self::assertSame('cancelled', $worker->runOnce('fence')['status']); + self::assertSame([], $this->sends($transport)); + if ($mutation === 'lease') { + self::assertSame('other', $jobs->get($id)['lease_owner'], 'A stale worker must never finish another owner\'s lease.'); + } + } + + public function testClockCrossingDuringLocalReservationStillCannotDispatch(): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => 'Reminder']); + $cutoff = $clock->now()->getTimestamp() + 1; + $advance = static function () use ($clock): int { + $clock->set($clock->now()->modify('+2 seconds')); + return 1; + }; + if ($db->pdo() instanceof \Pdo\Sqlite) { + $db->pdo()->createFunction('advance_clock', $advance); + } else { + $db->pdo()->sqliteCreateFunction('advance_clock', $advance); + } + $db->pdo()->exec('CREATE TRIGGER simulate_slow_storage AFTER UPDATE OF last_dispatch_at ON scheduled_jobs BEGIN SELECT advance_clock(); END'); + $transport = new DispatchFenceTransport(); + $worker = new OutboxWorker($jobs, $settings, new MadelineGateway($transport), $clock, static fn(): bool => true, null, null, static fn(array $job, array $group, DateTimeImmutable $now): bool => $now->getTimestamp() < $cutoff); + self::assertSame('cancelled', $worker->runOnce('fence')['status']); + self::assertSame([], $this->sends($transport)); + self::assertNotNull($jobs->get($id)['dispatch_started_at'], 'Never erase a conservative dispatch reservation after the final boundary closes.'); + } + + public function testWorkerEmojiFallbackKeepsOneDurablePayloadAndIdentity(): void + { + [$db, $clock, $settings, $jobs, $id] = $this->context(['text' => '๐Ÿ”” Reminder']); + $transport = new DispatchFenceTransport(); + $error = $this->rpc('PREMIUM_ACCOUNT_REQUIRED'); + $attempts = 0; + $transport->onCall = static function (string $method) use ($error, &$attempts): void { + if ($method === 'messages.sendMessage' && ++$attempts === 1) { + throw $error; + } + }; + $prepared = 0; + $worker = new OutboxWorker($jobs, $settings, new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()), $clock, static fn(): bool => true, static function (array $job) use (&$prepared): array { + $prepared++; + return $job['payload']; + }, null, static fn(): bool => true); + self::assertSame('sent', $worker->runOnce('fence')['status']); + self::assertSame(1, $prepared); + self::assertSame(1, $jobs->get($id)['attempts']); + self::assertCount(2, $this->sends($transport)); + self::assertSame($this->sends($transport)[0][1]['random_id'], $this->sends($transport)[1][1]['random_id']); + } + + public function testBroadcastNativeRateLimitIsNotHiddenOrAwaitedAndZeroLimitWins(): void + { + $transport = new DispatchFenceTransport(); + $native = new \danog\MadelineProto\RPCError\FloodWaitError('FLOOD_WAIT_12', 12, 420, 'messages.sendMessage'); + $transport->onCall = static function () use ($native): void { + throw $native; + }; + try { + (new MadelineGateway($transport))->broadcastCall('sendMessage', ['peer' => '-100123', 'random_id' => 42, 'message' => 'Broadcast', 'floodWaitLimit' => 172800]); + self::fail('Expected native retry classification.'); + } catch (\danog\MadelineProto\RPCError\FloodWaitError $caught) { + self::assertSame($native, $caught); + self::assertSame(0, $transport->calls[0][1]['floodWaitLimit']); + } + } +} diff --git a/tests/DispatchFenceTransport.php b/tests/DispatchFenceTransport.php new file mode 100644 index 0000000..d5348d3 --- /dev/null +++ b/tests/DispatchFenceTransport.php @@ -0,0 +1,51 @@ +calls[] = [$method, $parameters]; + ($this->onCall)?->__invoke($method, $parameters); + if ($method === 'messages.getForumTopicsByID') { + return ['topics' => [['id' => 7, 'closed' => false]]]; + } + if ($method === 'messages.getCustomEmojiDocuments') { + return (new EmojiFixtureTransport())->documents; + } + return ['_' => 'updates', 'updates' => []]; + } + + public function fileInfo(string $fileId): array + { + ($this->onFile)?->__invoke(); + return ['InputMedia' => ['_' => 'inputMediaPhoto', 'id' => ['_' => 'inputPhoto', 'id' => 42, 'access_hash' => 88, 'file_reference' => 'fixture']]]; + } + + public function info(string $peer): array + { + return ['type' => 'supergroup', 'Chat' => ['id' => 123]]; + } + + public function fullInfo(string $peer): array + { + return FreshPeerFixture::info($peer, null); + } + + public function self(): array + { + return ['id' => 777, 'username' => 'FixtureBot', 'bot' => true]; + } +} diff --git a/tests/DonationCustomAmountTest.php b/tests/DonationCustomAmountTest.php new file mode 100644 index 0000000..059674f --- /dev/null +++ b/tests/DonationCustomAmountTest.php @@ -0,0 +1,231 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->flows = new FlowTokenRepository($this->db, $this->clock); + $this->gateway = new GuidedUiGateway(); + $this->donations = new Donations($this->db, $this->clock, function (string $method, array $args): mixed { + $this->db->transaction(static fn(): null => null); + $this->calls[] = compact('method', 'args'); + if ($method === 'payments.exportInvoice') { + if ($this->duringExport !== null) { + $hook = $this->duringExport; + $this->duringExport = null; + $hook(); + } + return ['url' => 'https://t.me/invoice-fixture-' . count($this->calls)]; + } + return true; + }); + $this->ui = $this->controller(); + } + + private function controller(): DonationController + { + return new DonationController($this->db, $this->settings, $this->flows, $this->clock, $this->donations, $this->gateway); + } + + private function click(string $data = 'v3:donate:custom', int $panel = 123, string $user = '1'): bool + { + return $this->ui->callback(['user_id' => $user,'private' => true,'chat_id' => $user,'message_id' => $panel,'data' => $data,'query_id' => 'query-fixture']); + } + + private function message(string $text, string $user = '1', bool $private = true): bool + { + return $this->ui->message(['user_id' => $user,'private' => $private,'text' => $text]); + } + + public function testCustomBoundsAndInvalidInputsProduceNoInvoicesOrRecords(): void + { + foreach ([1,'1',10000,'10000'] as $value) { + self::assertSame((int)$value, Donations::customAmount($value)); + } + foreach ([0,-1,10001,1.5,1.0,true,null,[], '0','10001','1.5','1e3',' 1','01','1\n'] as $value) { + try { + $this->donations->createCustomOffer('1', $value); + self::fail('Invalid custom Stars accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame([], $this->calls); + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM payment_intents')->fetchColumn()); + } + } + } + + public function testPresetMenuHasOtherAndCustomInvoicesRetainNativeShapeInSixLanguages(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $user = $this->settings->getUser('1'); + $this->settings->updateUser('1', $user['revision'], ['language' => $language]); + $menu = $this->donations->createOffers('1', $language); + self::assertSame(Donations::AMOUNTS, array_column($menu['offers'], 'amount')); + self::assertSame('v3:donate:custom', $menu['reply_markup']['rows'][2]['buttons'][0]['data']); + self::assertSame($lang->text($language, 'donate_other'), $menu['reply_markup']['rows'][2]['buttons'][0]['text']); + foreach ([1,10000] as $amount) { + $offer = $this->donations->createCustomOffer('1', $amount, $language); + self::assertSame($amount, $offer['amount']); + $last = end($this->calls); + self::assertSame('payments.exportInvoice', $last['method']); + $media = $last['args']['invoice_media']; + self::assertSame('XTR', $media['invoice']['currency']); + self::assertSame($amount, $media['invoice']['prices'][0]['amount']); + self::assertSame($lang->text($language, 'donate_title'), $media['title']); + self::assertSame($offer['payload'], $media['payload']); + } + } + } + + public function testPromptAndValidationEditExactlyTheOriginalPrivatePanelWithoutFlooding(): void + { + self::assertTrue($this->click()); + self::assertNotNull($this->flows->latest('1', 'donation_input')); + foreach (['0','10001','12.5'] as $input) { + self::assertTrue($this->message($input)); + } + self::assertSame([], $this->calls); + self::assertSame([], $this->gateway->sent); + self::assertCount(4, $this->gateway->edited); + foreach ($this->gateway->edited as $edit) { + self::assertSame(123, $edit['id']); + } + self::assertTrue($this->message('10000')); + self::assertCount(1, $this->calls); + self::assertSame(10000, $this->calls[0]['args']['invoice_media']['invoice']['prices'][0]['amount']); + self::assertSame(123, $this->gateway->edited[4]['id']); + } + + public function testDurablePromptSurvivesControllerRestartButDuplicateTextCannotExportAgain(): void + { + $this->click(); + $this->ui = $this->controller(); + $this->duringExport = function (): void { + self::assertFalse($this->message('5')); + }; + self::assertTrue($this->message('1')); + self::assertFalse($this->message('1')); + $this->ui = $this->controller(); + self::assertFalse($this->message('1')); + self::assertCount(1, $this->calls); + self::assertSame(1, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM payment_intents')->fetchColumn()); + self::assertSame([], $this->gateway->sent); + } + + public function testNavigationAndReplacedPromptDuringExportNeverOverwriteTheNewPanel(): void + { + $this->click(panel: 123); + $before = count($this->gateway->edited); + $this->duringExport = function (): void { + $this->ui->cancel('1'); + }; + self::assertTrue($this->message('5')); + self::assertCount($before, $this->gateway->edited); + $this->click(panel: 124); + $this->duringExport = function (): void { + $this->click(panel: 125); + }; + self::assertTrue($this->message('6')); + self::assertSame(125, end($this->gateway->edited)['id']); + self::assertSame(125, $this->flows->latest('1', 'donation_input')['payload']['panel_id']); + self::assertSame([], $this->gateway->sent); + } + + public function testCommandsCancelPromptExpiryAndForeignMessagesCannotExport(): void + { + $this->click(); + self::assertFalse($this->message('5', '2')); + self::assertFalse($this->message('5', '1', false)); + self::assertFalse($this->message('/start')); + self::assertFalse($this->message('5')); + $this->click(); + $this->clock->set($this->clock->now()->modify('+11 minutes')); + self::assertFalse($this->message('5')); + self::assertSame([], $this->calls); + } + + public function testForwardedOrNonPrivateCallbackIsRejectedBeforePromptMutation(): void + { + foreach ([['private' => false],['chat_id' => '2']] as $change) { + try { + $this->ui->callback(array_replace(['user_id' => '1','private' => true,'chat_id' => '1','message_id' => 123,'data' => 'v3:donate:custom','query_id' => 'fixture'], $change)); + self::fail('Foreign donation panel accepted.'); + } catch (\InvalidArgumentException) { + self::assertNull($this->flows->latest('1', 'donation_input')); + self::assertSame([], $this->calls); + } + } + } + + public function testCustomPrecheckoutBindsPayerAndPaymentRecordsOnceAcrossRestart(): void + { + $offer = $this->donations->createCustomOffer('1', 10000, 'en'); + $query = ['query_id' => '123','user_id' => '1','currency' => 'XTR','total_amount' => 10000,'payload' => $offer['payload']]; + self::assertFalse($this->donations->validatePrecheckout(array_replace($query, ['user_id' => '2']))['ok']); + self::assertFalse($this->donations->validatePrecheckout(array_replace($query, ['total_amount' => 9999]))['ok']); + self::assertTrue($this->donations->answerPrecheckout($query)['ok']); + $payment = ['message' => ['id' => 55,'from_id' => ['_' => 'peerUser','user_id' => 1],'action' => ['_' => 'messageActionPaymentSentMe','payload' => $offer['payload'],'currency' => 'XTR','total_amount' => 10000,'charge' => ['id' => 'custom-fixture-charge']]]]; + self::assertSame('recorded', $this->donations->recordPayment($payment)['status']); + $restarted = new Donations($this->db, $this->clock, static fn(): never => throw new \RuntimeException('No recording RPC.')); + self::assertSame('duplicate', $restarted->recordPayment($payment)['status']); + self::assertSame(['donations' => 1,'stars' => 10000], $restarted->stats()); + } + + public function testActualAsyncInvoiceExportCannotRepaintAfterNavigationCancellation(): void + { + $this->click(); + $before = count($this->gateway->edited); + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $this->duringExport = static function () use ($entered, $release): void { + $entered->complete(); + $release->getFuture()->await(); + }; + $pending = \Amp\async(fn(): bool => $this->message('25')); + $entered->getFuture()->await(); + $this->ui->cancel('1'); + $release->complete(); + self::assertTrue($pending->await()); + self::assertCount($before, $this->gateway->edited); + self::assertSame([], $this->gateway->sent); + self::assertCount(1, $this->calls); + } + + public function testDeletedPanelCannotFallBackToSendingAnInvoiceAsANewMessage(): void + { + $this->click(); + $this->gateway->editFails = true; + try { + $this->message('5'); + self::fail('Deleted panel reported a successful display.'); + } catch (PanelEditException) { + self::assertSame([], $this->gateway->sent); + } + self::assertCount(1, $this->calls); + } +} diff --git a/tests/DonationsTest.php b/tests/DonationsTest.php new file mode 100644 index 0000000..d1f9800 --- /dev/null +++ b/tests/DonationsTest.php @@ -0,0 +1,212 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $this->donations = new Donations($this->db, $this->clock, function (string $method, array $args): mixed { + $this->db->transaction(static fn(): null=>null); // Proves RPC is outside an application transaction. + $this->calls[] = [$method,$args]; + return $method === 'payments.exportInvoice' ? ['_' => 'payments.exportedInvoice','url' => 'https://t.me/invoice-fixture-' . $args['invoice_media']['invoice']['prices'][0]['amount']] : true; + }); + $this->offers = $this->donations->createOffers('17', 'en')['offers']; + } + private function query(int $index = 0, string $id = '123'): array + { + return ['query_id' => $id,'user_id' => 17,'currency' => 'XTR','total_amount' => $this->offers[$index]['amount'],'payload' => $this->offers[$index]['payload']]; + } + private function payment(int $index = 0, string $charge = 'fixture-charge'): array + { + return ['message' => ['id' => 1234,'from_id' => ['_' => 'peerUser','user_id' => 17], + 'action' => ['_' => 'messageActionPaymentSentMe','payload' => $this->offers[$index]['payload'],'currency' => 'XTR','total_amount' => $this->offers[$index]['amount'],'charge' => ['id' => $charge]]]]; + } + public function testOffersPreserveSixPresetsAndUseNativeStarsInvoicesWithNeutralChoices(): void + { + self::assertSame([5,25,100,150,250,400], array_column($this->offers, 'amount')); + self::assertCount(6, array_unique(array_column($this->offers, 'payload'))); + foreach ($this->calls as [$method,$args]) { + self::assertSame('payments.exportInvoice', $method); + $invoice = $args['invoice_media']; + self::assertSame('XTR', $invoice['invoice']['currency']); + self::assertSame(['_' => 'dataJSON','data' => '{}'], $invoice['provider_data']); + self::assertArrayNotHasKey('provider', $invoice); + self::assertArrayNotHasKey('email_requested', $invoice['invoice']); + } + foreach ($this->offers as $offer) { + self::assertSame('keyboardButtonUrl', $offer['button']['_']); + self::assertSame(['_' => 'keyboardButtonStyle'], $offer['button']['style']); + } + self::assertSame(6, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM payment_intents WHERE status='exported'")->fetchColumn()); + } + public function testPrecheckoutValidatesPayerAmountCurrencyPayloadAndExpiry(): void + { + self::assertTrue($this->donations->validatePrecheckout($this->query())['ok']); + foreach ([['user_id' => 18],['total_amount' => 6],['total_amount' => '5'],['currency' => 'USD'],['payload' => 'untrusted'],['query_id' => 'bad']] as $changes) { + self::assertFalse($this->donations->validatePrecheckout(array_replace($this->query(), $changes))['ok']); + } + $this->clock->set(new DateTimeImmutable('2026-10-13T10:00:00Z')); + self::assertSame('invoice_expired', $this->donations->validatePrecheckout($this->query())['reason']); + } + public function testPrecheckoutApprovalReservesOneQueryAndRejectsPaidOrConcurrentQueries(): void + { + self::assertTrue($this->donations->answerPrecheckout($this->query())['ok']); + self::assertTrue($this->donations->answerPrecheckout($this->query())['ok']); + self::assertFalse($this->donations->answerPrecheckout($this->query(0, '124'))['ok']); + $last = end($this->calls); + self::assertSame('messages.setBotPrecheckoutResults', $last[0]); + self::assertFalse($last[1]['success']); + self::assertArrayHasKey('error', $last[1]); + $this->donations->recordPayment($this->payment()); + self::assertFalse($this->donations->validatePrecheckout($this->query())['ok']); + } + public function testPaymentRequiresApprovalThenRecordsOnceAcrossRestartAndLateDelivery(): void + { + self::assertSame('rejected', $this->donations->recordPayment($this->payment())['status']); + $this->donations->answerPrecheckout($this->query()); + $this->clock->set(new DateTimeImmutable('2026-10-13T11:00:00Z')); // Accepted before expiry; service update can arrive later. + $received = $this->donations->recordPayment($this->payment()); + self::assertSame('recorded', $received['status']); + self::assertSame(5, $received['amount']); + self::assertArrayNotHasKey('charge_uid', $received); + self::assertArrayNotHasKey('payload', $received); + $restarted = new Donations($this->db, $this->clock, static function (): never { + throw new RuntimeException('Recording a payment must not make an RPC.'); + }); + self::assertSame('duplicate', $restarted->recordPayment($this->payment())['status']); + self::assertSame(['donations' => 1,'stars' => 5], $restarted->stats()); + } + public function testNativeIntegerSenderAndPrivateBotPeerRecordOnceWithoutRpc(): void + { + $this->donations->answerPrecheckout($this->query()); + $payment = $this->payment(); + $payment['_'] = 'updateNewMessage'; + $payment['message']['_'] = 'messageService'; + $payment['message']['from_id'] = 17; + $payment['message']['peer_id'] = 987654321; // Incoming private peer can be the bot itself. + $service = new Donations($this->db, $this->clock, static function (): never { + throw new RuntimeException('Payment recording must not make an RPC.'); + }); + $recorded = $service->recordPayment($payment); + self::assertSame('recorded', $recorded['status']); + self::assertSame('17', $recorded['user_id']); + self::assertSame(1234, $recorded['service_message_id']); + self::assertSame('duplicate', $service->recordPayment($payment)['status']); + self::assertSame(['donations' => 1, 'stars' => 5], $service->stats()); + } + + public function testNativeSenderBindingRejectsWrongOrInvalidIdsWithoutUsingPrivatePeerAsPayer(): void + { + $this->donations->answerPrecheckout($this->query()); + foreach ([18, -17, 0, true, null, ['_' => 'peerChannel', 'channel_id' => 17]] as $sender) { + $payment = $this->payment(); + $payment['message']['from_id'] = $sender; + $payment['message']['peer_id'] = 17; + self::assertSame('rejected', $this->donations->recordPayment($payment)['status']); + } + $forged = $this->payment(); + $forged['message']['from_id'] = 18; + $forged['user_id'] = 17; + self::assertSame('sender_mismatch', $this->donations->recordPayment($forged)['reason']); + $native = $this->payment(); + $native['message']['from_id'] = 17; + $native['message']['peer_id'] = ['_' => 'peerUser', 'user_id' => 987654321]; + self::assertSame('recorded', $this->donations->recordPayment($native['message'])['status']); + self::assertSame(['donations' => 1, 'stars' => 5], $this->donations->stats()); + } + + public function testWrongSenderOrCurrencyCannotUseAValidPayload(): void + { + $this->donations->answerPrecheckout($this->query()); + $wrong = $this->payment(); + $wrong['message']['from_id']['user_id'] = 18; + self::assertSame('rejected', $this->donations->recordPayment($wrong)['status']); + $wrong = $this->payment(); + $wrong['message']['action']['currency'] = 'USD'; + self::assertSame('rejected', $this->donations->recordPayment($wrong)['status']); + $wrong = $this->payment(); + $wrong['message']['from_id'] = ['_' => 'peerChannel','channel_id' => 17]; + self::assertSame('rejected', $this->donations->recordPayment($wrong)['status']); + self::assertSame(['donations' => 0,'stars' => 0], $this->donations->stats()); + } + public function testExplicitUserCannotOverrideTheAuthenticatedRawSender(): void + { + $this->donations->answerPrecheckout($this->query()); + $forged = $this->payment(); + $forged['message']['from_id']['user_id'] = 18; + $forged['user_id'] = 17; + self::assertSame('rejected', $this->donations->recordPayment($forged)['status']); + self::assertSame(['donations' => 0,'stars' => 0], $this->donations->stats()); + } + public function testUnacknowledgedPrecheckoutRpcDoesNotReportSuccess(): void + { + $service = new Donations($this->db, $this->clock, static fn(): bool=>false); + $this->expectException(RuntimeException::class); + $service->answerPrecheckout($this->query()); + } + public function testChargeUniquenessPreventsConflictingIntentReplays(): void + { + $this->donations->answerPrecheckout($this->query()); + $this->donations->recordPayment($this->payment()); + $this->donations->answerPrecheckout($this->query(1, '124')); + self::assertSame('rejected', $this->donations->recordPayment($this->payment(1))['status']); + self::assertSame(['donations' => 1,'stars' => 5], $this->donations->stats()); + } + public function testLegacyPendingPayloadGetsExplicitFreshInvoiceRequest(): void + { + $legacy = $this->query(); + $legacy['payload'] = 'donate|17|5'; + $reply = $this->donations->answerPrecheckout($legacy); + self::assertFalse($reply['ok']); + self::assertSame('legacy_invoice_expired', $reply['reason']); + self::assertStringContainsString('/donate', $reply['error']); + $payment = $this->payment(); + $payment['message']['action']['payload'] = 'donate|17|5'; + self::assertSame('legacy_unverifiable', $this->donations->recordPayment($payment)['reason']); + } + public function testFailedDonationWriteRollsBackPaymentStatusAndCanBeRetried(): void + { + $this->donations->answerPrecheckout($this->query()); + $this->db->pdo()->exec("CREATE TRIGGER reject_donation BEFORE INSERT ON donations BEGIN SELECT RAISE(FAIL,'simulated disk error'); END"); + try { + $this->donations->recordPayment($this->payment()); + self::fail('Database failure was ignored.'); + } catch (PDOException) { + } + self::assertSame('precheckout_approved', $this->db->pdo()->query('SELECT status FROM payment_intents ORDER BY intent_id LIMIT 1')->fetchColumn()); + self::assertSame(['donations' => 0,'stars' => 0], $this->donations->stats()); + $this->db->pdo()->exec('DROP TRIGGER reject_donation'); + self::assertSame('recorded', $this->donations->recordPayment($this->payment())['status']); + } + public function testInvalidExportUrlsAreNotOfferedAndDoNotDiscardOtherDonorRecords(): void + { + $invalid = new Donations($this->db, $this->clock, static fn(): array=>['url' => 'https://evil.invalid/invoice']); + $result = $invalid->createOffers('18'); + self::assertSame([], $result['offers']); + self::assertCount(6, $result['errors']); + self::assertSame(6, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM payment_intents WHERE user_id='17' AND status='exported'")->fetchColumn()); + } +} diff --git a/tests/EmojiFixtureTransport.php b/tests/EmojiFixtureTransport.php new file mode 100644 index 0000000..56a3da4 --- /dev/null +++ b/tests/EmojiFixtureTransport.php @@ -0,0 +1,62 @@ + $alt) { + $this->documents[] = ['_' => 'document', 'id' => (int)$id, + 'attributes' => [['_' => 'documentAttributeCustomEmoji', 'alt' => $alt]]]; + } + } + + public static function alternatives(): array + { + return array_combine(array_values(Presentation::ICONS), ['โš™๏ธ','โญ','โ„น๏ธ','๐Ÿ’ป','๐Ÿ‘ฅ','โž•','๐Ÿ•ฏ','๐Ÿ””','๐Ÿ•’','๐Ÿ“–','๐ŸŒพ','โœก๏ธ']); + } + + public function call(string $method, array $parameters): mixed + { + $this->calls[] = [$method, $parameters]; + $this->failures[$method] ??= []; + $error = array_shift($this->failures[$method]); + if ($error instanceof \Throwable) { + throw $error; + } + return $method === 'messages.getCustomEmojiDocuments' ? $this->documents : ['_' => 'updates', 'updates' => []]; + } + + public function info(string $peer): array + { + return ['type' => 'supergroup', 'Chat' => ['id' => 123]]; + } + + public function fullInfo(string $peer): array + { + return ['type' => 'supergroup', 'Chat' => ['id' => 123]]; + } + + public function fileInfo(string $fileId): array + { + return ['InputMedia' => ['_' => 'inputMediaPhoto', 'id' => ['_' => 'inputPhoto', 'id' => 42, 'access_hash' => 88, 'file_reference' => 'fixture']]]; + } + + public function self(): array + { + return ['id' => 777, 'username' => 'FixtureShabbatBot', 'bot' => true]; + } +} diff --git a/tests/Fixtures/CalendarFixtureClient.php b/tests/Fixtures/CalendarFixtureClient.php new file mode 100644 index 0000000..658a3e8 --- /dev/null +++ b/tests/Fixtures/CalendarFixtureClient.php @@ -0,0 +1,44 @@ +throwIfRequested(); $this->calls[]=[$url,$query]; + if($this->override!==null) { return ($this->override)($url,$query); } + $start=$query['start']; $end=$query['end']; + if(str_ends_with($url,'/hebcal')) { + $items=[]; + for($day=new \DateTimeImmutable($start,new \DateTimeZone('UTC'));$day->format('Y-m-d')<=$end;$day=$day->modify('+1 day')) { + $date=$day->format('Y-m-d'); + $items[]=['date'=>$date,'category'=>'hebdate','hebrew'=>'ืชืืจื™ืš ื‘ื“ื™ืงื”','hdate'=>'fixture Hebrew date']; + $items[]=['date'=>$date,'category'=>'dafyomi','title'=>'Berakhot '.((int)$day->format('j')+2),'hebrew'=>'ื‘ืจื›ื•ืช '.((int)$day->format('j')+2),'link'=>'https://www.sefaria.org/Berakhot.'.((int)$day->format('j')+2).'a']; + $count=(int)(new \DateTimeImmutable('2026-04-03',new \DateTimeZone('UTC')))->diff($day)->format('%r%a')+1; + if($count>=1 && $count<=49) { $items[]=['date'=>$date,'category'=>'omer','title_orig'=>'Omer '.$count,'title'=>'Omer '.$count,'hdate'=>'fixture Omer Hebrew date']; } + if($date==='2026-05-22' || ($date==='2026-05-23' && $query['i']==='off')) { + $items[]=['date'=>$date,'category'=>'holiday','yomtov'=>true,'title_orig'=>'Shavuot','title'=>'Shavuot','hebrew'=>'ืฉื‘ื•ืขื•ืช']; + } + if($date==='2019-08-11') { $items[]=['date'=>$date,'category'=>'holiday','title_orig'=>"Tish'a B'Av (observed)",'title'=>"Tish'a B'Av (observed)"]; } + if(in_array($date,['2026-04-04','2026-04-05','2026-04-06','2026-04-07'],true)) { $items[]=['date'=>$date,'category'=>'holiday','title_orig'=>"Pesach (CH'M)",'title'=>'Pesach intermediate day']; } + } + return ['range'=>['start'=>$start,'end'=>$end],'items'=>$items]; + } + $zone=new \DateTimeZone($query['tzid']); $times=[]; + $offset=(float)$query['latitude']>32?10:0; + foreach(['alotHaShachar'=>'05:00','misheyakir'=>'05:45','sunrise'=>'06:00','chatzot'=>'12:00','minchaGedola'=>'12:30','sunset'=>'18:00','tzeit7083deg'=>'18:35','tzeit85deg'=>'18:40'] as $name=>$time) { + for($day=new \DateTimeImmutable($start,new \DateTimeZone('UTC'));$day->format('Y-m-d')<=$end;$day=$day->modify('+1 day')) { + $date=$day->format('Y-m-d'); $instant=new \DateTimeImmutable($date.' '.$time,$zone); + if(in_array($name,['sunset','tzeit7083deg','tzeit85deg'],true)) { $instant=$instant->modify('+'.$offset.' minutes'); } + $times[$name][$date]=$instant->format('Y-m-d\TH:i:sP'); + } + } + return ['date'=>['start'=>$start,'end'=>$end],'location'=>['tzid'=>$query['tzid']],'times'=>$times]; + } +} diff --git a/tests/Fixtures/PatchLoop/API.php b/tests/Fixtures/PatchLoop/API.php new file mode 100644 index 0000000..2cc47ab --- /dev/null +++ b/tests/Fixtures/PatchLoop/API.php @@ -0,0 +1,9 @@ +API = $API; + } +} diff --git a/tests/Fixtures/PatchLoop/MTProto.php b/tests/Fixtures/PatchLoop/MTProto.php new file mode 100644 index 0000000..4cebde5 --- /dev/null +++ b/tests/Fixtures/PatchLoop/MTProto.php @@ -0,0 +1,88 @@ + ['bot' => true]]; + public object $loginState; + public int $calls = 0; + public int $reports = 0; + public ?\Closure $onCall = null; + public function __construct(public string $scenario) + { + $this->loginState = new class + { + public function subscribe(object $listener): void + { + } + }; + } + public function loadUpdateState(): object + { + return $this->state(); + } + public function loadChannelState(int $id): object + { + return $this->state(); + } + private function state(): object + { + return new class + { + public function pts(): int + { + return 2; + } + public function date(): int + { + return 100; + } + public function qts(): int + { + return 0; + } + public function update(array $data): void + { + } + }; + } + public function logger(string $message, mixed $level = null): void + { + } + public function report(string $message): void + { + $this->reports++; + } + public function methodCallAsyncRead(string $method, array $args, ?int $dc = null): array + { + $this->calls++; + if ($this->calls === 1) { + $this->onCall?->__invoke(); + if ($this->scenario === 'direct' || $this->scenario === 'shutdown-direct') { + throw new \Amp\TimeoutException('fixture direct timeout'); + } + if ($this->scenario === 'wrapped' || $this->scenario === 'shutdown-wrapped') { + $pending = new \Amp\DeferredFuture(); + $keepAlive = \Revolt\EventLoop::repeat(1.0, static function (): void { + }); + try { + $pending->getFuture()->await(new \Amp\TimeoutCancellation(0.001, 'fixture timeout')); + } finally { + \Revolt\EventLoop::cancel($keepAlive); + } + throw new \LogicException('Timeout cancellation unexpectedly completed.'); + } + if ($this->scenario === 'intentional' || $this->scenario === 'shutdown-intentional') { + $cancel = new \Amp\DeferredCancellation(); + $cancel->cancel(new \RuntimeException('fixture deliberate cancellation')); + (new \Amp\DeferredFuture())->getFuture()->await($cancel->getCancellation()); + throw new \LogicException('Deliberate cancellation unexpectedly completed.'); + } + } + return $method === 'updates.getChannelDifference' ? ['_' => 'updates.channelDifferenceEmpty', 'pts' => 3, 'timeout' => 10.0] : ['_' => 'updates.differenceEmpty', 'date' => 100, 'seq' => 1]; + } +} diff --git a/tests/Fixtures/hebcal-full-runtime-README.md b/tests/Fixtures/hebcal-full-runtime-README.md new file mode 100644 index 0000000..47da8ee --- /dev/null +++ b/tests/Fixtures/hebcal-full-runtime-README.md @@ -0,0 +1,38 @@ +# Complete Hebcal runtime regression inputs + +The files listed in [hebcal-full-runtime-provenance.json](hebcal-full-runtime-provenance.json) +are complete unchanged public API response bodies retrieved on 2026-10-15. +No JSON properties, time series, calendar events or optional metadata were removed. +Exact URLs and SHA-256 file hashes are recorded in the provenance file. +Attribution: Hebcal.com, licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). + +Solar requests use the application's actual default coordinates, latitude 31.76904 +and longitude 35.21633, with Asia/Jerusalem. April and May match the exact monthly +CalendarService request ranges. April and October calendars cover Hebrew, +English, Russian, French, Spanish and Brazilian Portuguese using the provider's +he, s, ru, fr, es and pt aliases. May includes separate Israel and diaspora +calendar responses; coordinates and the Israel/diaspora holiday policy are +independent settings. + +CalendarFullResponseIntegrationTest replays these files through the real +provider, CalendarService, default database settings, private commands and group +views. It checks active and inactive Omer season, all six languages, sunset, +twilight, nightfall, dawn, the first and last Omer counts, Shabbat quiet periods, +custom nightfall methods, full-response persistent cache reuse, and closure and +restoration across the actual 2026 Shavuot-to-Shabbat sequence. Telegram calls +use an in-memory fake and never reach a real account or group. + +The paired complete October Hebrew/default-coordinate fixtures are maintained +with their own provenance in +[hebcal-jerusalem-default-full-provenance.json](hebcal-jerusalem-default-full-provenance.json). +The earlier preceding-night fixtures deliberately retained only a few daily +series. They remain useful for midnight semantics but do not establish that an +entire API response satisfies the consumer contract. The complete fixtures +retain both empty elevation-only series and sparse lunar event maps so that +this previously missed contract is exercised. + +The response bodies contain public astronomical and calendar data only. Their +embedded response-generation timestamps remain unchanged. They are immutable +regression inputs and must not be presented as current network availability or +freshly calculated end-user times. A successful request from a development +computer does not prove connectivity from the deployment server. diff --git a/tests/Fixtures/hebcal-full-runtime-provenance.json b/tests/Fixtures/hebcal-full-runtime-provenance.json new file mode 100644 index 0000000..9c211c1 --- /dev/null +++ b/tests/Fixtures/hebcal-full-runtime-provenance.json @@ -0,0 +1,86 @@ +{ + "retrieved_date": "2026-10-15", + "attribution": "Hebcal.com", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "service_docs": "https://www.hebcal.com/home/1663/zmanim-halachic-times-api", + "calendar_docs": "https://www.hebcal.com/home/195/jewish-calendar-rest-api", + "notes": "Complete unchanged public UTF-8 response bodies, without projection or JSON normalization. Exact default Jerusalem coordinates, all supported display languages, active Omer season and Israel/diaspora holiday policy. No Telegram, credentials or production data. Recorded API response time fields are provider data, not asserted freshness dates. Offline fixtures do not establish server network availability.", + "fixtures": [ + { + "file": "hebcal-jerusalem-full-solar-2026-04.json", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.76904&longitude=35.21633&tzid=Asia%2FJerusalem&start=2026-03-24&end=2026-06-09", + "sha256": "063f2f3e366b219ffcd7f439cb3d005d234b48e1a7d4fd00619f84fcf6384569" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=he", + "sha256": "ff714cf5bce2ae122e0e2c2c3f017cc861ff0d53e75ff145f5d70f3753ae1d88" + }, + { + "file": "hebcal-jerusalem-full-solar-2026-05.json", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.76904&longitude=35.21633&tzid=Asia%2FJerusalem&start=2026-04-23&end=2026-07-10", + "sha256": "746a8d36bea856e42b4242c78e2b0a2ed52bfbc9d866cd403681cced2f5c64e5" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-05.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-23&end=2026-07-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=he", + "sha256": "a79101ceece782dc2c5736f04f3a47036cf66c5f60e3b9afbcf19b09da306a6a" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04-en.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=s", + "sha256": "fd968354c11e2110049ed9c61bedc1c4c8257a61e0b97d2f5d26ca31f2e65fc6" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-10-en.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=s", + "sha256": "adf5319e9176144347dfb3183c537974cc8f6c76a0ea2928cee20fa106c9c08f" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04-ru.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=ru", + "sha256": "7c3e2c4827f91a84a201d51264462d7ff2629d2d31a44b56f0707f667302fdee" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-10-ru.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=ru", + "sha256": "393cc6bca9d17e22d4cc51408fca0b03db2df25af0def7b98c29afa4f8ad9794" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04-fr.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=fr", + "sha256": "204184bf47b9e293b8ad4614d4313d7cb49de7cf039f10fe05b290f8e624abee" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-10-fr.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=fr", + "sha256": "f7b87f3341c8fa1915923c11da1f188f5335d4fad2207fe8cbed691617b97763" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04-es.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=es", + "sha256": "381b2299c1214aa95d0b999cad4d0c73bd31a8bbd26c264372b2d349400e8642" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-10-es.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=es", + "sha256": "192b20cc3a9a79486c9f888b418eb0fc3e4c3944674d1abe6022e951b5b68894" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-04-pt-br.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-03-24&end=2026-06-09&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=pt", + "sha256": "47242c6cd22cd1d64cb0c8c3b58432d7c832c962a92c697c75af5a231a4c7f6b" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-10-pt-br.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=pt", + "sha256": "bdc1e5bce32ba6cb1443d5e77c5c530795e91749c4942a6e63b4020336984e50" + }, + { + "file": "hebcal-jerusalem-full-calendar-2026-05-diaspora.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-04-23&end=2026-07-10&i=off&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=he", + "sha256": "4d8ebf54009cae94a582db12f56021e23d932319fb505efeba8c3b78d87854c4" + } + ] +} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-README.md b/tests/Fixtures/hebcal-jerusalem-README.md new file mode 100644 index 0000000..dc88462 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-README.md @@ -0,0 +1,31 @@ +# Hebcal Jerusalem preceding-night regression data + +These public API response excerpts were retrieved on 2026-10-14. Attribution: +Hebcal.com, CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/). +Exact request URLs, retained fields and SHA-256 hashes are recorded in +`hebcal-jerusalem-provenance.json`. JSON formatting was normalized; unused fields +were removed. All retained provider values are unchanged. + +The October fixtures cover the actual monthly CalendarService request range, +including the Israeli autumn clock change and the winter dates fetched ahead of +the current day. July covers summer. March covers the spring clock change. +`HebcalMidnightRegressionTest` replays these fixtures offline. + +Hebcal `chatzotNight` is the midpoint of the night between the **previous day's** +sunset and the nominal day's sunrise. It can therefore fall on the previous +civil date; for example the 2026-10-26 entry is 2026-10-25T23:23:00+02:00. Its +original epoch is preserved. The compatibility check is specific to this field, +keeps its exact IANA offset validation, bounds it to the preceding night and +checks sunset/sunrise ordering when those values are available. Other solar +fields retain their existing strict nominal-date validation. + +Provider references: + +- https://www.hebcal.com/home/1663/zmanim-halachic-times-api +- https://hebcal.github.io/api/core/classes/Zmanim.html#nighthour +- https://hebcal.github.io/api/core/classes/Zmanim.html#chatzotnight + +The fixtures contain public location/calendar data only, with no Telegram data, +credentials or production data. They are regression inputs, not published times +for end users. An API request from this development computer does not establish +network connectivity from the deployment server. diff --git a/tests/Fixtures/hebcal-jerusalem-calendar-2026-10.json b/tests/Fixtures/hebcal-jerusalem-calendar-2026-10.json new file mode 100644 index 0000000..ee163f5 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-calendar-2026-10.json @@ -0,0 +1,1506 @@ +{ + "range": { + "start": "2026-09-23", + "end": "2026-12-10" + }, + "items": [ + { + "date": "2026-09-23", + "category": "hebdate", + "title": "ื™ืดื‘ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "12 Tishrei 5787", + "hebrew": "ื™ืดื‘ ืชืฉืจื™", + "hdate": "12 Tishrei 5787" + }, + { + "date": "2026-09-23", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ", + "title_orig": "Bechorot 5", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ", + "hdate": "12 Tishrei 5787" + }, + { + "date": "2026-09-24", + "category": "hebdate", + "title": "ื™ืดื’ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "13 Tishrei 5787", + "hebrew": "ื™ืดื’ ืชืฉืจื™", + "hdate": "13 Tishrei 5787" + }, + { + "date": "2026-09-24", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ", + "title_orig": "Bechorot 6", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ", + "hdate": "13 Tishrei 5787" + }, + { + "date": "2026-09-25", + "category": "hebdate", + "title": "ื™ืดื“ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "14 Tishrei 5787", + "hebrew": "ื™ืดื“ ืชืฉืจื™", + "hdate": "14 Tishrei 5787" + }, + { + "date": "2026-09-25", + "category": "holiday", + "title": "ืขึถืจึถื‘ ืกึปื›ึผื•ึนืช", + "title_orig": "Erev Sukkot", + "hebrew": "ืขืจื‘ ืกื•ื›ื•ืช", + "hdate": "14 Tishrei 5787" + }, + { + "date": "2026-09-25", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ", + "title_orig": "Bechorot 7", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ", + "hdate": "14 Tishrei 5787" + }, + { + "date": "2026-09-26", + "category": "hebdate", + "title": "ื˜ืดื• ืชึดึผืฉึฐืืจึตื™", + "title_orig": "15 Tishrei 5787", + "hebrew": "ื˜ืดื• ืชืฉืจื™", + "hdate": "15 Tishrei 5787" + }, + { + "date": "2026-09-26", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ืืณ", + "title_orig": "Sukkot I", + "hebrew": "ืกื•ื›ื•ืช ืืณ", + "hdate": "15 Tishrei 5787", + "yomtov": true + }, + { + "date": "2026-09-26", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ", + "title_orig": "Bechorot 8", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ", + "hdate": "15 Tishrei 5787" + }, + { + "date": "2026-09-27", + "category": "hebdate", + "title": "ื˜ืดื– ืชึดึผืฉึฐืืจึตื™", + "title_orig": "16 Tishrei 5787", + "hebrew": "ื˜ืดื– ืชืฉืจื™", + "hdate": "16 Tishrei 5787" + }, + { + "date": "2026-09-27", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื‘ืณ (ื—ื•ื”ืดืž)", + "title_orig": "Sukkot II (CH''M)", + "hebrew": "ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)", + "hdate": "16 Tishrei 5787" + }, + { + "date": "2026-09-27", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ", + "title_orig": "Bechorot 9", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ", + "hdate": "16 Tishrei 5787" + }, + { + "date": "2026-09-28", + "category": "hebdate", + "title": "ื™ืดื– ืชึดึผืฉึฐืืจึตื™", + "title_orig": "17 Tishrei 5787", + "hebrew": "ื™ืดื– ืชืฉืจื™", + "hdate": "17 Tishrei 5787" + }, + { + "date": "2026-09-28", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื’ืณ (ื—ื•ื”ืดืž)", + "title_orig": "Sukkot III (CH''M)", + "hebrew": "ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)", + "hdate": "17 Tishrei 5787" + }, + { + "date": "2026-09-28", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ", + "title_orig": "Bechorot 10", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ", + "hdate": "17 Tishrei 5787" + }, + { + "date": "2026-09-29", + "category": "hebdate", + "title": "ื™ืดื— ืชึดึผืฉึฐืืจึตื™", + "title_orig": "18 Tishrei 5787", + "hebrew": "ื™ืดื— ืชืฉืจื™", + "hdate": "18 Tishrei 5787" + }, + { + "date": "2026-09-29", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื“ืณ (ื—ื•ื”ืดืž)", + "title_orig": "Sukkot IV (CH''M)", + "hebrew": "ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)", + "hdate": "18 Tishrei 5787" + }, + { + "date": "2026-09-29", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื", + "title_orig": "Bechorot 11", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื", + "hdate": "18 Tishrei 5787" + }, + { + "date": "2026-09-30", + "category": "hebdate", + "title": "ื™ืดื˜ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "19 Tishrei 5787", + "hebrew": "ื™ืดื˜ ืชืฉืจื™", + "hdate": "19 Tishrei 5787" + }, + { + "date": "2026-09-30", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื”ืณ (ื—ื•ื”ืดืž)", + "title_orig": "Sukkot V (CH''M)", + "hebrew": "ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)", + "hdate": "19 Tishrei 5787" + }, + { + "date": "2026-09-30", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘", + "title_orig": "Bechorot 12", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘", + "hdate": "19 Tishrei 5787" + }, + { + "date": "2026-10-01", + "category": "hebdate", + "title": "ื›ืณ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "20 Tishrei 5787", + "hebrew": "ื›ืณ ืชืฉืจื™", + "hdate": "20 Tishrei 5787" + }, + { + "date": "2026-10-01", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื•ืณ (ื—ื•ื”ืดืž)", + "title_orig": "Sukkot VI (CH''M)", + "hebrew": "ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)", + "hdate": "20 Tishrei 5787" + }, + { + "date": "2026-10-01", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’", + "title_orig": "Bechorot 13", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’", + "hdate": "20 Tishrei 5787" + }, + { + "date": "2026-10-02", + "category": "hebdate", + "title": "ื›ืดื ืชึดึผืฉึฐืืจึตื™", + "title_orig": "21 Tishrei 5787", + "hebrew": "ื›ืดื ืชืฉืจื™", + "hdate": "21 Tishrei 5787" + }, + { + "date": "2026-10-02", + "category": "holiday", + "title": "ืกึปื›ึผื•ึนืช ื–ืณ (ื”ื•ึนืฉึทืืขึฐื ึธื ืจึทื‘ึธึผื”)", + "title_orig": "Sukkot VII (Hoshana Raba)", + "hebrew": "ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)", + "hdate": "21 Tishrei 5787" + }, + { + "date": "2026-10-02", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“", + "title_orig": "Bechorot 14", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“", + "hdate": "21 Tishrei 5787" + }, + { + "date": "2026-10-03", + "category": "hebdate", + "title": "ื›ืดื‘ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "22 Tishrei 5787", + "hebrew": "ื›ืดื‘ ืชืฉืจื™", + "hdate": "22 Tishrei 5787" + }, + { + "date": "2026-10-03", + "category": "holiday", + "title": "ืฉึฐืืžึดื™ื ึดื™ ืขึฒืฆึถืจึถืช", + "title_orig": "Shmini Atzeret", + "hebrew": "ืฉืžื™ื ื™ ืขืฆืจืช", + "hdate": "22 Tishrei 5787", + "yomtov": true + }, + { + "date": "2026-10-03", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•", + "title_orig": "Bechorot 15", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•", + "hdate": "22 Tishrei 5787" + }, + { + "date": "2026-10-04", + "category": "hebdate", + "title": "ื›ืดื’ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "23 Tishrei 5787", + "hebrew": "ื›ืดื’ ืชืฉืจื™", + "hdate": "23 Tishrei 5787" + }, + { + "date": "2026-10-04", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–", + "title_orig": "Bechorot 16", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–", + "hdate": "23 Tishrei 5787" + }, + { + "date": "2026-10-05", + "category": "hebdate", + "title": "ื›ืดื“ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "24 Tishrei 5787", + "hebrew": "ื›ืดื“ ืชืฉืจื™", + "hdate": "24 Tishrei 5787" + }, + { + "date": "2026-10-05", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–", + "title_orig": "Bechorot 17", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–", + "hdate": "24 Tishrei 5787" + }, + { + "date": "2026-10-06", + "category": "hebdate", + "title": "ื›ืดื” ืชึดึผืฉึฐืืจึตื™", + "title_orig": "25 Tishrei 5787", + "hebrew": "ื›ืดื” ืชืฉืจื™", + "hdate": "25 Tishrei 5787" + }, + { + "date": "2026-10-06", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—", + "title_orig": "Bechorot 18", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—", + "hdate": "25 Tishrei 5787" + }, + { + "date": "2026-10-07", + "category": "hebdate", + "title": "ื›ืดื• ืชึดึผืฉึฐืืจึตื™", + "title_orig": "26 Tishrei 5787", + "hebrew": "ื›ืดื• ืชืฉืจื™", + "hdate": "26 Tishrei 5787" + }, + { + "date": "2026-10-07", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜", + "title_orig": "Bechorot 19", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜", + "hdate": "26 Tishrei 5787" + }, + { + "date": "2026-10-08", + "category": "hebdate", + "title": "ื›ืดื– ืชึดึผืฉึฐืืจึตื™", + "title_orig": "27 Tishrei 5787", + "hebrew": "ื›ืดื– ืชืฉืจื™", + "hdate": "27 Tishrei 5787" + }, + { + "date": "2026-10-08", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ", + "title_orig": "Bechorot 20", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ", + "hdate": "27 Tishrei 5787" + }, + { + "date": "2026-10-09", + "category": "hebdate", + "title": "ื›ืดื— ืชึดึผืฉึฐืืจึตื™", + "title_orig": "28 Tishrei 5787", + "hebrew": "ื›ืดื— ืชืฉืจื™", + "hdate": "28 Tishrei 5787" + }, + { + "date": "2026-10-09", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื", + "title_orig": "Bechorot 21", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื", + "hdate": "28 Tishrei 5787" + }, + { + "date": "2026-10-10", + "category": "hebdate", + "title": "ื›ืดื˜ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "29 Tishrei 5787", + "hebrew": "ื›ืดื˜ ืชืฉืจื™", + "hdate": "29 Tishrei 5787" + }, + { + "date": "2026-10-10", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื‘ึฐึผืจึตืืฉึดืื™ืช", + "title_orig": "Parashat Bereshit", + "hebrew": "ืคืจืฉืช ื‘ืจืืฉื™ืช", + "hdate": "29 Tishrei 5787" + }, + { + "date": "2026-10-10", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘", + "title_orig": "Bechorot 22", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘", + "hdate": "29 Tishrei 5787" + }, + { + "date": "2026-10-10", + "category": "mevarchim", + "title": "ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "Shabbat Mevarchim Chodesh Cheshvan", + "hebrew": "ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื—ึถืฉึฐืื•ึธืŸ", + "hdate": "29 Tishrei 5787" + }, + { + "date": "2026-10-11", + "category": "hebdate", + "title": "ืœืณ ืชึดึผืฉึฐืืจึตื™", + "title_orig": "30 Tishrei 5787", + "hebrew": "ืœืณ ืชืฉืจื™", + "hdate": "30 Tishrei 5787" + }, + { + "date": "2026-10-11", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’", + "title_orig": "Bechorot 23", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’", + "hdate": "30 Tishrei 5787" + }, + { + "date": "2026-10-12", + "category": "hebdate", + "title": "ืืณ ื—ึถืฉึฐืื•ึธืŸ ืชืฉืคืดื–", + "title_orig": "1 Cheshvan 5787", + "hebrew": "ืืณ ื—ืฉื•ืŸ", + "hdate": "1 Cheshvan 5787" + }, + { + "date": "2026-10-12", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“", + "title_orig": "Bechorot 24", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“", + "hdate": "1 Cheshvan 5787" + }, + { + "date": "2026-10-13", + "category": "hebdate", + "title": "ื‘ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "2 Cheshvan 5787", + "hebrew": "ื‘ืณ ื—ืฉื•ืŸ", + "hdate": "2 Cheshvan 5787" + }, + { + "date": "2026-10-13", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”", + "title_orig": "Bechorot 25", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”", + "hdate": "2 Cheshvan 5787" + }, + { + "date": "2026-10-14", + "category": "hebdate", + "title": "ื’ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "3 Cheshvan 5787", + "hebrew": "ื’ืณ ื—ืฉื•ืŸ", + "hdate": "3 Cheshvan 5787" + }, + { + "date": "2026-10-14", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•", + "title_orig": "Bechorot 26", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•", + "hdate": "3 Cheshvan 5787" + }, + { + "date": "2026-10-15", + "category": "hebdate", + "title": "ื“ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "4 Cheshvan 5787", + "hebrew": "ื“ืณ ื—ืฉื•ืŸ", + "hdate": "4 Cheshvan 5787" + }, + { + "date": "2026-10-15", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–", + "title_orig": "Bechorot 27", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–", + "hdate": "4 Cheshvan 5787" + }, + { + "date": "2026-10-16", + "category": "hebdate", + "title": "ื”ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "5 Cheshvan 5787", + "hebrew": "ื”ืณ ื—ืฉื•ืŸ", + "hdate": "5 Cheshvan 5787" + }, + { + "date": "2026-10-16", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—", + "title_orig": "Bechorot 28", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—", + "hdate": "5 Cheshvan 5787" + }, + { + "date": "2026-10-17", + "category": "hebdate", + "title": "ื•ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "6 Cheshvan 5787", + "hebrew": "ื•ืณ ื—ืฉื•ืŸ", + "hdate": "6 Cheshvan 5787" + }, + { + "date": "2026-10-17", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื ึนื—ึท", + "title_orig": "Parashat Noach", + "hebrew": "ืคืจืฉืช ื ื—", + "hdate": "6 Cheshvan 5787" + }, + { + "date": "2026-10-17", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜", + "title_orig": "Bechorot 29", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜", + "hdate": "6 Cheshvan 5787" + }, + { + "date": "2026-10-18", + "category": "hebdate", + "title": "ื–ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "7 Cheshvan 5787", + "hebrew": "ื–ืณ ื—ืฉื•ืŸ", + "hdate": "7 Cheshvan 5787" + }, + { + "date": "2026-10-18", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ", + "title_orig": "Bechorot 30", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ", + "hdate": "7 Cheshvan 5787" + }, + { + "date": "2026-10-19", + "category": "hebdate", + "title": "ื—ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "8 Cheshvan 5787", + "hebrew": "ื—ืณ ื—ืฉื•ืŸ", + "hdate": "8 Cheshvan 5787" + }, + { + "date": "2026-10-19", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื", + "title_orig": "Bechorot 31", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื", + "hdate": "8 Cheshvan 5787" + }, + { + "date": "2026-10-20", + "category": "hebdate", + "title": "ื˜ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "9 Cheshvan 5787", + "hebrew": "ื˜ืณ ื—ืฉื•ืŸ", + "hdate": "9 Cheshvan 5787" + }, + { + "date": "2026-10-20", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘", + "title_orig": "Bechorot 32", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘", + "hdate": "9 Cheshvan 5787" + }, + { + "date": "2026-10-21", + "category": "hebdate", + "title": "ื™ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "10 Cheshvan 5787", + "hebrew": "ื™ืณ ื—ืฉื•ืŸ", + "hdate": "10 Cheshvan 5787" + }, + { + "date": "2026-10-21", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’", + "title_orig": "Bechorot 33", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’", + "hdate": "10 Cheshvan 5787" + }, + { + "date": "2026-10-22", + "category": "hebdate", + "title": "ื™ืดื ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "11 Cheshvan 5787", + "hebrew": "ื™ืดื ื—ืฉื•ืŸ", + "hdate": "11 Cheshvan 5787" + }, + { + "date": "2026-10-22", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“", + "title_orig": "Bechorot 34", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“", + "hdate": "11 Cheshvan 5787" + }, + { + "date": "2026-10-23", + "category": "hebdate", + "title": "ื™ืดื‘ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "12 Cheshvan 5787", + "hebrew": "ื™ืดื‘ ื—ืฉื•ืŸ", + "hdate": "12 Cheshvan 5787" + }, + { + "date": "2026-10-23", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”", + "title_orig": "Bechorot 35", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”", + "hdate": "12 Cheshvan 5787" + }, + { + "date": "2026-10-24", + "category": "hebdate", + "title": "ื™ืดื’ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "13 Cheshvan 5787", + "hebrew": "ื™ืดื’ ื—ืฉื•ืŸ", + "hdate": "13 Cheshvan 5787" + }, + { + "date": "2026-10-24", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ืœึถืšึฐึพืœึฐืšึธ", + "title_orig": "Parashat Lech-Lecha", + "hebrew": "ืคืจืฉืช ืœืšึพืœืš", + "hdate": "13 Cheshvan 5787" + }, + { + "date": "2026-10-24", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•", + "title_orig": "Bechorot 36", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•", + "hdate": "13 Cheshvan 5787" + }, + { + "date": "2026-10-25", + "category": "hebdate", + "title": "ื™ืดื“ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "14 Cheshvan 5787", + "hebrew": "ื™ืดื“ ื—ืฉื•ืŸ", + "hdate": "14 Cheshvan 5787" + }, + { + "date": "2026-10-25", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–", + "title_orig": "Bechorot 37", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–", + "hdate": "14 Cheshvan 5787" + }, + { + "date": "2026-10-26", + "category": "hebdate", + "title": "ื˜ืดื• ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "15 Cheshvan 5787", + "hebrew": "ื˜ืดื• ื—ืฉื•ืŸ", + "hdate": "15 Cheshvan 5787" + }, + { + "date": "2026-10-26", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—", + "title_orig": "Bechorot 38", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—", + "hdate": "15 Cheshvan 5787" + }, + { + "date": "2026-10-27", + "category": "hebdate", + "title": "ื˜ืดื– ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "16 Cheshvan 5787", + "hebrew": "ื˜ืดื– ื—ืฉื•ืŸ", + "hdate": "16 Cheshvan 5787" + }, + { + "date": "2026-10-27", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜", + "title_orig": "Bechorot 39", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜", + "hdate": "16 Cheshvan 5787" + }, + { + "date": "2026-10-28", + "category": "hebdate", + "title": "ื™ืดื– ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "17 Cheshvan 5787", + "hebrew": "ื™ืดื– ื—ืฉื•ืŸ", + "hdate": "17 Cheshvan 5787" + }, + { + "date": "2026-10-28", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ", + "title_orig": "Bechorot 40", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ", + "hdate": "17 Cheshvan 5787" + }, + { + "date": "2026-10-29", + "category": "hebdate", + "title": "ื™ืดื— ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "18 Cheshvan 5787", + "hebrew": "ื™ืดื— ื—ืฉื•ืŸ", + "hdate": "18 Cheshvan 5787" + }, + { + "date": "2026-10-29", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื", + "title_orig": "Bechorot 41", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื", + "hdate": "18 Cheshvan 5787" + }, + { + "date": "2026-10-30", + "category": "hebdate", + "title": "ื™ืดื˜ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "19 Cheshvan 5787", + "hebrew": "ื™ืดื˜ ื—ืฉื•ืŸ", + "hdate": "19 Cheshvan 5787" + }, + { + "date": "2026-10-30", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘", + "title_orig": "Bechorot 42", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘", + "hdate": "19 Cheshvan 5787" + }, + { + "date": "2026-10-31", + "category": "hebdate", + "title": "ื›ืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "20 Cheshvan 5787", + "hebrew": "ื›ืณ ื—ืฉื•ืŸ", + "hdate": "20 Cheshvan 5787" + }, + { + "date": "2026-10-31", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืจึธื", + "title_orig": "Parashat Vayera", + "hebrew": "ืคืจืฉืช ื•ื™ืจื", + "hdate": "20 Cheshvan 5787" + }, + { + "date": "2026-10-31", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’", + "title_orig": "Bechorot 43", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’", + "hdate": "20 Cheshvan 5787" + }, + { + "date": "2026-11-01", + "category": "hebdate", + "title": "ื›ืดื ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "21 Cheshvan 5787", + "hebrew": "ื›ืดื ื—ืฉื•ืŸ", + "hdate": "21 Cheshvan 5787" + }, + { + "date": "2026-11-01", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“", + "title_orig": "Bechorot 44", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“", + "hdate": "21 Cheshvan 5787" + }, + { + "date": "2026-11-02", + "category": "hebdate", + "title": "ื›ืดื‘ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "22 Cheshvan 5787", + "hebrew": "ื›ืดื‘ ื—ืฉื•ืŸ", + "hdate": "22 Cheshvan 5787" + }, + { + "date": "2026-11-02", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”", + "title_orig": "Bechorot 45", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”", + "hdate": "22 Cheshvan 5787" + }, + { + "date": "2026-11-03", + "category": "hebdate", + "title": "ื›ืดื’ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "23 Cheshvan 5787", + "hebrew": "ื›ืดื’ ื—ืฉื•ืŸ", + "hdate": "23 Cheshvan 5787" + }, + { + "date": "2026-11-03", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•", + "title_orig": "Bechorot 46", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•", + "hdate": "23 Cheshvan 5787" + }, + { + "date": "2026-11-04", + "category": "hebdate", + "title": "ื›ืดื“ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "24 Cheshvan 5787", + "hebrew": "ื›ืดื“ ื—ืฉื•ืŸ", + "hdate": "24 Cheshvan 5787" + }, + { + "date": "2026-11-04", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–", + "title_orig": "Bechorot 47", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–", + "hdate": "24 Cheshvan 5787" + }, + { + "date": "2026-11-05", + "category": "hebdate", + "title": "ื›ืดื” ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "25 Cheshvan 5787", + "hebrew": "ื›ืดื” ื—ืฉื•ืŸ", + "hdate": "25 Cheshvan 5787" + }, + { + "date": "2026-11-05", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—", + "title_orig": "Bechorot 48", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—", + "hdate": "25 Cheshvan 5787" + }, + { + "date": "2026-11-06", + "category": "hebdate", + "title": "ื›ืดื• ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "26 Cheshvan 5787", + "hebrew": "ื›ืดื• ื—ืฉื•ืŸ", + "hdate": "26 Cheshvan 5787" + }, + { + "date": "2026-11-06", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜", + "title_orig": "Bechorot 49", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜", + "hdate": "26 Cheshvan 5787" + }, + { + "date": "2026-11-07", + "category": "hebdate", + "title": "ื›ืดื– ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "27 Cheshvan 5787", + "hebrew": "ื›ืดื– ื—ืฉื•ืŸ", + "hdate": "27 Cheshvan 5787" + }, + { + "date": "2026-11-07", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื—ึทื™ึตึผื™ ืฉึธึนืจึธื”", + "title_orig": "Parashat Chayei Sara", + "hebrew": "ืคืจืฉืช ื—ื™ื™ ืฉืจื”", + "hdate": "27 Cheshvan 5787" + }, + { + "date": "2026-11-07", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ", + "title_orig": "Bechorot 50", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ", + "hdate": "27 Cheshvan 5787" + }, + { + "date": "2026-11-07", + "category": "mevarchim", + "title": "ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื›ึดึผืกึฐืœึตื•", + "title_orig": "Shabbat Mevarchim Chodesh Kislev", + "hebrew": "ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื›ึดึผืกึฐืœึตื•", + "hdate": "27 Cheshvan 5787" + }, + { + "date": "2026-11-08", + "category": "hebdate", + "title": "ื›ืดื— ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "28 Cheshvan 5787", + "hebrew": "ื›ืดื— ื—ืฉื•ืŸ", + "hdate": "28 Cheshvan 5787" + }, + { + "date": "2026-11-08", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื", + "title_orig": "Bechorot 51", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื", + "hdate": "28 Cheshvan 5787" + }, + { + "date": "2026-11-09", + "category": "hebdate", + "title": "ื›ืดื˜ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "29 Cheshvan 5787", + "hebrew": "ื›ืดื˜ ื—ืฉื•ืŸ", + "hdate": "29 Cheshvan 5787" + }, + { + "date": "2026-11-09", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘", + "title_orig": "Bechorot 52", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘", + "hdate": "29 Cheshvan 5787" + }, + { + "date": "2026-11-10", + "category": "hebdate", + "title": "ืœืณ ื—ึถืฉึฐืื•ึธืŸ", + "title_orig": "30 Cheshvan 5787", + "hebrew": "ืœืณ ื—ืฉื•ืŸ", + "hdate": "30 Cheshvan 5787" + }, + { + "date": "2026-11-10", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’", + "title_orig": "Bechorot 53", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’", + "hdate": "30 Cheshvan 5787" + }, + { + "date": "2026-11-11", + "category": "hebdate", + "title": "ืืณ ื›ึดึผืกึฐืœึตื• ืชืฉืคืดื–", + "title_orig": "1 Kislev 5787", + "hebrew": "ืืณ ื›ืกืœื•", + "hdate": "1 Kislev 5787" + }, + { + "date": "2026-11-11", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“", + "title_orig": "Bechorot 54", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“", + "hdate": "1 Kislev 5787" + }, + { + "date": "2026-11-12", + "category": "hebdate", + "title": "ื‘ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "2 Kislev 5787", + "hebrew": "ื‘ืณ ื›ืกืœื•", + "hdate": "2 Kislev 5787" + }, + { + "date": "2026-11-12", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”", + "title_orig": "Bechorot 55", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”", + "hdate": "2 Kislev 5787" + }, + { + "date": "2026-11-13", + "category": "hebdate", + "title": "ื’ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "3 Kislev 5787", + "hebrew": "ื’ืณ ื›ืกืœื•", + "hdate": "3 Kislev 5787" + }, + { + "date": "2026-11-13", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•", + "title_orig": "Bechorot 56", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•", + "hdate": "3 Kislev 5787" + }, + { + "date": "2026-11-14", + "category": "hebdate", + "title": "ื“ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "4 Kislev 5787", + "hebrew": "ื“ืณ ื›ืกืœื•", + "hdate": "4 Kislev 5787" + }, + { + "date": "2026-11-14", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ืชึผื•ึนืœึฐื“ื•ึนืช", + "title_orig": "Parashat Toldot", + "hebrew": "ืคืจืฉืช ืชื•ืœื“ื•ืช", + "hdate": "4 Kislev 5787" + }, + { + "date": "2026-11-14", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–", + "title_orig": "Bechorot 57", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–", + "hdate": "4 Kislev 5787" + }, + { + "date": "2026-11-15", + "category": "hebdate", + "title": "ื”ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "5 Kislev 5787", + "hebrew": "ื”ืณ ื›ืกืœื•", + "hdate": "5 Kislev 5787" + }, + { + "date": "2026-11-15", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—", + "title_orig": "Bechorot 58", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—", + "hdate": "5 Kislev 5787" + }, + { + "date": "2026-11-16", + "category": "hebdate", + "title": "ื•ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "6 Kislev 5787", + "hebrew": "ื•ืณ ื›ืกืœื•", + "hdate": "6 Kislev 5787" + }, + { + "date": "2026-11-16", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜", + "title_orig": "Bechorot 59", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜", + "hdate": "6 Kislev 5787" + }, + { + "date": "2026-11-17", + "category": "hebdate", + "title": "ื–ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "7 Kislev 5787", + "hebrew": "ื–ืณ ื›ืกืœื•", + "hdate": "7 Kislev 5787" + }, + { + "date": "2026-11-17", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ", + "title_orig": "Bechorot 60", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ", + "hdate": "7 Kislev 5787" + }, + { + "date": "2026-11-18", + "category": "hebdate", + "title": "ื—ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "8 Kislev 5787", + "hebrew": "ื—ืณ ื›ืกืœื•", + "hdate": "8 Kislev 5787" + }, + { + "date": "2026-11-18", + "category": "dafyomi", + "title": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื", + "title_orig": "Bechorot 61", + "hebrew": "ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื", + "hdate": "8 Kislev 5787" + }, + { + "date": "2026-11-19", + "category": "hebdate", + "title": "ื˜ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "9 Kislev 5787", + "hebrew": "ื˜ืณ ื›ืกืœื•", + "hdate": "9 Kislev 5787" + }, + { + "date": "2026-11-19", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ", + "title_orig": "Arachin 2", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ", + "hdate": "9 Kislev 5787" + }, + { + "date": "2026-11-20", + "category": "hebdate", + "title": "ื™ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "10 Kislev 5787", + "hebrew": "ื™ืณ ื›ืกืœื•", + "hdate": "10 Kislev 5787" + }, + { + "date": "2026-11-20", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ", + "title_orig": "Arachin 3", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ", + "hdate": "10 Kislev 5787" + }, + { + "date": "2026-11-21", + "category": "hebdate", + "title": "ื™ืดื ื›ึดึผืกึฐืœึตื•", + "title_orig": "11 Kislev 5787", + "hebrew": "ื™ืดื ื›ืกืœื•", + "hdate": "11 Kislev 5787" + }, + { + "date": "2026-11-21", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืฆึตื", + "title_orig": "Parashat Vayetzei", + "hebrew": "ืคืจืฉืช ื•ื™ืฆื", + "hdate": "11 Kislev 5787" + }, + { + "date": "2026-11-21", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ", + "title_orig": "Arachin 4", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ", + "hdate": "11 Kislev 5787" + }, + { + "date": "2026-11-22", + "category": "hebdate", + "title": "ื™ืดื‘ ื›ึดึผืกึฐืœึตื•", + "title_orig": "12 Kislev 5787", + "hebrew": "ื™ืดื‘ ื›ืกืœื•", + "hdate": "12 Kislev 5787" + }, + { + "date": "2026-11-22", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ", + "title_orig": "Arachin 5", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ", + "hdate": "12 Kislev 5787" + }, + { + "date": "2026-11-23", + "category": "hebdate", + "title": "ื™ืดื’ ื›ึดึผืกึฐืœึตื•", + "title_orig": "13 Kislev 5787", + "hebrew": "ื™ืดื’ ื›ืกืœื•", + "hdate": "13 Kislev 5787" + }, + { + "date": "2026-11-23", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ", + "title_orig": "Arachin 6", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ", + "hdate": "13 Kislev 5787" + }, + { + "date": "2026-11-24", + "category": "hebdate", + "title": "ื™ืดื“ ื›ึดึผืกึฐืœึตื•", + "title_orig": "14 Kislev 5787", + "hebrew": "ื™ืดื“ ื›ืกืœื•", + "hdate": "14 Kislev 5787" + }, + { + "date": "2026-11-24", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ", + "title_orig": "Arachin 7", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ", + "hdate": "14 Kislev 5787" + }, + { + "date": "2026-11-25", + "category": "hebdate", + "title": "ื˜ืดื• ื›ึดึผืกึฐืœึตื•", + "title_orig": "15 Kislev 5787", + "hebrew": "ื˜ืดื• ื›ืกืœื•", + "hdate": "15 Kislev 5787" + }, + { + "date": "2026-11-25", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ", + "title_orig": "Arachin 8", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ", + "hdate": "15 Kislev 5787" + }, + { + "date": "2026-11-26", + "category": "hebdate", + "title": "ื˜ืดื– ื›ึดึผืกึฐืœึตื•", + "title_orig": "16 Kislev 5787", + "hebrew": "ื˜ืดื– ื›ืกืœื•", + "hdate": "16 Kislev 5787" + }, + { + "date": "2026-11-26", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ", + "title_orig": "Arachin 9", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ", + "hdate": "16 Kislev 5787" + }, + { + "date": "2026-11-27", + "category": "hebdate", + "title": "ื™ืดื– ื›ึดึผืกึฐืœึตื•", + "title_orig": "17 Kislev 5787", + "hebrew": "ื™ืดื– ื›ืกืœื•", + "hdate": "17 Kislev 5787" + }, + { + "date": "2026-11-27", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ", + "title_orig": "Arachin 10", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ", + "hdate": "17 Kislev 5787" + }, + { + "date": "2026-11-28", + "category": "hebdate", + "title": "ื™ืดื— ื›ึดึผืกึฐืœึตื•", + "title_orig": "18 Kislev 5787", + "hebrew": "ื™ืดื— ื›ืกืœื•", + "hdate": "18 Kislev 5787" + }, + { + "date": "2026-11-28", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื•ึทื™ึดึผืฉึฐืืœึทื—", + "title_orig": "Parashat Vayishlach", + "hebrew": "ืคืจืฉืช ื•ื™ืฉืœื—", + "hdate": "18 Kislev 5787" + }, + { + "date": "2026-11-28", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื", + "title_orig": "Arachin 11", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื", + "hdate": "18 Kislev 5787" + }, + { + "date": "2026-11-29", + "category": "hebdate", + "title": "ื™ืดื˜ ื›ึดึผืกึฐืœึตื•", + "title_orig": "19 Kislev 5787", + "hebrew": "ื™ืดื˜ ื›ืกืœื•", + "hdate": "19 Kislev 5787" + }, + { + "date": "2026-11-29", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘", + "title_orig": "Arachin 12", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘", + "hdate": "19 Kislev 5787" + }, + { + "date": "2026-11-30", + "category": "hebdate", + "title": "ื›ืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "20 Kislev 5787", + "hebrew": "ื›ืณ ื›ืกืœื•", + "hdate": "20 Kislev 5787" + }, + { + "date": "2026-11-30", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’", + "title_orig": "Arachin 13", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’", + "hdate": "20 Kislev 5787" + }, + { + "date": "2026-12-01", + "category": "hebdate", + "title": "ื›ืดื ื›ึดึผืกึฐืœึตื•", + "title_orig": "21 Kislev 5787", + "hebrew": "ื›ืดื ื›ืกืœื•", + "hdate": "21 Kislev 5787" + }, + { + "date": "2026-12-01", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“", + "title_orig": "Arachin 14", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“", + "hdate": "21 Kislev 5787" + }, + { + "date": "2026-12-02", + "category": "hebdate", + "title": "ื›ืดื‘ ื›ึดึผืกึฐืœึตื•", + "title_orig": "22 Kislev 5787", + "hebrew": "ื›ืดื‘ ื›ืกืœื•", + "hdate": "22 Kislev 5787" + }, + { + "date": "2026-12-02", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•", + "title_orig": "Arachin 15", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•", + "hdate": "22 Kislev 5787" + }, + { + "date": "2026-12-03", + "category": "hebdate", + "title": "ื›ืดื’ ื›ึดึผืกึฐืœึตื•", + "title_orig": "23 Kislev 5787", + "hebrew": "ื›ืดื’ ื›ืกืœื•", + "hdate": "23 Kislev 5787" + }, + { + "date": "2026-12-03", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–", + "title_orig": "Arachin 16", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–", + "hdate": "23 Kislev 5787" + }, + { + "date": "2026-12-04", + "category": "hebdate", + "title": "ื›ืดื“ ื›ึดึผืกึฐืœึตื•", + "title_orig": "24 Kislev 5787", + "hebrew": "ื›ืดื“ ื›ืกืœื•", + "hdate": "24 Kislev 5787" + }, + { + "date": "2026-12-04", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ืืณ ื ึตืจ", + "title_orig": "Chanukah: 1 Candle", + "hebrew": "ื—ื ื•ื›ื”: ืืณ ื ืจ", + "hdate": "24 Kislev 5787" + }, + { + "date": "2026-12-04", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–", + "title_orig": "Arachin 17", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–", + "hdate": "24 Kislev 5787" + }, + { + "date": "2026-12-05", + "category": "hebdate", + "title": "ื›ืดื” ื›ึดึผืกึฐืœึตื•", + "title_orig": "25 Kislev 5787", + "hebrew": "ื›ืดื” ื›ืกืœื•", + "hdate": "25 Kislev 5787" + }, + { + "date": "2026-12-05", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื‘ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 2 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช", + "hdate": "25 Kislev 5787" + }, + { + "date": "2026-12-05", + "category": "parashat", + "title": "ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืฉึถืื‘", + "title_orig": "Parashat Vayeshev", + "hebrew": "ืคืจืฉืช ื•ื™ืฉื‘", + "hdate": "25 Kislev 5787" + }, + { + "date": "2026-12-05", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—", + "title_orig": "Arachin 18", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—", + "hdate": "25 Kislev 5787" + }, + { + "date": "2026-12-05", + "category": "mevarchim", + "title": "ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื˜ึตื‘ึตืช", + "title_orig": "Shabbat Mevarchim Chodesh Tevet", + "hebrew": "ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื˜ึตื‘ึตืช", + "hdate": "25 Kislev 5787" + }, + { + "date": "2026-12-06", + "category": "hebdate", + "title": "ื›ืดื• ื›ึดึผืกึฐืœึตื•", + "title_orig": "26 Kislev 5787", + "hebrew": "ื›ืดื• ื›ืกืœื•", + "hdate": "26 Kislev 5787" + }, + { + "date": "2026-12-06", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื’ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 3 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช", + "hdate": "26 Kislev 5787" + }, + { + "date": "2026-12-06", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜", + "title_orig": "Arachin 19", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜", + "hdate": "26 Kislev 5787" + }, + { + "date": "2026-12-07", + "category": "hebdate", + "title": "ื›ืดื– ื›ึดึผืกึฐืœึตื•", + "title_orig": "27 Kislev 5787", + "hebrew": "ื›ืดื– ื›ืกืœื•", + "hdate": "27 Kislev 5787" + }, + { + "date": "2026-12-07", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื“ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 4 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช", + "hdate": "27 Kislev 5787" + }, + { + "date": "2026-12-07", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ", + "title_orig": "Arachin 20", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ", + "hdate": "27 Kislev 5787" + }, + { + "date": "2026-12-08", + "category": "hebdate", + "title": "ื›ืดื— ื›ึดึผืกึฐืœึตื•", + "title_orig": "28 Kislev 5787", + "hebrew": "ื›ืดื— ื›ืกืœื•", + "hdate": "28 Kislev 5787" + }, + { + "date": "2026-12-08", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื”ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 5 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช", + "hdate": "28 Kislev 5787" + }, + { + "date": "2026-12-08", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื", + "title_orig": "Arachin 21", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื", + "hdate": "28 Kislev 5787" + }, + { + "date": "2026-12-09", + "category": "hebdate", + "title": "ื›ืดื˜ ื›ึดึผืกึฐืœึตื•", + "title_orig": "29 Kislev 5787", + "hebrew": "ื›ืดื˜ ื›ืกืœื•", + "hdate": "29 Kislev 5787" + }, + { + "date": "2026-12-09", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื•ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 6 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช", + "hdate": "29 Kislev 5787" + }, + { + "date": "2026-12-09", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘", + "title_orig": "Arachin 22", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘", + "hdate": "29 Kislev 5787" + }, + { + "date": "2026-12-10", + "category": "hebdate", + "title": "ืœืณ ื›ึดึผืกึฐืœึตื•", + "title_orig": "30 Kislev 5787", + "hebrew": "ืœืณ ื›ืกืœื•", + "hdate": "30 Kislev 5787" + }, + { + "date": "2026-12-10", + "category": "holiday", + "title": "ื—ึทื’ ื”ึทื‘ึธึผื ื•ึนืช", + "title_orig": "Chag HaBanot", + "hebrew": "ื—ื’ ื”ื‘ื ื•ืช", + "hdate": "30 Kislev 5787" + }, + { + "date": "2026-12-10", + "category": "holiday", + "title": "ื—ึฒื ื•ึผื›ึธึผื”: ื–ืณ ื ึตืจื•ึนืช", + "title_orig": "Chanukah: 7 Candles", + "hebrew": "ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช", + "hdate": "30 Kislev 5787" + }, + { + "date": "2026-12-10", + "category": "dafyomi", + "title": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’", + "title_orig": "Arachin 23", + "hebrew": "ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’", + "hdate": "30 Kislev 5787" + } + ] +} diff --git a/tests/Fixtures/hebcal-jerusalem-default-full-README.md b/tests/Fixtures/hebcal-jerusalem-default-full-README.md new file mode 100644 index 0000000..63f2765 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-default-full-README.md @@ -0,0 +1,39 @@ +# Complete default Jerusalem Hebcal regression responses + +These complete public responses were retrieved on 2026-10-15 using the exact +application default coordinates (31.76904, 35.21633) and Asia/Jerusalem. Attribution: +Hebcal.com, CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/). The response +text was decoded and saved as UTF-8 without a BOM. All response fields and values +are retained, with no projection, normalization, recalculation or invented time. +Request URLs and SHA-256 hashes are recorded in +hebcal-jerusalem-default-full-provenance.json. + +The date range 2026-09-23 through 2026-12-10 is the actual October CalendarService +monthly request. The full solar response includes 42 fields: 35 complete daily +solar series, two empty elevation-only series (seaLevelSunrise, seaLevelSunset) +and five sparse lunar series (molad, tchilasZmanKidushLevana3Days, +tchilasZmanKidushLevana7Days, sofZmanKidushLevanaBetweenMoldos, +sofZmanKidushLevana15Days). Lunar entries occur only on their applicable dates. +They do not provide a time for every day. The current application does not consume +either the elevation-only or lunar solar response fields. + +Before the fix, HebcalProvider incorrectly required every field to cover every +date, rejecting the full valid response on seaLevelSunrise for 2026-09-23. The +earlier preceding-night regression inputs retained only selected fields and did +not include this real provider shape. + +HebcalProvider now explicitly recognizes the supported daily fields. Every +present recognized daily field still requires complete date coverage and strict +timestamp, civil-date and IANA offset validation; sunset remains mandatory. +Explicit null polar results remain null. The guarded preceding-night rule for +chatzotNight remains in place. Unsupported elevation-only, lunar and future +extension fields do not become operational times and do not control daily +coverage. Missing operational values still fail in the calendar engine rather +than changing group permissions using invented times. + +Reference: https://www.hebcal.com/home/1663/zmanim-halachic-times-api + +The fixtures contain public API data only, with no Telegram credentials or +production data. They are offline regression inputs rather than schedules to +publish. Successful API access from this development machine does not establish +the deployment server's network connectivity. diff --git a/tests/Fixtures/hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json b/tests/Fixtures/hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json new file mode 100644 index 0000000..5bbf39c --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:38:04.131Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"ื™ืดื‘ ืชึดึผืฉึฐืืจึตื™","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 5","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืชึดึผืฉึฐืืจึตื™","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 6","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืชึดึผืฉึฐืืจึตื™","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"ืขึถืจึถื‘ ืกึปื›ึผื•ึนืช","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Erev Sukkot","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 7","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืชึดึผืฉึฐืืจึตื™","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"ืกึปื›ึผื•ึนืช ืืณ","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Sukkot I","hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 8","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืชึดึผืฉึฐืืจึตื™","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"ืกึปื›ึผื•ึนืช ื‘ืณ (ื—ื•ื”ืดืž)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 9","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืชึดึผืฉึฐืืจึตื™","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"ืกึปื›ึผื•ึนืช ื’ืณ (ื—ื•ื”ืดืž)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 10","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืชึดึผืฉึฐืืจึตื™","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"ืกึปื›ึผื•ึนืช ื“ืณ (ื—ื•ื”ืดืž)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 11","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืชึดึผืฉึฐืืจึตื™","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"ืกึปื›ึผื•ึนืช ื”ืณ (ื—ื•ื”ืดืž)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 12","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"ืกึปื›ึผื•ึนืช ื•ืณ (ื—ื•ื”ืดืž)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 13","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"ื›ืดื ืชึดึผืฉึฐืืจึตื™","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"ืกึปื›ึผื•ึนืช ื–ืณ (ื”ื•ึนืฉึทืืขึฐื ึธื ืจึทื‘ึธึผื”)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VII (Hoshana Raba)","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"ื—ื’ ื”ืกื•ื›ื•ืช. ื”ื—ื’ ืฉื ืžืฉืš ืฉื‘ืขื” ื™ืžื™ื ื”ื•ื ืื—ื“ ืžืฉืœื•ืฉืช ื”ืจื’ืœื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 14","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"ืฉึฐืืžึดื™ื ึดื™ ืขึฒืฆึถืจึถืช","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shmini Atzeret","hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"ืฉืžื™ื ื™ ืขืฆืจืช. ื—ืœ ืžื™ื“ ืœืื—ืจ ืกื•ื›ื•ืช; ื‘ืชืคื•ืฆื•ืช ื ื—ื’ื’ ื›ื—ื’ ื ืคืจื“ ื•ื‘ื™ืฉืจืืœ ืžืฉื•ืœื‘ ืขื ืฉืžื—ืช ืชื•ืจื”"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 15","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 16","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 17","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืชึดึผืฉึฐืืจึตื™","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 18","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืชึดึผืฉึฐืืจึตื™","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 19","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืชึดึผืฉึฐืืจึตื™","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 20","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืชึดึผืฉึฐืืจึตื™","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 21","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผืจึตืืฉึดืื™ืช","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","title_orig":"Parashat Bereshit","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื—ึถืฉึฐืื•ึธืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ื—ึถืฉึฐืื•ึธืŸ ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืจึดืืฉืื•ึนืŸ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 9 ื‘ึทึผื‘ึนึผืงึถืจ, ื•-43 ื“ึทึผืงึผื•ึนืช ื•-2 ื—ึฒืœึธืงึดื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 22","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"ืœืณ ืชึดึผืฉึฐืืจึตื™","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 23","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"ืืณ ื—ึถืฉึฐืื•ึธืŸ ืชืฉืคืดื–","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 24","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"ื‘ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 25","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"ื’ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 26","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"ื“ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 27","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"ื”ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 28","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"ื•ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ื ึนื—ึท","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","title_orig":"Parashat Noach","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 29","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"ื–ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 30","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 31","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"ื˜ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 32","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"ื™ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 33","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"ื™ืดื ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 34","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 35","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"ืคึธึผืจึธืฉึทืืช ืœึถืšึฐึพืœึฐืšึธ","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","title_orig":"Parashat Lech-Lecha","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 36","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 37","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 38","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 39","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 40","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 41","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 42","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืจึธื","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","title_orig":"Parashat Vayera","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 43","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"ื›ืดื ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 44","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 45","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 46","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 47","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 48","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 49","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"ืคึธึผืจึธืฉึทืืช ื—ึทื™ึตึผื™ ืฉึธึนืจึธื”","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","title_orig":"Parashat Chayei Sara","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื›ึดึผืกึฐืœึตื•","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื›ึดึผืกึฐืœึตื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ื›ึดึผืกึฐืœึตื• ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึตืื ึดื™ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 22 ื‘ึทึผืœึทึผึฅื™ึฐืœึธื”, ื•-27 ื“ึทึผืงึผื•ึนืช ื•-3 ื—ึฒืœึธืงึดื™ื"},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 50","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 51","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 52","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"ืœืณ ื—ึถืฉึฐืื•ึธืŸ","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 53","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"ืืณ ื›ึดึผืกึฐืœึตื• ืชืฉืคืดื–","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 54","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"ื‘ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 55","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"ื’ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 56","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"ื“ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืชึผื•ึนืœึฐื“ื•ึนืช","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","title_orig":"Parashat Toldot","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 57","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"ื”ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 58","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"ื•ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 59","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"ื–ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 60","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"ื—ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 61","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"ื˜ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","title_orig":"Arachin 2","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"ื™ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","title_orig":"Arachin 3","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"ื™ืดื ื›ึดึผืกึฐืœึตื•","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืฆึตื","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","title_orig":"Parashat Vayetzei","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","title_orig":"Arachin 4","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ื›ึดึผืกึฐืœึตื•","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","title_orig":"Arachin 5","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ื›ึดึผืกึฐืœึตื•","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","title_orig":"Arachin 6","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ื›ึดึผืกึฐืœึตื•","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","title_orig":"Arachin 7","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ื›ึดึผืกึฐืœึตื•","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","title_orig":"Arachin 8","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ื›ึดึผืกึฐืœึตื•","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","title_orig":"Arachin 9","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ื›ึดึผืกึฐืœึตื•","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","title_orig":"Arachin 10","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"ื™ืดื— ื›ึดึผืกึฐืœึตื•","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"ืคึธึผืจึธืฉึทืืช ื•ึทื™ึดึผืฉึฐืืœึทื—","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","title_orig":"Parashat Vayishlach","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","title_orig":"Arachin 11","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ื›ึดึผืกึฐืœึตื•","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","title_orig":"Arachin 12","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"ื›ืณ ื›ึดึผืกึฐืœึตื•","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","title_orig":"Arachin 13","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"ื›ืดื ื›ึดึผืกึฐืœึตื•","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","title_orig":"Arachin 14","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ื›ึดึผืกึฐืœึตื•","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","title_orig":"Arachin 15","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ื›ึดึผืกึฐืœึตื•","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","title_orig":"Arachin 16","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ื›ึดึผืกึฐืœึตื•","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ืืณ ื ึตืจ","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 1 Candle","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","title_orig":"Arachin 17","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"ื›ืดื” ื›ึดึผืกึฐืœึตื•","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"ืคึธึผืจึธืฉึทืืช ื•ึทื™ึตึผืฉึถืื‘","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","title_orig":"Parashat Vayeshev","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ื˜ึตื‘ึตืช","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื˜ึตื‘ึตืช","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ื˜ึตื‘ึตืช ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืจึฐื‘ึดื™ืขึดื™ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 11 ื‘ึทึผื‘ึนึผืงึถืจ, ื•-11 ื“ึทึผืงึผื•ึนืช ื•-4 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื‘ืณ ื ึตืจื•ึนืช","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 2 Candles","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","title_orig":"Arachin 18","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"ื›ืดื• ื›ึดึผืกึฐืœึตื•","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื’ืณ ื ึตืจื•ึนืช","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 3 Candles","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","title_orig":"Arachin 19","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"ื›ืดื– ื›ึดึผืกึฐืœึตื•","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื“ืณ ื ึตืจื•ึนืช","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 4 Candles","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","title_orig":"Arachin 20","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"ื›ืดื— ื›ึดึผืกึฐืœึตื•","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื”ืณ ื ึตืจื•ึนืช","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 5 Candles","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","title_orig":"Arachin 21","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ื›ึดึผืกึฐืœึตื•","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื•ืณ ื ึตืจื•ึนืช","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 6 Candles","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","title_orig":"Arachin 22","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"ืœืณ ื›ึดึผืกึฐืœึตื•","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"ื—ึทื’ ื”ึทื‘ึธึผื ื•ึนืช","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","title_orig":"Chag HaBanot","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"ื—ื’ ื”ื‘ื ื•ืช ืฉืœ ื—ื ื•ื›ื” (ืขื™ื“ ืืœ-ื‘ื ืืช), ืฉืžืงื•ืจื• ื‘ื™ื”ื“ื•ืช ืฆืคื•ืŸ ืืคืจื™ืงื” ื•ื ืฉืชืžืจ ื‘ืขื™ืงืจ ื‘ืชื•ื ื™ืกื™ื”"},{"title":"ื—ึฒื ื•ึผื›ึธึผื”: ื–ืณ ื ึตืจื•ึนืช","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 7 Candles","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"ื—ื ื•ื›ื”, ื—ื’ ื”ื—ื ื•ื›ื” ืžื—ื“ืฉ ื”ื™ื”ื•ื“ื™. ื™ื“ื•ืข ื’ื ื›ื—ื’ ื”ืื•ืจื™ื; ื”ื—ื’ ื ื—ื’ื’ ื‘ืžืฉืš ืฉืžื•ื ื” ื™ืžื™ื ืขืœ ื™ื“ื™ ื”ื“ืœืงืช ื ืจื•ืช ื‘ื—ื ื•ื›ื™ื™ื”"},{"title":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","title_orig":"Arachin 23","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-default-full-provenance.json b/tests/Fixtures/hebcal-jerusalem-default-full-provenance.json new file mode 100644 index 0000000..df5d6fa --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-default-full-provenance.json @@ -0,0 +1,22 @@ +{ + "service_docs": "https://www.hebcal.com/home/1663/zmanim-halachic-times-api", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "encoding": "Decoded complete HTTP response text saved as UTF-8 without BOM; all fields and values unchanged.", + "retrieved_date": "2026-10-15", + "license": "CC-BY-4.0", + "attribution": "Hebcal.com", + "fixtures": [ + { + "file": "hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.76904&longitude=35.21633&tzid=Asia%2FJerusalem&start=2026-09-23&end=2026-12-10", + "retention": "complete response; no fields projected or values changed", + "sha256": "bbe8796ff10cca0cb4826a778f969d44cd28186577c15f3a991491766c567624" + }, + { + "file": "hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=he", + "retention": "complete response; no fields projected or values changed", + "sha256": "989bf42359f072b949876dd43c54f839350b4fe20465161fe1ff59c6dd56aa5d" + } + ] +} diff --git a/tests/Fixtures/hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json b/tests/Fixtures/hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json new file mode 100644 index 0000000..26dc72a --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json @@ -0,0 +1 @@ +{"date":{"start":"2026-09-23","end":"2026-12-10"},"version":"v1.14.2","location":{"title":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","city":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","tzid":"Asia/Jerusalem","latitude":31.76904,"longitude":35.21633,"geo":"pos"},"times":{"chatzotNight":{"2026-09-23":"2026-09-23T00:32:00+03:00","2026-09-24":"2026-09-24T00:32:00+03:00","2026-09-25":"2026-09-25T00:31:00+03:00","2026-09-26":"2026-09-26T00:31:00+03:00","2026-09-27":"2026-09-27T00:31:00+03:00","2026-09-28":"2026-09-28T00:30:00+03:00","2026-09-29":"2026-09-29T00:30:00+03:00","2026-09-30":"2026-09-30T00:30:00+03:00","2026-10-01":"2026-10-01T00:29:00+03:00","2026-10-02":"2026-10-02T00:29:00+03:00","2026-10-03":"2026-10-03T00:29:00+03:00","2026-10-04":"2026-10-04T00:28:00+03:00","2026-10-05":"2026-10-05T00:28:00+03:00","2026-10-06":"2026-10-06T00:28:00+03:00","2026-10-07":"2026-10-07T00:27:00+03:00","2026-10-08":"2026-10-08T00:27:00+03:00","2026-10-09":"2026-10-09T00:27:00+03:00","2026-10-10":"2026-10-10T00:27:00+03:00","2026-10-11":"2026-10-11T00:26:00+03:00","2026-10-12":"2026-10-12T00:26:00+03:00","2026-10-13":"2026-10-13T00:26:00+03:00","2026-10-14":"2026-10-14T00:26:00+03:00","2026-10-15":"2026-10-15T00:25:00+03:00","2026-10-16":"2026-10-16T00:25:00+03:00","2026-10-17":"2026-10-17T00:25:00+03:00","2026-10-18":"2026-10-18T00:25:00+03:00","2026-10-19":"2026-10-19T00:24:00+03:00","2026-10-20":"2026-10-20T00:24:00+03:00","2026-10-21":"2026-10-21T00:24:00+03:00","2026-10-22":"2026-10-22T00:24:00+03:00","2026-10-23":"2026-10-23T00:24:00+03:00","2026-10-24":"2026-10-24T00:24:00+03:00","2026-10-25":"2026-10-25T00:23:00+03:00","2026-10-26":"2026-10-25T23:23:00+02:00","2026-10-27":"2026-10-26T23:23:00+02:00","2026-10-28":"2026-10-27T23:23:00+02:00","2026-10-29":"2026-10-28T23:23:00+02:00","2026-10-30":"2026-10-29T23:23:00+02:00","2026-10-31":"2026-10-30T23:23:00+02:00","2026-11-01":"2026-10-31T23:23:00+02:00","2026-11-02":"2026-11-01T23:23:00+02:00","2026-11-03":"2026-11-02T23:23:00+02:00","2026-11-04":"2026-11-03T23:23:00+02:00","2026-11-05":"2026-11-04T23:23:00+02:00","2026-11-06":"2026-11-05T23:23:00+02:00","2026-11-07":"2026-11-06T23:23:00+02:00","2026-11-08":"2026-11-07T23:23:00+02:00","2026-11-09":"2026-11-08T23:23:00+02:00","2026-11-10":"2026-11-09T23:23:00+02:00","2026-11-11":"2026-11-10T23:23:00+02:00","2026-11-12":"2026-11-11T23:23:00+02:00","2026-11-13":"2026-11-12T23:23:00+02:00","2026-11-14":"2026-11-13T23:24:00+02:00","2026-11-15":"2026-11-14T23:24:00+02:00","2026-11-16":"2026-11-15T23:24:00+02:00","2026-11-17":"2026-11-16T23:24:00+02:00","2026-11-18":"2026-11-17T23:24:00+02:00","2026-11-19":"2026-11-18T23:25:00+02:00","2026-11-20":"2026-11-19T23:25:00+02:00","2026-11-21":"2026-11-20T23:25:00+02:00","2026-11-22":"2026-11-21T23:25:00+02:00","2026-11-23":"2026-11-22T23:26:00+02:00","2026-11-24":"2026-11-23T23:26:00+02:00","2026-11-25":"2026-11-24T23:26:00+02:00","2026-11-26":"2026-11-25T23:26:00+02:00","2026-11-27":"2026-11-26T23:27:00+02:00","2026-11-28":"2026-11-27T23:27:00+02:00","2026-11-29":"2026-11-28T23:27:00+02:00","2026-11-30":"2026-11-29T23:28:00+02:00","2026-12-01":"2026-11-30T23:28:00+02:00","2026-12-02":"2026-12-01T23:28:00+02:00","2026-12-03":"2026-12-02T23:29:00+02:00","2026-12-04":"2026-12-03T23:29:00+02:00","2026-12-05":"2026-12-04T23:30:00+02:00","2026-12-06":"2026-12-05T23:30:00+02:00","2026-12-07":"2026-12-06T23:30:00+02:00","2026-12-08":"2026-12-07T23:31:00+02:00","2026-12-09":"2026-12-08T23:31:00+02:00","2026-12-10":"2026-12-09T23:32:00+02:00"},"alosBaalHatanya":{"2026-09-23":"2026-09-23T05:12:00+03:00","2026-09-24":"2026-09-24T05:12:00+03:00","2026-09-25":"2026-09-25T05:13:00+03:00","2026-09-26":"2026-09-26T05:14:00+03:00","2026-09-27":"2026-09-27T05:14:00+03:00","2026-09-28":"2026-09-28T05:15:00+03:00","2026-09-29":"2026-09-29T05:16:00+03:00","2026-09-30":"2026-09-30T05:16:00+03:00","2026-10-01":"2026-10-01T05:17:00+03:00","2026-10-02":"2026-10-02T05:18:00+03:00","2026-10-03":"2026-10-03T05:18:00+03:00","2026-10-04":"2026-10-04T05:19:00+03:00","2026-10-05":"2026-10-05T05:20:00+03:00","2026-10-06":"2026-10-06T05:20:00+03:00","2026-10-07":"2026-10-07T05:21:00+03:00","2026-10-08":"2026-10-08T05:22:00+03:00","2026-10-09":"2026-10-09T05:22:00+03:00","2026-10-10":"2026-10-10T05:23:00+03:00","2026-10-11":"2026-10-11T05:24:00+03:00","2026-10-12":"2026-10-12T05:24:00+03:00","2026-10-13":"2026-10-13T05:25:00+03:00","2026-10-14":"2026-10-14T05:26:00+03:00","2026-10-15":"2026-10-15T05:26:00+03:00","2026-10-16":"2026-10-16T05:27:00+03:00","2026-10-17":"2026-10-17T05:28:00+03:00","2026-10-18":"2026-10-18T05:28:00+03:00","2026-10-19":"2026-10-19T05:29:00+03:00","2026-10-20":"2026-10-20T05:30:00+03:00","2026-10-21":"2026-10-21T05:30:00+03:00","2026-10-22":"2026-10-22T05:31:00+03:00","2026-10-23":"2026-10-23T05:32:00+03:00","2026-10-24":"2026-10-24T05:32:00+03:00","2026-10-25":"2026-10-25T04:33:00+02:00","2026-10-26":"2026-10-26T04:34:00+02:00","2026-10-27":"2026-10-27T04:34:00+02:00","2026-10-28":"2026-10-28T04:35:00+02:00","2026-10-29":"2026-10-29T04:36:00+02:00","2026-10-30":"2026-10-30T04:36:00+02:00","2026-10-31":"2026-10-31T04:37:00+02:00","2026-11-01":"2026-11-01T04:38:00+02:00","2026-11-02":"2026-11-02T04:39:00+02:00","2026-11-03":"2026-11-03T04:39:00+02:00","2026-11-04":"2026-11-04T04:40:00+02:00","2026-11-05":"2026-11-05T04:41:00+02:00","2026-11-06":"2026-11-06T04:42:00+02:00","2026-11-07":"2026-11-07T04:42:00+02:00","2026-11-08":"2026-11-08T04:43:00+02:00","2026-11-09":"2026-11-09T04:44:00+02:00","2026-11-10":"2026-11-10T04:44:00+02:00","2026-11-11":"2026-11-11T04:45:00+02:00","2026-11-12":"2026-11-12T04:46:00+02:00","2026-11-13":"2026-11-13T04:47:00+02:00","2026-11-14":"2026-11-14T04:47:00+02:00","2026-11-15":"2026-11-15T04:48:00+02:00","2026-11-16":"2026-11-16T04:49:00+02:00","2026-11-17":"2026-11-17T04:50:00+02:00","2026-11-18":"2026-11-18T04:51:00+02:00","2026-11-19":"2026-11-19T04:51:00+02:00","2026-11-20":"2026-11-20T04:52:00+02:00","2026-11-21":"2026-11-21T04:53:00+02:00","2026-11-22":"2026-11-22T04:54:00+02:00","2026-11-23":"2026-11-23T04:54:00+02:00","2026-11-24":"2026-11-24T04:55:00+02:00","2026-11-25":"2026-11-25T04:56:00+02:00","2026-11-26":"2026-11-26T04:57:00+02:00","2026-11-27":"2026-11-27T04:57:00+02:00","2026-11-28":"2026-11-28T04:58:00+02:00","2026-11-29":"2026-11-29T04:59:00+02:00","2026-11-30":"2026-11-30T05:00:00+02:00","2026-12-01":"2026-12-01T05:00:00+02:00","2026-12-02":"2026-12-02T05:01:00+02:00","2026-12-03":"2026-12-03T05:02:00+02:00","2026-12-04":"2026-12-04T05:02:00+02:00","2026-12-05":"2026-12-05T05:03:00+02:00","2026-12-06":"2026-12-06T05:04:00+02:00","2026-12-07":"2026-12-07T05:05:00+02:00","2026-12-08":"2026-12-08T05:05:00+02:00","2026-12-09":"2026-12-09T05:06:00+02:00","2026-12-10":"2026-12-10T05:07:00+02:00"},"alotHaShachar":{"2026-09-23":"2026-09-23T05:16:00+03:00","2026-09-24":"2026-09-24T05:16:00+03:00","2026-09-25":"2026-09-25T05:17:00+03:00","2026-09-26":"2026-09-26T05:18:00+03:00","2026-09-27":"2026-09-27T05:18:00+03:00","2026-09-28":"2026-09-28T05:19:00+03:00","2026-09-29":"2026-09-29T05:20:00+03:00","2026-09-30":"2026-09-30T05:20:00+03:00","2026-10-01":"2026-10-01T05:21:00+03:00","2026-10-02":"2026-10-02T05:22:00+03:00","2026-10-03":"2026-10-03T05:22:00+03:00","2026-10-04":"2026-10-04T05:23:00+03:00","2026-10-05":"2026-10-05T05:23:00+03:00","2026-10-06":"2026-10-06T05:24:00+03:00","2026-10-07":"2026-10-07T05:25:00+03:00","2026-10-08":"2026-10-08T05:25:00+03:00","2026-10-09":"2026-10-09T05:26:00+03:00","2026-10-10":"2026-10-10T05:27:00+03:00","2026-10-11":"2026-10-11T05:27:00+03:00","2026-10-12":"2026-10-12T05:28:00+03:00","2026-10-13":"2026-10-13T05:29:00+03:00","2026-10-14":"2026-10-14T05:29:00+03:00","2026-10-15":"2026-10-15T05:30:00+03:00","2026-10-16":"2026-10-16T05:31:00+03:00","2026-10-17":"2026-10-17T05:31:00+03:00","2026-10-18":"2026-10-18T05:32:00+03:00","2026-10-19":"2026-10-19T05:33:00+03:00","2026-10-20":"2026-10-20T05:33:00+03:00","2026-10-21":"2026-10-21T05:34:00+03:00","2026-10-22":"2026-10-22T05:35:00+03:00","2026-10-23":"2026-10-23T05:35:00+03:00","2026-10-24":"2026-10-24T05:36:00+03:00","2026-10-25":"2026-10-25T04:37:00+02:00","2026-10-26":"2026-10-26T04:37:00+02:00","2026-10-27":"2026-10-27T04:38:00+02:00","2026-10-28":"2026-10-28T04:39:00+02:00","2026-10-29":"2026-10-29T04:40:00+02:00","2026-10-30":"2026-10-30T04:40:00+02:00","2026-10-31":"2026-10-31T04:41:00+02:00","2026-11-01":"2026-11-01T04:42:00+02:00","2026-11-02":"2026-11-02T04:42:00+02:00","2026-11-03":"2026-11-03T04:43:00+02:00","2026-11-04":"2026-11-04T04:44:00+02:00","2026-11-05":"2026-11-05T04:45:00+02:00","2026-11-06":"2026-11-06T04:45:00+02:00","2026-11-07":"2026-11-07T04:46:00+02:00","2026-11-08":"2026-11-08T04:47:00+02:00","2026-11-09":"2026-11-09T04:48:00+02:00","2026-11-10":"2026-11-10T04:48:00+02:00","2026-11-11":"2026-11-11T04:49:00+02:00","2026-11-12":"2026-11-12T04:50:00+02:00","2026-11-13":"2026-11-13T04:51:00+02:00","2026-11-14":"2026-11-14T04:51:00+02:00","2026-11-15":"2026-11-15T04:52:00+02:00","2026-11-16":"2026-11-16T04:53:00+02:00","2026-11-17":"2026-11-17T04:54:00+02:00","2026-11-18":"2026-11-18T04:54:00+02:00","2026-11-19":"2026-11-19T04:55:00+02:00","2026-11-20":"2026-11-20T04:56:00+02:00","2026-11-21":"2026-11-21T04:57:00+02:00","2026-11-22":"2026-11-22T04:57:00+02:00","2026-11-23":"2026-11-23T04:58:00+02:00","2026-11-24":"2026-11-24T04:59:00+02:00","2026-11-25":"2026-11-25T05:00:00+02:00","2026-11-26":"2026-11-26T05:00:00+02:00","2026-11-27":"2026-11-27T05:01:00+02:00","2026-11-28":"2026-11-28T05:02:00+02:00","2026-11-29":"2026-11-29T05:03:00+02:00","2026-11-30":"2026-11-30T05:03:00+02:00","2026-12-01":"2026-12-01T05:04:00+02:00","2026-12-02":"2026-12-02T05:05:00+02:00","2026-12-03":"2026-12-03T05:06:00+02:00","2026-12-04":"2026-12-04T05:06:00+02:00","2026-12-05":"2026-12-05T05:07:00+02:00","2026-12-06":"2026-12-06T05:08:00+02:00","2026-12-07":"2026-12-07T05:08:00+02:00","2026-12-08":"2026-12-08T05:09:00+02:00","2026-12-09":"2026-12-09T05:10:00+02:00","2026-12-10":"2026-12-10T05:11:00+02:00"},"misheyakir":{"2026-09-23":"2026-09-23T05:38:00+03:00","2026-09-24":"2026-09-24T05:38:00+03:00","2026-09-25":"2026-09-25T05:39:00+03:00","2026-09-26":"2026-09-26T05:39:00+03:00","2026-09-27":"2026-09-27T05:40:00+03:00","2026-09-28":"2026-09-28T05:41:00+03:00","2026-09-29":"2026-09-29T05:41:00+03:00","2026-09-30":"2026-09-30T05:42:00+03:00","2026-10-01":"2026-10-01T05:43:00+03:00","2026-10-02":"2026-10-02T05:43:00+03:00","2026-10-03":"2026-10-03T05:44:00+03:00","2026-10-04":"2026-10-04T05:45:00+03:00","2026-10-05":"2026-10-05T05:45:00+03:00","2026-10-06":"2026-10-06T05:46:00+03:00","2026-10-07":"2026-10-07T05:46:00+03:00","2026-10-08":"2026-10-08T05:47:00+03:00","2026-10-09":"2026-10-09T05:48:00+03:00","2026-10-10":"2026-10-10T05:48:00+03:00","2026-10-11":"2026-10-11T05:49:00+03:00","2026-10-12":"2026-10-12T05:50:00+03:00","2026-10-13":"2026-10-13T05:50:00+03:00","2026-10-14":"2026-10-14T05:51:00+03:00","2026-10-15":"2026-10-15T05:52:00+03:00","2026-10-16":"2026-10-16T05:52:00+03:00","2026-10-17":"2026-10-17T05:53:00+03:00","2026-10-18":"2026-10-18T05:54:00+03:00","2026-10-19":"2026-10-19T05:54:00+03:00","2026-10-20":"2026-10-20T05:55:00+03:00","2026-10-21":"2026-10-21T05:56:00+03:00","2026-10-22":"2026-10-22T05:56:00+03:00","2026-10-23":"2026-10-23T05:57:00+03:00","2026-10-24":"2026-10-24T05:58:00+03:00","2026-10-25":"2026-10-25T04:59:00+02:00","2026-10-26":"2026-10-26T04:59:00+02:00","2026-10-27":"2026-10-27T05:00:00+02:00","2026-10-28":"2026-10-28T05:01:00+02:00","2026-10-29":"2026-10-29T05:01:00+02:00","2026-10-30":"2026-10-30T05:02:00+02:00","2026-10-31":"2026-10-31T05:03:00+02:00","2026-11-01":"2026-11-01T05:04:00+02:00","2026-11-02":"2026-11-02T05:04:00+02:00","2026-11-03":"2026-11-03T05:05:00+02:00","2026-11-04":"2026-11-04T05:06:00+02:00","2026-11-05":"2026-11-05T05:07:00+02:00","2026-11-06":"2026-11-06T05:07:00+02:00","2026-11-07":"2026-11-07T05:08:00+02:00","2026-11-08":"2026-11-08T05:09:00+02:00","2026-11-09":"2026-11-09T05:10:00+02:00","2026-11-10":"2026-11-10T05:10:00+02:00","2026-11-11":"2026-11-11T05:11:00+02:00","2026-11-12":"2026-11-12T05:12:00+02:00","2026-11-13":"2026-11-13T05:13:00+02:00","2026-11-14":"2026-11-14T05:14:00+02:00","2026-11-15":"2026-11-15T05:14:00+02:00","2026-11-16":"2026-11-16T05:15:00+02:00","2026-11-17":"2026-11-17T05:16:00+02:00","2026-11-18":"2026-11-18T05:17:00+02:00","2026-11-19":"2026-11-19T05:18:00+02:00","2026-11-20":"2026-11-20T05:18:00+02:00","2026-11-21":"2026-11-21T05:19:00+02:00","2026-11-22":"2026-11-22T05:20:00+02:00","2026-11-23":"2026-11-23T05:21:00+02:00","2026-11-24":"2026-11-24T05:21:00+02:00","2026-11-25":"2026-11-25T05:22:00+02:00","2026-11-26":"2026-11-26T05:23:00+02:00","2026-11-27":"2026-11-27T05:24:00+02:00","2026-11-28":"2026-11-28T05:25:00+02:00","2026-11-29":"2026-11-29T05:25:00+02:00","2026-11-30":"2026-11-30T05:26:00+02:00","2026-12-01":"2026-12-01T05:27:00+02:00","2026-12-02":"2026-12-02T05:28:00+02:00","2026-12-03":"2026-12-03T05:28:00+02:00","2026-12-04":"2026-12-04T05:29:00+02:00","2026-12-05":"2026-12-05T05:30:00+02:00","2026-12-06":"2026-12-06T05:31:00+02:00","2026-12-07":"2026-12-07T05:31:00+02:00","2026-12-08":"2026-12-08T05:32:00+02:00","2026-12-09":"2026-12-09T05:33:00+02:00","2026-12-10":"2026-12-10T05:33:00+02:00"},"misheyakirMachmir":{"2026-09-23":"2026-09-23T05:44:00+03:00","2026-09-24":"2026-09-24T05:44:00+03:00","2026-09-25":"2026-09-25T05:45:00+03:00","2026-09-26":"2026-09-26T05:46:00+03:00","2026-09-27":"2026-09-27T05:46:00+03:00","2026-09-28":"2026-09-28T05:47:00+03:00","2026-09-29":"2026-09-29T05:47:00+03:00","2026-09-30":"2026-09-30T05:48:00+03:00","2026-10-01":"2026-10-01T05:49:00+03:00","2026-10-02":"2026-10-02T05:49:00+03:00","2026-10-03":"2026-10-03T05:50:00+03:00","2026-10-04":"2026-10-04T05:51:00+03:00","2026-10-05":"2026-10-05T05:51:00+03:00","2026-10-06":"2026-10-06T05:52:00+03:00","2026-10-07":"2026-10-07T05:53:00+03:00","2026-10-08":"2026-10-08T05:53:00+03:00","2026-10-09":"2026-10-09T05:54:00+03:00","2026-10-10":"2026-10-10T05:55:00+03:00","2026-10-11":"2026-10-11T05:55:00+03:00","2026-10-12":"2026-10-12T05:56:00+03:00","2026-10-13":"2026-10-13T05:56:00+03:00","2026-10-14":"2026-10-14T05:57:00+03:00","2026-10-15":"2026-10-15T05:58:00+03:00","2026-10-16":"2026-10-16T05:58:00+03:00","2026-10-17":"2026-10-17T05:59:00+03:00","2026-10-18":"2026-10-18T06:00:00+03:00","2026-10-19":"2026-10-19T06:01:00+03:00","2026-10-20":"2026-10-20T06:01:00+03:00","2026-10-21":"2026-10-21T06:02:00+03:00","2026-10-22":"2026-10-22T06:03:00+03:00","2026-10-23":"2026-10-23T06:03:00+03:00","2026-10-24":"2026-10-24T06:04:00+03:00","2026-10-25":"2026-10-25T05:05:00+02:00","2026-10-26":"2026-10-26T05:05:00+02:00","2026-10-27":"2026-10-27T05:06:00+02:00","2026-10-28":"2026-10-28T05:07:00+02:00","2026-10-29":"2026-10-29T05:08:00+02:00","2026-10-30":"2026-10-30T05:08:00+02:00","2026-10-31":"2026-10-31T05:09:00+02:00","2026-11-01":"2026-11-01T05:10:00+02:00","2026-11-02":"2026-11-02T05:11:00+02:00","2026-11-03":"2026-11-03T05:11:00+02:00","2026-11-04":"2026-11-04T05:12:00+02:00","2026-11-05":"2026-11-05T05:13:00+02:00","2026-11-06":"2026-11-06T05:14:00+02:00","2026-11-07":"2026-11-07T05:14:00+02:00","2026-11-08":"2026-11-08T05:15:00+02:00","2026-11-09":"2026-11-09T05:16:00+02:00","2026-11-10":"2026-11-10T05:17:00+02:00","2026-11-11":"2026-11-11T05:18:00+02:00","2026-11-12":"2026-11-12T05:18:00+02:00","2026-11-13":"2026-11-13T05:19:00+02:00","2026-11-14":"2026-11-14T05:20:00+02:00","2026-11-15":"2026-11-15T05:21:00+02:00","2026-11-16":"2026-11-16T05:21:00+02:00","2026-11-17":"2026-11-17T05:22:00+02:00","2026-11-18":"2026-11-18T05:23:00+02:00","2026-11-19":"2026-11-19T05:24:00+02:00","2026-11-20":"2026-11-20T05:25:00+02:00","2026-11-21":"2026-11-21T05:25:00+02:00","2026-11-22":"2026-11-22T05:26:00+02:00","2026-11-23":"2026-11-23T05:27:00+02:00","2026-11-24":"2026-11-24T05:28:00+02:00","2026-11-25":"2026-11-25T05:29:00+02:00","2026-11-26":"2026-11-26T05:29:00+02:00","2026-11-27":"2026-11-27T05:30:00+02:00","2026-11-28":"2026-11-28T05:31:00+02:00","2026-11-29":"2026-11-29T05:32:00+02:00","2026-11-30":"2026-11-30T05:33:00+02:00","2026-12-01":"2026-12-01T05:33:00+02:00","2026-12-02":"2026-12-02T05:34:00+02:00","2026-12-03":"2026-12-03T05:35:00+02:00","2026-12-04":"2026-12-04T05:36:00+02:00","2026-12-05":"2026-12-05T05:36:00+02:00","2026-12-06":"2026-12-06T05:37:00+02:00","2026-12-07":"2026-12-07T05:38:00+02:00","2026-12-08":"2026-12-08T05:39:00+02:00","2026-12-09":"2026-12-09T05:39:00+02:00","2026-12-10":"2026-12-10T05:40:00+02:00"},"dawn":{"2026-09-23":"2026-09-23T06:04:00+03:00","2026-09-24":"2026-09-24T06:04:00+03:00","2026-09-25":"2026-09-25T06:05:00+03:00","2026-09-26":"2026-09-26T06:05:00+03:00","2026-09-27":"2026-09-27T06:06:00+03:00","2026-09-28":"2026-09-28T06:07:00+03:00","2026-09-29":"2026-09-29T06:07:00+03:00","2026-09-30":"2026-09-30T06:08:00+03:00","2026-10-01":"2026-10-01T06:09:00+03:00","2026-10-02":"2026-10-02T06:09:00+03:00","2026-10-03":"2026-10-03T06:10:00+03:00","2026-10-04":"2026-10-04T06:10:00+03:00","2026-10-05":"2026-10-05T06:11:00+03:00","2026-10-06":"2026-10-06T06:12:00+03:00","2026-10-07":"2026-10-07T06:12:00+03:00","2026-10-08":"2026-10-08T06:13:00+03:00","2026-10-09":"2026-10-09T06:14:00+03:00","2026-10-10":"2026-10-10T06:14:00+03:00","2026-10-11":"2026-10-11T06:15:00+03:00","2026-10-12":"2026-10-12T06:16:00+03:00","2026-10-13":"2026-10-13T06:16:00+03:00","2026-10-14":"2026-10-14T06:17:00+03:00","2026-10-15":"2026-10-15T06:18:00+03:00","2026-10-16":"2026-10-16T06:18:00+03:00","2026-10-17":"2026-10-17T06:19:00+03:00","2026-10-18":"2026-10-18T06:20:00+03:00","2026-10-19":"2026-10-19T06:20:00+03:00","2026-10-20":"2026-10-20T06:21:00+03:00","2026-10-21":"2026-10-21T06:22:00+03:00","2026-10-22":"2026-10-22T06:23:00+03:00","2026-10-23":"2026-10-23T06:23:00+03:00","2026-10-24":"2026-10-24T06:24:00+03:00","2026-10-25":"2026-10-25T05:25:00+02:00","2026-10-26":"2026-10-26T05:25:00+02:00","2026-10-27":"2026-10-27T05:26:00+02:00","2026-10-28":"2026-10-28T05:27:00+02:00","2026-10-29":"2026-10-29T05:28:00+02:00","2026-10-30":"2026-10-30T05:29:00+02:00","2026-10-31":"2026-10-31T05:29:00+02:00","2026-11-01":"2026-11-01T05:30:00+02:00","2026-11-02":"2026-11-02T05:31:00+02:00","2026-11-03":"2026-11-03T05:32:00+02:00","2026-11-04":"2026-11-04T05:32:00+02:00","2026-11-05":"2026-11-05T05:33:00+02:00","2026-11-06":"2026-11-06T05:34:00+02:00","2026-11-07":"2026-11-07T05:35:00+02:00","2026-11-08":"2026-11-08T05:36:00+02:00","2026-11-09":"2026-11-09T05:36:00+02:00","2026-11-10":"2026-11-10T05:37:00+02:00","2026-11-11":"2026-11-11T05:38:00+02:00","2026-11-12":"2026-11-12T05:39:00+02:00","2026-11-13":"2026-11-13T05:40:00+02:00","2026-11-14":"2026-11-14T05:41:00+02:00","2026-11-15":"2026-11-15T05:41:00+02:00","2026-11-16":"2026-11-16T05:42:00+02:00","2026-11-17":"2026-11-17T05:43:00+02:00","2026-11-18":"2026-11-18T05:44:00+02:00","2026-11-19":"2026-11-19T05:45:00+02:00","2026-11-20":"2026-11-20T05:45:00+02:00","2026-11-21":"2026-11-21T05:46:00+02:00","2026-11-22":"2026-11-22T05:47:00+02:00","2026-11-23":"2026-11-23T05:48:00+02:00","2026-11-24":"2026-11-24T05:49:00+02:00","2026-11-25":"2026-11-25T05:50:00+02:00","2026-11-26":"2026-11-26T05:50:00+02:00","2026-11-27":"2026-11-27T05:51:00+02:00","2026-11-28":"2026-11-28T05:52:00+02:00","2026-11-29":"2026-11-29T05:53:00+02:00","2026-11-30":"2026-11-30T05:54:00+02:00","2026-12-01":"2026-12-01T05:54:00+02:00","2026-12-02":"2026-12-02T05:55:00+02:00","2026-12-03":"2026-12-03T05:56:00+02:00","2026-12-04":"2026-12-04T05:57:00+02:00","2026-12-05":"2026-12-05T05:58:00+02:00","2026-12-06":"2026-12-06T05:58:00+02:00","2026-12-07":"2026-12-07T05:59:00+02:00","2026-12-08":"2026-12-08T06:00:00+02:00","2026-12-09":"2026-12-09T06:01:00+02:00","2026-12-10":"2026-12-10T06:01:00+02:00"},"sunrise":{"2026-09-23":"2026-09-23T06:28:00+03:00","2026-09-24":"2026-09-24T06:28:00+03:00","2026-09-25":"2026-09-25T06:29:00+03:00","2026-09-26":"2026-09-26T06:30:00+03:00","2026-09-27":"2026-09-27T06:30:00+03:00","2026-09-28":"2026-09-28T06:31:00+03:00","2026-09-29":"2026-09-29T06:32:00+03:00","2026-09-30":"2026-09-30T06:32:00+03:00","2026-10-01":"2026-10-01T06:33:00+03:00","2026-10-02":"2026-10-02T06:33:00+03:00","2026-10-03":"2026-10-03T06:34:00+03:00","2026-10-04":"2026-10-04T06:35:00+03:00","2026-10-05":"2026-10-05T06:35:00+03:00","2026-10-06":"2026-10-06T06:36:00+03:00","2026-10-07":"2026-10-07T06:37:00+03:00","2026-10-08":"2026-10-08T06:37:00+03:00","2026-10-09":"2026-10-09T06:38:00+03:00","2026-10-10":"2026-10-10T06:39:00+03:00","2026-10-11":"2026-10-11T06:39:00+03:00","2026-10-12":"2026-10-12T06:40:00+03:00","2026-10-13":"2026-10-13T06:41:00+03:00","2026-10-14":"2026-10-14T06:42:00+03:00","2026-10-15":"2026-10-15T06:42:00+03:00","2026-10-16":"2026-10-16T06:43:00+03:00","2026-10-17":"2026-10-17T06:44:00+03:00","2026-10-18":"2026-10-18T06:44:00+03:00","2026-10-19":"2026-10-19T06:45:00+03:00","2026-10-20":"2026-10-20T06:46:00+03:00","2026-10-21":"2026-10-21T06:47:00+03:00","2026-10-22":"2026-10-22T06:47:00+03:00","2026-10-23":"2026-10-23T06:48:00+03:00","2026-10-24":"2026-10-24T06:49:00+03:00","2026-10-25":"2026-10-25T05:50:00+02:00","2026-10-26":"2026-10-26T05:50:00+02:00","2026-10-27":"2026-10-27T05:51:00+02:00","2026-10-28":"2026-10-28T05:52:00+02:00","2026-10-29":"2026-10-29T05:53:00+02:00","2026-10-30":"2026-10-30T05:54:00+02:00","2026-10-31":"2026-10-31T05:54:00+02:00","2026-11-01":"2026-11-01T05:55:00+02:00","2026-11-02":"2026-11-02T05:56:00+02:00","2026-11-03":"2026-11-03T05:57:00+02:00","2026-11-04":"2026-11-04T05:58:00+02:00","2026-11-05":"2026-11-05T05:59:00+02:00","2026-11-06":"2026-11-06T05:59:00+02:00","2026-11-07":"2026-11-07T06:00:00+02:00","2026-11-08":"2026-11-08T06:01:00+02:00","2026-11-09":"2026-11-09T06:02:00+02:00","2026-11-10":"2026-11-10T06:03:00+02:00","2026-11-11":"2026-11-11T06:04:00+02:00","2026-11-12":"2026-11-12T06:05:00+02:00","2026-11-13":"2026-11-13T06:05:00+02:00","2026-11-14":"2026-11-14T06:06:00+02:00","2026-11-15":"2026-11-15T06:07:00+02:00","2026-11-16":"2026-11-16T06:08:00+02:00","2026-11-17":"2026-11-17T06:09:00+02:00","2026-11-18":"2026-11-18T06:10:00+02:00","2026-11-19":"2026-11-19T06:11:00+02:00","2026-11-20":"2026-11-20T06:12:00+02:00","2026-11-21":"2026-11-21T06:12:00+02:00","2026-11-22":"2026-11-22T06:13:00+02:00","2026-11-23":"2026-11-23T06:14:00+02:00","2026-11-24":"2026-11-24T06:15:00+02:00","2026-11-25":"2026-11-25T06:16:00+02:00","2026-11-26":"2026-11-26T06:17:00+02:00","2026-11-27":"2026-11-27T06:18:00+02:00","2026-11-28":"2026-11-28T06:19:00+02:00","2026-11-29":"2026-11-29T06:19:00+02:00","2026-11-30":"2026-11-30T06:20:00+02:00","2026-12-01":"2026-12-01T06:21:00+02:00","2026-12-02":"2026-12-02T06:22:00+02:00","2026-12-03":"2026-12-03T06:23:00+02:00","2026-12-04":"2026-12-04T06:23:00+02:00","2026-12-05":"2026-12-05T06:24:00+02:00","2026-12-06":"2026-12-06T06:25:00+02:00","2026-12-07":"2026-12-07T06:26:00+02:00","2026-12-08":"2026-12-08T06:27:00+02:00","2026-12-09":"2026-12-09T06:27:00+02:00","2026-12-10":"2026-12-10T06:28:00+02:00"},"seaLevelSunrise":{},"sofZmanShmaMGA19Point8":{"2026-09-23":"2026-09-23T08:45:00+03:00","2026-09-24":"2026-09-24T08:45:00+03:00","2026-09-25":"2026-09-25T08:45:00+03:00","2026-09-26":"2026-09-26T08:45:00+03:00","2026-09-27":"2026-09-27T08:45:00+03:00","2026-09-28":"2026-09-28T08:45:00+03:00","2026-09-29":"2026-09-29T08:46:00+03:00","2026-09-30":"2026-09-30T08:46:00+03:00","2026-10-01":"2026-10-01T08:46:00+03:00","2026-10-02":"2026-10-02T08:46:00+03:00","2026-10-03":"2026-10-03T08:46:00+03:00","2026-10-04":"2026-10-04T08:46:00+03:00","2026-10-05":"2026-10-05T08:47:00+03:00","2026-10-06":"2026-10-06T08:47:00+03:00","2026-10-07":"2026-10-07T08:47:00+03:00","2026-10-08":"2026-10-08T08:47:00+03:00","2026-10-09":"2026-10-09T08:47:00+03:00","2026-10-10":"2026-10-10T08:48:00+03:00","2026-10-11":"2026-10-11T08:48:00+03:00","2026-10-12":"2026-10-12T08:48:00+03:00","2026-10-13":"2026-10-13T08:48:00+03:00","2026-10-14":"2026-10-14T08:48:00+03:00","2026-10-15":"2026-10-15T08:49:00+03:00","2026-10-16":"2026-10-16T08:49:00+03:00","2026-10-17":"2026-10-17T08:49:00+03:00","2026-10-18":"2026-10-18T08:49:00+03:00","2026-10-19":"2026-10-19T08:50:00+03:00","2026-10-20":"2026-10-20T08:50:00+03:00","2026-10-21":"2026-10-21T08:50:00+03:00","2026-10-22":"2026-10-22T08:50:00+03:00","2026-10-23":"2026-10-23T08:51:00+03:00","2026-10-24":"2026-10-24T08:51:00+03:00","2026-10-25":"2026-10-25T07:51:00+02:00","2026-10-26":"2026-10-26T07:51:00+02:00","2026-10-27":"2026-10-27T07:52:00+02:00","2026-10-28":"2026-10-28T07:52:00+02:00","2026-10-29":"2026-10-29T07:52:00+02:00","2026-10-30":"2026-10-30T07:53:00+02:00","2026-10-31":"2026-10-31T07:53:00+02:00","2026-11-01":"2026-11-01T07:53:00+02:00","2026-11-02":"2026-11-02T07:54:00+02:00","2026-11-03":"2026-11-03T07:54:00+02:00","2026-11-04":"2026-11-04T07:54:00+02:00","2026-11-05":"2026-11-05T07:55:00+02:00","2026-11-06":"2026-11-06T07:55:00+02:00","2026-11-07":"2026-11-07T07:56:00+02:00","2026-11-08":"2026-11-08T07:56:00+02:00","2026-11-09":"2026-11-09T07:56:00+02:00","2026-11-10":"2026-11-10T07:57:00+02:00","2026-11-11":"2026-11-11T07:57:00+02:00","2026-11-12":"2026-11-12T07:58:00+02:00","2026-11-13":"2026-11-13T07:58:00+02:00","2026-11-14":"2026-11-14T07:58:00+02:00","2026-11-15":"2026-11-15T07:59:00+02:00","2026-11-16":"2026-11-16T07:59:00+02:00","2026-11-17":"2026-11-17T08:00:00+02:00","2026-11-18":"2026-11-18T08:00:00+02:00","2026-11-19":"2026-11-19T08:01:00+02:00","2026-11-20":"2026-11-20T08:01:00+02:00","2026-11-21":"2026-11-21T08:02:00+02:00","2026-11-22":"2026-11-22T08:02:00+02:00","2026-11-23":"2026-11-23T08:03:00+02:00","2026-11-24":"2026-11-24T08:03:00+02:00","2026-11-25":"2026-11-25T08:04:00+02:00","2026-11-26":"2026-11-26T08:04:00+02:00","2026-11-27":"2026-11-27T08:05:00+02:00","2026-11-28":"2026-11-28T08:05:00+02:00","2026-11-29":"2026-11-29T08:06:00+02:00","2026-11-30":"2026-11-30T08:07:00+02:00","2026-12-01":"2026-12-01T08:07:00+02:00","2026-12-02":"2026-12-02T08:08:00+02:00","2026-12-03":"2026-12-03T08:08:00+02:00","2026-12-04":"2026-12-04T08:09:00+02:00","2026-12-05":"2026-12-05T08:09:00+02:00","2026-12-06":"2026-12-06T08:10:00+02:00","2026-12-07":"2026-12-07T08:10:00+02:00","2026-12-08":"2026-12-08T08:11:00+02:00","2026-12-09":"2026-12-09T08:12:00+02:00","2026-12-10":"2026-12-10T08:12:00+02:00"},"sofZmanShmaMGA16Point1":{"2026-09-23":"2026-09-23T08:53:00+03:00","2026-09-24":"2026-09-24T08:54:00+03:00","2026-09-25":"2026-09-25T08:54:00+03:00","2026-09-26":"2026-09-26T08:54:00+03:00","2026-09-27":"2026-09-27T08:54:00+03:00","2026-09-28":"2026-09-28T08:54:00+03:00","2026-09-29":"2026-09-29T08:54:00+03:00","2026-09-30":"2026-09-30T08:55:00+03:00","2026-10-01":"2026-10-01T08:55:00+03:00","2026-10-02":"2026-10-02T08:55:00+03:00","2026-10-03":"2026-10-03T08:55:00+03:00","2026-10-04":"2026-10-04T08:55:00+03:00","2026-10-05":"2026-10-05T08:55:00+03:00","2026-10-06":"2026-10-06T08:56:00+03:00","2026-10-07":"2026-10-07T08:56:00+03:00","2026-10-08":"2026-10-08T08:56:00+03:00","2026-10-09":"2026-10-09T08:56:00+03:00","2026-10-10":"2026-10-10T08:56:00+03:00","2026-10-11":"2026-10-11T08:56:00+03:00","2026-10-12":"2026-10-12T08:57:00+03:00","2026-10-13":"2026-10-13T08:57:00+03:00","2026-10-14":"2026-10-14T08:57:00+03:00","2026-10-15":"2026-10-15T08:57:00+03:00","2026-10-16":"2026-10-16T08:58:00+03:00","2026-10-17":"2026-10-17T08:58:00+03:00","2026-10-18":"2026-10-18T08:58:00+03:00","2026-10-19":"2026-10-19T08:58:00+03:00","2026-10-20":"2026-10-20T08:59:00+03:00","2026-10-21":"2026-10-21T08:59:00+03:00","2026-10-22":"2026-10-22T08:59:00+03:00","2026-10-23":"2026-10-23T08:59:00+03:00","2026-10-24":"2026-10-24T09:00:00+03:00","2026-10-25":"2026-10-25T08:00:00+02:00","2026-10-26":"2026-10-26T08:00:00+02:00","2026-10-27":"2026-10-27T08:00:00+02:00","2026-10-28":"2026-10-28T08:01:00+02:00","2026-10-29":"2026-10-29T08:01:00+02:00","2026-10-30":"2026-10-30T08:01:00+02:00","2026-10-31":"2026-10-31T08:02:00+02:00","2026-11-01":"2026-11-01T08:02:00+02:00","2026-11-02":"2026-11-02T08:02:00+02:00","2026-11-03":"2026-11-03T08:03:00+02:00","2026-11-04":"2026-11-04T08:03:00+02:00","2026-11-05":"2026-11-05T08:04:00+02:00","2026-11-06":"2026-11-06T08:04:00+02:00","2026-11-07":"2026-11-07T08:04:00+02:00","2026-11-08":"2026-11-08T08:05:00+02:00","2026-11-09":"2026-11-09T08:05:00+02:00","2026-11-10":"2026-11-10T08:06:00+02:00","2026-11-11":"2026-11-11T08:06:00+02:00","2026-11-12":"2026-11-12T08:06:00+02:00","2026-11-13":"2026-11-13T08:07:00+02:00","2026-11-14":"2026-11-14T08:07:00+02:00","2026-11-15":"2026-11-15T08:08:00+02:00","2026-11-16":"2026-11-16T08:08:00+02:00","2026-11-17":"2026-11-17T08:09:00+02:00","2026-11-18":"2026-11-18T08:09:00+02:00","2026-11-19":"2026-11-19T08:10:00+02:00","2026-11-20":"2026-11-20T08:10:00+02:00","2026-11-21":"2026-11-21T08:11:00+02:00","2026-11-22":"2026-11-22T08:11:00+02:00","2026-11-23":"2026-11-23T08:12:00+02:00","2026-11-24":"2026-11-24T08:12:00+02:00","2026-11-25":"2026-11-25T08:13:00+02:00","2026-11-26":"2026-11-26T08:13:00+02:00","2026-11-27":"2026-11-27T08:14:00+02:00","2026-11-28":"2026-11-28T08:14:00+02:00","2026-11-29":"2026-11-29T08:15:00+02:00","2026-11-30":"2026-11-30T08:16:00+02:00","2026-12-01":"2026-12-01T08:16:00+02:00","2026-12-02":"2026-12-02T08:17:00+02:00","2026-12-03":"2026-12-03T08:17:00+02:00","2026-12-04":"2026-12-04T08:18:00+02:00","2026-12-05":"2026-12-05T08:18:00+02:00","2026-12-06":"2026-12-06T08:19:00+02:00","2026-12-07":"2026-12-07T08:19:00+02:00","2026-12-08":"2026-12-08T08:20:00+02:00","2026-12-09":"2026-12-09T08:21:00+02:00","2026-12-10":"2026-12-10T08:21:00+02:00"},"sofZmanShmaMGA":{"2026-09-23":"2026-09-23T08:54:00+03:00","2026-09-24":"2026-09-24T08:54:00+03:00","2026-09-25":"2026-09-25T08:54:00+03:00","2026-09-26":"2026-09-26T08:54:00+03:00","2026-09-27":"2026-09-27T08:54:00+03:00","2026-09-28":"2026-09-28T08:54:00+03:00","2026-09-29":"2026-09-29T08:54:00+03:00","2026-09-30":"2026-09-30T08:55:00+03:00","2026-10-01":"2026-10-01T08:55:00+03:00","2026-10-02":"2026-10-02T08:55:00+03:00","2026-10-03":"2026-10-03T08:55:00+03:00","2026-10-04":"2026-10-04T08:55:00+03:00","2026-10-05":"2026-10-05T08:55:00+03:00","2026-10-06":"2026-10-06T08:56:00+03:00","2026-10-07":"2026-10-07T08:56:00+03:00","2026-10-08":"2026-10-08T08:56:00+03:00","2026-10-09":"2026-10-09T08:56:00+03:00","2026-10-10":"2026-10-10T08:56:00+03:00","2026-10-11":"2026-10-11T08:57:00+03:00","2026-10-12":"2026-10-12T08:57:00+03:00","2026-10-13":"2026-10-13T08:57:00+03:00","2026-10-14":"2026-10-14T08:57:00+03:00","2026-10-15":"2026-10-15T08:57:00+03:00","2026-10-16":"2026-10-16T08:58:00+03:00","2026-10-17":"2026-10-17T08:58:00+03:00","2026-10-18":"2026-10-18T08:58:00+03:00","2026-10-19":"2026-10-19T08:58:00+03:00","2026-10-20":"2026-10-20T08:59:00+03:00","2026-10-21":"2026-10-21T08:59:00+03:00","2026-10-22":"2026-10-22T08:59:00+03:00","2026-10-23":"2026-10-23T09:00:00+03:00","2026-10-24":"2026-10-24T09:00:00+03:00","2026-10-25":"2026-10-25T08:00:00+02:00","2026-10-26":"2026-10-26T08:01:00+02:00","2026-10-27":"2026-10-27T08:01:00+02:00","2026-10-28":"2026-10-28T08:01:00+02:00","2026-10-29":"2026-10-29T08:02:00+02:00","2026-10-30":"2026-10-30T08:02:00+02:00","2026-10-31":"2026-10-31T08:02:00+02:00","2026-11-01":"2026-11-01T08:03:00+02:00","2026-11-02":"2026-11-02T08:03:00+02:00","2026-11-03":"2026-11-03T08:04:00+02:00","2026-11-04":"2026-11-04T08:04:00+02:00","2026-11-05":"2026-11-05T08:05:00+02:00","2026-11-06":"2026-11-06T08:05:00+02:00","2026-11-07":"2026-11-07T08:05:00+02:00","2026-11-08":"2026-11-08T08:06:00+02:00","2026-11-09":"2026-11-09T08:06:00+02:00","2026-11-10":"2026-11-10T08:07:00+02:00","2026-11-11":"2026-11-11T08:07:00+02:00","2026-11-12":"2026-11-12T08:08:00+02:00","2026-11-13":"2026-11-13T08:08:00+02:00","2026-11-14":"2026-11-14T08:09:00+02:00","2026-11-15":"2026-11-15T08:09:00+02:00","2026-11-16":"2026-11-16T08:10:00+02:00","2026-11-17":"2026-11-17T08:10:00+02:00","2026-11-18":"2026-11-18T08:11:00+02:00","2026-11-19":"2026-11-19T08:11:00+02:00","2026-11-20":"2026-11-20T08:12:00+02:00","2026-11-21":"2026-11-21T08:13:00+02:00","2026-11-22":"2026-11-22T08:13:00+02:00","2026-11-23":"2026-11-23T08:14:00+02:00","2026-11-24":"2026-11-24T08:14:00+02:00","2026-11-25":"2026-11-25T08:15:00+02:00","2026-11-26":"2026-11-26T08:16:00+02:00","2026-11-27":"2026-11-27T08:16:00+02:00","2026-11-28":"2026-11-28T08:17:00+02:00","2026-11-29":"2026-11-29T08:17:00+02:00","2026-11-30":"2026-11-30T08:18:00+02:00","2026-12-01":"2026-12-01T08:19:00+02:00","2026-12-02":"2026-12-02T08:19:00+02:00","2026-12-03":"2026-12-03T08:20:00+02:00","2026-12-04":"2026-12-04T08:20:00+02:00","2026-12-05":"2026-12-05T08:21:00+02:00","2026-12-06":"2026-12-06T08:22:00+02:00","2026-12-07":"2026-12-07T08:22:00+02:00","2026-12-08":"2026-12-08T08:23:00+02:00","2026-12-09":"2026-12-09T08:23:00+02:00","2026-12-10":"2026-12-10T08:24:00+02:00"},"sofZmanShmaBaalHatanya":{"2026-09-23":"2026-09-23T09:28:00+03:00","2026-09-24":"2026-09-24T09:28:00+03:00","2026-09-25":"2026-09-25T09:28:00+03:00","2026-09-26":"2026-09-26T09:28:00+03:00","2026-09-27":"2026-09-27T09:28:00+03:00","2026-09-28":"2026-09-28T09:29:00+03:00","2026-09-29":"2026-09-29T09:29:00+03:00","2026-09-30":"2026-09-30T09:29:00+03:00","2026-10-01":"2026-10-01T09:29:00+03:00","2026-10-02":"2026-10-02T09:29:00+03:00","2026-10-03":"2026-10-03T09:29:00+03:00","2026-10-04":"2026-10-04T09:29:00+03:00","2026-10-05":"2026-10-05T09:30:00+03:00","2026-10-06":"2026-10-06T09:30:00+03:00","2026-10-07":"2026-10-07T09:30:00+03:00","2026-10-08":"2026-10-08T09:30:00+03:00","2026-10-09":"2026-10-09T09:30:00+03:00","2026-10-10":"2026-10-10T09:31:00+03:00","2026-10-11":"2026-10-11T09:31:00+03:00","2026-10-12":"2026-10-12T09:31:00+03:00","2026-10-13":"2026-10-13T09:31:00+03:00","2026-10-14":"2026-10-14T09:31:00+03:00","2026-10-15":"2026-10-15T09:32:00+03:00","2026-10-16":"2026-10-16T09:32:00+03:00","2026-10-17":"2026-10-17T09:32:00+03:00","2026-10-18":"2026-10-18T09:32:00+03:00","2026-10-19":"2026-10-19T09:33:00+03:00","2026-10-20":"2026-10-20T09:33:00+03:00","2026-10-21":"2026-10-21T09:33:00+03:00","2026-10-22":"2026-10-22T09:34:00+03:00","2026-10-23":"2026-10-23T09:34:00+03:00","2026-10-24":"2026-10-24T09:34:00+03:00","2026-10-25":"2026-10-25T08:34:00+02:00","2026-10-26":"2026-10-26T08:35:00+02:00","2026-10-27":"2026-10-27T08:35:00+02:00","2026-10-28":"2026-10-28T08:36:00+02:00","2026-10-29":"2026-10-29T08:36:00+02:00","2026-10-30":"2026-10-30T08:36:00+02:00","2026-10-31":"2026-10-31T08:37:00+02:00","2026-11-01":"2026-11-01T08:37:00+02:00","2026-11-02":"2026-11-02T08:37:00+02:00","2026-11-03":"2026-11-03T08:38:00+02:00","2026-11-04":"2026-11-04T08:38:00+02:00","2026-11-05":"2026-11-05T08:39:00+02:00","2026-11-06":"2026-11-06T08:39:00+02:00","2026-11-07":"2026-11-07T08:40:00+02:00","2026-11-08":"2026-11-08T08:40:00+02:00","2026-11-09":"2026-11-09T08:40:00+02:00","2026-11-10":"2026-11-10T08:41:00+02:00","2026-11-11":"2026-11-11T08:41:00+02:00","2026-11-12":"2026-11-12T08:42:00+02:00","2026-11-13":"2026-11-13T08:42:00+02:00","2026-11-14":"2026-11-14T08:43:00+02:00","2026-11-15":"2026-11-15T08:43:00+02:00","2026-11-16":"2026-11-16T08:44:00+02:00","2026-11-17":"2026-11-17T08:45:00+02:00","2026-11-18":"2026-11-18T08:45:00+02:00","2026-11-19":"2026-11-19T08:46:00+02:00","2026-11-20":"2026-11-20T08:46:00+02:00","2026-11-21":"2026-11-21T08:47:00+02:00","2026-11-22":"2026-11-22T08:47:00+02:00","2026-11-23":"2026-11-23T08:48:00+02:00","2026-11-24":"2026-11-24T08:48:00+02:00","2026-11-25":"2026-11-25T08:49:00+02:00","2026-11-26":"2026-11-26T08:50:00+02:00","2026-11-27":"2026-11-27T08:50:00+02:00","2026-11-28":"2026-11-28T08:51:00+02:00","2026-11-29":"2026-11-29T08:51:00+02:00","2026-11-30":"2026-11-30T08:52:00+02:00","2026-12-01":"2026-12-01T08:53:00+02:00","2026-12-02":"2026-12-02T08:53:00+02:00","2026-12-03":"2026-12-03T08:54:00+02:00","2026-12-04":"2026-12-04T08:54:00+02:00","2026-12-05":"2026-12-05T08:55:00+02:00","2026-12-06":"2026-12-06T08:56:00+02:00","2026-12-07":"2026-12-07T08:56:00+02:00","2026-12-08":"2026-12-08T08:57:00+02:00","2026-12-09":"2026-12-09T08:57:00+02:00","2026-12-10":"2026-12-10T08:58:00+02:00"},"sofZmanShma":{"2026-09-23":"2026-09-23T09:30:00+03:00","2026-09-24":"2026-09-24T09:30:00+03:00","2026-09-25":"2026-09-25T09:30:00+03:00","2026-09-26":"2026-09-26T09:30:00+03:00","2026-09-27":"2026-09-27T09:30:00+03:00","2026-09-28":"2026-09-28T09:30:00+03:00","2026-09-29":"2026-09-29T09:30:00+03:00","2026-09-30":"2026-09-30T09:31:00+03:00","2026-10-01":"2026-10-01T09:31:00+03:00","2026-10-02":"2026-10-02T09:31:00+03:00","2026-10-03":"2026-10-03T09:31:00+03:00","2026-10-04":"2026-10-04T09:31:00+03:00","2026-10-05":"2026-10-05T09:31:00+03:00","2026-10-06":"2026-10-06T09:32:00+03:00","2026-10-07":"2026-10-07T09:32:00+03:00","2026-10-08":"2026-10-08T09:32:00+03:00","2026-10-09":"2026-10-09T09:32:00+03:00","2026-10-10":"2026-10-10T09:32:00+03:00","2026-10-11":"2026-10-11T09:33:00+03:00","2026-10-12":"2026-10-12T09:33:00+03:00","2026-10-13":"2026-10-13T09:33:00+03:00","2026-10-14":"2026-10-14T09:33:00+03:00","2026-10-15":"2026-10-15T09:33:00+03:00","2026-10-16":"2026-10-16T09:34:00+03:00","2026-10-17":"2026-10-17T09:34:00+03:00","2026-10-18":"2026-10-18T09:34:00+03:00","2026-10-19":"2026-10-19T09:34:00+03:00","2026-10-20":"2026-10-20T09:35:00+03:00","2026-10-21":"2026-10-21T09:35:00+03:00","2026-10-22":"2026-10-22T09:35:00+03:00","2026-10-23":"2026-10-23T09:36:00+03:00","2026-10-24":"2026-10-24T09:36:00+03:00","2026-10-25":"2026-10-25T08:36:00+02:00","2026-10-26":"2026-10-26T08:37:00+02:00","2026-10-27":"2026-10-27T08:37:00+02:00","2026-10-28":"2026-10-28T08:37:00+02:00","2026-10-29":"2026-10-29T08:38:00+02:00","2026-10-30":"2026-10-30T08:38:00+02:00","2026-10-31":"2026-10-31T08:38:00+02:00","2026-11-01":"2026-11-01T08:39:00+02:00","2026-11-02":"2026-11-02T08:39:00+02:00","2026-11-03":"2026-11-03T08:40:00+02:00","2026-11-04":"2026-11-04T08:40:00+02:00","2026-11-05":"2026-11-05T08:41:00+02:00","2026-11-06":"2026-11-06T08:41:00+02:00","2026-11-07":"2026-11-07T08:41:00+02:00","2026-11-08":"2026-11-08T08:42:00+02:00","2026-11-09":"2026-11-09T08:42:00+02:00","2026-11-10":"2026-11-10T08:43:00+02:00","2026-11-11":"2026-11-11T08:43:00+02:00","2026-11-12":"2026-11-12T08:44:00+02:00","2026-11-13":"2026-11-13T08:44:00+02:00","2026-11-14":"2026-11-14T08:45:00+02:00","2026-11-15":"2026-11-15T08:45:00+02:00","2026-11-16":"2026-11-16T08:46:00+02:00","2026-11-17":"2026-11-17T08:46:00+02:00","2026-11-18":"2026-11-18T08:47:00+02:00","2026-11-19":"2026-11-19T08:47:00+02:00","2026-11-20":"2026-11-20T08:48:00+02:00","2026-11-21":"2026-11-21T08:49:00+02:00","2026-11-22":"2026-11-22T08:49:00+02:00","2026-11-23":"2026-11-23T08:50:00+02:00","2026-11-24":"2026-11-24T08:50:00+02:00","2026-11-25":"2026-11-25T08:51:00+02:00","2026-11-26":"2026-11-26T08:52:00+02:00","2026-11-27":"2026-11-27T08:52:00+02:00","2026-11-28":"2026-11-28T08:53:00+02:00","2026-11-29":"2026-11-29T08:53:00+02:00","2026-11-30":"2026-11-30T08:54:00+02:00","2026-12-01":"2026-12-01T08:55:00+02:00","2026-12-02":"2026-12-02T08:55:00+02:00","2026-12-03":"2026-12-03T08:56:00+02:00","2026-12-04":"2026-12-04T08:56:00+02:00","2026-12-05":"2026-12-05T08:57:00+02:00","2026-12-06":"2026-12-06T08:58:00+02:00","2026-12-07":"2026-12-07T08:58:00+02:00","2026-12-08":"2026-12-08T08:59:00+02:00","2026-12-09":"2026-12-09T08:59:00+02:00","2026-12-10":"2026-12-10T09:00:00+02:00"},"sofZmanTfillaMGA19Point8":{"2026-09-23":"2026-09-23T10:00:00+03:00","2026-09-24":"2026-09-24T10:00:00+03:00","2026-09-25":"2026-09-25T10:00:00+03:00","2026-09-26":"2026-09-26T10:00:00+03:00","2026-09-27":"2026-09-27T10:00:00+03:00","2026-09-28":"2026-09-28T10:00:00+03:00","2026-09-29":"2026-09-29T10:00:00+03:00","2026-09-30":"2026-09-30T10:00:00+03:00","2026-10-01":"2026-10-01T10:00:00+03:00","2026-10-02":"2026-10-02T10:00:00+03:00","2026-10-03":"2026-10-03T10:00:00+03:00","2026-10-04":"2026-10-04T10:00:00+03:00","2026-10-05":"2026-10-05T10:00:00+03:00","2026-10-06":"2026-10-06T10:00:00+03:00","2026-10-07":"2026-10-07T10:00:00+03:00","2026-10-08":"2026-10-08T10:00:00+03:00","2026-10-09":"2026-10-09T10:00:00+03:00","2026-10-10":"2026-10-10T10:00:00+03:00","2026-10-11":"2026-10-11T10:00:00+03:00","2026-10-12":"2026-10-12T10:00:00+03:00","2026-10-13":"2026-10-13T10:00:00+03:00","2026-10-14":"2026-10-14T10:01:00+03:00","2026-10-15":"2026-10-15T10:01:00+03:00","2026-10-16":"2026-10-16T10:01:00+03:00","2026-10-17":"2026-10-17T10:01:00+03:00","2026-10-18":"2026-10-18T10:01:00+03:00","2026-10-19":"2026-10-19T10:01:00+03:00","2026-10-20":"2026-10-20T10:01:00+03:00","2026-10-21":"2026-10-21T10:01:00+03:00","2026-10-22":"2026-10-22T10:01:00+03:00","2026-10-23":"2026-10-23T10:01:00+03:00","2026-10-24":"2026-10-24T10:02:00+03:00","2026-10-25":"2026-10-25T09:02:00+02:00","2026-10-26":"2026-10-26T09:02:00+02:00","2026-10-27":"2026-10-27T09:02:00+02:00","2026-10-28":"2026-10-28T09:02:00+02:00","2026-10-29":"2026-10-29T09:02:00+02:00","2026-10-30":"2026-10-30T09:03:00+02:00","2026-10-31":"2026-10-31T09:03:00+02:00","2026-11-01":"2026-11-01T09:03:00+02:00","2026-11-02":"2026-11-02T09:03:00+02:00","2026-11-03":"2026-11-03T09:03:00+02:00","2026-11-04":"2026-11-04T09:04:00+02:00","2026-11-05":"2026-11-05T09:04:00+02:00","2026-11-06":"2026-11-06T09:04:00+02:00","2026-11-07":"2026-11-07T09:05:00+02:00","2026-11-08":"2026-11-08T09:05:00+02:00","2026-11-09":"2026-11-09T09:05:00+02:00","2026-11-10":"2026-11-10T09:05:00+02:00","2026-11-11":"2026-11-11T09:06:00+02:00","2026-11-12":"2026-11-12T09:06:00+02:00","2026-11-13":"2026-11-13T09:06:00+02:00","2026-11-14":"2026-11-14T09:07:00+02:00","2026-11-15":"2026-11-15T09:07:00+02:00","2026-11-16":"2026-11-16T09:07:00+02:00","2026-11-17":"2026-11-17T09:08:00+02:00","2026-11-18":"2026-11-18T09:08:00+02:00","2026-11-19":"2026-11-19T09:09:00+02:00","2026-11-20":"2026-11-20T09:09:00+02:00","2026-11-21":"2026-11-21T09:09:00+02:00","2026-11-22":"2026-11-22T09:10:00+02:00","2026-11-23":"2026-11-23T09:10:00+02:00","2026-11-24":"2026-11-24T09:11:00+02:00","2026-11-25":"2026-11-25T09:11:00+02:00","2026-11-26":"2026-11-26T09:12:00+02:00","2026-11-27":"2026-11-27T09:12:00+02:00","2026-11-28":"2026-11-28T09:13:00+02:00","2026-11-29":"2026-11-29T09:13:00+02:00","2026-11-30":"2026-11-30T09:14:00+02:00","2026-12-01":"2026-12-01T09:14:00+02:00","2026-12-02":"2026-12-02T09:15:00+02:00","2026-12-03":"2026-12-03T09:15:00+02:00","2026-12-04":"2026-12-04T09:16:00+02:00","2026-12-05":"2026-12-05T09:16:00+02:00","2026-12-06":"2026-12-06T09:17:00+02:00","2026-12-07":"2026-12-07T09:17:00+02:00","2026-12-08":"2026-12-08T09:18:00+02:00","2026-12-09":"2026-12-09T09:18:00+02:00","2026-12-10":"2026-12-10T09:19:00+02:00"},"sofZmanTfillaMGA16Point1":{"2026-09-23":"2026-09-23T10:06:00+03:00","2026-09-24":"2026-09-24T10:06:00+03:00","2026-09-25":"2026-09-25T10:06:00+03:00","2026-09-26":"2026-09-26T10:06:00+03:00","2026-09-27":"2026-09-27T10:06:00+03:00","2026-09-28":"2026-09-28T10:06:00+03:00","2026-09-29":"2026-09-29T10:06:00+03:00","2026-09-30":"2026-09-30T10:06:00+03:00","2026-10-01":"2026-10-01T10:06:00+03:00","2026-10-02":"2026-10-02T10:06:00+03:00","2026-10-03":"2026-10-03T10:06:00+03:00","2026-10-04":"2026-10-04T10:06:00+03:00","2026-10-05":"2026-10-05T10:06:00+03:00","2026-10-06":"2026-10-06T10:06:00+03:00","2026-10-07":"2026-10-07T10:06:00+03:00","2026-10-08":"2026-10-08T10:06:00+03:00","2026-10-09":"2026-10-09T10:06:00+03:00","2026-10-10":"2026-10-10T10:06:00+03:00","2026-10-11":"2026-10-11T10:06:00+03:00","2026-10-12":"2026-10-12T10:06:00+03:00","2026-10-13":"2026-10-13T10:06:00+03:00","2026-10-14":"2026-10-14T10:06:00+03:00","2026-10-15":"2026-10-15T10:06:00+03:00","2026-10-16":"2026-10-16T10:07:00+03:00","2026-10-17":"2026-10-17T10:07:00+03:00","2026-10-18":"2026-10-18T10:07:00+03:00","2026-10-19":"2026-10-19T10:07:00+03:00","2026-10-20":"2026-10-20T10:07:00+03:00","2026-10-21":"2026-10-21T10:07:00+03:00","2026-10-22":"2026-10-22T10:07:00+03:00","2026-10-23":"2026-10-23T10:07:00+03:00","2026-10-24":"2026-10-24T10:07:00+03:00","2026-10-25":"2026-10-25T09:08:00+02:00","2026-10-26":"2026-10-26T09:08:00+02:00","2026-10-27":"2026-10-27T09:08:00+02:00","2026-10-28":"2026-10-28T09:08:00+02:00","2026-10-29":"2026-10-29T09:08:00+02:00","2026-10-30":"2026-10-30T09:08:00+02:00","2026-10-31":"2026-10-31T09:09:00+02:00","2026-11-01":"2026-11-01T09:09:00+02:00","2026-11-02":"2026-11-02T09:09:00+02:00","2026-11-03":"2026-11-03T09:09:00+02:00","2026-11-04":"2026-11-04T09:10:00+02:00","2026-11-05":"2026-11-05T09:10:00+02:00","2026-11-06":"2026-11-06T09:10:00+02:00","2026-11-07":"2026-11-07T09:10:00+02:00","2026-11-08":"2026-11-08T09:11:00+02:00","2026-11-09":"2026-11-09T09:11:00+02:00","2026-11-10":"2026-11-10T09:11:00+02:00","2026-11-11":"2026-11-11T09:12:00+02:00","2026-11-12":"2026-11-12T09:12:00+02:00","2026-11-13":"2026-11-13T09:12:00+02:00","2026-11-14":"2026-11-14T09:13:00+02:00","2026-11-15":"2026-11-15T09:13:00+02:00","2026-11-16":"2026-11-16T09:13:00+02:00","2026-11-17":"2026-11-17T09:14:00+02:00","2026-11-18":"2026-11-18T09:14:00+02:00","2026-11-19":"2026-11-19T09:15:00+02:00","2026-11-20":"2026-11-20T09:15:00+02:00","2026-11-21":"2026-11-21T09:15:00+02:00","2026-11-22":"2026-11-22T09:16:00+02:00","2026-11-23":"2026-11-23T09:16:00+02:00","2026-11-24":"2026-11-24T09:17:00+02:00","2026-11-25":"2026-11-25T09:17:00+02:00","2026-11-26":"2026-11-26T09:18:00+02:00","2026-11-27":"2026-11-27T09:18:00+02:00","2026-11-28":"2026-11-28T09:19:00+02:00","2026-11-29":"2026-11-29T09:19:00+02:00","2026-11-30":"2026-11-30T09:20:00+02:00","2026-12-01":"2026-12-01T09:20:00+02:00","2026-12-02":"2026-12-02T09:21:00+02:00","2026-12-03":"2026-12-03T09:21:00+02:00","2026-12-04":"2026-12-04T09:22:00+02:00","2026-12-05":"2026-12-05T09:22:00+02:00","2026-12-06":"2026-12-06T09:23:00+02:00","2026-12-07":"2026-12-07T09:23:00+02:00","2026-12-08":"2026-12-08T09:24:00+02:00","2026-12-09":"2026-12-09T09:24:00+02:00","2026-12-10":"2026-12-10T09:25:00+02:00"},"sofZmanTfillaMGA":{"2026-09-23":"2026-09-23T10:06:00+03:00","2026-09-24":"2026-09-24T10:06:00+03:00","2026-09-25":"2026-09-25T10:06:00+03:00","2026-09-26":"2026-09-26T10:06:00+03:00","2026-09-27":"2026-09-27T10:06:00+03:00","2026-09-28":"2026-09-28T10:06:00+03:00","2026-09-29":"2026-09-29T10:06:00+03:00","2026-09-30":"2026-09-30T10:06:00+03:00","2026-10-01":"2026-10-01T10:06:00+03:00","2026-10-02":"2026-10-02T10:06:00+03:00","2026-10-03":"2026-10-03T10:06:00+03:00","2026-10-04":"2026-10-04T10:06:00+03:00","2026-10-05":"2026-10-05T10:06:00+03:00","2026-10-06":"2026-10-06T10:06:00+03:00","2026-10-07":"2026-10-07T10:06:00+03:00","2026-10-08":"2026-10-08T10:06:00+03:00","2026-10-09":"2026-10-09T10:06:00+03:00","2026-10-10":"2026-10-10T10:06:00+03:00","2026-10-11":"2026-10-11T10:06:00+03:00","2026-10-12":"2026-10-12T10:06:00+03:00","2026-10-13":"2026-10-13T10:06:00+03:00","2026-10-14":"2026-10-14T10:06:00+03:00","2026-10-15":"2026-10-15T10:07:00+03:00","2026-10-16":"2026-10-16T10:07:00+03:00","2026-10-17":"2026-10-17T10:07:00+03:00","2026-10-18":"2026-10-18T10:07:00+03:00","2026-10-19":"2026-10-19T10:07:00+03:00","2026-10-20":"2026-10-20T10:07:00+03:00","2026-10-21":"2026-10-21T10:07:00+03:00","2026-10-22":"2026-10-22T10:07:00+03:00","2026-10-23":"2026-10-23T10:08:00+03:00","2026-10-24":"2026-10-24T10:08:00+03:00","2026-10-25":"2026-10-25T09:08:00+02:00","2026-10-26":"2026-10-26T09:08:00+02:00","2026-10-27":"2026-10-27T09:08:00+02:00","2026-10-28":"2026-10-28T09:08:00+02:00","2026-10-29":"2026-10-29T09:09:00+02:00","2026-10-30":"2026-10-30T09:09:00+02:00","2026-10-31":"2026-10-31T09:09:00+02:00","2026-11-01":"2026-11-01T09:09:00+02:00","2026-11-02":"2026-11-02T09:10:00+02:00","2026-11-03":"2026-11-03T09:10:00+02:00","2026-11-04":"2026-11-04T09:10:00+02:00","2026-11-05":"2026-11-05T09:10:00+02:00","2026-11-06":"2026-11-06T09:11:00+02:00","2026-11-07":"2026-11-07T09:11:00+02:00","2026-11-08":"2026-11-08T09:11:00+02:00","2026-11-09":"2026-11-09T09:12:00+02:00","2026-11-10":"2026-11-10T09:12:00+02:00","2026-11-11":"2026-11-11T09:13:00+02:00","2026-11-12":"2026-11-12T09:13:00+02:00","2026-11-13":"2026-11-13T09:13:00+02:00","2026-11-14":"2026-11-14T09:14:00+02:00","2026-11-15":"2026-11-15T09:14:00+02:00","2026-11-16":"2026-11-16T09:14:00+02:00","2026-11-17":"2026-11-17T09:15:00+02:00","2026-11-18":"2026-11-18T09:15:00+02:00","2026-11-19":"2026-11-19T09:16:00+02:00","2026-11-20":"2026-11-20T09:16:00+02:00","2026-11-21":"2026-11-21T09:17:00+02:00","2026-11-22":"2026-11-22T09:17:00+02:00","2026-11-23":"2026-11-23T09:18:00+02:00","2026-11-24":"2026-11-24T09:18:00+02:00","2026-11-25":"2026-11-25T09:19:00+02:00","2026-11-26":"2026-11-26T09:19:00+02:00","2026-11-27":"2026-11-27T09:20:00+02:00","2026-11-28":"2026-11-28T09:20:00+02:00","2026-11-29":"2026-11-29T09:21:00+02:00","2026-11-30":"2026-11-30T09:21:00+02:00","2026-12-01":"2026-12-01T09:22:00+02:00","2026-12-02":"2026-12-02T09:22:00+02:00","2026-12-03":"2026-12-03T09:23:00+02:00","2026-12-04":"2026-12-04T09:23:00+02:00","2026-12-05":"2026-12-05T09:24:00+02:00","2026-12-06":"2026-12-06T09:24:00+02:00","2026-12-07":"2026-12-07T09:25:00+02:00","2026-12-08":"2026-12-08T09:25:00+02:00","2026-12-09":"2026-12-09T09:26:00+02:00","2026-12-10":"2026-12-10T09:27:00+02:00"},"sofZmanTfilaBaalHatanya":{"2026-09-23":"2026-09-23T10:29:00+03:00","2026-09-24":"2026-09-24T10:29:00+03:00","2026-09-25":"2026-09-25T10:29:00+03:00","2026-09-26":"2026-09-26T10:29:00+03:00","2026-09-27":"2026-09-27T10:29:00+03:00","2026-09-28":"2026-09-28T10:29:00+03:00","2026-09-29":"2026-09-29T10:29:00+03:00","2026-09-30":"2026-09-30T10:29:00+03:00","2026-10-01":"2026-10-01T10:29:00+03:00","2026-10-02":"2026-10-02T10:29:00+03:00","2026-10-03":"2026-10-03T10:29:00+03:00","2026-10-04":"2026-10-04T10:29:00+03:00","2026-10-05":"2026-10-05T10:29:00+03:00","2026-10-06":"2026-10-06T10:29:00+03:00","2026-10-07":"2026-10-07T10:29:00+03:00","2026-10-08":"2026-10-08T10:29:00+03:00","2026-10-09":"2026-10-09T10:29:00+03:00","2026-10-10":"2026-10-10T10:29:00+03:00","2026-10-11":"2026-10-11T10:29:00+03:00","2026-10-12":"2026-10-12T10:29:00+03:00","2026-10-13":"2026-10-13T10:29:00+03:00","2026-10-14":"2026-10-14T10:29:00+03:00","2026-10-15":"2026-10-15T10:29:00+03:00","2026-10-16":"2026-10-16T10:29:00+03:00","2026-10-17":"2026-10-17T10:30:00+03:00","2026-10-18":"2026-10-18T10:30:00+03:00","2026-10-19":"2026-10-19T10:30:00+03:00","2026-10-20":"2026-10-20T10:30:00+03:00","2026-10-21":"2026-10-21T10:30:00+03:00","2026-10-22":"2026-10-22T10:30:00+03:00","2026-10-23":"2026-10-23T10:30:00+03:00","2026-10-24":"2026-10-24T10:30:00+03:00","2026-10-25":"2026-10-25T09:31:00+02:00","2026-10-26":"2026-10-26T09:31:00+02:00","2026-10-27":"2026-10-27T09:31:00+02:00","2026-10-28":"2026-10-28T09:31:00+02:00","2026-10-29":"2026-10-29T09:31:00+02:00","2026-10-30":"2026-10-30T09:32:00+02:00","2026-10-31":"2026-10-31T09:32:00+02:00","2026-11-01":"2026-11-01T09:32:00+02:00","2026-11-02":"2026-11-02T09:32:00+02:00","2026-11-03":"2026-11-03T09:33:00+02:00","2026-11-04":"2026-11-04T09:33:00+02:00","2026-11-05":"2026-11-05T09:33:00+02:00","2026-11-06":"2026-11-06T09:34:00+02:00","2026-11-07":"2026-11-07T09:34:00+02:00","2026-11-08":"2026-11-08T09:34:00+02:00","2026-11-09":"2026-11-09T09:35:00+02:00","2026-11-10":"2026-11-10T09:35:00+02:00","2026-11-11":"2026-11-11T09:35:00+02:00","2026-11-12":"2026-11-12T09:36:00+02:00","2026-11-13":"2026-11-13T09:36:00+02:00","2026-11-14":"2026-11-14T09:36:00+02:00","2026-11-15":"2026-11-15T09:37:00+02:00","2026-11-16":"2026-11-16T09:37:00+02:00","2026-11-17":"2026-11-17T09:38:00+02:00","2026-11-18":"2026-11-18T09:38:00+02:00","2026-11-19":"2026-11-19T09:39:00+02:00","2026-11-20":"2026-11-20T09:39:00+02:00","2026-11-21":"2026-11-21T09:39:00+02:00","2026-11-22":"2026-11-22T09:40:00+02:00","2026-11-23":"2026-11-23T09:40:00+02:00","2026-11-24":"2026-11-24T09:41:00+02:00","2026-11-25":"2026-11-25T09:41:00+02:00","2026-11-26":"2026-11-26T09:42:00+02:00","2026-11-27":"2026-11-27T09:42:00+02:00","2026-11-28":"2026-11-28T09:43:00+02:00","2026-11-29":"2026-11-29T09:43:00+02:00","2026-11-30":"2026-11-30T09:44:00+02:00","2026-12-01":"2026-12-01T09:44:00+02:00","2026-12-02":"2026-12-02T09:45:00+02:00","2026-12-03":"2026-12-03T09:45:00+02:00","2026-12-04":"2026-12-04T09:46:00+02:00","2026-12-05":"2026-12-05T09:47:00+02:00","2026-12-06":"2026-12-06T09:47:00+02:00","2026-12-07":"2026-12-07T09:48:00+02:00","2026-12-08":"2026-12-08T09:48:00+02:00","2026-12-09":"2026-12-09T09:49:00+02:00","2026-12-10":"2026-12-10T09:49:00+02:00"},"sofZmanTfilla":{"2026-09-23":"2026-09-23T10:30:00+03:00","2026-09-24":"2026-09-24T10:30:00+03:00","2026-09-25":"2026-09-25T10:30:00+03:00","2026-09-26":"2026-09-26T10:30:00+03:00","2026-09-27":"2026-09-27T10:30:00+03:00","2026-09-28":"2026-09-28T10:30:00+03:00","2026-09-29":"2026-09-29T10:30:00+03:00","2026-09-30":"2026-09-30T10:30:00+03:00","2026-10-01":"2026-10-01T10:30:00+03:00","2026-10-02":"2026-10-02T10:30:00+03:00","2026-10-03":"2026-10-03T10:30:00+03:00","2026-10-04":"2026-10-04T10:30:00+03:00","2026-10-05":"2026-10-05T10:30:00+03:00","2026-10-06":"2026-10-06T10:30:00+03:00","2026-10-07":"2026-10-07T10:30:00+03:00","2026-10-08":"2026-10-08T10:30:00+03:00","2026-10-09":"2026-10-09T10:30:00+03:00","2026-10-10":"2026-10-10T10:30:00+03:00","2026-10-11":"2026-10-11T10:30:00+03:00","2026-10-12":"2026-10-12T10:30:00+03:00","2026-10-13":"2026-10-13T10:30:00+03:00","2026-10-14":"2026-10-14T10:30:00+03:00","2026-10-15":"2026-10-15T10:31:00+03:00","2026-10-16":"2026-10-16T10:31:00+03:00","2026-10-17":"2026-10-17T10:31:00+03:00","2026-10-18":"2026-10-18T10:31:00+03:00","2026-10-19":"2026-10-19T10:31:00+03:00","2026-10-20":"2026-10-20T10:31:00+03:00","2026-10-21":"2026-10-21T10:31:00+03:00","2026-10-22":"2026-10-22T10:31:00+03:00","2026-10-23":"2026-10-23T10:32:00+03:00","2026-10-24":"2026-10-24T10:32:00+03:00","2026-10-25":"2026-10-25T09:32:00+02:00","2026-10-26":"2026-10-26T09:32:00+02:00","2026-10-27":"2026-10-27T09:32:00+02:00","2026-10-28":"2026-10-28T09:32:00+02:00","2026-10-29":"2026-10-29T09:33:00+02:00","2026-10-30":"2026-10-30T09:33:00+02:00","2026-10-31":"2026-10-31T09:33:00+02:00","2026-11-01":"2026-11-01T09:33:00+02:00","2026-11-02":"2026-11-02T09:34:00+02:00","2026-11-03":"2026-11-03T09:34:00+02:00","2026-11-04":"2026-11-04T09:34:00+02:00","2026-11-05":"2026-11-05T09:34:00+02:00","2026-11-06":"2026-11-06T09:35:00+02:00","2026-11-07":"2026-11-07T09:35:00+02:00","2026-11-08":"2026-11-08T09:35:00+02:00","2026-11-09":"2026-11-09T09:36:00+02:00","2026-11-10":"2026-11-10T09:36:00+02:00","2026-11-11":"2026-11-11T09:37:00+02:00","2026-11-12":"2026-11-12T09:37:00+02:00","2026-11-13":"2026-11-13T09:37:00+02:00","2026-11-14":"2026-11-14T09:38:00+02:00","2026-11-15":"2026-11-15T09:38:00+02:00","2026-11-16":"2026-11-16T09:38:00+02:00","2026-11-17":"2026-11-17T09:39:00+02:00","2026-11-18":"2026-11-18T09:39:00+02:00","2026-11-19":"2026-11-19T09:40:00+02:00","2026-11-20":"2026-11-20T09:40:00+02:00","2026-11-21":"2026-11-21T09:41:00+02:00","2026-11-22":"2026-11-22T09:41:00+02:00","2026-11-23":"2026-11-23T09:42:00+02:00","2026-11-24":"2026-11-24T09:42:00+02:00","2026-11-25":"2026-11-25T09:43:00+02:00","2026-11-26":"2026-11-26T09:43:00+02:00","2026-11-27":"2026-11-27T09:44:00+02:00","2026-11-28":"2026-11-28T09:44:00+02:00","2026-11-29":"2026-11-29T09:45:00+02:00","2026-11-30":"2026-11-30T09:45:00+02:00","2026-12-01":"2026-12-01T09:46:00+02:00","2026-12-02":"2026-12-02T09:46:00+02:00","2026-12-03":"2026-12-03T09:47:00+02:00","2026-12-04":"2026-12-04T09:47:00+02:00","2026-12-05":"2026-12-05T09:48:00+02:00","2026-12-06":"2026-12-06T09:48:00+02:00","2026-12-07":"2026-12-07T09:49:00+02:00","2026-12-08":"2026-12-08T09:49:00+02:00","2026-12-09":"2026-12-09T09:50:00+02:00","2026-12-10":"2026-12-10T09:51:00+02:00"},"chatzot":{"2026-09-23":"2026-09-23T12:31:00+03:00","2026-09-24":"2026-09-24T12:31:00+03:00","2026-09-25":"2026-09-25T12:31:00+03:00","2026-09-26":"2026-09-26T12:30:00+03:00","2026-09-27":"2026-09-27T12:30:00+03:00","2026-09-28":"2026-09-28T12:30:00+03:00","2026-09-29":"2026-09-29T12:29:00+03:00","2026-09-30":"2026-09-30T12:29:00+03:00","2026-10-01":"2026-10-01T12:29:00+03:00","2026-10-02":"2026-10-02T12:28:00+03:00","2026-10-03":"2026-10-03T12:28:00+03:00","2026-10-04":"2026-10-04T12:28:00+03:00","2026-10-05":"2026-10-05T12:27:00+03:00","2026-10-06":"2026-10-06T12:27:00+03:00","2026-10-07":"2026-10-07T12:27:00+03:00","2026-10-08":"2026-10-08T12:26:00+03:00","2026-10-09":"2026-10-09T12:26:00+03:00","2026-10-10":"2026-10-10T12:26:00+03:00","2026-10-11":"2026-10-11T12:26:00+03:00","2026-10-12":"2026-10-12T12:25:00+03:00","2026-10-13":"2026-10-13T12:25:00+03:00","2026-10-14":"2026-10-14T12:25:00+03:00","2026-10-15":"2026-10-15T12:25:00+03:00","2026-10-16":"2026-10-16T12:24:00+03:00","2026-10-17":"2026-10-17T12:24:00+03:00","2026-10-18":"2026-10-18T12:24:00+03:00","2026-10-19":"2026-10-19T12:24:00+03:00","2026-10-20":"2026-10-20T12:24:00+03:00","2026-10-21":"2026-10-21T12:24:00+03:00","2026-10-22":"2026-10-22T12:23:00+03:00","2026-10-23":"2026-10-23T12:23:00+03:00","2026-10-24":"2026-10-24T12:23:00+03:00","2026-10-25":"2026-10-25T11:23:00+02:00","2026-10-26":"2026-10-26T11:23:00+02:00","2026-10-27":"2026-10-27T11:23:00+02:00","2026-10-28":"2026-10-28T11:23:00+02:00","2026-10-29":"2026-10-29T11:23:00+02:00","2026-10-30":"2026-10-30T11:23:00+02:00","2026-10-31":"2026-10-31T11:23:00+02:00","2026-11-01":"2026-11-01T11:22:00+02:00","2026-11-02":"2026-11-02T11:22:00+02:00","2026-11-03":"2026-11-03T11:22:00+02:00","2026-11-04":"2026-11-04T11:22:00+02:00","2026-11-05":"2026-11-05T11:22:00+02:00","2026-11-06":"2026-11-06T11:23:00+02:00","2026-11-07":"2026-11-07T11:23:00+02:00","2026-11-08":"2026-11-08T11:23:00+02:00","2026-11-09":"2026-11-09T11:23:00+02:00","2026-11-10":"2026-11-10T11:23:00+02:00","2026-11-11":"2026-11-11T11:23:00+02:00","2026-11-12":"2026-11-12T11:23:00+02:00","2026-11-13":"2026-11-13T11:23:00+02:00","2026-11-14":"2026-11-14T11:23:00+02:00","2026-11-15":"2026-11-15T11:24:00+02:00","2026-11-16":"2026-11-16T11:24:00+02:00","2026-11-17":"2026-11-17T11:24:00+02:00","2026-11-18":"2026-11-18T11:24:00+02:00","2026-11-19":"2026-11-19T11:24:00+02:00","2026-11-20":"2026-11-20T11:25:00+02:00","2026-11-21":"2026-11-21T11:25:00+02:00","2026-11-22":"2026-11-22T11:25:00+02:00","2026-11-23":"2026-11-23T11:25:00+02:00","2026-11-24":"2026-11-24T11:26:00+02:00","2026-11-25":"2026-11-25T11:26:00+02:00","2026-11-26":"2026-11-26T11:26:00+02:00","2026-11-27":"2026-11-27T11:27:00+02:00","2026-11-28":"2026-11-28T11:27:00+02:00","2026-11-29":"2026-11-29T11:27:00+02:00","2026-11-30":"2026-11-30T11:28:00+02:00","2026-12-01":"2026-12-01T11:28:00+02:00","2026-12-02":"2026-12-02T11:28:00+02:00","2026-12-03":"2026-12-03T11:29:00+02:00","2026-12-04":"2026-12-04T11:29:00+02:00","2026-12-05":"2026-12-05T11:30:00+02:00","2026-12-06":"2026-12-06T11:30:00+02:00","2026-12-07":"2026-12-07T11:30:00+02:00","2026-12-08":"2026-12-08T11:31:00+02:00","2026-12-09":"2026-12-09T11:31:00+02:00","2026-12-10":"2026-12-10T11:32:00+02:00"},"minchaGedola":{"2026-09-23":"2026-09-23T13:02:00+03:00","2026-09-24":"2026-09-24T13:01:00+03:00","2026-09-25":"2026-09-25T13:01:00+03:00","2026-09-26":"2026-09-26T13:00:00+03:00","2026-09-27":"2026-09-27T13:00:00+03:00","2026-09-28":"2026-09-28T12:59:00+03:00","2026-09-29":"2026-09-29T12:59:00+03:00","2026-09-30":"2026-09-30T12:59:00+03:00","2026-10-01":"2026-10-01T12:58:00+03:00","2026-10-02":"2026-10-02T12:58:00+03:00","2026-10-03":"2026-10-03T12:57:00+03:00","2026-10-04":"2026-10-04T12:57:00+03:00","2026-10-05":"2026-10-05T12:57:00+03:00","2026-10-06":"2026-10-06T12:56:00+03:00","2026-10-07":"2026-10-07T12:56:00+03:00","2026-10-08":"2026-10-08T12:56:00+03:00","2026-10-09":"2026-10-09T12:55:00+03:00","2026-10-10":"2026-10-10T12:55:00+03:00","2026-10-11":"2026-10-11T12:55:00+03:00","2026-10-12":"2026-10-12T12:54:00+03:00","2026-10-13":"2026-10-13T12:54:00+03:00","2026-10-14":"2026-10-14T12:54:00+03:00","2026-10-15":"2026-10-15T12:53:00+03:00","2026-10-16":"2026-10-16T12:53:00+03:00","2026-10-17":"2026-10-17T12:53:00+03:00","2026-10-18":"2026-10-18T12:52:00+03:00","2026-10-19":"2026-10-19T12:52:00+03:00","2026-10-20":"2026-10-20T12:52:00+03:00","2026-10-21":"2026-10-21T12:52:00+03:00","2026-10-22":"2026-10-22T12:51:00+03:00","2026-10-23":"2026-10-23T12:51:00+03:00","2026-10-24":"2026-10-24T12:51:00+03:00","2026-10-25":"2026-10-25T11:51:00+02:00","2026-10-26":"2026-10-26T11:51:00+02:00","2026-10-27":"2026-10-27T11:50:00+02:00","2026-10-28":"2026-10-28T11:50:00+02:00","2026-10-29":"2026-10-29T11:50:00+02:00","2026-10-30":"2026-10-30T11:50:00+02:00","2026-10-31":"2026-10-31T11:50:00+02:00","2026-11-01":"2026-11-01T11:50:00+02:00","2026-11-02":"2026-11-02T11:50:00+02:00","2026-11-03":"2026-11-03T11:50:00+02:00","2026-11-04":"2026-11-04T11:50:00+02:00","2026-11-05":"2026-11-05T11:49:00+02:00","2026-11-06":"2026-11-06T11:49:00+02:00","2026-11-07":"2026-11-07T11:49:00+02:00","2026-11-08":"2026-11-08T11:49:00+02:00","2026-11-09":"2026-11-09T11:49:00+02:00","2026-11-10":"2026-11-10T11:49:00+02:00","2026-11-11":"2026-11-11T11:50:00+02:00","2026-11-12":"2026-11-12T11:50:00+02:00","2026-11-13":"2026-11-13T11:50:00+02:00","2026-11-14":"2026-11-14T11:50:00+02:00","2026-11-15":"2026-11-15T11:50:00+02:00","2026-11-16":"2026-11-16T11:50:00+02:00","2026-11-17":"2026-11-17T11:50:00+02:00","2026-11-18":"2026-11-18T11:50:00+02:00","2026-11-19":"2026-11-19T11:50:00+02:00","2026-11-20":"2026-11-20T11:51:00+02:00","2026-11-21":"2026-11-21T11:51:00+02:00","2026-11-22":"2026-11-22T11:51:00+02:00","2026-11-23":"2026-11-23T11:51:00+02:00","2026-11-24":"2026-11-24T11:52:00+02:00","2026-11-25":"2026-11-25T11:52:00+02:00","2026-11-26":"2026-11-26T11:52:00+02:00","2026-11-27":"2026-11-27T11:52:00+02:00","2026-11-28":"2026-11-28T11:53:00+02:00","2026-11-29":"2026-11-29T11:53:00+02:00","2026-11-30":"2026-11-30T11:53:00+02:00","2026-12-01":"2026-12-01T11:54:00+02:00","2026-12-02":"2026-12-02T11:54:00+02:00","2026-12-03":"2026-12-03T11:54:00+02:00","2026-12-04":"2026-12-04T11:55:00+02:00","2026-12-05":"2026-12-05T11:55:00+02:00","2026-12-06":"2026-12-06T11:55:00+02:00","2026-12-07":"2026-12-07T11:56:00+02:00","2026-12-08":"2026-12-08T11:56:00+02:00","2026-12-09":"2026-12-09T11:57:00+02:00","2026-12-10":"2026-12-10T11:57:00+02:00"},"minchaGedolaBaalHatanya":{"2026-09-23":"2026-09-23T13:02:00+03:00","2026-09-24":"2026-09-24T13:01:00+03:00","2026-09-25":"2026-09-25T13:01:00+03:00","2026-09-26":"2026-09-26T13:01:00+03:00","2026-09-27":"2026-09-27T13:00:00+03:00","2026-09-28":"2026-09-28T13:00:00+03:00","2026-09-29":"2026-09-29T12:59:00+03:00","2026-09-30":"2026-09-30T12:59:00+03:00","2026-10-01":"2026-10-01T12:59:00+03:00","2026-10-02":"2026-10-02T12:58:00+03:00","2026-10-03":"2026-10-03T12:58:00+03:00","2026-10-04":"2026-10-04T12:57:00+03:00","2026-10-05":"2026-10-05T12:57:00+03:00","2026-10-06":"2026-10-06T12:57:00+03:00","2026-10-07":"2026-10-07T12:56:00+03:00","2026-10-08":"2026-10-08T12:56:00+03:00","2026-10-09":"2026-10-09T12:55:00+03:00","2026-10-10":"2026-10-10T12:55:00+03:00","2026-10-11":"2026-10-11T12:55:00+03:00","2026-10-12":"2026-10-12T12:54:00+03:00","2026-10-13":"2026-10-13T12:54:00+03:00","2026-10-14":"2026-10-14T12:54:00+03:00","2026-10-15":"2026-10-15T12:54:00+03:00","2026-10-16":"2026-10-16T12:53:00+03:00","2026-10-17":"2026-10-17T12:53:00+03:00","2026-10-18":"2026-10-18T12:53:00+03:00","2026-10-19":"2026-10-19T12:52:00+03:00","2026-10-20":"2026-10-20T12:52:00+03:00","2026-10-21":"2026-10-21T12:52:00+03:00","2026-10-22":"2026-10-22T12:52:00+03:00","2026-10-23":"2026-10-23T12:51:00+03:00","2026-10-24":"2026-10-24T12:51:00+03:00","2026-10-25":"2026-10-25T11:51:00+02:00","2026-10-26":"2026-10-26T11:51:00+02:00","2026-10-27":"2026-10-27T11:51:00+02:00","2026-10-28":"2026-10-28T11:51:00+02:00","2026-10-29":"2026-10-29T11:50:00+02:00","2026-10-30":"2026-10-30T11:50:00+02:00","2026-10-31":"2026-10-31T11:50:00+02:00","2026-11-01":"2026-11-01T11:50:00+02:00","2026-11-02":"2026-11-02T11:50:00+02:00","2026-11-03":"2026-11-03T11:50:00+02:00","2026-11-04":"2026-11-04T11:50:00+02:00","2026-11-05":"2026-11-05T11:50:00+02:00","2026-11-06":"2026-11-06T11:50:00+02:00","2026-11-07":"2026-11-07T11:50:00+02:00","2026-11-08":"2026-11-08T11:50:00+02:00","2026-11-09":"2026-11-09T11:50:00+02:00","2026-11-10":"2026-11-10T11:50:00+02:00","2026-11-11":"2026-11-11T11:50:00+02:00","2026-11-12":"2026-11-12T11:50:00+02:00","2026-11-13":"2026-11-13T11:50:00+02:00","2026-11-14":"2026-11-14T11:50:00+02:00","2026-11-15":"2026-11-15T11:50:00+02:00","2026-11-16":"2026-11-16T11:50:00+02:00","2026-11-17":"2026-11-17T11:50:00+02:00","2026-11-18":"2026-11-18T11:51:00+02:00","2026-11-19":"2026-11-19T11:51:00+02:00","2026-11-20":"2026-11-20T11:51:00+02:00","2026-11-21":"2026-11-21T11:51:00+02:00","2026-11-22":"2026-11-22T11:51:00+02:00","2026-11-23":"2026-11-23T11:52:00+02:00","2026-11-24":"2026-11-24T11:52:00+02:00","2026-11-25":"2026-11-25T11:52:00+02:00","2026-11-26":"2026-11-26T11:52:00+02:00","2026-11-27":"2026-11-27T11:53:00+02:00","2026-11-28":"2026-11-28T11:53:00+02:00","2026-11-29":"2026-11-29T11:53:00+02:00","2026-11-30":"2026-11-30T11:54:00+02:00","2026-12-01":"2026-12-01T11:54:00+02:00","2026-12-02":"2026-12-02T11:54:00+02:00","2026-12-03":"2026-12-03T11:55:00+02:00","2026-12-04":"2026-12-04T11:55:00+02:00","2026-12-05":"2026-12-05T11:55:00+02:00","2026-12-06":"2026-12-06T11:56:00+02:00","2026-12-07":"2026-12-07T11:56:00+02:00","2026-12-08":"2026-12-08T11:57:00+02:00","2026-12-09":"2026-12-09T11:57:00+02:00","2026-12-10":"2026-12-10T11:57:00+02:00"},"minchaGedolaMGA":{"2026-09-23":"2026-09-23T13:08:00+03:00","2026-09-24":"2026-09-24T13:07:00+03:00","2026-09-25":"2026-09-25T13:07:00+03:00","2026-09-26":"2026-09-26T13:06:00+03:00","2026-09-27":"2026-09-27T13:06:00+03:00","2026-09-28":"2026-09-28T13:05:00+03:00","2026-09-29":"2026-09-29T13:05:00+03:00","2026-09-30":"2026-09-30T13:05:00+03:00","2026-10-01":"2026-10-01T13:04:00+03:00","2026-10-02":"2026-10-02T13:04:00+03:00","2026-10-03":"2026-10-03T13:03:00+03:00","2026-10-04":"2026-10-04T13:03:00+03:00","2026-10-05":"2026-10-05T13:03:00+03:00","2026-10-06":"2026-10-06T13:02:00+03:00","2026-10-07":"2026-10-07T13:02:00+03:00","2026-10-08":"2026-10-08T13:02:00+03:00","2026-10-09":"2026-10-09T13:01:00+03:00","2026-10-10":"2026-10-10T13:01:00+03:00","2026-10-11":"2026-10-11T13:01:00+03:00","2026-10-12":"2026-10-12T13:00:00+03:00","2026-10-13":"2026-10-13T13:00:00+03:00","2026-10-14":"2026-10-14T13:00:00+03:00","2026-10-15":"2026-10-15T12:59:00+03:00","2026-10-16":"2026-10-16T12:59:00+03:00","2026-10-17":"2026-10-17T12:59:00+03:00","2026-10-18":"2026-10-18T12:58:00+03:00","2026-10-19":"2026-10-19T12:58:00+03:00","2026-10-20":"2026-10-20T12:58:00+03:00","2026-10-21":"2026-10-21T12:58:00+03:00","2026-10-22":"2026-10-22T12:57:00+03:00","2026-10-23":"2026-10-23T12:57:00+03:00","2026-10-24":"2026-10-24T12:57:00+03:00","2026-10-25":"2026-10-25T11:57:00+02:00","2026-10-26":"2026-10-26T11:57:00+02:00","2026-10-27":"2026-10-27T11:56:00+02:00","2026-10-28":"2026-10-28T11:56:00+02:00","2026-10-29":"2026-10-29T11:56:00+02:00","2026-10-30":"2026-10-30T11:56:00+02:00","2026-10-31":"2026-10-31T11:56:00+02:00","2026-11-01":"2026-11-01T11:56:00+02:00","2026-11-02":"2026-11-02T11:56:00+02:00","2026-11-03":"2026-11-03T11:56:00+02:00","2026-11-04":"2026-11-04T11:56:00+02:00","2026-11-05":"2026-11-05T11:55:00+02:00","2026-11-06":"2026-11-06T11:55:00+02:00","2026-11-07":"2026-11-07T11:55:00+02:00","2026-11-08":"2026-11-08T11:55:00+02:00","2026-11-09":"2026-11-09T11:55:00+02:00","2026-11-10":"2026-11-10T11:55:00+02:00","2026-11-11":"2026-11-11T11:56:00+02:00","2026-11-12":"2026-11-12T11:56:00+02:00","2026-11-13":"2026-11-13T11:56:00+02:00","2026-11-14":"2026-11-14T11:56:00+02:00","2026-11-15":"2026-11-15T11:56:00+02:00","2026-11-16":"2026-11-16T11:56:00+02:00","2026-11-17":"2026-11-17T11:56:00+02:00","2026-11-18":"2026-11-18T11:56:00+02:00","2026-11-19":"2026-11-19T11:56:00+02:00","2026-11-20":"2026-11-20T11:57:00+02:00","2026-11-21":"2026-11-21T11:57:00+02:00","2026-11-22":"2026-11-22T11:57:00+02:00","2026-11-23":"2026-11-23T11:57:00+02:00","2026-11-24":"2026-11-24T11:58:00+02:00","2026-11-25":"2026-11-25T11:58:00+02:00","2026-11-26":"2026-11-26T11:58:00+02:00","2026-11-27":"2026-11-27T11:58:00+02:00","2026-11-28":"2026-11-28T11:59:00+02:00","2026-11-29":"2026-11-29T11:59:00+02:00","2026-11-30":"2026-11-30T11:59:00+02:00","2026-12-01":"2026-12-01T12:00:00+02:00","2026-12-02":"2026-12-02T12:00:00+02:00","2026-12-03":"2026-12-03T12:00:00+02:00","2026-12-04":"2026-12-04T12:01:00+02:00","2026-12-05":"2026-12-05T12:01:00+02:00","2026-12-06":"2026-12-06T12:01:00+02:00","2026-12-07":"2026-12-07T12:02:00+02:00","2026-12-08":"2026-12-08T12:02:00+02:00","2026-12-09":"2026-12-09T12:03:00+02:00","2026-12-10":"2026-12-10T12:03:00+02:00"},"minchaKetana":{"2026-09-23":"2026-09-23T16:03:00+03:00","2026-09-24":"2026-09-24T16:02:00+03:00","2026-09-25":"2026-09-25T16:01:00+03:00","2026-09-26":"2026-09-26T16:01:00+03:00","2026-09-27":"2026-09-27T16:00:00+03:00","2026-09-28":"2026-09-28T15:59:00+03:00","2026-09-29":"2026-09-29T15:58:00+03:00","2026-09-30":"2026-09-30T15:57:00+03:00","2026-10-01":"2026-10-01T15:56:00+03:00","2026-10-02":"2026-10-02T15:55:00+03:00","2026-10-03":"2026-10-03T15:54:00+03:00","2026-10-04":"2026-10-04T15:53:00+03:00","2026-10-05":"2026-10-05T15:53:00+03:00","2026-10-06":"2026-10-06T15:52:00+03:00","2026-10-07":"2026-10-07T15:51:00+03:00","2026-10-08":"2026-10-08T15:50:00+03:00","2026-10-09":"2026-10-09T15:49:00+03:00","2026-10-10":"2026-10-10T15:48:00+03:00","2026-10-11":"2026-10-11T15:48:00+03:00","2026-10-12":"2026-10-12T15:47:00+03:00","2026-10-13":"2026-10-13T15:46:00+03:00","2026-10-14":"2026-10-14T15:45:00+03:00","2026-10-15":"2026-10-15T15:44:00+03:00","2026-10-16":"2026-10-16T15:44:00+03:00","2026-10-17":"2026-10-17T15:43:00+03:00","2026-10-18":"2026-10-18T15:42:00+03:00","2026-10-19":"2026-10-19T15:41:00+03:00","2026-10-20":"2026-10-20T15:41:00+03:00","2026-10-21":"2026-10-21T15:40:00+03:00","2026-10-22":"2026-10-22T15:39:00+03:00","2026-10-23":"2026-10-23T15:39:00+03:00","2026-10-24":"2026-10-24T15:38:00+03:00","2026-10-25":"2026-10-25T14:37:00+02:00","2026-10-26":"2026-10-26T14:37:00+02:00","2026-10-27":"2026-10-27T14:36:00+02:00","2026-10-28":"2026-10-28T14:36:00+02:00","2026-10-29":"2026-10-29T14:35:00+02:00","2026-10-30":"2026-10-30T14:34:00+02:00","2026-10-31":"2026-10-31T14:34:00+02:00","2026-11-01":"2026-11-01T14:33:00+02:00","2026-11-02":"2026-11-02T14:33:00+02:00","2026-11-03":"2026-11-03T14:32:00+02:00","2026-11-04":"2026-11-04T14:32:00+02:00","2026-11-05":"2026-11-05T14:31:00+02:00","2026-11-06":"2026-11-06T14:31:00+02:00","2026-11-07":"2026-11-07T14:31:00+02:00","2026-11-08":"2026-11-08T14:30:00+02:00","2026-11-09":"2026-11-09T14:30:00+02:00","2026-11-10":"2026-11-10T14:29:00+02:00","2026-11-11":"2026-11-11T14:29:00+02:00","2026-11-12":"2026-11-12T14:29:00+02:00","2026-11-13":"2026-11-13T14:29:00+02:00","2026-11-14":"2026-11-14T14:28:00+02:00","2026-11-15":"2026-11-15T14:28:00+02:00","2026-11-16":"2026-11-16T14:28:00+02:00","2026-11-17":"2026-11-17T14:28:00+02:00","2026-11-18":"2026-11-18T14:27:00+02:00","2026-11-19":"2026-11-19T14:27:00+02:00","2026-11-20":"2026-11-20T14:27:00+02:00","2026-11-21":"2026-11-21T14:27:00+02:00","2026-11-22":"2026-11-22T14:27:00+02:00","2026-11-23":"2026-11-23T14:27:00+02:00","2026-11-24":"2026-11-24T14:27:00+02:00","2026-11-25":"2026-11-25T14:27:00+02:00","2026-11-26":"2026-11-26T14:27:00+02:00","2026-11-27":"2026-11-27T14:27:00+02:00","2026-11-28":"2026-11-28T14:27:00+02:00","2026-11-29":"2026-11-29T14:27:00+02:00","2026-11-30":"2026-11-30T14:27:00+02:00","2026-12-01":"2026-12-01T14:27:00+02:00","2026-12-02":"2026-12-02T14:27:00+02:00","2026-12-03":"2026-12-03T14:27:00+02:00","2026-12-04":"2026-12-04T14:28:00+02:00","2026-12-05":"2026-12-05T14:28:00+02:00","2026-12-06":"2026-12-06T14:28:00+02:00","2026-12-07":"2026-12-07T14:28:00+02:00","2026-12-08":"2026-12-08T14:28:00+02:00","2026-12-09":"2026-12-09T14:29:00+02:00","2026-12-10":"2026-12-10T14:29:00+02:00"},"minchaKetanaBaalHatanya":{"2026-09-23":"2026-09-23T16:05:00+03:00","2026-09-24":"2026-09-24T16:04:00+03:00","2026-09-25":"2026-09-25T16:04:00+03:00","2026-09-26":"2026-09-26T16:03:00+03:00","2026-09-27":"2026-09-27T16:02:00+03:00","2026-09-28":"2026-09-28T16:01:00+03:00","2026-09-29":"2026-09-29T16:00:00+03:00","2026-09-30":"2026-09-30T15:59:00+03:00","2026-10-01":"2026-10-01T15:58:00+03:00","2026-10-02":"2026-10-02T15:57:00+03:00","2026-10-03":"2026-10-03T15:56:00+03:00","2026-10-04":"2026-10-04T15:56:00+03:00","2026-10-05":"2026-10-05T15:55:00+03:00","2026-10-06":"2026-10-06T15:54:00+03:00","2026-10-07":"2026-10-07T15:53:00+03:00","2026-10-08":"2026-10-08T15:52:00+03:00","2026-10-09":"2026-10-09T15:51:00+03:00","2026-10-10":"2026-10-10T15:51:00+03:00","2026-10-11":"2026-10-11T15:50:00+03:00","2026-10-12":"2026-10-12T15:49:00+03:00","2026-10-13":"2026-10-13T15:48:00+03:00","2026-10-14":"2026-10-14T15:47:00+03:00","2026-10-15":"2026-10-15T15:47:00+03:00","2026-10-16":"2026-10-16T15:46:00+03:00","2026-10-17":"2026-10-17T15:45:00+03:00","2026-10-18":"2026-10-18T15:44:00+03:00","2026-10-19":"2026-10-19T15:44:00+03:00","2026-10-20":"2026-10-20T15:43:00+03:00","2026-10-21":"2026-10-21T15:42:00+03:00","2026-10-22":"2026-10-22T15:41:00+03:00","2026-10-23":"2026-10-23T15:41:00+03:00","2026-10-24":"2026-10-24T15:40:00+03:00","2026-10-25":"2026-10-25T14:40:00+02:00","2026-10-26":"2026-10-26T14:39:00+02:00","2026-10-27":"2026-10-27T14:38:00+02:00","2026-10-28":"2026-10-28T14:38:00+02:00","2026-10-29":"2026-10-29T14:37:00+02:00","2026-10-30":"2026-10-30T14:37:00+02:00","2026-10-31":"2026-10-31T14:36:00+02:00","2026-11-01":"2026-11-01T14:36:00+02:00","2026-11-02":"2026-11-02T14:35:00+02:00","2026-11-03":"2026-11-03T14:35:00+02:00","2026-11-04":"2026-11-04T14:34:00+02:00","2026-11-05":"2026-11-05T14:34:00+02:00","2026-11-06":"2026-11-06T14:33:00+02:00","2026-11-07":"2026-11-07T14:33:00+02:00","2026-11-08":"2026-11-08T14:32:00+02:00","2026-11-09":"2026-11-09T14:32:00+02:00","2026-11-10":"2026-11-10T14:32:00+02:00","2026-11-11":"2026-11-11T14:31:00+02:00","2026-11-12":"2026-11-12T14:31:00+02:00","2026-11-13":"2026-11-13T14:31:00+02:00","2026-11-14":"2026-11-14T14:30:00+02:00","2026-11-15":"2026-11-15T14:30:00+02:00","2026-11-16":"2026-11-16T14:30:00+02:00","2026-11-17":"2026-11-17T14:30:00+02:00","2026-11-18":"2026-11-18T14:30:00+02:00","2026-11-19":"2026-11-19T14:29:00+02:00","2026-11-20":"2026-11-20T14:29:00+02:00","2026-11-21":"2026-11-21T14:29:00+02:00","2026-11-22":"2026-11-22T14:29:00+02:00","2026-11-23":"2026-11-23T14:29:00+02:00","2026-11-24":"2026-11-24T14:29:00+02:00","2026-11-25":"2026-11-25T14:29:00+02:00","2026-11-26":"2026-11-26T14:29:00+02:00","2026-11-27":"2026-11-27T14:29:00+02:00","2026-11-28":"2026-11-28T14:29:00+02:00","2026-11-29":"2026-11-29T14:29:00+02:00","2026-11-30":"2026-11-30T14:29:00+02:00","2026-12-01":"2026-12-01T14:29:00+02:00","2026-12-02":"2026-12-02T14:29:00+02:00","2026-12-03":"2026-12-03T14:30:00+02:00","2026-12-04":"2026-12-04T14:30:00+02:00","2026-12-05":"2026-12-05T14:30:00+02:00","2026-12-06":"2026-12-06T14:30:00+02:00","2026-12-07":"2026-12-07T14:30:00+02:00","2026-12-08":"2026-12-08T14:31:00+02:00","2026-12-09":"2026-12-09T14:31:00+02:00","2026-12-10":"2026-12-10T14:31:00+02:00"},"minchaKetanaMGA":{"2026-09-23":"2026-09-23T16:45:00+03:00","2026-09-24":"2026-09-24T16:44:00+03:00","2026-09-25":"2026-09-25T16:43:00+03:00","2026-09-26":"2026-09-26T16:43:00+03:00","2026-09-27":"2026-09-27T16:42:00+03:00","2026-09-28":"2026-09-28T16:41:00+03:00","2026-09-29":"2026-09-29T16:40:00+03:00","2026-09-30":"2026-09-30T16:39:00+03:00","2026-10-01":"2026-10-01T16:38:00+03:00","2026-10-02":"2026-10-02T16:37:00+03:00","2026-10-03":"2026-10-03T16:36:00+03:00","2026-10-04":"2026-10-04T16:35:00+03:00","2026-10-05":"2026-10-05T16:35:00+03:00","2026-10-06":"2026-10-06T16:34:00+03:00","2026-10-07":"2026-10-07T16:33:00+03:00","2026-10-08":"2026-10-08T16:32:00+03:00","2026-10-09":"2026-10-09T16:31:00+03:00","2026-10-10":"2026-10-10T16:30:00+03:00","2026-10-11":"2026-10-11T16:30:00+03:00","2026-10-12":"2026-10-12T16:29:00+03:00","2026-10-13":"2026-10-13T16:28:00+03:00","2026-10-14":"2026-10-14T16:27:00+03:00","2026-10-15":"2026-10-15T16:26:00+03:00","2026-10-16":"2026-10-16T16:26:00+03:00","2026-10-17":"2026-10-17T16:25:00+03:00","2026-10-18":"2026-10-18T16:24:00+03:00","2026-10-19":"2026-10-19T16:23:00+03:00","2026-10-20":"2026-10-20T16:23:00+03:00","2026-10-21":"2026-10-21T16:22:00+03:00","2026-10-22":"2026-10-22T16:21:00+03:00","2026-10-23":"2026-10-23T16:21:00+03:00","2026-10-24":"2026-10-24T16:20:00+03:00","2026-10-25":"2026-10-25T15:19:00+02:00","2026-10-26":"2026-10-26T15:19:00+02:00","2026-10-27":"2026-10-27T15:18:00+02:00","2026-10-28":"2026-10-28T15:18:00+02:00","2026-10-29":"2026-10-29T15:17:00+02:00","2026-10-30":"2026-10-30T15:16:00+02:00","2026-10-31":"2026-10-31T15:16:00+02:00","2026-11-01":"2026-11-01T15:15:00+02:00","2026-11-02":"2026-11-02T15:15:00+02:00","2026-11-03":"2026-11-03T15:14:00+02:00","2026-11-04":"2026-11-04T15:14:00+02:00","2026-11-05":"2026-11-05T15:13:00+02:00","2026-11-06":"2026-11-06T15:13:00+02:00","2026-11-07":"2026-11-07T15:13:00+02:00","2026-11-08":"2026-11-08T15:12:00+02:00","2026-11-09":"2026-11-09T15:12:00+02:00","2026-11-10":"2026-11-10T15:11:00+02:00","2026-11-11":"2026-11-11T15:11:00+02:00","2026-11-12":"2026-11-12T15:11:00+02:00","2026-11-13":"2026-11-13T15:11:00+02:00","2026-11-14":"2026-11-14T15:10:00+02:00","2026-11-15":"2026-11-15T15:10:00+02:00","2026-11-16":"2026-11-16T15:10:00+02:00","2026-11-17":"2026-11-17T15:10:00+02:00","2026-11-18":"2026-11-18T15:09:00+02:00","2026-11-19":"2026-11-19T15:09:00+02:00","2026-11-20":"2026-11-20T15:09:00+02:00","2026-11-21":"2026-11-21T15:09:00+02:00","2026-11-22":"2026-11-22T15:09:00+02:00","2026-11-23":"2026-11-23T15:09:00+02:00","2026-11-24":"2026-11-24T15:09:00+02:00","2026-11-25":"2026-11-25T15:09:00+02:00","2026-11-26":"2026-11-26T15:09:00+02:00","2026-11-27":"2026-11-27T15:09:00+02:00","2026-11-28":"2026-11-28T15:09:00+02:00","2026-11-29":"2026-11-29T15:09:00+02:00","2026-11-30":"2026-11-30T15:09:00+02:00","2026-12-01":"2026-12-01T15:09:00+02:00","2026-12-02":"2026-12-02T15:09:00+02:00","2026-12-03":"2026-12-03T15:09:00+02:00","2026-12-04":"2026-12-04T15:10:00+02:00","2026-12-05":"2026-12-05T15:10:00+02:00","2026-12-06":"2026-12-06T15:10:00+02:00","2026-12-07":"2026-12-07T15:10:00+02:00","2026-12-08":"2026-12-08T15:10:00+02:00","2026-12-09":"2026-12-09T15:11:00+02:00","2026-12-10":"2026-12-10T15:11:00+02:00"},"plagHaMincha":{"2026-09-23":"2026-09-23T17:19:00+03:00","2026-09-24":"2026-09-24T17:18:00+03:00","2026-09-25":"2026-09-25T17:17:00+03:00","2026-09-26":"2026-09-26T17:16:00+03:00","2026-09-27":"2026-09-27T17:15:00+03:00","2026-09-28":"2026-09-28T17:14:00+03:00","2026-09-29":"2026-09-29T17:12:00+03:00","2026-09-30":"2026-09-30T17:11:00+03:00","2026-10-01":"2026-10-01T17:10:00+03:00","2026-10-02":"2026-10-02T17:09:00+03:00","2026-10-03":"2026-10-03T17:08:00+03:00","2026-10-04":"2026-10-04T17:07:00+03:00","2026-10-05":"2026-10-05T17:06:00+03:00","2026-10-06":"2026-10-06T17:05:00+03:00","2026-10-07":"2026-10-07T17:04:00+03:00","2026-10-08":"2026-10-08T17:03:00+03:00","2026-10-09":"2026-10-09T17:02:00+03:00","2026-10-10":"2026-10-10T17:01:00+03:00","2026-10-11":"2026-10-11T17:00:00+03:00","2026-10-12":"2026-10-12T16:59:00+03:00","2026-10-13":"2026-10-13T16:58:00+03:00","2026-10-14":"2026-10-14T16:57:00+03:00","2026-10-15":"2026-10-15T16:56:00+03:00","2026-10-16":"2026-10-16T16:55:00+03:00","2026-10-17":"2026-10-17T16:54:00+03:00","2026-10-18":"2026-10-18T16:53:00+03:00","2026-10-19":"2026-10-19T16:52:00+03:00","2026-10-20":"2026-10-20T16:51:00+03:00","2026-10-21":"2026-10-21T16:50:00+03:00","2026-10-22":"2026-10-22T16:49:00+03:00","2026-10-23":"2026-10-23T16:49:00+03:00","2026-10-24":"2026-10-24T16:48:00+03:00","2026-10-25":"2026-10-25T15:47:00+02:00","2026-10-26":"2026-10-26T15:46:00+02:00","2026-10-27":"2026-10-27T15:45:00+02:00","2026-10-28":"2026-10-28T15:44:00+02:00","2026-10-29":"2026-10-29T15:44:00+02:00","2026-10-30":"2026-10-30T15:43:00+02:00","2026-10-31":"2026-10-31T15:42:00+02:00","2026-11-01":"2026-11-01T15:42:00+02:00","2026-11-02":"2026-11-02T15:41:00+02:00","2026-11-03":"2026-11-03T15:40:00+02:00","2026-11-04":"2026-11-04T15:40:00+02:00","2026-11-05":"2026-11-05T15:39:00+02:00","2026-11-06":"2026-11-06T15:38:00+02:00","2026-11-07":"2026-11-07T15:38:00+02:00","2026-11-08":"2026-11-08T15:37:00+02:00","2026-11-09":"2026-11-09T15:37:00+02:00","2026-11-10":"2026-11-10T15:36:00+02:00","2026-11-11":"2026-11-11T15:36:00+02:00","2026-11-12":"2026-11-12T15:35:00+02:00","2026-11-13":"2026-11-13T15:35:00+02:00","2026-11-14":"2026-11-14T15:34:00+02:00","2026-11-15":"2026-11-15T15:34:00+02:00","2026-11-16":"2026-11-16T15:34:00+02:00","2026-11-17":"2026-11-17T15:33:00+02:00","2026-11-18":"2026-11-18T15:33:00+02:00","2026-11-19":"2026-11-19T15:33:00+02:00","2026-11-20":"2026-11-20T15:32:00+02:00","2026-11-21":"2026-11-21T15:32:00+02:00","2026-11-22":"2026-11-22T15:32:00+02:00","2026-11-23":"2026-11-23T15:32:00+02:00","2026-11-24":"2026-11-24T15:32:00+02:00","2026-11-25":"2026-11-25T15:31:00+02:00","2026-11-26":"2026-11-26T15:31:00+02:00","2026-11-27":"2026-11-27T15:31:00+02:00","2026-11-28":"2026-11-28T15:31:00+02:00","2026-11-29":"2026-11-29T15:31:00+02:00","2026-11-30":"2026-11-30T15:31:00+02:00","2026-12-01":"2026-12-01T15:31:00+02:00","2026-12-02":"2026-12-02T15:31:00+02:00","2026-12-03":"2026-12-03T15:31:00+02:00","2026-12-04":"2026-12-04T15:31:00+02:00","2026-12-05":"2026-12-05T15:31:00+02:00","2026-12-06":"2026-12-06T15:32:00+02:00","2026-12-07":"2026-12-07T15:32:00+02:00","2026-12-08":"2026-12-08T15:32:00+02:00","2026-12-09":"2026-12-09T15:32:00+02:00","2026-12-10":"2026-12-10T15:32:00+02:00"},"plagHaminchaBaalHatanya":{"2026-09-23":"2026-09-23T17:22:00+03:00","2026-09-24":"2026-09-24T17:21:00+03:00","2026-09-25":"2026-09-25T17:20:00+03:00","2026-09-26":"2026-09-26T17:18:00+03:00","2026-09-27":"2026-09-27T17:17:00+03:00","2026-09-28":"2026-09-28T17:16:00+03:00","2026-09-29":"2026-09-29T17:15:00+03:00","2026-09-30":"2026-09-30T17:14:00+03:00","2026-10-01":"2026-10-01T17:13:00+03:00","2026-10-02":"2026-10-02T17:12:00+03:00","2026-10-03":"2026-10-03T17:11:00+03:00","2026-10-04":"2026-10-04T17:10:00+03:00","2026-10-05":"2026-10-05T17:09:00+03:00","2026-10-06":"2026-10-06T17:08:00+03:00","2026-10-07":"2026-10-07T17:07:00+03:00","2026-10-08":"2026-10-08T17:06:00+03:00","2026-10-09":"2026-10-09T17:05:00+03:00","2026-10-10":"2026-10-10T17:04:00+03:00","2026-10-11":"2026-10-11T17:03:00+03:00","2026-10-12":"2026-10-12T17:02:00+03:00","2026-10-13":"2026-10-13T17:01:00+03:00","2026-10-14":"2026-10-14T17:00:00+03:00","2026-10-15":"2026-10-15T16:59:00+03:00","2026-10-16":"2026-10-16T16:58:00+03:00","2026-10-17":"2026-10-17T16:57:00+03:00","2026-10-18":"2026-10-18T16:56:00+03:00","2026-10-19":"2026-10-19T16:55:00+03:00","2026-10-20":"2026-10-20T16:54:00+03:00","2026-10-21":"2026-10-21T16:53:00+03:00","2026-10-22":"2026-10-22T16:52:00+03:00","2026-10-23":"2026-10-23T16:51:00+03:00","2026-10-24":"2026-10-24T16:51:00+03:00","2026-10-25":"2026-10-25T15:50:00+02:00","2026-10-26":"2026-10-26T15:49:00+02:00","2026-10-27":"2026-10-27T15:48:00+02:00","2026-10-28":"2026-10-28T15:47:00+02:00","2026-10-29":"2026-10-29T15:47:00+02:00","2026-10-30":"2026-10-30T15:46:00+02:00","2026-10-31":"2026-10-31T15:45:00+02:00","2026-11-01":"2026-11-01T15:44:00+02:00","2026-11-02":"2026-11-02T15:44:00+02:00","2026-11-03":"2026-11-03T15:43:00+02:00","2026-11-04":"2026-11-04T15:42:00+02:00","2026-11-05":"2026-11-05T15:42:00+02:00","2026-11-06":"2026-11-06T15:41:00+02:00","2026-11-07":"2026-11-07T15:41:00+02:00","2026-11-08":"2026-11-08T15:40:00+02:00","2026-11-09":"2026-11-09T15:40:00+02:00","2026-11-10":"2026-11-10T15:39:00+02:00","2026-11-11":"2026-11-11T15:39:00+02:00","2026-11-12":"2026-11-12T15:38:00+02:00","2026-11-13":"2026-11-13T15:38:00+02:00","2026-11-14":"2026-11-14T15:37:00+02:00","2026-11-15":"2026-11-15T15:37:00+02:00","2026-11-16":"2026-11-16T15:37:00+02:00","2026-11-17":"2026-11-17T15:36:00+02:00","2026-11-18":"2026-11-18T15:36:00+02:00","2026-11-19":"2026-11-19T15:36:00+02:00","2026-11-20":"2026-11-20T15:35:00+02:00","2026-11-21":"2026-11-21T15:35:00+02:00","2026-11-22":"2026-11-22T15:35:00+02:00","2026-11-23":"2026-11-23T15:35:00+02:00","2026-11-24":"2026-11-24T15:35:00+02:00","2026-11-25":"2026-11-25T15:34:00+02:00","2026-11-26":"2026-11-26T15:34:00+02:00","2026-11-27":"2026-11-27T15:34:00+02:00","2026-11-28":"2026-11-28T15:34:00+02:00","2026-11-29":"2026-11-29T15:34:00+02:00","2026-11-30":"2026-11-30T15:34:00+02:00","2026-12-01":"2026-12-01T15:34:00+02:00","2026-12-02":"2026-12-02T15:34:00+02:00","2026-12-03":"2026-12-03T15:34:00+02:00","2026-12-04":"2026-12-04T15:34:00+02:00","2026-12-05":"2026-12-05T15:34:00+02:00","2026-12-06":"2026-12-06T15:35:00+02:00","2026-12-07":"2026-12-07T15:35:00+02:00","2026-12-08":"2026-12-08T15:35:00+02:00","2026-12-09":"2026-12-09T15:35:00+02:00","2026-12-10":"2026-12-10T15:35:00+02:00"},"seaLevelSunset":{},"sunset":{"2026-09-23":"2026-09-23T18:35:00+03:00","2026-09-24":"2026-09-24T18:33:00+03:00","2026-09-25":"2026-09-25T18:32:00+03:00","2026-09-26":"2026-09-26T18:31:00+03:00","2026-09-27":"2026-09-27T18:30:00+03:00","2026-09-28":"2026-09-28T18:28:00+03:00","2026-09-29":"2026-09-29T18:27:00+03:00","2026-09-30":"2026-09-30T18:26:00+03:00","2026-10-01":"2026-10-01T18:24:00+03:00","2026-10-02":"2026-10-02T18:23:00+03:00","2026-10-03":"2026-10-03T18:22:00+03:00","2026-10-04":"2026-10-04T18:21:00+03:00","2026-10-05":"2026-10-05T18:19:00+03:00","2026-10-06":"2026-10-06T18:18:00+03:00","2026-10-07":"2026-10-07T18:17:00+03:00","2026-10-08":"2026-10-08T18:16:00+03:00","2026-10-09":"2026-10-09T18:14:00+03:00","2026-10-10":"2026-10-10T18:13:00+03:00","2026-10-11":"2026-10-11T18:12:00+03:00","2026-10-12":"2026-10-12T18:11:00+03:00","2026-10-13":"2026-10-13T18:09:00+03:00","2026-10-14":"2026-10-14T18:08:00+03:00","2026-10-15":"2026-10-15T18:07:00+03:00","2026-10-16":"2026-10-16T18:06:00+03:00","2026-10-17":"2026-10-17T18:05:00+03:00","2026-10-18":"2026-10-18T18:04:00+03:00","2026-10-19":"2026-10-19T18:03:00+03:00","2026-10-20":"2026-10-20T18:02:00+03:00","2026-10-21":"2026-10-21T18:00:00+03:00","2026-10-22":"2026-10-22T17:59:00+03:00","2026-10-23":"2026-10-23T17:58:00+03:00","2026-10-24":"2026-10-24T17:57:00+03:00","2026-10-25":"2026-10-25T16:56:00+02:00","2026-10-26":"2026-10-26T16:55:00+02:00","2026-10-27":"2026-10-27T16:54:00+02:00","2026-10-28":"2026-10-28T16:53:00+02:00","2026-10-29":"2026-10-29T16:52:00+02:00","2026-10-30":"2026-10-30T16:52:00+02:00","2026-10-31":"2026-10-31T16:51:00+02:00","2026-11-01":"2026-11-01T16:50:00+02:00","2026-11-02":"2026-11-02T16:49:00+02:00","2026-11-03":"2026-11-03T16:48:00+02:00","2026-11-04":"2026-11-04T16:47:00+02:00","2026-11-05":"2026-11-05T16:46:00+02:00","2026-11-06":"2026-11-06T16:46:00+02:00","2026-11-07":"2026-11-07T16:45:00+02:00","2026-11-08":"2026-11-08T16:44:00+02:00","2026-11-09":"2026-11-09T16:44:00+02:00","2026-11-10":"2026-11-10T16:43:00+02:00","2026-11-11":"2026-11-11T16:42:00+02:00","2026-11-12":"2026-11-12T16:42:00+02:00","2026-11-13":"2026-11-13T16:41:00+02:00","2026-11-14":"2026-11-14T16:40:00+02:00","2026-11-15":"2026-11-15T16:40:00+02:00","2026-11-16":"2026-11-16T16:39:00+02:00","2026-11-17":"2026-11-17T16:39:00+02:00","2026-11-18":"2026-11-18T16:38:00+02:00","2026-11-19":"2026-11-19T16:38:00+02:00","2026-11-20":"2026-11-20T16:38:00+02:00","2026-11-21":"2026-11-21T16:37:00+02:00","2026-11-22":"2026-11-22T16:37:00+02:00","2026-11-23":"2026-11-23T16:37:00+02:00","2026-11-24":"2026-11-24T16:36:00+02:00","2026-11-25":"2026-11-25T16:36:00+02:00","2026-11-26":"2026-11-26T16:36:00+02:00","2026-11-27":"2026-11-27T16:36:00+02:00","2026-11-28":"2026-11-28T16:35:00+02:00","2026-11-29":"2026-11-29T16:35:00+02:00","2026-11-30":"2026-11-30T16:35:00+02:00","2026-12-01":"2026-12-01T16:35:00+02:00","2026-12-02":"2026-12-02T16:35:00+02:00","2026-12-03":"2026-12-03T16:35:00+02:00","2026-12-04":"2026-12-04T16:35:00+02:00","2026-12-05":"2026-12-05T16:35:00+02:00","2026-12-06":"2026-12-06T16:35:00+02:00","2026-12-07":"2026-12-07T16:35:00+02:00","2026-12-08":"2026-12-08T16:35:00+02:00","2026-12-09":"2026-12-09T16:35:00+02:00","2026-12-10":"2026-12-10T16:36:00+02:00"},"beinHaShmashos":{"2026-09-23":"2026-09-23T18:51:00+03:00","2026-09-24":"2026-09-24T18:49:00+03:00","2026-09-25":"2026-09-25T18:48:00+03:00","2026-09-26":"2026-09-26T18:47:00+03:00","2026-09-27":"2026-09-27T18:45:00+03:00","2026-09-28":"2026-09-28T18:44:00+03:00","2026-09-29":"2026-09-29T18:43:00+03:00","2026-09-30":"2026-09-30T18:42:00+03:00","2026-10-01":"2026-10-01T18:40:00+03:00","2026-10-02":"2026-10-02T18:39:00+03:00","2026-10-03":"2026-10-03T18:38:00+03:00","2026-10-04":"2026-10-04T18:36:00+03:00","2026-10-05":"2026-10-05T18:35:00+03:00","2026-10-06":"2026-10-06T18:34:00+03:00","2026-10-07":"2026-10-07T18:33:00+03:00","2026-10-08":"2026-10-08T18:32:00+03:00","2026-10-09":"2026-10-09T18:30:00+03:00","2026-10-10":"2026-10-10T18:29:00+03:00","2026-10-11":"2026-10-11T18:28:00+03:00","2026-10-12":"2026-10-12T18:27:00+03:00","2026-10-13":"2026-10-13T18:26:00+03:00","2026-10-14":"2026-10-14T18:24:00+03:00","2026-10-15":"2026-10-15T18:23:00+03:00","2026-10-16":"2026-10-16T18:22:00+03:00","2026-10-17":"2026-10-17T18:21:00+03:00","2026-10-18":"2026-10-18T18:20:00+03:00","2026-10-19":"2026-10-19T18:19:00+03:00","2026-10-20":"2026-10-20T18:18:00+03:00","2026-10-21":"2026-10-21T18:17:00+03:00","2026-10-22":"2026-10-22T18:16:00+03:00","2026-10-23":"2026-10-23T18:15:00+03:00","2026-10-24":"2026-10-24T18:14:00+03:00","2026-10-25":"2026-10-25T17:13:00+02:00","2026-10-26":"2026-10-26T17:12:00+02:00","2026-10-27":"2026-10-27T17:11:00+02:00","2026-10-28":"2026-10-28T17:10:00+02:00","2026-10-29":"2026-10-29T17:09:00+02:00","2026-10-30":"2026-10-30T17:08:00+02:00","2026-10-31":"2026-10-31T17:07:00+02:00","2026-11-01":"2026-11-01T17:07:00+02:00","2026-11-02":"2026-11-02T17:06:00+02:00","2026-11-03":"2026-11-03T17:05:00+02:00","2026-11-04":"2026-11-04T17:04:00+02:00","2026-11-05":"2026-11-05T17:04:00+02:00","2026-11-06":"2026-11-06T17:03:00+02:00","2026-11-07":"2026-11-07T17:02:00+02:00","2026-11-08":"2026-11-08T17:01:00+02:00","2026-11-09":"2026-11-09T17:01:00+02:00","2026-11-10":"2026-11-10T17:00:00+02:00","2026-11-11":"2026-11-11T17:00:00+02:00","2026-11-12":"2026-11-12T16:59:00+02:00","2026-11-13":"2026-11-13T16:59:00+02:00","2026-11-14":"2026-11-14T16:58:00+02:00","2026-11-15":"2026-11-15T16:58:00+02:00","2026-11-16":"2026-11-16T16:57:00+02:00","2026-11-17":"2026-11-17T16:57:00+02:00","2026-11-18":"2026-11-18T16:56:00+02:00","2026-11-19":"2026-11-19T16:56:00+02:00","2026-11-20":"2026-11-20T16:56:00+02:00","2026-11-21":"2026-11-21T16:55:00+02:00","2026-11-22":"2026-11-22T16:55:00+02:00","2026-11-23":"2026-11-23T16:55:00+02:00","2026-11-24":"2026-11-24T16:54:00+02:00","2026-11-25":"2026-11-25T16:54:00+02:00","2026-11-26":"2026-11-26T16:54:00+02:00","2026-11-27":"2026-11-27T16:54:00+02:00","2026-11-28":"2026-11-28T16:54:00+02:00","2026-11-29":"2026-11-29T16:54:00+02:00","2026-11-30":"2026-11-30T16:54:00+02:00","2026-12-01":"2026-12-01T16:54:00+02:00","2026-12-02":"2026-12-02T16:54:00+02:00","2026-12-03":"2026-12-03T16:54:00+02:00","2026-12-04":"2026-12-04T16:54:00+02:00","2026-12-05":"2026-12-05T16:54:00+02:00","2026-12-06":"2026-12-06T16:54:00+02:00","2026-12-07":"2026-12-07T16:54:00+02:00","2026-12-08":"2026-12-08T16:54:00+02:00","2026-12-09":"2026-12-09T16:54:00+02:00","2026-12-10":"2026-12-10T16:54:00+02:00"},"dusk":{"2026-09-23":"2026-09-23T18:59:00+03:00","2026-09-24":"2026-09-24T18:58:00+03:00","2026-09-25":"2026-09-25T18:56:00+03:00","2026-09-26":"2026-09-26T18:55:00+03:00","2026-09-27":"2026-09-27T18:54:00+03:00","2026-09-28":"2026-09-28T18:53:00+03:00","2026-09-29":"2026-09-29T18:51:00+03:00","2026-09-30":"2026-09-30T18:50:00+03:00","2026-10-01":"2026-10-01T18:49:00+03:00","2026-10-02":"2026-10-02T18:47:00+03:00","2026-10-03":"2026-10-03T18:46:00+03:00","2026-10-04":"2026-10-04T18:45:00+03:00","2026-10-05":"2026-10-05T18:44:00+03:00","2026-10-06":"2026-10-06T18:42:00+03:00","2026-10-07":"2026-10-07T18:41:00+03:00","2026-10-08":"2026-10-08T18:40:00+03:00","2026-10-09":"2026-10-09T18:39:00+03:00","2026-10-10":"2026-10-10T18:38:00+03:00","2026-10-11":"2026-10-11T18:36:00+03:00","2026-10-12":"2026-10-12T18:35:00+03:00","2026-10-13":"2026-10-13T18:34:00+03:00","2026-10-14":"2026-10-14T18:33:00+03:00","2026-10-15":"2026-10-15T18:32:00+03:00","2026-10-16":"2026-10-16T18:31:00+03:00","2026-10-17":"2026-10-17T18:29:00+03:00","2026-10-18":"2026-10-18T18:28:00+03:00","2026-10-19":"2026-10-19T18:27:00+03:00","2026-10-20":"2026-10-20T18:26:00+03:00","2026-10-21":"2026-10-21T18:25:00+03:00","2026-10-22":"2026-10-22T18:24:00+03:00","2026-10-23":"2026-10-23T18:23:00+03:00","2026-10-24":"2026-10-24T18:22:00+03:00","2026-10-25":"2026-10-25T17:21:00+02:00","2026-10-26":"2026-10-26T17:20:00+02:00","2026-10-27":"2026-10-27T17:19:00+02:00","2026-10-28":"2026-10-28T17:18:00+02:00","2026-10-29":"2026-10-29T17:17:00+02:00","2026-10-30":"2026-10-30T17:17:00+02:00","2026-10-31":"2026-10-31T17:16:00+02:00","2026-11-01":"2026-11-01T17:15:00+02:00","2026-11-02":"2026-11-02T17:14:00+02:00","2026-11-03":"2026-11-03T17:13:00+02:00","2026-11-04":"2026-11-04T17:13:00+02:00","2026-11-05":"2026-11-05T17:12:00+02:00","2026-11-06":"2026-11-06T17:11:00+02:00","2026-11-07":"2026-11-07T17:10:00+02:00","2026-11-08":"2026-11-08T17:10:00+02:00","2026-11-09":"2026-11-09T17:09:00+02:00","2026-11-10":"2026-11-10T17:08:00+02:00","2026-11-11":"2026-11-11T17:08:00+02:00","2026-11-12":"2026-11-12T17:07:00+02:00","2026-11-13":"2026-11-13T17:07:00+02:00","2026-11-14":"2026-11-14T17:06:00+02:00","2026-11-15":"2026-11-15T17:06:00+02:00","2026-11-16":"2026-11-16T17:05:00+02:00","2026-11-17":"2026-11-17T17:05:00+02:00","2026-11-18":"2026-11-18T17:04:00+02:00","2026-11-19":"2026-11-19T17:04:00+02:00","2026-11-20":"2026-11-20T17:04:00+02:00","2026-11-21":"2026-11-21T17:03:00+02:00","2026-11-22":"2026-11-22T17:03:00+02:00","2026-11-23":"2026-11-23T17:03:00+02:00","2026-11-24":"2026-11-24T17:02:00+02:00","2026-11-25":"2026-11-25T17:02:00+02:00","2026-11-26":"2026-11-26T17:02:00+02:00","2026-11-27":"2026-11-27T17:02:00+02:00","2026-11-28":"2026-11-28T17:02:00+02:00","2026-11-29":"2026-11-29T17:02:00+02:00","2026-11-30":"2026-11-30T17:02:00+02:00","2026-12-01":"2026-12-01T17:02:00+02:00","2026-12-02":"2026-12-02T17:02:00+02:00","2026-12-03":"2026-12-03T17:02:00+02:00","2026-12-04":"2026-12-04T17:02:00+02:00","2026-12-05":"2026-12-05T17:02:00+02:00","2026-12-06":"2026-12-06T17:02:00+02:00","2026-12-07":"2026-12-07T17:02:00+02:00","2026-12-08":"2026-12-08T17:02:00+02:00","2026-12-09":"2026-12-09T17:02:00+02:00","2026-12-10":"2026-12-10T17:02:00+02:00"},"tzaisBaalHatanya":{"2026-09-23":"2026-09-23T18:59:00+03:00","2026-09-24":"2026-09-24T18:58:00+03:00","2026-09-25":"2026-09-25T18:56:00+03:00","2026-09-26":"2026-09-26T18:55:00+03:00","2026-09-27":"2026-09-27T18:54:00+03:00","2026-09-28":"2026-09-28T18:53:00+03:00","2026-09-29":"2026-09-29T18:51:00+03:00","2026-09-30":"2026-09-30T18:50:00+03:00","2026-10-01":"2026-10-01T18:49:00+03:00","2026-10-02":"2026-10-02T18:47:00+03:00","2026-10-03":"2026-10-03T18:46:00+03:00","2026-10-04":"2026-10-04T18:45:00+03:00","2026-10-05":"2026-10-05T18:44:00+03:00","2026-10-06":"2026-10-06T18:42:00+03:00","2026-10-07":"2026-10-07T18:41:00+03:00","2026-10-08":"2026-10-08T18:40:00+03:00","2026-10-09":"2026-10-09T18:39:00+03:00","2026-10-10":"2026-10-10T18:38:00+03:00","2026-10-11":"2026-10-11T18:36:00+03:00","2026-10-12":"2026-10-12T18:35:00+03:00","2026-10-13":"2026-10-13T18:34:00+03:00","2026-10-14":"2026-10-14T18:33:00+03:00","2026-10-15":"2026-10-15T18:32:00+03:00","2026-10-16":"2026-10-16T18:31:00+03:00","2026-10-17":"2026-10-17T18:29:00+03:00","2026-10-18":"2026-10-18T18:28:00+03:00","2026-10-19":"2026-10-19T18:27:00+03:00","2026-10-20":"2026-10-20T18:26:00+03:00","2026-10-21":"2026-10-21T18:25:00+03:00","2026-10-22":"2026-10-22T18:24:00+03:00","2026-10-23":"2026-10-23T18:23:00+03:00","2026-10-24":"2026-10-24T18:22:00+03:00","2026-10-25":"2026-10-25T17:21:00+02:00","2026-10-26":"2026-10-26T17:20:00+02:00","2026-10-27":"2026-10-27T17:19:00+02:00","2026-10-28":"2026-10-28T17:18:00+02:00","2026-10-29":"2026-10-29T17:17:00+02:00","2026-10-30":"2026-10-30T17:17:00+02:00","2026-10-31":"2026-10-31T17:16:00+02:00","2026-11-01":"2026-11-01T17:15:00+02:00","2026-11-02":"2026-11-02T17:14:00+02:00","2026-11-03":"2026-11-03T17:13:00+02:00","2026-11-04":"2026-11-04T17:13:00+02:00","2026-11-05":"2026-11-05T17:12:00+02:00","2026-11-06":"2026-11-06T17:11:00+02:00","2026-11-07":"2026-11-07T17:10:00+02:00","2026-11-08":"2026-11-08T17:10:00+02:00","2026-11-09":"2026-11-09T17:09:00+02:00","2026-11-10":"2026-11-10T17:08:00+02:00","2026-11-11":"2026-11-11T17:08:00+02:00","2026-11-12":"2026-11-12T17:07:00+02:00","2026-11-13":"2026-11-13T17:07:00+02:00","2026-11-14":"2026-11-14T17:06:00+02:00","2026-11-15":"2026-11-15T17:06:00+02:00","2026-11-16":"2026-11-16T17:05:00+02:00","2026-11-17":"2026-11-17T17:05:00+02:00","2026-11-18":"2026-11-18T17:04:00+02:00","2026-11-19":"2026-11-19T17:04:00+02:00","2026-11-20":"2026-11-20T17:04:00+02:00","2026-11-21":"2026-11-21T17:03:00+02:00","2026-11-22":"2026-11-22T17:03:00+02:00","2026-11-23":"2026-11-23T17:03:00+02:00","2026-11-24":"2026-11-24T17:02:00+02:00","2026-11-25":"2026-11-25T17:02:00+02:00","2026-11-26":"2026-11-26T17:02:00+02:00","2026-11-27":"2026-11-27T17:02:00+02:00","2026-11-28":"2026-11-28T17:02:00+02:00","2026-11-29":"2026-11-29T17:02:00+02:00","2026-11-30":"2026-11-30T17:02:00+02:00","2026-12-01":"2026-12-01T17:02:00+02:00","2026-12-02":"2026-12-02T17:02:00+02:00","2026-12-03":"2026-12-03T17:02:00+02:00","2026-12-04":"2026-12-04T17:02:00+02:00","2026-12-05":"2026-12-05T17:02:00+02:00","2026-12-06":"2026-12-06T17:02:00+02:00","2026-12-07":"2026-12-07T17:02:00+02:00","2026-12-08":"2026-12-08T17:02:00+02:00","2026-12-09":"2026-12-09T17:02:00+02:00","2026-12-10":"2026-12-10T17:02:00+02:00"},"tzeit7083deg":{"2026-09-23":"2026-09-23T19:04:00+03:00","2026-09-24":"2026-09-24T19:03:00+03:00","2026-09-25":"2026-09-25T19:02:00+03:00","2026-09-26":"2026-09-26T19:00:00+03:00","2026-09-27":"2026-09-27T18:59:00+03:00","2026-09-28":"2026-09-28T18:58:00+03:00","2026-09-29":"2026-09-29T18:56:00+03:00","2026-09-30":"2026-09-30T18:55:00+03:00","2026-10-01":"2026-10-01T18:54:00+03:00","2026-10-02":"2026-10-02T18:52:00+03:00","2026-10-03":"2026-10-03T18:51:00+03:00","2026-10-04":"2026-10-04T18:50:00+03:00","2026-10-05":"2026-10-05T18:49:00+03:00","2026-10-06":"2026-10-06T18:47:00+03:00","2026-10-07":"2026-10-07T18:46:00+03:00","2026-10-08":"2026-10-08T18:45:00+03:00","2026-10-09":"2026-10-09T18:44:00+03:00","2026-10-10":"2026-10-10T18:43:00+03:00","2026-10-11":"2026-10-11T18:41:00+03:00","2026-10-12":"2026-10-12T18:40:00+03:00","2026-10-13":"2026-10-13T18:39:00+03:00","2026-10-14":"2026-10-14T18:38:00+03:00","2026-10-15":"2026-10-15T18:37:00+03:00","2026-10-16":"2026-10-16T18:36:00+03:00","2026-10-17":"2026-10-17T18:35:00+03:00","2026-10-18":"2026-10-18T18:34:00+03:00","2026-10-19":"2026-10-19T18:32:00+03:00","2026-10-20":"2026-10-20T18:31:00+03:00","2026-10-21":"2026-10-21T18:30:00+03:00","2026-10-22":"2026-10-22T18:29:00+03:00","2026-10-23":"2026-10-23T18:28:00+03:00","2026-10-24":"2026-10-24T18:27:00+03:00","2026-10-25":"2026-10-25T17:26:00+02:00","2026-10-26":"2026-10-26T17:25:00+02:00","2026-10-27":"2026-10-27T17:24:00+02:00","2026-10-28":"2026-10-28T17:24:00+02:00","2026-10-29":"2026-10-29T17:23:00+02:00","2026-10-30":"2026-10-30T17:22:00+02:00","2026-10-31":"2026-10-31T17:21:00+02:00","2026-11-01":"2026-11-01T17:20:00+02:00","2026-11-02":"2026-11-02T17:19:00+02:00","2026-11-03":"2026-11-03T17:19:00+02:00","2026-11-04":"2026-11-04T17:18:00+02:00","2026-11-05":"2026-11-05T17:17:00+02:00","2026-11-06":"2026-11-06T17:16:00+02:00","2026-11-07":"2026-11-07T17:16:00+02:00","2026-11-08":"2026-11-08T17:15:00+02:00","2026-11-09":"2026-11-09T17:14:00+02:00","2026-11-10":"2026-11-10T17:14:00+02:00","2026-11-11":"2026-11-11T17:13:00+02:00","2026-11-12":"2026-11-12T17:13:00+02:00","2026-11-13":"2026-11-13T17:12:00+02:00","2026-11-14":"2026-11-14T17:12:00+02:00","2026-11-15":"2026-11-15T17:11:00+02:00","2026-11-16":"2026-11-16T17:11:00+02:00","2026-11-17":"2026-11-17T17:10:00+02:00","2026-11-18":"2026-11-18T17:10:00+02:00","2026-11-19":"2026-11-19T17:09:00+02:00","2026-11-20":"2026-11-20T17:09:00+02:00","2026-11-21":"2026-11-21T17:09:00+02:00","2026-11-22":"2026-11-22T17:08:00+02:00","2026-11-23":"2026-11-23T17:08:00+02:00","2026-11-24":"2026-11-24T17:08:00+02:00","2026-11-25":"2026-11-25T17:08:00+02:00","2026-11-26":"2026-11-26T17:08:00+02:00","2026-11-27":"2026-11-27T17:07:00+02:00","2026-11-28":"2026-11-28T17:07:00+02:00","2026-11-29":"2026-11-29T17:07:00+02:00","2026-11-30":"2026-11-30T17:07:00+02:00","2026-12-01":"2026-12-01T17:07:00+02:00","2026-12-02":"2026-12-02T17:07:00+02:00","2026-12-03":"2026-12-03T17:07:00+02:00","2026-12-04":"2026-12-04T17:07:00+02:00","2026-12-05":"2026-12-05T17:07:00+02:00","2026-12-06":"2026-12-06T17:07:00+02:00","2026-12-07":"2026-12-07T17:07:00+02:00","2026-12-08":"2026-12-08T17:08:00+02:00","2026-12-09":"2026-12-09T17:08:00+02:00","2026-12-10":"2026-12-10T17:08:00+02:00"},"tzeit85deg":{"2026-09-23":"2026-09-23T19:11:00+03:00","2026-09-24":"2026-09-24T19:10:00+03:00","2026-09-25":"2026-09-25T19:08:00+03:00","2026-09-26":"2026-09-26T19:07:00+03:00","2026-09-27":"2026-09-27T19:06:00+03:00","2026-09-28":"2026-09-28T19:04:00+03:00","2026-09-29":"2026-09-29T19:03:00+03:00","2026-09-30":"2026-09-30T19:02:00+03:00","2026-10-01":"2026-10-01T19:00:00+03:00","2026-10-02":"2026-10-02T18:59:00+03:00","2026-10-03":"2026-10-03T18:58:00+03:00","2026-10-04":"2026-10-04T18:57:00+03:00","2026-10-05":"2026-10-05T18:55:00+03:00","2026-10-06":"2026-10-06T18:54:00+03:00","2026-10-07":"2026-10-07T18:53:00+03:00","2026-10-08":"2026-10-08T18:52:00+03:00","2026-10-09":"2026-10-09T18:50:00+03:00","2026-10-10":"2026-10-10T18:49:00+03:00","2026-10-11":"2026-10-11T18:48:00+03:00","2026-10-12":"2026-10-12T18:47:00+03:00","2026-10-13":"2026-10-13T18:46:00+03:00","2026-10-14":"2026-10-14T18:45:00+03:00","2026-10-15":"2026-10-15T18:44:00+03:00","2026-10-16":"2026-10-16T18:42:00+03:00","2026-10-17":"2026-10-17T18:41:00+03:00","2026-10-18":"2026-10-18T18:40:00+03:00","2026-10-19":"2026-10-19T18:39:00+03:00","2026-10-20":"2026-10-20T18:38:00+03:00","2026-10-21":"2026-10-21T18:37:00+03:00","2026-10-22":"2026-10-22T18:36:00+03:00","2026-10-23":"2026-10-23T18:35:00+03:00","2026-10-24":"2026-10-24T18:34:00+03:00","2026-10-25":"2026-10-25T17:33:00+02:00","2026-10-26":"2026-10-26T17:32:00+02:00","2026-10-27":"2026-10-27T17:31:00+02:00","2026-10-28":"2026-10-28T17:30:00+02:00","2026-10-29":"2026-10-29T17:29:00+02:00","2026-10-30":"2026-10-30T17:29:00+02:00","2026-10-31":"2026-10-31T17:28:00+02:00","2026-11-01":"2026-11-01T17:27:00+02:00","2026-11-02":"2026-11-02T17:26:00+02:00","2026-11-03":"2026-11-03T17:25:00+02:00","2026-11-04":"2026-11-04T17:25:00+02:00","2026-11-05":"2026-11-05T17:24:00+02:00","2026-11-06":"2026-11-06T17:23:00+02:00","2026-11-07":"2026-11-07T17:23:00+02:00","2026-11-08":"2026-11-08T17:22:00+02:00","2026-11-09":"2026-11-09T17:21:00+02:00","2026-11-10":"2026-11-10T17:21:00+02:00","2026-11-11":"2026-11-11T17:20:00+02:00","2026-11-12":"2026-11-12T17:20:00+02:00","2026-11-13":"2026-11-13T17:19:00+02:00","2026-11-14":"2026-11-14T17:18:00+02:00","2026-11-15":"2026-11-15T17:18:00+02:00","2026-11-16":"2026-11-16T17:18:00+02:00","2026-11-17":"2026-11-17T17:17:00+02:00","2026-11-18":"2026-11-18T17:17:00+02:00","2026-11-19":"2026-11-19T17:16:00+02:00","2026-11-20":"2026-11-20T17:16:00+02:00","2026-11-21":"2026-11-21T17:16:00+02:00","2026-11-22":"2026-11-22T17:15:00+02:00","2026-11-23":"2026-11-23T17:15:00+02:00","2026-11-24":"2026-11-24T17:15:00+02:00","2026-11-25":"2026-11-25T17:15:00+02:00","2026-11-26":"2026-11-26T17:15:00+02:00","2026-11-27":"2026-11-27T17:14:00+02:00","2026-11-28":"2026-11-28T17:14:00+02:00","2026-11-29":"2026-11-29T17:14:00+02:00","2026-11-30":"2026-11-30T17:14:00+02:00","2026-12-01":"2026-12-01T17:14:00+02:00","2026-12-02":"2026-12-02T17:14:00+02:00","2026-12-03":"2026-12-03T17:14:00+02:00","2026-12-04":"2026-12-04T17:14:00+02:00","2026-12-05":"2026-12-05T17:14:00+02:00","2026-12-06":"2026-12-06T17:14:00+02:00","2026-12-07":"2026-12-07T17:15:00+02:00","2026-12-08":"2026-12-08T17:15:00+02:00","2026-12-09":"2026-12-09T17:15:00+02:00","2026-12-10":"2026-12-10T17:15:00+02:00"},"tzeit42min":{"2026-09-23":"2026-09-23T19:17:00+03:00","2026-09-24":"2026-09-24T19:15:00+03:00","2026-09-25":"2026-09-25T19:14:00+03:00","2026-09-26":"2026-09-26T19:13:00+03:00","2026-09-27":"2026-09-27T19:12:00+03:00","2026-09-28":"2026-09-28T19:10:00+03:00","2026-09-29":"2026-09-29T19:09:00+03:00","2026-09-30":"2026-09-30T19:08:00+03:00","2026-10-01":"2026-10-01T19:06:00+03:00","2026-10-02":"2026-10-02T19:05:00+03:00","2026-10-03":"2026-10-03T19:04:00+03:00","2026-10-04":"2026-10-04T19:03:00+03:00","2026-10-05":"2026-10-05T19:01:00+03:00","2026-10-06":"2026-10-06T19:00:00+03:00","2026-10-07":"2026-10-07T18:59:00+03:00","2026-10-08":"2026-10-08T18:58:00+03:00","2026-10-09":"2026-10-09T18:56:00+03:00","2026-10-10":"2026-10-10T18:55:00+03:00","2026-10-11":"2026-10-11T18:54:00+03:00","2026-10-12":"2026-10-12T18:53:00+03:00","2026-10-13":"2026-10-13T18:51:00+03:00","2026-10-14":"2026-10-14T18:50:00+03:00","2026-10-15":"2026-10-15T18:49:00+03:00","2026-10-16":"2026-10-16T18:48:00+03:00","2026-10-17":"2026-10-17T18:47:00+03:00","2026-10-18":"2026-10-18T18:46:00+03:00","2026-10-19":"2026-10-19T18:45:00+03:00","2026-10-20":"2026-10-20T18:44:00+03:00","2026-10-21":"2026-10-21T18:42:00+03:00","2026-10-22":"2026-10-22T18:41:00+03:00","2026-10-23":"2026-10-23T18:40:00+03:00","2026-10-24":"2026-10-24T18:39:00+03:00","2026-10-25":"2026-10-25T17:38:00+02:00","2026-10-26":"2026-10-26T17:37:00+02:00","2026-10-27":"2026-10-27T17:36:00+02:00","2026-10-28":"2026-10-28T17:35:00+02:00","2026-10-29":"2026-10-29T17:34:00+02:00","2026-10-30":"2026-10-30T17:34:00+02:00","2026-10-31":"2026-10-31T17:33:00+02:00","2026-11-01":"2026-11-01T17:32:00+02:00","2026-11-02":"2026-11-02T17:31:00+02:00","2026-11-03":"2026-11-03T17:30:00+02:00","2026-11-04":"2026-11-04T17:29:00+02:00","2026-11-05":"2026-11-05T17:28:00+02:00","2026-11-06":"2026-11-06T17:28:00+02:00","2026-11-07":"2026-11-07T17:27:00+02:00","2026-11-08":"2026-11-08T17:26:00+02:00","2026-11-09":"2026-11-09T17:26:00+02:00","2026-11-10":"2026-11-10T17:25:00+02:00","2026-11-11":"2026-11-11T17:24:00+02:00","2026-11-12":"2026-11-12T17:24:00+02:00","2026-11-13":"2026-11-13T17:23:00+02:00","2026-11-14":"2026-11-14T17:22:00+02:00","2026-11-15":"2026-11-15T17:22:00+02:00","2026-11-16":"2026-11-16T17:21:00+02:00","2026-11-17":"2026-11-17T17:21:00+02:00","2026-11-18":"2026-11-18T17:20:00+02:00","2026-11-19":"2026-11-19T17:20:00+02:00","2026-11-20":"2026-11-20T17:20:00+02:00","2026-11-21":"2026-11-21T17:19:00+02:00","2026-11-22":"2026-11-22T17:19:00+02:00","2026-11-23":"2026-11-23T17:19:00+02:00","2026-11-24":"2026-11-24T17:18:00+02:00","2026-11-25":"2026-11-25T17:18:00+02:00","2026-11-26":"2026-11-26T17:18:00+02:00","2026-11-27":"2026-11-27T17:18:00+02:00","2026-11-28":"2026-11-28T17:17:00+02:00","2026-11-29":"2026-11-29T17:17:00+02:00","2026-11-30":"2026-11-30T17:17:00+02:00","2026-12-01":"2026-12-01T17:17:00+02:00","2026-12-02":"2026-12-02T17:17:00+02:00","2026-12-03":"2026-12-03T17:17:00+02:00","2026-12-04":"2026-12-04T17:17:00+02:00","2026-12-05":"2026-12-05T17:17:00+02:00","2026-12-06":"2026-12-06T17:17:00+02:00","2026-12-07":"2026-12-07T17:17:00+02:00","2026-12-08":"2026-12-08T17:17:00+02:00","2026-12-09":"2026-12-09T17:17:00+02:00","2026-12-10":"2026-12-10T17:18:00+02:00"},"tzeit50min":{"2026-09-23":"2026-09-23T19:25:00+03:00","2026-09-24":"2026-09-24T19:23:00+03:00","2026-09-25":"2026-09-25T19:22:00+03:00","2026-09-26":"2026-09-26T19:21:00+03:00","2026-09-27":"2026-09-27T19:20:00+03:00","2026-09-28":"2026-09-28T19:18:00+03:00","2026-09-29":"2026-09-29T19:17:00+03:00","2026-09-30":"2026-09-30T19:16:00+03:00","2026-10-01":"2026-10-01T19:14:00+03:00","2026-10-02":"2026-10-02T19:13:00+03:00","2026-10-03":"2026-10-03T19:12:00+03:00","2026-10-04":"2026-10-04T19:11:00+03:00","2026-10-05":"2026-10-05T19:09:00+03:00","2026-10-06":"2026-10-06T19:08:00+03:00","2026-10-07":"2026-10-07T19:07:00+03:00","2026-10-08":"2026-10-08T19:06:00+03:00","2026-10-09":"2026-10-09T19:04:00+03:00","2026-10-10":"2026-10-10T19:03:00+03:00","2026-10-11":"2026-10-11T19:02:00+03:00","2026-10-12":"2026-10-12T19:01:00+03:00","2026-10-13":"2026-10-13T18:59:00+03:00","2026-10-14":"2026-10-14T18:58:00+03:00","2026-10-15":"2026-10-15T18:57:00+03:00","2026-10-16":"2026-10-16T18:56:00+03:00","2026-10-17":"2026-10-17T18:55:00+03:00","2026-10-18":"2026-10-18T18:54:00+03:00","2026-10-19":"2026-10-19T18:53:00+03:00","2026-10-20":"2026-10-20T18:52:00+03:00","2026-10-21":"2026-10-21T18:50:00+03:00","2026-10-22":"2026-10-22T18:49:00+03:00","2026-10-23":"2026-10-23T18:48:00+03:00","2026-10-24":"2026-10-24T18:47:00+03:00","2026-10-25":"2026-10-25T17:46:00+02:00","2026-10-26":"2026-10-26T17:45:00+02:00","2026-10-27":"2026-10-27T17:44:00+02:00","2026-10-28":"2026-10-28T17:43:00+02:00","2026-10-29":"2026-10-29T17:42:00+02:00","2026-10-30":"2026-10-30T17:42:00+02:00","2026-10-31":"2026-10-31T17:41:00+02:00","2026-11-01":"2026-11-01T17:40:00+02:00","2026-11-02":"2026-11-02T17:39:00+02:00","2026-11-03":"2026-11-03T17:38:00+02:00","2026-11-04":"2026-11-04T17:37:00+02:00","2026-11-05":"2026-11-05T17:36:00+02:00","2026-11-06":"2026-11-06T17:36:00+02:00","2026-11-07":"2026-11-07T17:35:00+02:00","2026-11-08":"2026-11-08T17:34:00+02:00","2026-11-09":"2026-11-09T17:34:00+02:00","2026-11-10":"2026-11-10T17:33:00+02:00","2026-11-11":"2026-11-11T17:32:00+02:00","2026-11-12":"2026-11-12T17:32:00+02:00","2026-11-13":"2026-11-13T17:31:00+02:00","2026-11-14":"2026-11-14T17:30:00+02:00","2026-11-15":"2026-11-15T17:30:00+02:00","2026-11-16":"2026-11-16T17:29:00+02:00","2026-11-17":"2026-11-17T17:29:00+02:00","2026-11-18":"2026-11-18T17:28:00+02:00","2026-11-19":"2026-11-19T17:28:00+02:00","2026-11-20":"2026-11-20T17:28:00+02:00","2026-11-21":"2026-11-21T17:27:00+02:00","2026-11-22":"2026-11-22T17:27:00+02:00","2026-11-23":"2026-11-23T17:27:00+02:00","2026-11-24":"2026-11-24T17:26:00+02:00","2026-11-25":"2026-11-25T17:26:00+02:00","2026-11-26":"2026-11-26T17:26:00+02:00","2026-11-27":"2026-11-27T17:26:00+02:00","2026-11-28":"2026-11-28T17:25:00+02:00","2026-11-29":"2026-11-29T17:25:00+02:00","2026-11-30":"2026-11-30T17:25:00+02:00","2026-12-01":"2026-12-01T17:25:00+02:00","2026-12-02":"2026-12-02T17:25:00+02:00","2026-12-03":"2026-12-03T17:25:00+02:00","2026-12-04":"2026-12-04T17:25:00+02:00","2026-12-05":"2026-12-05T17:25:00+02:00","2026-12-06":"2026-12-06T17:25:00+02:00","2026-12-07":"2026-12-07T17:25:00+02:00","2026-12-08":"2026-12-08T17:25:00+02:00","2026-12-09":"2026-12-09T17:25:00+02:00","2026-12-10":"2026-12-10T17:26:00+02:00"},"tzeit72min":{"2026-09-23":"2026-09-23T19:47:00+03:00","2026-09-24":"2026-09-24T19:45:00+03:00","2026-09-25":"2026-09-25T19:44:00+03:00","2026-09-26":"2026-09-26T19:43:00+03:00","2026-09-27":"2026-09-27T19:42:00+03:00","2026-09-28":"2026-09-28T19:40:00+03:00","2026-09-29":"2026-09-29T19:39:00+03:00","2026-09-30":"2026-09-30T19:38:00+03:00","2026-10-01":"2026-10-01T19:36:00+03:00","2026-10-02":"2026-10-02T19:35:00+03:00","2026-10-03":"2026-10-03T19:34:00+03:00","2026-10-04":"2026-10-04T19:33:00+03:00","2026-10-05":"2026-10-05T19:31:00+03:00","2026-10-06":"2026-10-06T19:30:00+03:00","2026-10-07":"2026-10-07T19:29:00+03:00","2026-10-08":"2026-10-08T19:28:00+03:00","2026-10-09":"2026-10-09T19:26:00+03:00","2026-10-10":"2026-10-10T19:25:00+03:00","2026-10-11":"2026-10-11T19:24:00+03:00","2026-10-12":"2026-10-12T19:23:00+03:00","2026-10-13":"2026-10-13T19:21:00+03:00","2026-10-14":"2026-10-14T19:20:00+03:00","2026-10-15":"2026-10-15T19:19:00+03:00","2026-10-16":"2026-10-16T19:18:00+03:00","2026-10-17":"2026-10-17T19:17:00+03:00","2026-10-18":"2026-10-18T19:16:00+03:00","2026-10-19":"2026-10-19T19:15:00+03:00","2026-10-20":"2026-10-20T19:14:00+03:00","2026-10-21":"2026-10-21T19:12:00+03:00","2026-10-22":"2026-10-22T19:11:00+03:00","2026-10-23":"2026-10-23T19:10:00+03:00","2026-10-24":"2026-10-24T19:09:00+03:00","2026-10-25":"2026-10-25T18:08:00+02:00","2026-10-26":"2026-10-26T18:07:00+02:00","2026-10-27":"2026-10-27T18:06:00+02:00","2026-10-28":"2026-10-28T18:05:00+02:00","2026-10-29":"2026-10-29T18:04:00+02:00","2026-10-30":"2026-10-30T18:04:00+02:00","2026-10-31":"2026-10-31T18:03:00+02:00","2026-11-01":"2026-11-01T18:02:00+02:00","2026-11-02":"2026-11-02T18:01:00+02:00","2026-11-03":"2026-11-03T18:00:00+02:00","2026-11-04":"2026-11-04T17:59:00+02:00","2026-11-05":"2026-11-05T17:58:00+02:00","2026-11-06":"2026-11-06T17:58:00+02:00","2026-11-07":"2026-11-07T17:57:00+02:00","2026-11-08":"2026-11-08T17:56:00+02:00","2026-11-09":"2026-11-09T17:56:00+02:00","2026-11-10":"2026-11-10T17:55:00+02:00","2026-11-11":"2026-11-11T17:54:00+02:00","2026-11-12":"2026-11-12T17:54:00+02:00","2026-11-13":"2026-11-13T17:53:00+02:00","2026-11-14":"2026-11-14T17:52:00+02:00","2026-11-15":"2026-11-15T17:52:00+02:00","2026-11-16":"2026-11-16T17:51:00+02:00","2026-11-17":"2026-11-17T17:51:00+02:00","2026-11-18":"2026-11-18T17:50:00+02:00","2026-11-19":"2026-11-19T17:50:00+02:00","2026-11-20":"2026-11-20T17:50:00+02:00","2026-11-21":"2026-11-21T17:49:00+02:00","2026-11-22":"2026-11-22T17:49:00+02:00","2026-11-23":"2026-11-23T17:49:00+02:00","2026-11-24":"2026-11-24T17:48:00+02:00","2026-11-25":"2026-11-25T17:48:00+02:00","2026-11-26":"2026-11-26T17:48:00+02:00","2026-11-27":"2026-11-27T17:48:00+02:00","2026-11-28":"2026-11-28T17:47:00+02:00","2026-11-29":"2026-11-29T17:47:00+02:00","2026-11-30":"2026-11-30T17:47:00+02:00","2026-12-01":"2026-12-01T17:47:00+02:00","2026-12-02":"2026-12-02T17:47:00+02:00","2026-12-03":"2026-12-03T17:47:00+02:00","2026-12-04":"2026-12-04T17:47:00+02:00","2026-12-05":"2026-12-05T17:47:00+02:00","2026-12-06":"2026-12-06T17:47:00+02:00","2026-12-07":"2026-12-07T17:47:00+02:00","2026-12-08":"2026-12-08T17:47:00+02:00","2026-12-09":"2026-12-09T17:47:00+02:00","2026-12-10":"2026-12-10T17:48:00+02:00"},"molad":{"2026-10-11":"2026-10-11T10:22:00+03:00","2026-11-09":"2026-11-09T22:06:00+02:00","2026-12-09":"2026-12-09T10:50:00+02:00"},"tchilasZmanKidushLevana3Days":{"2026-10-14":"2026-10-14T10:23:00+03:00","2026-11-12":"2026-11-12T22:07:00+02:00"},"tchilasZmanKidushLevana7Days":{"2026-10-18":"2026-10-18T10:23:00+03:00","2026-11-16":"2026-11-16T22:07:00+02:00"},"sofZmanKidushLevanaBetweenMoldos":{"2026-09-26":"2026-09-26T16:00:00+03:00","2026-10-26":"2026-10-26T03:44:00+02:00","2026-11-24":"2026-11-24T16:28:00+02:00"},"sofZmanKidushLevana15Days":{"2026-09-26":"2026-09-26T21:38:00+03:00","2026-10-26":"2026-10-26T09:22:00+02:00","2026-11-24":"2026-11-24T22:06:00+02:00"}}} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-en.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-en.json new file mode 100644 index 0000000..21ed666 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-en.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:48.534Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"6th of Nisan","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"Menachot 72","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"7th of Nisan","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"Menachot 73","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"8th of Nisan","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"Menachot 74","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"9th of Nisan","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"Menachot 75","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"10th of Nisan","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Parashat Tzav","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Menachot 76","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"11th of Nisan","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"Menachot 77","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"12th of Nisan","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"Menachot 78","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"13th of Nisan","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"Menachot 79","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"14th of Nisan","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"Taโ€™anit Bechorot","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Fast of the First Born"},{"title":"Erev Pesach","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 80","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"15th of Nisan","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"Pesach I","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 81","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"16th of Nisan","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"Pesach II (CHโ€™โ€™M)","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 82","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"1st day of the Omer","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"17th of Nisan","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"Pesach III (CHโ€™โ€™M)","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 83","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"2nd day of the Omer","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"18th of Nisan","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"Pesach IV (CHโ€™โ€™M)","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 84","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"3rd day of the Omer","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"19th of Nisan","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"Pesach V (CHโ€™โ€™M)","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 85","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"4th day of the Omer","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"20th of Nisan","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"Pesach VI (CHโ€™โ€™M)","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 86","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"5th day of the Omer","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"21st of Nisan","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"Pesach VII","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 87","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"6th day of the Omer","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"22nd of Nisan","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"Menachot 88","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"7th day of the Omer","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"23rd of Nisan","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"Menachot 89","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"8th day of the Omer","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"24th of Nisan","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Parashat Shmini","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Mevarchim Chodesh Iyyar","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"Molad Iyyar: Friday, 5:18 and 14 chalakim"},{"title":"Menachot 90","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"9th day of the Omer","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"25th of Nisan","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"Menachot 91","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"10th day of the Omer","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"26th of Nisan","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"Menachot 92","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"11th day of the Omer","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"27th of Nisan","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"Menachot 93","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"12th day of the Omer","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"28th of Nisan","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"Menachot 94","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"13th day of the Omer","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"29th of Nisan","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"Menachot 95","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"14th day of the Omer","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"30th of Nisan","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"Menachot 96","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"15th day of the Omer","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"1st of Iyyar, 5786","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Parashat Tazria-Metzora","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Menachot 97","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"16th day of the Omer","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"2nd of Iyyar","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"Menachot 98","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"17th day of the Omer","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"3rd of Iyyar","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"Menachot 99","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"18th day of the Omer","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"4th of Iyyar","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"Menachot 100","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"19th day of the Omer","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"5th of Iyyar","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"Menachot 101","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"20th day of the Omer","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"6th of Iyyar","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"Menachot 102","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"21st day of the Omer","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"7th of Iyyar","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"Menachot 103","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"22nd day of the Omer","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"8th of Iyyar","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Parashat Achrei Mot-Kedoshim","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Menachot 104","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"23rd day of the Omer","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"9th of Iyyar","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"Menachot 105","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"24th day of the Omer","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"10th of Iyyar","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"Menachot 106","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"25th day of the Omer","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"11th of Iyyar","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"Menachot 107","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"26th day of the Omer","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"12th of Iyyar","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"Menachot 108","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"27th day of the Omer","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"13th of Iyyar","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"Menachot 109","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"28th day of the Omer","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"14th of Iyyar","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"Pesach Sheni","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"Second Passover, one month after Passover"},{"title":"Menachot 110","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"29th day of the Omer","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"15th of Iyyar","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Parashat Emor","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Chullin 2","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"30th day of the Omer","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"16th of Iyyar","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"Chullin 3","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"31st day of the Omer","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"17th of Iyyar","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"Chullin 4","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"32nd day of the Omer","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"18th of Iyyar","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"Lag BaOmer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"33rd day of counting the Omer. The holiday is a temporary break from the semi-mourning period the counting of the Omer. Practices include lighting bonfires, getting haircuts, and Jewish weddings"},{"title":"Chullin 5","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"33rd day of the Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"19th of Iyyar","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"Chullin 6","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"34th day of the Omer","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"20th of Iyyar","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"Chullin 7","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"35th day of the Omer","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"21st of Iyyar","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"Chullin 8","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"36th day of the Omer","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"22nd of Iyyar","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Parashat Behar-Bechukotai","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Chullin 9","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"37th day of the Omer","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"23rd of Iyyar","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"Chullin 10","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"38th day of the Omer","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"24th of Iyyar","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"Chullin 11","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"39th day of the Omer","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"25th of Iyyar","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"Chullin 12","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"40th day of the Omer","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"26th of Iyyar","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"Chullin 13","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"41st day of the Omer","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"27th of Iyyar","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"Chullin 14","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"42nd day of the Omer","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"28th of Iyyar","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"Chullin 15","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"43rd day of the Omer","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"29th of Iyyar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Parashat Bamidbar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Sivan","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"Molad Sivan: Saturday, 18:02 and 15 chalakim"},{"title":"Chullin 16","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"44th day of the Omer","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"1st of Sivan, 5786","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"Chullin 17","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"45th day of the Omer","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"2nd of Sivan","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"Chullin 18","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"46th day of the Omer","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"3rd of Sivan","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"Chullin 19","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"47th day of the Omer","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"4th of Sivan","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"Chullin 20","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"48th day of the Omer","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"5th of Sivan","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"Erev Shavuot","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Chullin 21","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"49th day of the Omer","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"6th of Sivan","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"Shavuot","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Chullin 22","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"7th of Sivan","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Parashat Nasso","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Chullin 23","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"8th of Sivan","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"Chullin 24","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"9th of Sivan","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"Chullin 25","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"10th of Sivan","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"Chullin 26","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"11th of Sivan","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"Chullin 27","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"12th of Sivan","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"Chullin 28","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"13th of Sivan","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"Chullin 29","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"14th of Sivan","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Parashat Behaโ€™alotcha","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Chullin 30","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"15th of Sivan","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"Chullin 31","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"16th of Sivan","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"Chullin 32","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"17th of Sivan","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"Chullin 33","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"18th of Sivan","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"Chullin 34","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"19th of Sivan","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"Chullin 35","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"20th of Sivan","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"Chullin 36","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"21st of Sivan","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Parashat Shโ€™lach","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Chullin 37","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"22nd of Sivan","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"Chullin 38","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"23rd of Sivan","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"Chullin 39","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"24th of Sivan","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"Chullin 40","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-es.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-es.json new file mode 100644 index 0000000..5a4d5f2 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-es.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:54.792Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"6ยบ Nisรกn","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"Menajot 72","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","title_orig":"Menachot 72","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"7ยบ Nisรกn","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"Menajot 73","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","title_orig":"Menachot 73","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"8ยบ Nisรกn","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"Menajot 74","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","title_orig":"Menachot 74","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"9ยบ Nisรกn","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"Menajot 75","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","title_orig":"Menachot 75","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"10ยบ Nisรกn","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Parashรก Tzav","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","title_orig":"Parashat Tzav","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Menajot 76","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","title_orig":"Menachot 76","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"11ยบ Nisรกn","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"Menajot 77","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","title_orig":"Menachot 77","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"12ยบ Nisรกn","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"Menajot 78","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","title_orig":"Menachot 78","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"13ยบ Nisรกn","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"Menajot 79","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","title_orig":"Menachot 79","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"14ยบ Nisรกn","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"Taโ€™anit Bejorot","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Fast of the First Born"},{"title":"Erev Pesaj","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Erev Pesach","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 80","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","title_orig":"Menachot 80","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"15ยบ Nisรกn","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"Pรฉsaj I","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach I","hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 81","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","title_orig":"Menachot 81","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"16ยบ Nisรกn","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"Pesaj II (Jโ€™โ€™M)","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 82","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","title_orig":"Menachot 82","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"1ยบ dรญa del Omer","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"17ยบ Nisรกn","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"Pรฉsaj III (Jโ€™โ€™M)","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 83","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","title_orig":"Menachot 83","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"2ยบ dรญa del Omer","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"18ยบ Nisรกn","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"Pรฉsaj IV (Jโ€™โ€™M)","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 84","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","title_orig":"Menachot 84","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"3ยบ dรญa del Omer","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"19ยบ Nisรกn","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"Pรฉsaj V (Jโ€™โ€™M)","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 85","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","title_orig":"Menachot 85","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"4ยบ dรญa del Omer","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"20ยบ Nisรกn","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"Pรฉsaj VI (Jโ€™โ€™M)","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 86","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","title_orig":"Menachot 86","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"5ยบ dรญa del Omer","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"21ยบ Nisรกn","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"Pรฉsaj VII","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach VII","hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menajot 87","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","title_orig":"Menachot 87","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"6ยบ dรญa del Omer","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"22ยบ Nisรกn","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"Menajot 88","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","title_orig":"Menachot 88","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"7ยบ dรญa del Omer","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"23ยบ Nisรกn","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"Menajot 89","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","title_orig":"Menachot 89","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"8ยบ dรญa del Omer","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"24ยบ Nisรกn","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Parashรก Shmini","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","title_orig":"Parashat Shmini","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Mevarchim Chodesh Iyar","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Iyar","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"Molad Iyar: Friday, 5:18 and 14 chalakim"},{"title":"Menajot 90","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","title_orig":"Menachot 90","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"9ยบ dรญa del Omer","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"25ยบ Nisรกn","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"Menajot 91","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","title_orig":"Menachot 91","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"10ยบ dรญa del Omer","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"26ยบ Nisรกn","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"Menajot 92","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","title_orig":"Menachot 92","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"11ยบ dรญa del Omer","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"27ยบ Nisรกn","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"Menajot 93","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","title_orig":"Menachot 93","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"12ยบ dรญa del Omer","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"28ยบ Nisรกn","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"Menajot 94","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","title_orig":"Menachot 94","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"13ยบ dรญa del Omer","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"29ยบ Nisรกn","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"Menajot 95","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","title_orig":"Menachot 95","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"14ยบ dรญa del Omer","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"30ยบ Nisรกn","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"Menajot 96","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","title_orig":"Menachot 96","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"15ยบ dรญa del Omer","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"1ยบ Iyar, 5786","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Parashรก Tazria-Metzora","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","title_orig":"Parashat Tazria-Metzora","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Menajot 97","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 97","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"16ยบ dรญa del Omer","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"2ยบ Iyar","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"Menajot 98","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 98","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"17ยบ dรญa del Omer","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"3ยบ Iyar","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"Menajot 99","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 99","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"18ยบ dรญa del Omer","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"4ยบ Iyar","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"Menajot 100","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 100","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"19ยบ dรญa del Omer","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"5ยบ Iyar","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"Menajot 101","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 101","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"20ยบ dรญa del Omer","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"6ยบ Iyar","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"Menajot 102","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"21ยบ dรญa del Omer","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"7ยบ Iyar","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"Menajot 103","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"22ยบ dรญa del Omer","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"8ยบ Iyar","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Parashรก Ajarei Mot-Kedoshim","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Menajot 104","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"23ยบ dรญa del Omer","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"9ยบ Iyar","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"Menajot 105","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"24ยบ dรญa del Omer","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"10ยบ Iyar","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"Menajot 106","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"25ยบ dรญa del Omer","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"11ยบ Iyar","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"Menajot 107","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"26ยบ dรญa del Omer","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"12ยบ Iyar","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"Menajot 108","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"27ยบ dรญa del Omer","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"13ยบ Iyar","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"Menajot 109","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"28ยบ dรญa del Omer","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"14ยบ Iyar","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"Pรฉsaj Sheni","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"Second Passover, one month after Passover"},{"title":"Menajot 110","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"29ยบ dรญa del Omer","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"15ยบ Iyar","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Parashรก Emor","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Julรญn 2","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"30ยบ dรญa del Omer","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"16ยบ Iyar","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"Julรญn 3","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"31ยบ dรญa del Omer","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"17ยบ Iyar","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"Julรญn 4","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"32ยบ dรญa del Omer","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"18ยบ Iyar","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"Lag BaOmer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"33rd day of counting the Omer. The holiday is a temporary break from the semi-mourning period the counting of the Omer. Practices include lighting bonfires, getting haircuts, and Jewish weddings"},{"title":"Julรญn 5","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"33ยบ dรญa del Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"19ยบ Iyar","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"Julรญn 6","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"34ยบ dรญa del Omer","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"20ยบ Iyar","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"Julรญn 7","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"35ยบ dรญa del Omer","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"21ยบ Iyar","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"Julรญn 8","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"36ยบ dรญa del Omer","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"22ยบ Iyar","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Parashรก Bejar-Bejukotai","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Julรญn 9","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"37ยบ dรญa del Omer","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"23ยบ Iyar","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"Julรญn 10","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"38ยบ dรญa del Omer","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"24ยบ Iyar","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"Julรญn 11","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"39ยบ dรญa del Omer","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"25ยบ Iyar","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"Julรญn 12","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"40ยบ dรญa del Omer","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"26ยบ Iyar","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"Julรญn 13","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"41ยบ dรญa del Omer","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"27ยบ Iyar","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"Julรญn 14","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"42ยบ dรญa del Omer","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"28ยบ Iyar","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"Julรญn 15","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"43ยบ dรญa del Omer","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"29ยบ Iyar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Parashรก Bamidbar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Sivan","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"Molad Sivan: Saturday, 18:02 and 15 chalakim"},{"title":"Julรญn 16","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"44ยบ dรญa del Omer","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"1ยบ Sivan, 5786","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"Julรญn 17","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"45ยบ dรญa del Omer","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"2ยบ Sivan","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"Julรญn 18","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"46ยบ dรญa del Omer","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"3ยบ Sivan","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"Julรญn 19","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"47ยบ dรญa del Omer","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"4ยบ Sivan","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"Julรญn 20","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"48ยบ dรญa del Omer","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"5ยบ Sivan","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"Erev Shavuot","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Julรญn 21","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"49ยบ dรญa del Omer","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"6ยบ Sivan","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"Shavuot","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Julรญn 22","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"7ยบ Sivan","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Parashรก Naso","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Julรญn 23","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"8ยบ Sivan","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"Julรญn 24","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"9ยบ Sivan","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"Julรญn 25","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"10ยบ Sivan","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"Julรญn 26","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"11ยบ Sivan","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"Julรญn 27","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"12ยบ Sivan","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"Julรญn 28","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"13ยบ Sivan","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"Julรญn 29","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"14ยบ Sivan","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Parashรก Behaaloteja","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Julรญn 30","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"15ยบ Sivan","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"Julรญn 31","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"16ยบ Sivan","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"Julรญn 32","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"17ยบ Sivan","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"Julรญn 33","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"18ยบ Sivan","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"Julรญn 34","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"19ยบ Sivan","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"Julรญn 35","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"20ยบ Sivan","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"Julรญn 36","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"21ยบ Sivan","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Parashรก Shโ€™laj","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Julรญn 37","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"22ยบ Sivan","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"Julรญn 38","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"23ยบ Sivan","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"Julรญn 39","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"24ยบ Sivan","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"Julรญn 40","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-fr.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-fr.json new file mode 100644 index 0000000..1ed2103 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-fr.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:53.300Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"6. Nissan","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"Menahฬฒot 72","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","title_orig":"Menachot 72","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"7. Nissan","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"Menahฬฒot 73","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","title_orig":"Menachot 73","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"8. Nissan","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"Menahฬฒot 74","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","title_orig":"Menachot 74","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"9. Nissan","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"Menahฬฒot 75","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","title_orig":"Menachot 75","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"10. Nissan","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Parachah Tzav","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","title_orig":"Parashat Tzav","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Menahฬฒot 76","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","title_orig":"Menachot 76","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"11. Nissan","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"Menahฬฒot 77","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","title_orig":"Menachot 77","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"12. Nissan","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"Menahฬฒot 78","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","title_orig":"Menachot 78","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"13. Nissan","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"Menahฬฒot 79","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","title_orig":"Menachot 79","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"14. Nissan","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"Taโ€™anit Bekhorot","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Fast of the First Born"},{"title":"Erev Pessahฬฒ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Erev Pesach","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 80","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","title_orig":"Menachot 80","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"15. Nissan","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"Pessahฬฒ I","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach I","hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 81","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","title_orig":"Menachot 81","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"16. Nissan","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"Pessahฬฒ II (Hฬฒโ€™โ€™M)","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 82","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","title_orig":"Menachot 82","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"1. day of the Omer","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"17. Nissan","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"Pessahฬฒ III (Hฬฒโ€™โ€™M)","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 83","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","title_orig":"Menachot 83","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"2. day of the Omer","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"18. Nissan","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"Pessahฬฒ IV (Hฬฒโ€™โ€™M)","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 84","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","title_orig":"Menachot 84","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"3. day of the Omer","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"19. Nissan","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"Pessahฬฒ V (Hฬฒโ€™โ€™M)","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 85","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","title_orig":"Menachot 85","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"4. day of the Omer","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"20. Nissan","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"Pessahฬฒ VI (Hฬฒโ€™โ€™M)","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 86","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","title_orig":"Menachot 86","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"5. day of the Omer","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"21. Nissan","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"Pessahฬฒ VII","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach VII","hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menahฬฒot 87","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","title_orig":"Menachot 87","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"6. day of the Omer","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"22. Nissan","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"Menahฬฒot 88","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","title_orig":"Menachot 88","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"7. day of the Omer","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"23. Nissan","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"Menahฬฒot 89","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","title_orig":"Menachot 89","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"8. day of the Omer","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"24. Nissan","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Parachah Chemini","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","title_orig":"Parashat Shmini","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Mevarchim Chodesh Iyar","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Iyar","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"Molad Iyar: Vendredi, 5:18 et 14 'halakim"},{"title":"Menahฬฒot 90","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","title_orig":"Menachot 90","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"9. day of the Omer","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"25. Nissan","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"Menahฬฒot 91","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","title_orig":"Menachot 91","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"10. day of the Omer","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"26. Nissan","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"Menahฬฒot 92","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","title_orig":"Menachot 92","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"11. day of the Omer","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"27. Nissan","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"Menahฬฒot 93","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","title_orig":"Menachot 93","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"12. day of the Omer","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"28. Nissan","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"Menahฬฒot 94","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","title_orig":"Menachot 94","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"13. day of the Omer","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"29. Nissan","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"Menahฬฒot 95","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","title_orig":"Menachot 95","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"14. day of the Omer","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"30. Nissan","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"Menahฬฒot 96","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","title_orig":"Menachot 96","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"15. day of the Omer","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"1. Iyar, 5786","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Parachah Tazriโ€™a-Metzora","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","title_orig":"Parashat Tazria-Metzora","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Menahฬฒot 97","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 97","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"16. day of the Omer","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"2. Iyar","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"Menahฬฒot 98","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 98","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"17. day of the Omer","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"3. Iyar","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"Menahฬฒot 99","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 99","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"18. day of the Omer","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"4. Iyar","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"Menahฬฒot 100","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 100","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"19. day of the Omer","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"5. Iyar","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"Menahฬฒot 101","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 101","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"20. day of the Omer","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"6. Iyar","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"Menahฬฒot 102","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"21. day of the Omer","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"7. Iyar","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"Menahฬฒot 103","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"22. day of the Omer","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"8. Iyar","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Parachah Ahฬฒarรฉ Mot-Kedochim","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Menahฬฒot 104","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"23. day of the Omer","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"9. Iyar","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"Menahฬฒot 105","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"24. day of the Omer","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"10. Iyar","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"Menahฬฒot 106","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"25. day of the Omer","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"11. Iyar","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"Menahฬฒot 107","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"26. day of the Omer","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"12. Iyar","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"Menahฬฒot 108","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"27. day of the Omer","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"13. Iyar","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"Menahฬฒot 109","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"28. day of the Omer","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"14. Iyar","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"Pessahฬฒ Cheni","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"Second Passover, one month after Passover"},{"title":"Menahฬฒot 110","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"29. day of the Omer","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"15. Iyar","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Parachah Emor","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Hฬฒoulin 2","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"30. day of the Omer","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"16. Iyar","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"Hฬฒoulin 3","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"31. day of the Omer","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"17. Iyar","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"Hฬฒoulin 4","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"32. day of the Omer","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"18. Iyar","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"Lag baโ€™Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"33rd day of counting the Omer. The holiday is a temporary break from the semi-mourning period the counting of the Omer. Practices include lighting bonfires, getting haircuts, and Jewish weddings"},{"title":"Hฬฒoulin 5","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"33. day of the Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"19. Iyar","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"Hฬฒoulin 6","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"34. day of the Omer","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"20. Iyar","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"Hฬฒoulin 7","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"35. day of the Omer","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"21. Iyar","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"Hฬฒoulin 8","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"36. day of the Omer","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"22. Iyar","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Parachah Behar-Behฬฒoukotaรฏ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Hฬฒoulin 9","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"37. day of the Omer","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"23. Iyar","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"Hฬฒoulin 10","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"38. day of the Omer","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"24. Iyar","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"Hฬฒoulin 11","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"39. day of the Omer","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"25. Iyar","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"Hฬฒoulin 12","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"40. day of the Omer","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"26. Iyar","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"Hฬฒoulin 13","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"41. day of the Omer","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"27. Iyar","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"Hฬฒoulin 14","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"42. day of the Omer","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"28. Iyar","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"Hฬฒoulin 15","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"43. day of the Omer","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"29. Iyar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Parachah Bamidbar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Sivan","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"Molad Sivan: Samedi, 18:02 et 15 'halakim"},{"title":"Hฬฒoulin 16","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"44. day of the Omer","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"1. Sivan, 5786","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"Hฬฒoulin 17","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"45. day of the Omer","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"2. Sivan","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"Hฬฒoulin 18","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"46. day of the Omer","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"3. Sivan","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"Hฬฒoulin 19","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"47. day of the Omer","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"4. Sivan","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"Hฬฒoulin 20","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"48. day of the Omer","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"5. Sivan","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"Erev Chavouโ€™ot","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Hฬฒoulin 21","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"49. day of the Omer","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"6. Sivan","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"Chavouโ€™ot","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot","hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Hฬฒoulin 22","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"7. Sivan","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Parachah Nasso","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Hฬฒoulin 23","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"8. Sivan","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"Hฬฒoulin 24","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"9. Sivan","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"Hฬฒoulin 25","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"10. Sivan","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"Hฬฒoulin 26","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"11. Sivan","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"Hฬฒoulin 27","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"12. Sivan","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"Hฬฒoulin 28","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. Sivan","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"Hฬฒoulin 29","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"14. Sivan","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Parachah Behaโ€™alotkha","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Hฬฒoulin 30","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"15. Sivan","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"Hฬฒoulin 31","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. Sivan","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"Hฬฒoulin 32","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. Sivan","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"Hฬฒoulin 33","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"18. Sivan","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"Hฬฒoulin 34","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"19. Sivan","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"Hฬฒoulin 35","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. Sivan","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"Hฬฒoulin 36","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"21. Sivan","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Parachah Chelahฬฒ Lekha","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Hฬฒoulin 37","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"22. Sivan","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"Hฬฒoulin 38","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. Sivan","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"Hฬฒoulin 39","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"24. Sivan","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"Hฬฒoulin 40","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-pt-br.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-pt-br.json new file mode 100644 index 0000000..3888233 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-pt-br.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:56.019Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"6. Nisan","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"Menachot 72","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"7. Nisan","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"Menachot 73","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"8. Nisan","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"Menachot 74","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"9. Nisan","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"Menachot 75","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"10. Nisan","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Parashรก Tzav","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","title_orig":"Parashat Tzav","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"Menachot 76","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"11. Nisan","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"Menachot 77","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"12. Nisan","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"Menachot 78","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"13. Nisan","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"Menachot 79","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"14. Nisan","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"Taanit Bechorot","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Fast of the First Born"},{"title":"Vรฉspera de Pesach","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Erev Pesach","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 80","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"15. Nisan","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"Pesach I","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 81","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"16. Nisan","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"Pesach II (Chol haMoed)","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 82","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"1. Dia do Omer","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"17. Nisan","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"Pesach III (Chol haMoed)","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 83","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"2. Dia do Omer","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"18. Nisan","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"Pesach IV (Chol haMoed)","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 84","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"3. Dia do Omer","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"19. Nisan","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"Pesach V (Chol haMoed)","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 85","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"4. Dia do Omer","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"20. Nisan","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"Pesach VI (Chol haMoed)","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 86","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"5. Dia do Omer","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"21. Nisan","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"Pesach VII","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"Menachot 87","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"6. Dia do Omer","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"22. Nisan","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"Menachot 88","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"7. Dia do Omer","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"23. Nisan","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"Menachot 89","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"8. Dia do Omer","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"24. Nisan","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Parashรก Shmini","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","title_orig":"Parashat Shmini","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"Mevarchim Chodesh Iyar","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Iyar","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"Molad Iyar: Friday, 5:18 and 14 chalakim"},{"title":"Menachot 90","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"9. Dia do Omer","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"25. Nisan","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"Menachot 91","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"10. Dia do Omer","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"26. Nisan","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"Menachot 92","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"11. Dia do Omer","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"27. Nisan","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"Menachot 93","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"12. Dia do Omer","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"28. Nisan","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"Menachot 94","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"13. Dia do Omer","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"29. Nisan","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"Menachot 95","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"14. Dia do Omer","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"30. Nisan","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"Menachot 96","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"15. Dia do Omer","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"1. Iyar, 5786","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Parashรก Tazria-Metzora","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","title_orig":"Parashat Tazria-Metzora","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"Menachot 97","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"16. Dia do Omer","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"2. Iyar","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"Menachot 98","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"17. Dia do Omer","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"3. Iyar","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"Menachot 99","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"18. Dia do Omer","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"4. Iyar","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"Menachot 100","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"19. Dia do Omer","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"5. Iyar","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"Menachot 101","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"20. Dia do Omer","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"6. Iyar","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"Menachot 102","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"21. Dia do Omer","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"7. Iyar","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"Menachot 103","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"22. Dia do Omer","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"8. Iyar","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Parashรก Acharei Mot-Kedoshim","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"Menachot 104","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"23. Dia do Omer","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"9. Iyar","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"Menachot 105","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"24. Dia do Omer","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"10. Iyar","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"Menachot 106","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"25. Dia do Omer","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"11. Iyar","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"Menachot 107","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"26. Dia do Omer","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"12. Iyar","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"Menachot 108","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"27. Dia do Omer","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"13. Iyar","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"Menachot 109","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"28. Dia do Omer","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"14. Iyar","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"Pesach Sheni","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"Second Passover, one month after Passover"},{"title":"Menachot 110","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"29. Dia do Omer","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"15. Iyar","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Parashรก Emor","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"Chulin 2","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"30. Dia do Omer","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"16. Iyar","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"Chulin 3","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"31. Dia do Omer","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"17. Iyar","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"Chulin 4","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"32. Dia do Omer","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"18. Iyar","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"Lag baOmer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"33rd day of counting the Omer. The holiday is a temporary break from the semi-mourning period the counting of the Omer. Practices include lighting bonfires, getting haircuts, and Jewish weddings"},{"title":"Chulin 5","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"33. Dia do Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"19. Iyar","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"Chulin 6","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"34. Dia do Omer","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"20. Iyar","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"Chulin 7","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"35. Dia do Omer","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"21. Iyar","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"Chulin 8","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"36. Dia do Omer","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"22. Iyar","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Parashรก Behar-Bechukotai","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"Chulin 9","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"37. Dia do Omer","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"23. Iyar","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"Chulin 10","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"38. Dia do Omer","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"24. Iyar","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"Chulin 11","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"39. Dia do Omer","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"25. Iyar","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"Chulin 12","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"40. Dia do Omer","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"26. Iyar","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"Chulin 13","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"41. Dia do Omer","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"27. Iyar","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"Chulin 14","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"42. Dia do Omer","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"28. Iyar","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"Chulin 15","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"43. Dia do Omer","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"29. Iyar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Parashรก Bamidbar","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Sivan","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"Molad Sivan: Saturday, 18:02 and 15 chalakim"},{"title":"Chulin 16","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"44. Dia do Omer","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"1. Sivan, 5786","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"Chulin 17","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"45. Dia do Omer","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"2. Sivan","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"Chulin 18","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"46. Dia do Omer","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"3. Sivan","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"Chulin 19","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"47. Dia do Omer","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"4. Sivan","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"Chulin 20","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"48. Dia do Omer","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"5. Sivan","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"Vรฉspera de Shavuot","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Chulin 21","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"49. Dia do Omer","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"6. Sivan","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"Shavuot","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"Chulin 22","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"7. Sivan","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Parashรก Naso","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"Chulin 23","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"8. Sivan","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"Chulin 24","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"9. Sivan","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"Chulin 25","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"10. Sivan","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"Chulin 26","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"11. Sivan","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"Chulin 27","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"12. Sivan","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"Chulin 28","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. Sivan","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"Chulin 29","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"14. Sivan","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Parashรก Behaalotecha","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"Chulin 30","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"15. Sivan","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"Chulin 31","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. Sivan","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"Chulin 32","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. Sivan","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"Chulin 33","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"18. Sivan","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"Chulin 34","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"19. Sivan","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"Chulin 35","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. Sivan","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"Chulin 36","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"21. Sivan","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Parashรก Shlach","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"Chulin 37","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"22. Sivan","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"Chulin 38","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. Sivan","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"Chulin 39","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"24. Sivan","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"Chulin 40","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-ru.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-ru.json new file mode 100644 index 0000000..68124b5 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04-ru.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:50.751Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"6. ะะธัะฐะฝ","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 72","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","title_orig":"Menachot 72","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"7. ะะธัะฐะฝ","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 73","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","title_orig":"Menachot 73","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"8. ะะธัะฐะฝ","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 74","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","title_orig":"Menachot 74","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"9. ะะธัะฐะฝ","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 75","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","title_orig":"Menachot 75","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"10. ะะธัะฐะฝ","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะฆะฐะฒ","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","title_orig":"Parashat Tzav","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 76","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","title_orig":"Menachot 76","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"11. ะะธัะฐะฝ","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"ะœะตะฝะฐั…ะพั‚ 77","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","title_orig":"Menachot 77","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"12. ะะธัะฐะฝ","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"ะœะตะฝะฐั…ะพั‚ 78","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","title_orig":"Menachot 78","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"13. ะะธัะฐะฝ","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"ะœะตะฝะฐั…ะพั‚ 79","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","title_orig":"Menachot 79","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"14. ะะธัะฐะฝ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"ะŸะพัั‚ ะฟะตั€ะฒะตะฝั†ะตะฒ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Fast of the First Born"},{"title":"ะ’ะตั‡ะตั€ ะŸััะฐั…ะฐ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Erev Pesach","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 80","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","title_orig":"Menachot 80","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"15. ะะธัะฐะฝ","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"ะŸััะฐั… I","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach I","hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 81","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","title_orig":"Menachot 81","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"16. ะะธัะฐะฝ","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"ะ’ั‚ะพั€ะพะน ะดะตะฝัŒ ะŸััะฐั…, ะฅะพะปัŒ ะะผะพัะด","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 82","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","title_orig":"Menachot 82","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"1. day of the Omer","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"17. ะะธัะฐะฝ","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"ะขั€ะตั‚ะธะน ะดะตะฝัŒ ะŸััะฐั…, ะฅะพะปัŒ ะะผะพัะด","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 83","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","title_orig":"Menachot 83","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"2. day of the Omer","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"18. ะะธัะฐะฝ","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"ะงะตั‚ะฒะตั€ั‚ั‹ะน ะดะตะฝัŒ ะŸััะฐั…, ะฅะพะปัŒ ะะผะพัะด","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 84","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","title_orig":"Menachot 84","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"3. day of the Omer","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"19. ะะธัะฐะฝ","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"ะŸัั‚ั‹ะน ะดะตะฝัŒ ะŸััะฐั…, ะฅะพะปัŒ ะะผะพัะด","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 85","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","title_orig":"Menachot 85","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"4. day of the Omer","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"20. ะะธัะฐะฝ","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"ะจะตัั‚ะพะน ะดะตะฝัŒ ะŸััะฐั…, ะฅะพะปัŒ ะะผะพัะด","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 86","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","title_orig":"Menachot 86","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"5. day of the Omer","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"21. ะะธัะฐะฝ","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"ะกะตะดัŒะผะพะน ะดะตะฝัŒ ะŸััะฐั…","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach VII","hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"Passover, the Feast of Unleavened Bread. Also called Chag HaMatzot (the Festival of Matzah), it commemorates the Exodus and freedom of the Israelites from ancient Egypt"},{"title":"ะœะตะฝะฐั…ะพั‚ 87","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","title_orig":"Menachot 87","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"6. day of the Omer","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"22. ะะธัะฐะฝ","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"ะœะตะฝะฐั…ะพั‚ 88","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","title_orig":"Menachot 88","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"7. day of the Omer","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"23. ะะธัะฐะฝ","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"ะœะตะฝะฐั…ะพั‚ 89","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","title_orig":"Menachot 89","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"8. day of the Omer","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"24. ะะธัะฐะฝ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ะ“ะปะฐะฒะฐ ะจะผะธะฝะธ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","title_orig":"Parashat Shmini","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ะผะตะฒะฐั€ั…ะธะผ ะะพะฒะพะผะตััั‡ัŒะตะผ ะ˜ัั€","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ะ˜ัั€","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"Molad ะ˜ัั€: Friday, 5:18 and 14 chalakim"},{"title":"ะœะตะฝะฐั…ะพั‚ 90","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","title_orig":"Menachot 90","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"9. day of the Omer","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"25. ะะธัะฐะฝ","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"ะœะตะฝะฐั…ะพั‚ 91","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","title_orig":"Menachot 91","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"10. day of the Omer","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"26. ะะธัะฐะฝ","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"ะœะตะฝะฐั…ะพั‚ 92","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","title_orig":"Menachot 92","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"11. day of the Omer","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"27. ะะธัะฐะฝ","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"ะœะตะฝะฐั…ะพั‚ 93","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","title_orig":"Menachot 93","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"12. day of the Omer","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"28. ะะธัะฐะฝ","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"ะœะตะฝะฐั…ะพั‚ 94","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","title_orig":"Menachot 94","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"13. day of the Omer","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"29. ะะธัะฐะฝ","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"ะœะตะฝะฐั…ะพั‚ 95","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","title_orig":"Menachot 95","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"14. day of the Omer","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"30. ะะธัะฐะฝ","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 96","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","title_orig":"Menachot 96","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"15. day of the Omer","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"1. ะ˜ัั€, 5786","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะขะฐะทั€ะธะฐ-ะœะตั†ะพั€ะฐ","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","title_orig":"Parashat Tazria-Metzora","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 97","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 97","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"16. day of the Omer","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"2. ะ˜ัั€","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 98","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 98","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"17. day of the Omer","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"3. ะ˜ัั€","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 99","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 99","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"18. day of the Omer","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"4. ะ˜ัั€","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 100","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 100","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"19. day of the Omer","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"5. ะ˜ัั€","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 101","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 101","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"20. day of the Omer","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"6. ะ˜ัั€","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 102","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"21. day of the Omer","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"7. ะ˜ัั€","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 103","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"22. day of the Omer","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"8. ะ˜ัั€","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะั…ะฐั€ะตะน ะœะพั‚-ะšะดะพัˆะธะผ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 104","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"23. day of the Omer","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"9. ะ˜ัั€","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 105","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"24. day of the Omer","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"10. ะ˜ัั€","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ะœะตะฝะฐั…ะพั‚ 106","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"25. day of the Omer","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"11. ะ˜ัั€","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ะœะตะฝะฐั…ะพั‚ 107","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"26. day of the Omer","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"12. ะ˜ัั€","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ะœะตะฝะฐั…ะพั‚ 108","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"27. day of the Omer","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"13. ะ˜ัั€","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ะœะตะฝะฐั…ะพั‚ 109","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"28. day of the Omer","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"14. ะ˜ัั€","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ะ’ั‚ะพั€ะพะน ะŸััะฐั…","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"Second Passover, one month after Passover"},{"title":"ะœะตะฝะฐั…ะพั‚ 110","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"29. day of the Omer","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"15. ะ˜ัั€","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ะ“ะปะฐะฒะฐ ะญะผะพั€","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ะฅัƒะปะธะฝ 2","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"30. day of the Omer","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"16. ะ˜ัั€","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ะฅัƒะปะธะฝ 3","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"31. day of the Omer","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"17. ะ˜ัั€","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ะฅัƒะปะธะฝ 4","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"32. day of the Omer","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"18. ะ˜ัั€","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ะ›ะฐะณ ะ‘ะฐ ะžะผะตั€","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"33rd day of counting the Omer. The holiday is a temporary break from the semi-mourning period the counting of the Omer. Practices include lighting bonfires, getting haircuts, and Jewish weddings"},{"title":"ะฅัƒะปะธะฝ 5","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"33. day of the Omer","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"19. ะ˜ัั€","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ะฅัƒะปะธะฝ 6","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"34. day of the Omer","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"20. ะ˜ัั€","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ะฅัƒะปะธะฝ 7","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"35. day of the Omer","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"21. ะ˜ัั€","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ะฅัƒะปะธะฝ 8","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"36. day of the Omer","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"22. ะ˜ัั€","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ะ“ะปะฐะฒะฐ ะ‘ะต-ะฐั€-ะ‘ะตั…ัƒะบะพั‚ะฐะน","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ะฅัƒะปะธะฝ 9","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"37. day of the Omer","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"23. ะ˜ัั€","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ะฅัƒะปะธะฝ 10","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"38. day of the Omer","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"24. ะ˜ัั€","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ะฅัƒะปะธะฝ 11","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"39. day of the Omer","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"25. ะ˜ัั€","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ะฅัƒะปะธะฝ 12","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"40. day of the Omer","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"26. ะ˜ัั€","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ะฅัƒะปะธะฝ 13","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"41. day of the Omer","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"27. ะ˜ัั€","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ะฅัƒะปะธะฝ 14","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"42. day of the Omer","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"28. ะ˜ัั€","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ะฅัƒะปะธะฝ 15","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"43. day of the Omer","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"29. ะ˜ัั€","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ะ“ะปะฐะฒะฐ ะ‘ะฐะผะธะดะฑะฐั€","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ะผะตะฒะฐั€ั…ะธะผ ะะพะฒะพะผะตััั‡ัŒะตะผ ะกะธะฒะฐะฝ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ะกะธะฒะฐะฝ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"Molad ะกะธะฒะฐะฝ: Saturday, 18:02 and 15 chalakim"},{"title":"ะฅัƒะปะธะฝ 16","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"44. day of the Omer","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"1. ะกะธะฒะฐะฝ, 5786","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ะฅัƒะปะธะฝ 17","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"45. day of the Omer","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"2. ะกะธะฒะฐะฝ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ะฅัƒะปะธะฝ 18","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"46. day of the Omer","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"3. ะกะธะฒะฐะฝ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ะฅัƒะปะธะฝ 19","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"47. day of the Omer","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"4. ะกะธะฒะฐะฝ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ะฅัƒะปะธะฝ 20","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"48. day of the Omer","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"5. ะกะธะฒะฐะฝ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ะ’ะตั‡ะตั€ ะจะฐะฒัƒะพั‚ะฐ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"ะฅัƒะปะธะฝ 21","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"49. day of the Omer","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"6. ะกะธะฒะฐะฝ","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ะจะฐะฒัƒะพั‚","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot","hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"Festival of Weeks. Commemorates the giving of the Torah at Mount Sinai"},{"title":"ะฅัƒะปะธะฝ 22","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"7. ะกะธะฒะฐะฝ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะะฐัะพ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ะฅัƒะปะธะฝ 23","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"8. ะกะธะฒะฐะฝ","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ะฅัƒะปะธะฝ 24","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"9. ะกะธะฒะฐะฝ","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ะฅัƒะปะธะฝ 25","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"10. ะกะธะฒะฐะฝ","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ะฅัƒะปะธะฝ 26","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"11. ะกะธะฒะฐะฝ","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ะฅัƒะปะธะฝ 27","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"12. ะกะธะฒะฐะฝ","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ะฅัƒะปะธะฝ 28","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. ะกะธะฒะฐะฝ","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ะฅัƒะปะธะฝ 29","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"14. ะกะธะฒะฐะฝ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ะ“ะปะฐะฒะฐ ะ’ะตะณะฐะฐะปะพั‚ั…ะฐ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ะฅัƒะปะธะฝ 30","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"15. ะกะธะฒะฐะฝ","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ะฅัƒะปะธะฝ 31","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. ะกะธะฒะฐะฝ","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ะฅัƒะปะธะฝ 32","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. ะกะธะฒะฐะฝ","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ะฅัƒะปะธะฝ 33","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"18. ะกะธะฒะฐะฝ","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ะฅัƒะปะธะฝ 34","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"19. ะกะธะฒะฐะฝ","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ะฅัƒะปะธะฝ 35","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. ะกะธะฒะฐะฝ","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ะฅัƒะปะธะฝ 36","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"21. ะกะธะฒะฐะฝ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ะ“ะปะฐะฒะฐ ะจะปะฐั…","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ะฅัƒะปะธะฝ 37","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"22. ะกะธะฒะฐะฝ","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ะฅัƒะปะธะฝ 38","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. ะกะธะฒะฐะฝ","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ะฅัƒะปะธะฝ 39","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"24. ะกะธะฒะฐะฝ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ะฅัƒะปะธะฝ 40","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04.json new file mode 100644 index 0000000..8b16a36 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-04.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:42:01.123Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-03-24","end":"2026-06-09"},"items":[{"title":"ื•ืณ ื ึดื™ืกึธืŸ","date":"2026-03-24","hdate":"6 Nisan 5786","category":"hebdate","title_orig":"6 Nisan 5786","hebrew":"ื•ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","date":"2026-03-24","hdate":"6 Nisan 5786","category":"dafyomi","title_orig":"Menachot 72","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื‘","link":"https://www.sefaria.org/Menachot.72a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื•ืณ"}},{"title":"ื–ืณ ื ึดื™ืกึธืŸ","date":"2026-03-25","hdate":"7 Nisan 5786","category":"hebdate","title_orig":"7 Nisan 5786","hebrew":"ื–ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","date":"2026-03-25","hdate":"7 Nisan 5786","category":"dafyomi","title_orig":"Menachot 73","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื’","link":"https://www.sefaria.org/Menachot.73a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื–ืณ"}},{"title":"ื—ืณ ื ึดื™ืกึธืŸ","date":"2026-03-26","hdate":"8 Nisan 5786","category":"hebdate","title_orig":"8 Nisan 5786","hebrew":"ื—ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","date":"2026-03-26","hdate":"8 Nisan 5786","category":"dafyomi","title_orig":"Menachot 74","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื“","link":"https://www.sefaria.org/Menachot.74a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื—ืณ"}},{"title":"ื˜ืณ ื ึดื™ืกึธืŸ","date":"2026-03-27","hdate":"9 Nisan 5786","category":"hebdate","title_orig":"9 Nisan 5786","hebrew":"ื˜ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","date":"2026-03-27","hdate":"9 Nisan 5786","category":"dafyomi","title_orig":"Menachot 75","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื”","link":"https://www.sefaria.org/Menachot.75a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืณ"}},{"title":"ื™ืณ ื ึดื™ืกึธืŸ","date":"2026-03-28","hdate":"10 Nisan 5786","category":"hebdate","title_orig":"10 Nisan 5786","hebrew":"ื™ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืฆึทื•","date":"2026-03-28","hdate":"10 Nisan 5786","category":"parashat","title_orig":"Parashat Tzav","hebrew":"ืคืจืฉืช ืฆื•","leyning":{"1":"Leviticus 6:1-6:11","2":"Leviticus 6:12-7:10","3":"Leviticus 7:11-7:38","4":"Leviticus 8:1-8:13","5":"Leviticus 8:14-8:21","6":"Leviticus 8:22-8:29","7":"Leviticus 8:30-8:36","torah":"Leviticus 6:1-8:36","haftarah":"Malachi 3:4-24 | Shabbat HaGadol","maftir":"Leviticus 8:33-8:36","triennial":{"1":"Leviticus 6:1-6:3","2":"Leviticus 6:4-6:6","3":"Leviticus 6:7-6:11","4":"Leviticus 6:12-6:16","5":"Leviticus 6:17-6:23","6":"Leviticus 7:1-7:6","7":"Leviticus 7:7-7:10","maftir":"Leviticus 7:7-7:10"}},"link":"https://hebcal.com/s/5786i/25?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","date":"2026-03-28","hdate":"10 Nisan 5786","category":"dafyomi","title_orig":"Menachot 76","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื•","link":"https://www.sefaria.org/Menachot.76a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืณ"}},{"title":"ื™ืดื ื ึดื™ืกึธืŸ","date":"2026-03-29","hdate":"11 Nisan 5786","category":"hebdate","title_orig":"11 Nisan 5786","hebrew":"ื™ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","date":"2026-03-29","hdate":"11 Nisan 5786","category":"dafyomi","title_orig":"Menachot 77","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื–","link":"https://www.sefaria.org/Menachot.77a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ื ึดื™ืกึธืŸ","date":"2026-03-30","hdate":"12 Nisan 5786","category":"hebdate","title_orig":"12 Nisan 5786","hebrew":"ื™ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","date":"2026-03-30","hdate":"12 Nisan 5786","category":"dafyomi","title_orig":"Menachot 78","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื—","link":"https://www.sefaria.org/Menachot.78a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ื ึดื™ืกึธืŸ","date":"2026-03-31","hdate":"13 Nisan 5786","category":"hebdate","title_orig":"13 Nisan 5786","hebrew":"ื™ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","date":"2026-03-31","hdate":"13 Nisan 5786","category":"dafyomi","title_orig":"Menachot 79","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืขืดื˜","link":"https://www.sefaria.org/Menachot.79a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ื ึดื™ืกึธืŸ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"hebdate","title_orig":"14 Nisan 5786","hebrew":"ื™ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"ืชึทึผืขึฒื ึดื™ืช ื‘ึฐึผื›ื•ึนืจื•ึนืช","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"fast","title_orig":"Ta'anit Bechorot","hebrew":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช","link":"https://hebcal.com/h/taanit-bechorot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"ืชืขื ื™ืช ื‘ื›ื•ืจื•ืช"},{"title":"ืขึถืจึถื‘ ืคึถึผืกึทื—","date":"2026-04-01","hdate":"14 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Erev Pesach","hebrew":"ืขืจื‘ ืคืกื—","link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","date":"2026-04-01","hdate":"14 Nisan 5786","category":"dafyomi","title_orig":"Menachot 80","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืณ","link":"https://www.sefaria.org/Menachot.80a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ื ึดื™ืกึธืŸ","date":"2026-04-02","hdate":"15 Nisan 5786","category":"hebdate","title_orig":"15 Nisan 5786","hebrew":"ื˜ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"ืคึถึผืกึทื— ืืณ","date":"2026-04-02","hdate":"15 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach I","hebrew":"ืคืกื— ืืณ","leyning":{"1":"Exodus 12:21-12:24","2":"Exodus 12:25-12:28","3":"Exodus 12:29-12:36","4":"Exodus 12:37-12:42","5":"Exodus 12:43-12:51","torah":"Exodus 12:21-51; Numbers 28:16-25","haftarah":"Joshua 3:5-7, 5:2-6:1, 6:27","haftarah_sephardic":"Joshua 5:2-6:1, 6:27","maftir":"Numbers 28:16-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","date":"2026-04-02","hdate":"15 Nisan 5786","category":"dafyomi","title_orig":"Menachot 81","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื","link":"https://www.sefaria.org/Menachot.81a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ื ึดื™ืกึธืŸ","date":"2026-04-03","hdate":"16 Nisan 5786","category":"hebdate","title_orig":"16 Nisan 5786","hebrew":"ื˜ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"ืคึถึผืกึทื— ื‘ืณ (ื—ื•ื”ืดืž)","date":"2026-04-03","hdate":"16 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach II (CH''M)","hebrew":"ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Leviticus 22:26-23:8","2":"Leviticus 23:9-23:14","3":"Leviticus 23:15-23:44","4":"Numbers 28:19-28:25","torah":"Leviticus 22:26-23:44; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","date":"2026-04-03","hdate":"16 Nisan 5786","category":"dafyomi","title_orig":"Menachot 82","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื‘","link":"https://www.sefaria.org/Menachot.82a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"ืืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-03","hdate":"16 Nisan 5786","category":"omer","title_orig":"Omer 1","hebrew":"ืขื•ืžืจ ื™ื•ื 1","link":"https://hebcal.com/o/5786/1?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Chesed sheb'Chesed","en":"Lovingkindness within Lovingkindness"},"anaBekoachWord":"ืึธื ึธึผื","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ื ึดื™ืกึธืŸ","date":"2026-04-04","hdate":"17 Nisan 5786","category":"hebdate","title_orig":"17 Nisan 5786","hebrew":"ื™ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"ืคึถึผืกึทื— ื’ืณ (ื—ื•ื”ืดืž)","date":"2026-04-04","hdate":"17 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach III (CH''M)","hebrew":"ืคืกื— ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 33:12-33:16","2":"Exodus 33:17-33:19","3":"Exodus 33:20-33:23","4":"Exodus 34:1-34:3","5":"Exodus 34:4-34:10","6":"Exodus 34:11-34:17","7":"Exodus 34:18-34:26","torah":"Exodus 33:12-34:26; Numbers 28:19-25; Song of Songs 1:1-8:14","haftarah":"Ezekiel 37:1-14","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","date":"2026-04-04","hdate":"17 Nisan 5786","category":"dafyomi","title_orig":"Menachot 83","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื’","link":"https://www.sefaria.org/Menachot.83a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"ื‘ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-04","hdate":"17 Nisan 5786","category":"omer","title_orig":"Omer 2","hebrew":"ืขื•ืžืจ ื™ื•ื 2","link":"https://hebcal.com/o/5786/2?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Gevurah sheb'Chesed","en":"Might within Lovingkindness"},"anaBekoachWord":"ื‘ึฐึผื›ึนื—ึท","lamnatzeachWord":"ื™ึฐื—ึธื ึตึผื ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ื ึดื™ืกึธืŸ","date":"2026-04-05","hdate":"18 Nisan 5786","category":"hebdate","title_orig":"18 Nisan 5786","hebrew":"ื™ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"ืคึถึผืกึทื— ื“ืณ (ื—ื•ื”ืดืž)","date":"2026-04-05","hdate":"18 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach IV (CH''M)","hebrew":"ืคืกื— ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 22:24-22:26","2":"Exodus 22:27-23:5","3":"Exodus 23:6-23:19","4":"Numbers 28:19-28:25","torah":"Exodus 22:24-23:19; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","date":"2026-04-05","hdate":"18 Nisan 5786","category":"dafyomi","title_orig":"Menachot 84","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื“","link":"https://www.sefaria.org/Menachot.84a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"ื’ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-05","hdate":"18 Nisan 5786","category":"omer","title_orig":"Omer 3","hebrew":"ืขื•ืžืจ ื™ื•ื 3","link":"https://hebcal.com/o/5786/3?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Tiferet sheb'Chesed","en":"Beauty within Lovingkindness"},"anaBekoachWord":"ื’ึฐึผื“ึปืœึทึผืช","lamnatzeachWord":"ื•ึดื™ื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ื ึดื™ืกึธืŸ","date":"2026-04-06","hdate":"19 Nisan 5786","category":"hebdate","title_orig":"19 Nisan 5786","hebrew":"ื™ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"ืคึถึผืกึทื— ื”ืณ (ื—ื•ื”ืดืž)","date":"2026-04-06","hdate":"19 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach V (CH''M)","hebrew":"ืคืกื— ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Exodus 34:1-34:10","2":"Exodus 34:11-34:17","3":"Exodus 34:18-34:26","4":"Numbers 28:19-28:25","torah":"Exodus 34:1-26; Numbers 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","date":"2026-04-06","hdate":"19 Nisan 5786","category":"dafyomi","title_orig":"Menachot 85","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื”","link":"https://www.sefaria.org/Menachot.85a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"ื“ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-06","hdate":"19 Nisan 5786","category":"omer","title_orig":"Omer 4","hebrew":"ืขื•ืžืจ ื™ื•ื 4","link":"https://hebcal.com/o/5786/4?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Netzach sheb'Chesed","en":"Eternity within Lovingkindness"},"anaBekoachWord":"ื™ึฐืžึดื™ื ึฐืšึธ","lamnatzeachWord":"ื™ึธืึตืจ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ื ึดื™ืกึธืŸ","date":"2026-04-07","hdate":"20 Nisan 5786","category":"hebdate","title_orig":"20 Nisan 5786","hebrew":"ื›ืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"ืคึถึผืกึทื— ื•ืณ (ื—ื•ื”ืดืž)","date":"2026-04-07","hdate":"20 Nisan 5786","category":"holiday","subcat":"major","title_orig":"Pesach VI (CH''M)","hebrew":"ืคืกื— ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 9:1-9:5","2":"Numbers 9:6-9:8","3":"Numbers 9:9-9:14","4":"Numbers 28:19-28:25","torah":"Numbers 9:1-14, 28:19-25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","date":"2026-04-07","hdate":"20 Nisan 5786","category":"dafyomi","title_orig":"Menachot 86","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื•","link":"https://www.sefaria.org/Menachot.86a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"ื”ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-07","hdate":"20 Nisan 5786","category":"omer","title_orig":"Omer 5","hebrew":"ืขื•ืžืจ ื™ื•ื 5","link":"https://hebcal.com/o/5786/5?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Hod sheb'Chesed","en":"Splendor within Lovingkindness"},"anaBekoachWord":"ืชึทึผืชึดึผื™ืจ","lamnatzeachWord":"ืคึธึผื ึธื™ื•","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืณ"}},{"title":"ื›ืดื ื ึดื™ืกึธืŸ","date":"2026-04-08","hdate":"21 Nisan 5786","category":"hebdate","title_orig":"21 Nisan 5786","hebrew":"ื›ืดื ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"ืคึถึผืกึทื— ื–ืณ","date":"2026-04-08","hdate":"21 Nisan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Pesach VII","hebrew":"ืคืกื— ื–ืณ","leyning":{"1":"Exodus 13:17-13:22","2":"Exodus 14:1-14:8","3":"Exodus 14:9-14:14","4":"Exodus 14:15-14:25","5":"Exodus 14:26-15:26","torah":"Exodus 13:17-15:26; Numbers 28:19-25","haftarah":"II Samuel 22:1-51","maftir":"Numbers 28:19-28:25"},"link":"https://hebcal.com/h/pesach-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"},"memo":"ืคืกื—, ื—ื’ ื”ืžืฆื•ืช. ืžืฆื™ื™ืŸ ืืช ื™ืฆื™ืืช ืžืฆืจื™ื ื•ืฉื—ืจื•ืจ ื‘ื ื™ ื™ืฉืจืืœ ืžืžืฆืจื™ื ื”ืขืชื™ืงื”"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","date":"2026-04-08","hdate":"21 Nisan 5786","category":"dafyomi","title_orig":"Menachot 87","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื–","link":"https://www.sefaria.org/Menachot.87a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"ื•ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-08","hdate":"21 Nisan 5786","category":"omer","title_orig":"Omer 6","hebrew":"ืขื•ืžืจ ื™ื•ื 6","link":"https://hebcal.com/o/5786/6?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Yesod sheb'Chesed","en":"Foundation within Lovingkindness"},"anaBekoachWord":"ืฆึฐืจื•ึผืจึธื”","lamnatzeachWord":"ืึดืชึธึผื ื•ึผ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ื ึดื™ืกึธืŸ","date":"2026-04-09","hdate":"22 Nisan 5786","category":"hebdate","title_orig":"22 Nisan 5786","hebrew":"ื›ืดื‘ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","date":"2026-04-09","hdate":"22 Nisan 5786","category":"dafyomi","title_orig":"Menachot 88","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื—","link":"https://www.sefaria.org/Menachot.88a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"ื–ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-09","hdate":"22 Nisan 5786","category":"omer","title_orig":"Omer 7","hebrew":"ืขื•ืžืจ ื™ื•ื 7","link":"https://hebcal.com/o/5786/7?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 7 days, which are 1 week of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื—ึถึฝืกึถื“","translit":"Malkhut sheb'Chesed","en":"Majesty within Lovingkindness"},"anaBekoachWord":"ืื‘ืดื’ ื™ืชืดืฅ","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ื ึดื™ืกึธืŸ","date":"2026-04-10","hdate":"23 Nisan 5786","category":"hebdate","title_orig":"23 Nisan 5786","hebrew":"ื›ืดื’ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","date":"2026-04-10","hdate":"23 Nisan 5786","category":"dafyomi","title_orig":"Menachot 89","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืคืดื˜","link":"https://www.sefaria.org/Menachot.89a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"ื—ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-10","hdate":"23 Nisan 5786","category":"omer","title_orig":"Omer 8","hebrew":"ืขื•ืžืจ ื™ื•ื 8","link":"https://hebcal.com/o/5786/8?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 8 days, which are 1 week and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Chesed shebiGevurah","en":"Lovingkindness within Might"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืœึธื“ึทืขึทืช","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ื ึดื™ืกึธืŸ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"hebdate","title_orig":"24 Nisan 5786","hebrew":"ื›ืดื“ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ืคึธึผืจึธืฉึทืืช ืฉึฐึผืืžึดื™ื ึดื™","date":"2026-04-11","hdate":"24 Nisan 5786","category":"parashat","title_orig":"Parashat Shmini","hebrew":"ืคืจืฉืช ืฉืžื™ื ื™","leyning":{"1":"Leviticus 9:1-9:16","2":"Leviticus 9:17-9:23","3":"Leviticus 9:24-10:11","4":"Leviticus 10:12-10:15","5":"Leviticus 10:16-10:20","6":"Leviticus 11:1-11:32","7":"Leviticus 11:33-11:47","torah":"Leviticus 9:1-11:47","haftarah":"II Samuel 6:1-7:17","haftarah_sephardic":"II Samuel 6:1-19","maftir":"Leviticus 11:45-11:47","triennial":{"1":"Leviticus 9:1-9:6","2":"Leviticus 9:7-9:10","3":"Leviticus 9:11-9:16","4":"Leviticus 9:17-9:23","5":"Leviticus 9:24-10:3","6":"Leviticus 10:4-10:7","7":"Leviticus 10:8-10:11","maftir":"Leviticus 10:8-10:11"}},"link":"https://hebcal.com/s/5786i/26?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืึดื™ึธึผื™ืจ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Iyyar","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืึดื™ึธึผื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืึดื™ึธึผื™ืจ ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึดืื™ืฉึดึผืื™ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 5 ื‘ึทึผื‘ึนึผืงึถืจ, ื•-18 ื“ึทึผืงึผื•ึนืช ื•-14 ื—ึฒืœึธืงึดื™ื"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"dafyomi","title_orig":"Menachot 90","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืณ","link":"https://www.sefaria.org/Menachot.90a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ื˜ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-11","hdate":"24 Nisan 5786","category":"omer","title_orig":"Omer 9","hebrew":"ืขื•ืžืจ ื™ื•ื 9","link":"https://hebcal.com/o/5786/9?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 9 days, which are 1 week and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Gevurah shebiGevurah","en":"Might within Might"},"anaBekoachWord":"ืจึดื ึทึผืช","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ื ึดื™ืกึธืŸ","date":"2026-04-12","hdate":"25 Nisan 5786","category":"hebdate","title_orig":"25 Nisan 5786","hebrew":"ื›ืดื” ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","date":"2026-04-12","hdate":"25 Nisan 5786","category":"dafyomi","title_orig":"Menachot 91","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื","link":"https://www.sefaria.org/Menachot.91a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"ื™ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-12","hdate":"25 Nisan 5786","category":"omer","title_orig":"Omer 10","hebrew":"ืขื•ืžืจ ื™ื•ื 10","link":"https://hebcal.com/o/5786/10?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึฒืฉึธื‚ืจึธื” ื™ึธืžึดื™ื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 10 days, which are 1 week and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Tiferet shebiGevurah","en":"Beauty within Might"},"anaBekoachWord":"ืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื“ึทึผืจึฐื›ึถึผืšึธ","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ื ึดื™ืกึธืŸ","date":"2026-04-13","hdate":"26 Nisan 5786","category":"hebdate","title_orig":"26 Nisan 5786","hebrew":"ื›ืดื• ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","date":"2026-04-13","hdate":"26 Nisan 5786","category":"dafyomi","title_orig":"Menachot 92","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื‘","link":"https://www.sefaria.org/Menachot.92a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"ื™ืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-13","hdate":"26 Nisan 5786","category":"omer","title_orig":"Omer 11","hebrew":"ืขื•ืžืจ ื™ื•ื 11","link":"https://hebcal.com/o/5786/11?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทื—ึทื“ ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 11 days, which are 1 week and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Netzach shebiGevurah","en":"Eternity within Might"},"anaBekoachWord":"ืฉึทื‚ื’ึฐึผื‘ึตื ื•ึผ","lamnatzeachWord":"ื‘ึฐึผื›ึธืœ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ื ึดื™ืกึธืŸ","date":"2026-04-14","hdate":"27 Nisan 5786","category":"hebdate","title_orig":"27 Nisan 5786","hebrew":"ื›ืดื– ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","date":"2026-04-14","hdate":"27 Nisan 5786","category":"dafyomi","title_orig":"Menachot 93","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื’","link":"https://www.sefaria.org/Menachot.93a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"ื™ืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-14","hdate":"27 Nisan 5786","category":"omer","title_orig":"Omer 12","hebrew":"ืขื•ืžืจ ื™ื•ื 12","link":"https://hebcal.com/o/5786/12?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึตื™ื ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 12 days, which are 1 week and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Hod shebiGevurah","en":"Splendor within Might"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื ื•ึผ","lamnatzeachWord":"ื’ึผื•ึนื™ึดื","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ื ึดื™ืกึธืŸ","date":"2026-04-15","hdate":"28 Nisan 5786","category":"hebdate","title_orig":"28 Nisan 5786","hebrew":"ื›ืดื— ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","date":"2026-04-15","hdate":"28 Nisan 5786","category":"dafyomi","title_orig":"Menachot 94","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื“","link":"https://www.sefaria.org/Menachot.94a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"ื™ืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-15","hdate":"28 Nisan 5786","category":"omer","title_orig":"Omer 13","hebrew":"ืขื•ืžืจ ื™ื•ื 13","link":"https://hebcal.com/o/5786/13?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึธืื‘ึฝื•ึผืขึท ืึถื—ึธื“ ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 13 days, which are 1 week and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Yesod shebiGevurah","en":"Foundation within Might"},"anaBekoachWord":"ื ื•ึนืจึธื","lamnatzeachWord":"ื™ึฐืฉืื•ึผืขึธืชึถืšึธ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ื ึดื™ืกึธืŸ","date":"2026-04-16","hdate":"29 Nisan 5786","category":"hebdate","title_orig":"29 Nisan 5786","hebrew":"ื›ืดื˜ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","date":"2026-04-16","hdate":"29 Nisan 5786","category":"dafyomi","title_orig":"Menachot 95","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื”","link":"https://www.sefaria.org/Menachot.95a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"ื™ืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-16","hdate":"29 Nisan 5786","category":"omer","title_orig":"Omer 14","hebrew":"ืขื•ืžืจ ื™ื•ื 14","link":"https://hebcal.com/o/5786/14?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 14 days, which are 2 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื’ึฐื‘ื•ึผืจึธื”","translit":"Malkhut shebiGevurah","en":"Majesty within Might"},"anaBekoachWord":"ืงืจืดืข ืฉื˜ืดืŸ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ื›ืดื˜"}},{"title":"ืœืณ ื ึดื™ืกึธืŸ","date":"2026-04-17","hdate":"30 Nisan 5786","category":"hebdate","title_orig":"30 Nisan 5786","hebrew":"ืœืณ ื ื™ืกืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","date":"2026-04-17","hdate":"30 Nisan 5786","category":"dafyomi","title_orig":"Menachot 96","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื•","link":"https://www.sefaria.org/Menachot.96a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"ื˜ืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-17","hdate":"30 Nisan 5786","category":"omer","title_orig":"Omer 15","hebrew":"ืขื•ืžืจ ื™ื•ื 15","link":"https://hebcal.com/o/5786/15?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 15 days, which are 2 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Chesed sheb'Tiferet","en":"Lovingkindness within Beauty"},"anaBekoachWord":"ื ึธื","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ื ื™ืกืŸ","d":"ืœืณ"}},{"title":"ืืณ ืึดื™ึธึผื™ืจ ืชืฉืคืดื•","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"hebdate","title_orig":"1 Iyyar 5786","hebrew":"ืืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืชึทื–ึฐืจึดื™ืขึทึพืžึฐืฆึนืจึธืข","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"parashat","title_orig":"Parashat Tazria-Metzora","hebrew":"ืคืจืฉืช ืชื–ืจื™ืขึพืžืฆืจืข","leyning":{"1":"Leviticus 12:1-13:23","2":"Leviticus 13:24-13:39","3":"Leviticus 13:40-13:54","4":"Leviticus 13:55-14:20","5":"Leviticus 14:21-14:32","6":"Leviticus 14:33-15:15","7":"Leviticus 15:16-15:33","torah":"Leviticus 12:1-15:33; Numbers 28:9-15","haftarah":"Isaiah 66:1-24 | Shabbat Rosh Chodesh","maftir":"Numbers 28:9-28:15 | Shabbat Rosh Chodesh","triennial":{"1":"Leviticus 12:1-12:4","2":"Leviticus 12:5-12:8","3":"Leviticus 13:1-13:5","4":"Leviticus 13:6-13:17","5":"Leviticus 13:18-13:23","6":"Leviticus 13:24-13:28","7":"Leviticus 13:29-13:39","maftir":"Numbers 28:9-28:15"}},"link":"https://hebcal.com/s/5786i/27d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 97","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื–","link":"https://www.sefaria.org/Menachot.97a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ื˜ืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-18","hdate":"1 Iyyar 5786","category":"omer","title_orig":"Omer 16","hebrew":"ืขื•ืžืจ ื™ื•ื 16","link":"https://hebcal.com/o/5786/16?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 16 days, which are 2 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Gevurah sheb'Tiferet","en":"Might within Beauty"},"anaBekoachWord":"ื’ึดื‘ึผื•ึนืจ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ืืณ"}},{"title":"ื‘ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"hebdate","title_orig":"2 Iyyar 5786","hebrew":"ื‘ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 98","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื—","link":"https://www.sefaria.org/Menachot.98a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"ื™ืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-19","hdate":"2 Iyyar 5786","category":"omer","title_orig":"Omer 17","hebrew":"ืขื•ืžืจ ื™ื•ื 17","link":"https://hebcal.com/o/5786/17?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 17 days, which are 2 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Tiferet sheb'Tiferet","en":"Beauty within Beauty"},"anaBekoachWord":"ื“ึผื•ึนืจึฐืฉึตืื™","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื›"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื‘ืณ"}},{"title":"ื’ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"hebdate","title_orig":"3 Iyyar 5786","hebrew":"ื’ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 99","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืฆืดื˜","link":"https://www.sefaria.org/Menachot.99a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"ื™ืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-20","hdate":"3 Iyyar 5786","category":"omer","title_orig":"Omer 18","hebrew":"ืขื•ืžืจ ื™ื•ื 18","link":"https://hebcal.com/o/5786/18?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 18 days, which are 2 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Netzach sheb'Tiferet","en":"Eternity within Beauty"},"anaBekoachWord":"ื™ึดื—ื•ึผื“ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื’ืณ"}},{"title":"ื“ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"hebdate","title_orig":"4 Iyyar 5786","hebrew":"ื“ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 100","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืณ","link":"https://www.sefaria.org/Menachot.100a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"ื™ืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-21","hdate":"4 Iyyar 5786","category":"omer","title_orig":"Omer 19","hebrew":"ืขื•ืžืจ ื™ื•ื 19","link":"https://hebcal.com/o/5786/19?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ืขึธืฉึธื‚ืจ ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 19 days, which are 2 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Hod sheb'Tiferet","en":"Splendor within Beauty"},"anaBekoachWord":"ื›ึฐึผื‘ึธื‘ึทืช","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื“ืณ"}},{"title":"ื”ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"hebdate","title_orig":"5 Iyyar 5786","hebrew":"ื”ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 101","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื","link":"https://www.sefaria.org/Menachot.101a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"ื›ืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-22","hdate":"5 Iyyar 5786","category":"omer","title_orig":"Omer 20","hebrew":"ืขื•ืžืจ ื™ื•ื 20","link":"https://hebcal.com/o/5786/20?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืื ึตื™ ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 20 days, which are 2 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Yesod sheb'Tiferet","en":"Foundation within Beauty"},"anaBekoachWord":"ืฉึธืืžึฐืจึตื","lamnatzeachWord":"ื™ึดืฉึฐื‚ืžึฐื—ื•ึผ","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื”ืณ"}},{"title":"ื•ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื›ืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื–ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื›ืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื—ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึทื—ึฒืจึตื™ ืžื•ึนืชึพืงึฐื“ึนืฉึดืื™ื","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื›ืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื›ืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื›ืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื™ืดื ืึดื™ึธึผื™ืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื›ืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื›ืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื›ืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ืคึถึผืกึทื— ืฉึตืื ึดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"ืคืกื— ืฉื ื™, ื—ื•ื“ืฉ ืœืื—ืจ ื—ื’ ื”ืคืกื—"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื›ืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึฑืžื•ึนืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืœืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ืœืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ืœืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"ื”ื™ื•ื ื”-33 ืœืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ื—ื’ ืžื”ื•ื•ื” ื”ืคื•ื’ื” ื–ืžื ื™ืช ืžืžื ื”ื’ื™ ื”ืื‘ืœื•ืช ืฉืœ ืชืงื•ืคืช ืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ืžื ื”ื’ื™ื ื›ื•ืœืœื™ื ื”ื“ืœืงืช ืžื“ื•ืจื•ืช, ืชืกืคื•ืจื•ืช ื•ื—ืชื•ื ื•ืช"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ืœืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืึดื™ึธึผื™ืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ืœืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื›ืดื ืึดื™ึธึผื™ืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ืœืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืจึพื‘ึฐึผื—ึปืงึนึผืชึทื™","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืœืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ืœืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ืœืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืึดื™ึธึผื™ืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ืžืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ืžืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ืžืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ืžืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผืžึดื“ึฐื‘ึทึผืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืกึดื™ื•ึธืŸ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกึดื™ื•ึธืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืกึดื™ื•ึธืŸ ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึทืื‘ึธึผืช ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 18 ื‘ึธึผืขึถืจึถื‘, ื•-2 ื“ึทึผืงึผื•ึนืช ื•-15 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืืณ ืกึดื™ื•ึธืŸ ืชืฉืคืดื•","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ืžืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื‘ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ืžืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื’ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ืžืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื“ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ืžืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื”ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืขึถืจึถื‘ ืฉึธืื‘ื•ึผืขื•ึนืช","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืžืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ื•ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ืฉึธืื‘ื•ึผืขื•ึนืช","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot","hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ื–ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ื ึธืฉื‚ื","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื™ืดื ืกึดื™ื•ึธืŸ","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืขึฒืœึนืชึฐืšึธ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืกึดื™ื•ึธืŸ","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืกึดื™ื•ึธืŸ","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืกึดื™ื•ึธืŸ","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืกึดื™ื•ึธืŸ","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื›ืดื ืกึดื™ื•ึธืŸ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ืคึธึผืจึธืฉึทืืช ืฉึฐืืœึทื—ึพืœึฐืšึธ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05-diaspora.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05-diaspora.json new file mode 100644 index 0000000..a350e60 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05-diaspora.json @@ -0,0 +1 @@ +{"title":"Hebcal Diaspora 2026","date":"2026-10-14T22:43:57.776Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-04-23","end":"2026-07-10"},"items":[{"title":"ื•ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื›ืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื–ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื›ืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื—ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึทื—ึฒืจึตื™ ืžื•ึนืชึพืงึฐื“ึนืฉึดืื™ื","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื›ืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื›ืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื›ืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื™ืดื ืึดื™ึธึผื™ืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื›ืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื›ืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื›ืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ืคึถึผืกึทื— ืฉึตืื ึดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"ืคืกื— ืฉื ื™, ื—ื•ื“ืฉ ืœืื—ืจ ื—ื’ ื”ืคืกื—"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื›ืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึฑืžื•ึนืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืœืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ืœืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ืœืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"ื”ื™ื•ื ื”-33 ืœืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ื—ื’ ืžื”ื•ื•ื” ื”ืคื•ื’ื” ื–ืžื ื™ืช ืžืžื ื”ื’ื™ ื”ืื‘ืœื•ืช ืฉืœ ืชืงื•ืคืช ืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ืžื ื”ื’ื™ื ื›ื•ืœืœื™ื ื”ื“ืœืงืช ืžื“ื•ืจื•ืช, ืชืกืคื•ืจื•ืช ื•ื—ืชื•ื ื•ืช"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ืœืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืึดื™ึธึผื™ืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ืœืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื›ืดื ืึดื™ึธึผื™ืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ืœืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืจึพื‘ึฐึผื—ึปืงึนึผืชึทื™","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืœืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ืœืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ืœืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืึดื™ึธึผื™ืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ืžืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ืžืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ืžืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ืžืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผืžึดื“ึฐื‘ึทึผืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืกึดื™ื•ึธืŸ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกึดื™ื•ึธืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืกึดื™ื•ึธืŸ ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึทืื‘ึธึผืช ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 18 ื‘ึธึผืขึถืจึถื‘, ื•-2 ื“ึทึผืงึผื•ึนืช ื•-15 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืืณ ืกึดื™ื•ึธืŸ ืชืฉืคืดื•","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ืžืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื‘ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ืžืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื’ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ืžืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื“ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ืžืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื”ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืขึถืจึถื‘ ืฉึธืื‘ื•ึผืขื•ึนืช","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืžืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ื•ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ืฉึธืื‘ื•ึผืขื•ึนืช ืืณ","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot I","hebrew":"ืฉื‘ื•ืขื•ืช ืืณ","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ื–ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ืฉึธืื‘ื•ึผืขื•ึนืช ื‘ืณ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot II","hebrew":"ืฉื‘ื•ืขื•ืช ื‘ืณ","leyning":{"1":"Deuteronomy 14:22-14:29","2":"Deuteronomy 15:1-15:18","3":"Deuteronomy 15:19-15:23","4":"Deuteronomy 16:1-16:3","5":"Deuteronomy 16:4-16:8","6":"Deuteronomy 16:9-16:12","7":"Deuteronomy 16:13-16:17","torah":"Deuteronomy 14:22-16:17; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Habakkuk 3:1-19","haftarah_sephardic":"Habakkuk 2:20-3:19","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื™ืดื ืกึดื™ื•ึธืŸ","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ืคึธึผืจึธืฉึทืืช ื ึธืฉื‚ื","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืกึดื™ื•ึธืŸ","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืกึดื™ื•ึธืŸ","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืกึดื™ื•ึธืŸ","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืกึดื™ื•ึธืŸ","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื›ืดื ืกึดื™ื•ึธืŸ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืขึฒืœึนืชึฐืšึธ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืกึดื™ื•ึธืŸ","date":"2026-06-10","hdate":"25 Sivan 5786","category":"hebdate","title_orig":"25 Sivan 5786","hebrew":"ื›ืดื” ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื","date":"2026-06-10","hdate":"25 Sivan 5786","category":"dafyomi","title_orig":"Chullin 41","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Chullin.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืกึดื™ื•ึธืŸ","date":"2026-06-11","hdate":"26 Sivan 5786","category":"hebdate","title_orig":"26 Sivan 5786","hebrew":"ื›ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื‘","date":"2026-06-11","hdate":"26 Sivan 5786","category":"dafyomi","title_orig":"Chullin 42","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Chullin.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-12","hdate":"27 Sivan 5786","category":"hebdate","title_orig":"27 Sivan 5786","hebrew":"ื›ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื’","date":"2026-06-12","hdate":"27 Sivan 5786","category":"dafyomi","title_orig":"Chullin 43","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Chullin.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืกึดื™ื•ึธืŸ","date":"2026-06-13","hdate":"28 Sivan 5786","category":"hebdate","title_orig":"28 Sivan 5786","hebrew":"ื›ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ืคึธึผืจึธืฉึทืืช ืฉึฐืืœึทื—ึพืœึฐืšึธ","date":"2026-06-13","hdate":"28 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืชึทึผืžึผื•ึผื–","date":"2026-06-13","hdate":"28 Sivan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tamuz","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืชึทึผืžึผื•ึผื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืชึทึผืžึผื•ึผื– ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึตืื ึดื™ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 6 ื‘ึทึผื‘ึนึผืงึถืจ, ื•-46 ื“ึทึผืงึผื•ึนืช ื•-16 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื“","date":"2026-06-13","hdate":"28 Sivan 5786","category":"dafyomi","title_orig":"Chullin 44","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Chullin.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืกึดื™ื•ึธืŸ","date":"2026-06-14","hdate":"29 Sivan 5786","category":"hebdate","title_orig":"29 Sivan 5786","hebrew":"ื›ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื”","date":"2026-06-14","hdate":"29 Sivan 5786","category":"dafyomi","title_orig":"Chullin 45","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Chullin.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื˜"}},{"title":"ืœืณ ืกึดื™ื•ึธืŸ","date":"2026-06-15","hdate":"30 Sivan 5786","category":"hebdate","title_orig":"30 Sivan 5786","hebrew":"ืœืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืœืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื•","date":"2026-06-15","hdate":"30 Sivan 5786","category":"dafyomi","title_orig":"Chullin 46","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Chullin.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืœืณ"}},{"title":"ืืณ ืชึทึผืžึผื•ึผื– ืชืฉืคืดื•","date":"2026-06-16","hdate":"1 Tamuz 5786","category":"hebdate","title_orig":"1 Tamuz 5786","hebrew":"ืืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ืืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื–","date":"2026-06-16","hdate":"1 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 47","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Chullin.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ืืณ"}},{"title":"ื‘ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-17","hdate":"2 Tamuz 5786","category":"hebdate","title_orig":"2 Tamuz 5786","hebrew":"ื‘ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื‘ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื—","date":"2026-06-17","hdate":"2 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 48","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Chullin.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื‘ืณ"}},{"title":"ื’ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-18","hdate":"3 Tamuz 5786","category":"hebdate","title_orig":"3 Tamuz 5786","hebrew":"ื’ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื’ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื˜","date":"2026-06-18","hdate":"3 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 49","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Chullin.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื’ืณ"}},{"title":"ื“ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-19","hdate":"4 Tamuz 5786","category":"hebdate","title_orig":"4 Tamuz 5786","hebrew":"ื“ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื“ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืณ","date":"2026-06-19","hdate":"4 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 50","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Chullin.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื“ืณ"}},{"title":"ื”ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"hebdate","title_orig":"5 Tamuz 5786","hebrew":"ื”ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืงึนืจึทื—","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"parashat","title_orig":"Parashat Korach","hebrew":"ืคืจืฉืช ืงื•ืจื—","leyning":{"1":"Numbers 16:1-16:13","2":"Numbers 16:14-16:19","3":"Numbers 16:20-17:8","4":"Numbers 17:9-17:15","5":"Numbers 17:16-17:24","6":"Numbers 17:25-18:20","7":"Numbers 18:21-18:32","torah":"Numbers 16:1-18:32","haftarah":"I Samuel 11:14-12:22","maftir":"Numbers 18:30-18:32","triennial":{"1":"Numbers 16:1-16:3","2":"Numbers 16:4-16:7","3":"Numbers 16:8-16:13","4":"Numbers 16:14-16:19","5":"Numbers 16:20-16:35","6":"Numbers 17:1-17:8","7":"Numbers 17:9-17:15","maftir":"Numbers 17:9-17:15"}},"link":"https://hebcal.com/s/5786/38?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 51","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Chullin.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ื•ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-21","hdate":"6 Tamuz 5786","category":"hebdate","title_orig":"6 Tamuz 5786","hebrew":"ื•ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื•ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื‘","date":"2026-06-21","hdate":"6 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 52","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Chullin.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื•ืณ"}},{"title":"ื–ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-22","hdate":"7 Tamuz 5786","category":"hebdate","title_orig":"7 Tamuz 5786","hebrew":"ื–ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื–ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื’","date":"2026-06-22","hdate":"7 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 53","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Chullin.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื–ืณ"}},{"title":"ื—ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-23","hdate":"8 Tamuz 5786","category":"hebdate","title_orig":"8 Tamuz 5786","hebrew":"ื—ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื—ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื“","date":"2026-06-23","hdate":"8 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 54","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Chullin.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื—ืณ"}},{"title":"ื˜ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-24","hdate":"9 Tamuz 5786","category":"hebdate","title_orig":"9 Tamuz 5786","hebrew":"ื˜ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื”","date":"2026-06-24","hdate":"9 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 55","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Chullin.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืณ"}},{"title":"ื™ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-25","hdate":"10 Tamuz 5786","category":"hebdate","title_orig":"10 Tamuz 5786","hebrew":"ื™ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื•","date":"2026-06-25","hdate":"10 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 56","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Chullin.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืณ"}},{"title":"ื™ืดื ืชึทึผืžึผื•ึผื–","date":"2026-06-26","hdate":"11 Tamuz 5786","category":"hebdate","title_orig":"11 Tamuz 5786","hebrew":"ื™ืดื ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื–","date":"2026-06-26","hdate":"11 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 57","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Chullin.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืชึทึผืžึผื•ึผื–","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"hebdate","title_orig":"12 Tamuz 5786","hebrew":"ื™ืดื‘ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ืคึธึผืจึธืฉึทืืช ื—ึปืงึทึผืชึพื‘ึธึผืœึธืง","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"parashat","title_orig":"Parashat Chukat-Balak","hebrew":"ืคืจืฉืช ื—ื•ืงืชึพื‘ืœืง","leyning":{"1":"Numbers 19:1-20:6","2":"Numbers 20:7-20:21","3":"Numbers 20:22-21:20","4":"Numbers 21:21-22:12","5":"Numbers 22:13-22:38","6":"Numbers 22:39-23:26","7":"Numbers 23:27-25:9","torah":"Numbers 19:1-25:9","haftarah":"Micah 5:6-6:8","maftir":"Numbers 25:7-25:9","triennial":{"1":"Numbers 19:1-19:9","2":"Numbers 19:10-19:17","3":"Numbers 19:18-20:6","4":"Numbers 20:7-20:13","5":"Numbers 20:14-20:21","6":"Numbers 20:22-21:9","7":"Numbers 21:10-21:20","maftir":"Numbers 21:16-21:20"}},"link":"https://hebcal.com/s/5786/39d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื—","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 58","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Chullin.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืชึทึผืžึผื•ึผื–","date":"2026-06-28","hdate":"13 Tamuz 5786","category":"hebdate","title_orig":"13 Tamuz 5786","hebrew":"ื™ืดื’ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื˜","date":"2026-06-28","hdate":"13 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 59","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Chullin.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืชึทึผืžึผื•ึผื–","date":"2026-06-29","hdate":"14 Tamuz 5786","category":"hebdate","title_orig":"14 Tamuz 5786","hebrew":"ื™ืดื“ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืณ","date":"2026-06-29","hdate":"14 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 60","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Chullin.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืชึทึผืžึผื•ึผื–","date":"2026-06-30","hdate":"15 Tamuz 5786","category":"hebdate","title_orig":"15 Tamuz 5786","hebrew":"ื˜ืดื• ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื","date":"2026-06-30","hdate":"15 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 61","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Chullin.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืชึทึผืžึผื•ึผื–","date":"2026-07-01","hdate":"16 Tamuz 5786","category":"hebdate","title_orig":"16 Tamuz 5786","hebrew":"ื˜ืดื– ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื‘","date":"2026-07-01","hdate":"16 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 62","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื‘","link":"https://www.sefaria.org/Chullin.62a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืชึทึผืžึผื•ึผื–","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"hebdate","title_orig":"17 Tamuz 5786","hebrew":"ื™ืดื– ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"}},{"title":"ืฆื•ึนื ื™ืดื– ื‘ึฐึผืชึทืžึผื•ึผื–","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"holiday","subcat":"fast","title_orig":"Tzom Tammuz","hebrew":"ืฆื•ื ื™ืดื– ื‘ืชืžื•ื–","leyning":{"1":"Exodus 32:11-32:14","2":"Exodus 34:1-34:3","3":"Exodus 34:4-34:10","torah":"Exodus 32:11-14, 34:1-10"},"link":"https://hebcal.com/h/tzom-tammuz-2026?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"},"memo":"ืฆื•ื ื™ืดื– ื‘ืชืžื•ื–. ืฆื•ื ื”ืžืฆื™ื™ืŸ ืืช ื”ื‘ืงืขืช ื—ื•ืžื•ืช ื™ืจื•ืฉืœื™ื ืœืคื ื™ ื—ื•ืจื‘ืŸ ื‘ื™ืช ื”ืžืงื“ืฉ ื”ืฉื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื’","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 63","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื’","link":"https://www.sefaria.org/Chullin.63a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืชึทึผืžึผื•ึผื–","date":"2026-07-03","hdate":"18 Tamuz 5786","category":"hebdate","title_orig":"18 Tamuz 5786","hebrew":"ื™ืดื— ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื“","date":"2026-07-03","hdate":"18 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 64","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื“","link":"https://www.sefaria.org/Chullin.64a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืชึทึผืžึผื•ึผื–","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"hebdate","title_orig":"19 Tamuz 5786","hebrew":"ื™ืดื˜ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ืคึดึผื™ื ึฐื—ึธืก","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"parashat","title_orig":"Parashat Pinchas","hebrew":"ืคืจืฉืช ืคื™ื ื—ืก","leyning":{"1":"Numbers 25:10-26:4","2":"Numbers 26:5-26:51","3":"Numbers 26:52-27:5","4":"Numbers 27:6-27:23","5":"Numbers 28:1-28:15","6":"Numbers 28:16-29:11","7":"Numbers 29:12-30:1","torah":"Numbers 25:10-30:1","haftarah":"Jeremiah 1:1-2:3 | Pinchas occurring after 17 Tammuz","maftir":"Numbers 29:35-30:1","triennial":{"1":"Numbers 25:10-25:12","2":"Numbers 25:13-25:15","3":"Numbers 25:16-26:4","4":"Numbers 26:5-26:11","5":"Numbers 26:12-26:22","6":"Numbers 26:23-26:34","7":"Numbers 26:35-26:51","maftir":"Numbers 26:48-26:51"}},"link":"https://hebcal.com/s/5786/41?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื”","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 65","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื”","link":"https://www.sefaria.org/Chullin.65a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืชึทึผืžึผื•ึผื–","date":"2026-07-05","hdate":"20 Tamuz 5786","category":"hebdate","title_orig":"20 Tamuz 5786","hebrew":"ื›ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื•","date":"2026-07-05","hdate":"20 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 66","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื•","link":"https://www.sefaria.org/Chullin.66a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืณ"}},{"title":"ื›ืดื ืชึทึผืžึผื•ึผื–","date":"2026-07-06","hdate":"21 Tamuz 5786","category":"hebdate","title_orig":"21 Tamuz 5786","hebrew":"ื›ืดื ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื–","date":"2026-07-06","hdate":"21 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 67","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื–","link":"https://www.sefaria.org/Chullin.67a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืชึทึผืžึผื•ึผื–","date":"2026-07-07","hdate":"22 Tamuz 5786","category":"hebdate","title_orig":"22 Tamuz 5786","hebrew":"ื›ืดื‘ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื—","date":"2026-07-07","hdate":"22 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 68","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื—","link":"https://www.sefaria.org/Chullin.68a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืชึทึผืžึผื•ึผื–","date":"2026-07-08","hdate":"23 Tamuz 5786","category":"hebdate","title_orig":"23 Tamuz 5786","hebrew":"ื›ืดื’ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื˜","date":"2026-07-08","hdate":"23 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 69","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื˜","link":"https://www.sefaria.org/Chullin.69a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืชึทึผืžึผื•ึผื–","date":"2026-07-09","hdate":"24 Tamuz 5786","category":"hebdate","title_orig":"24 Tamuz 5786","hebrew":"ื›ืดื“ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืณ","date":"2026-07-09","hdate":"24 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 70","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืณ","link":"https://www.sefaria.org/Chullin.70a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืชึทึผืžึผื•ึผื–","date":"2026-07-10","hdate":"25 Tamuz 5786","category":"hebdate","title_orig":"25 Tamuz 5786","hebrew":"ื›ืดื” ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืดื","date":"2026-07-10","hdate":"25 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 71","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืดื","link":"https://www.sefaria.org/Chullin.71a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื”"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05.json new file mode 100644 index 0000000..719c12e --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-05.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:42:40.840Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-04-23","end":"2026-07-10"},"items":[{"title":"ื•ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"hebdate","title_orig":"6 Iyyar 5786","hebrew":"ื•ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 102","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื‘","link":"https://www.sefaria.org/Menachot.102a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื›ืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-23","hdate":"6 Iyyar 5786","category":"omer","title_orig":"Omer 21","hebrew":"ืขื•ืžืจ ื™ื•ื 21","link":"https://hebcal.com/o/5786/21?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 21 days, which are 3 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืชึดืคึฐืึถึฝืจึถืช","translit":"Malkhut sheb'Tiferet","en":"Majesty within Beauty"},"anaBekoachWord":"ื ื’ืดื“ ื™ื›ืดืฉ","lamnatzeachWord":"ื•ึดื™ืจึทื ึฐึผื ื•ึผ","lamnatzeachLetter":"ืค"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื•ืณ"}},{"title":"ื–ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"hebdate","title_orig":"7 Iyyar 5786","hebrew":"ื–ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 103","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื’","link":"https://www.sefaria.org/Menachot.103a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื›ืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-24","hdate":"7 Iyyar 5786","category":"omer","title_orig":"Omer 22","hebrew":"ืขื•ืžืจ ื™ื•ื 22","link":"https://hebcal.com/o/5786/22?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 22 days, which are 3 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Chesed sheb'Netzach","en":"Lovingkindness within Eternity"},"anaBekoachWord":"ื‘ึธึผืจึฐื›ึตื","lamnatzeachWord":"ืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื–ืณ"}},{"title":"ื—ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"hebdate","title_orig":"8 Iyyar 5786","hebrew":"ื—ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึทื—ึฒืจึตื™ ืžื•ึนืชึพืงึฐื“ึนืฉึดืื™ื","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"parashat","title_orig":"Parashat Achrei Mot-Kedoshim","hebrew":"ืคืจืฉืช ืื—ืจื™ ืžื•ืชึพืงื“ืฉื™ื","leyning":{"1":"Leviticus 16:1-16:24","2":"Leviticus 16:25-17:7","3":"Leviticus 17:8-18:21","4":"Leviticus 18:22-19:14","5":"Leviticus 19:15-19:32","6":"Leviticus 19:33-20:7","7":"Leviticus 20:8-20:27","torah":"Leviticus 16:1-20:27","haftarah":"Amos 9:7-15","haftarah_sephardic":"Ezekiel 20:2-20","haftarah_chabad":"Amos 9:7-15","maftir":"Leviticus 20:25-20:27","triennial":{"1":"Leviticus 16:1-16:6","2":"Leviticus 16:7-16:11","3":"Leviticus 16:12-16:17","4":"Leviticus 16:18-16:24","5":"Leviticus 16:25-16:30","6":"Leviticus 16:31-16:34","7":"Leviticus 17:1-17:7","maftir":"Leviticus 17:5-17:7"}},"link":"https://hebcal.com/s/achrei-mot-kedoshim-20260425?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 104","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื“","link":"https://www.sefaria.org/Menachot.104a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื›ืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-25","hdate":"8 Iyyar 5786","category":"omer","title_orig":"Omer 23","hebrew":"ืขื•ืžืจ ื™ื•ื 23","link":"https://hebcal.com/o/5786/23?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 23 days, which are 3 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Gevurah sheb'Netzach","en":"Might within Eternity"},"anaBekoachWord":"ื˜ึทื”ึฒืจึตื","lamnatzeachWord":"ื›ึดึผื™","lamnatzeachLetter":"ื˜"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"hebdate","title_orig":"9 Iyyar 5786","hebrew":"ื˜ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 105","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื”","link":"https://www.sefaria.org/Menachot.105a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื›ืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-26","hdate":"9 Iyyar 5786","category":"omer","title_orig":"Omer 24","hebrew":"ืขื•ืžืจ ื™ื•ื 24","link":"https://hebcal.com/o/5786/24?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 24 days, which are 3 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Tiferet sheb'Netzach","en":"Beauty within Eternity"},"anaBekoachWord":"ืจึทื—ึฒืžึตื™","lamnatzeachWord":"ืชึดืฉึฐืืคึนึผื˜","lamnatzeachLetter":"ืข"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืึดื™ึธึผื™ืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"hebdate","title_orig":"10 Iyyar 5786","hebrew":"ื™ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 106","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื•","link":"https://www.sefaria.org/Menachot.106a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื›ืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-27","hdate":"10 Iyyar 5786","category":"omer","title_orig":"Omer 25","hebrew":"ืขื•ืžืจ ื™ื•ื 25","link":"https://hebcal.com/o/5786/25?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 25 days, which are 3 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Netzach sheb'Netzach","en":"Eternity within Eternity"},"anaBekoachWord":"ืฆึดื“ึฐืงึธืชึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืณ"}},{"title":"ื™ืดื ืึดื™ึธึผื™ืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"hebdate","title_orig":"11 Iyyar 5786","hebrew":"ื™ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 107","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื–","link":"https://www.sefaria.org/Menachot.107a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื›ืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-28","hdate":"11 Iyyar 5786","category":"omer","title_orig":"Omer 26","hebrew":"ืขื•ืžืจ ื™ื•ื 26","link":"https://hebcal.com/o/5786/26?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 26 days, which are 3 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Hod sheb'Netzach","en":"Splendor within Eternity"},"anaBekoachWord":"ืชึธึผืžึดื™ื“","lamnatzeachWord":"ืžึดื™ืฉืื•ึนืจ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"hebdate","title_orig":"12 Iyyar 5786","hebrew":"ื™ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 108","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื—","link":"https://www.sefaria.org/Menachot.108a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื›ืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-29","hdate":"12 Iyyar 5786","category":"omer","title_orig":"Omer 27","hebrew":"ืขื•ืžืจ ื™ื•ื 27","link":"https://hebcal.com/o/5786/27?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึฐืืœื•ึนืฉึธืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 27 days, which are 3 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Yesod sheb'Netzach","en":"Foundation within Eternity"},"anaBekoachWord":"ื’ึธึผืžึฐืœึตื","lamnatzeachWord":"ื•ึผืœึฐืึปืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"hebdate","title_orig":"13 Iyyar 5786","hebrew":"ื™ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 109","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื˜","link":"https://www.sefaria.org/Menachot.109a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื›ืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-04-30","hdate":"13 Iyyar 5786","category":"omer","title_orig":"Omer 28","hebrew":"ืขื•ืžืจ ื™ื•ื 28","link":"https://hebcal.com/o/5786/28?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 28 days, which are 4 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื ึถึฝืฆึทื—","translit":"Malkhut sheb'Netzach","en":"Majesty within Eternity"},"anaBekoachWord":"ื‘ื˜ืดืจ ืฆืชืดื’","lamnatzeachWord":"ื‘ึธึผืึธืจึถืฅ","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"hebdate","title_orig":"14 Iyyar 5786","hebrew":"ื™ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ืคึถึผืกึทื— ืฉึตืื ึดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Pesach Sheni","hebrew":"ืคืกื— ืฉื ื™","link":"https://hebcal.com/h/pesach-sheni-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"},"memo":"ืคืกื— ืฉื ื™, ื—ื•ื“ืฉ ืœืื—ืจ ื—ื’ ื”ืคืกื—"},{"title":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"dafyomi","title_orig":"Menachot 110","hebrew":"ืžื ื—ื•ืช ื“ืฃ ืงืดื™","link":"https://www.sefaria.org/Menachot.110a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื›ืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-01","hdate":"14 Iyyar 5786","category":"omer","title_orig":"Omer 29","hebrew":"ืขื•ืžืจ ื™ื•ื 29","link":"https://hebcal.com/o/5786/29?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืขึถืฉึฐื‚ืจึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 29 days, which are 4 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Chesed sheb'Hod","en":"Lovingkindness within Splendor"},"anaBekoachWord":"ื—ึฒืกึดื™ืŸ","lamnatzeachWord":"ืชึทึผื ื—ึตื","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"hebdate","title_orig":"15 Iyyar 5786","hebrew":"ื˜ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืคึธึผืจึธืฉึทืืช ืึฑืžื•ึนืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"parashat","title_orig":"Parashat Emor","hebrew":"ืคืจืฉืช ืืžื•ืจ","leyning":{"1":"Leviticus 21:1-21:15","2":"Leviticus 21:16-22:16","3":"Leviticus 22:17-22:33","4":"Leviticus 23:1-23:22","5":"Leviticus 23:23-23:32","6":"Leviticus 23:33-23:44","7":"Leviticus 24:1-24:23","torah":"Leviticus 21:1-24:23","haftarah":"Ezekiel 44:15-31","maftir":"Leviticus 24:21-24:23","triennial":{"1":"Leviticus 21:1-21:6","2":"Leviticus 21:7-21:12","3":"Leviticus 21:13-21:15","4":"Leviticus 21:16-21:24","5":"Leviticus 22:1-22:9","6":"Leviticus 22:10-22:12","7":"Leviticus 22:13-22:16","maftir":"Leviticus 22:13-22:16"}},"link":"https://hebcal.com/s/5786i/31?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 2","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Chullin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ืœืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-02","hdate":"15 Iyyar 5786","category":"omer","title_orig":"Omer 30","hebrew":"ืขื•ืžืจ ื™ื•ื 30","link":"https://hebcal.com/o/5786/30?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 30 days, which are 4 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Gevurah sheb'Hod","en":"Might within Splendor"},"anaBekoachWord":"ืงึธื“ื•ึนืฉื","lamnatzeachWord":"ืกึถืœึธื”","lamnatzeachLetter":"ืฉ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"hebdate","title_orig":"16 Iyyar 5786","hebrew":"ื˜ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 3","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Chullin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ืœืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-03","hdate":"16 Iyyar 5786","category":"omer","title_orig":"Omer 31","hebrew":"ืขื•ืžืจ ื™ื•ื 31","link":"https://hebcal.com/o/5786/31?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 31 days, which are 4 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Tiferet sheb'Hod","en":"Beauty within Splendor"},"anaBekoachWord":"ื‘ึฐึผืจึนื‘","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"hebdate","title_orig":"17 Iyyar 5786","hebrew":"ื™ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 4","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Chullin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ืœืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-04","hdate":"17 Iyyar 5786","category":"omer","title_orig":"Omer 32","hebrew":"ืขื•ืžืจ ื™ื•ื 32","link":"https://hebcal.com/o/5786/32?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 32 days, which are 4 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Netzach sheb'Hod","en":"Eternity within Splendor"},"anaBekoachWord":"ื˜ื•ึผื‘ึฐืšึธ","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"hebdate","title_orig":"18 Iyyar 5786","hebrew":"ื™ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"holiday","subcat":"minor","title_orig":"Lag BaOmer","hebrew":"ืœืดื’ ื‘ืขื•ืžืจ","link":"https://hebcal.com/h/lag-baomer-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"},"memo":"ื”ื™ื•ื ื”-33 ืœืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ื—ื’ ืžื”ื•ื•ื” ื”ืคื•ื’ื” ื–ืžื ื™ืช ืžืžื ื”ื’ื™ ื”ืื‘ืœื•ืช ืฉืœ ืชืงื•ืคืช ืกืคื™ืจืช ื”ืขื•ืžืจ. ื”ืžื ื”ื’ื™ื ื›ื•ืœืœื™ื ื”ื“ืœืงืช ืžื“ื•ืจื•ืช, ืชืกืคื•ืจื•ืช ื•ื—ืชื•ื ื•ืช"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 5","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Chullin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ืœืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-05","hdate":"18 Iyyar 5786","category":"omer","title_orig":"Omer 33","hebrew":"ืขื•ืžืจ ื™ื•ื 33","link":"https://hebcal.com/o/5786/33?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 33 days, which are 4 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Hod sheb'Hod","en":"Splendor within Splendor"},"anaBekoachWord":"ื ึทื”ึตืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื•"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"hebdate","title_orig":"19 Iyyar 5786","hebrew":"ื™ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 6","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Chullin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ืœืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-06","hdate":"19 Iyyar 5786","category":"omer","title_orig":"Omer 34","hebrew":"ืขื•ืžืจ ื™ื•ื 34","link":"https://hebcal.com/o/5786/34?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืึทืจึฐื‘ึธึผืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 34 days, which are 4 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Yesod sheb'Hod","en":"Foundation within Splendor"},"anaBekoachWord":"ืขึฒื“ึธืชึถืšึธ","lamnatzeachWord":"ื™ื•ึนื“ื•ึผืšึธ","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืึดื™ึธึผื™ืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"hebdate","title_orig":"20 Iyyar 5786","hebrew":"ื›ืณ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 7","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Chullin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ืœืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-07","hdate":"20 Iyyar 5786","category":"omer","title_orig":"Omer 35","hebrew":"ืขื•ืžืจ ื™ื•ื 35","link":"https://hebcal.com/o/5786/35?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 35 days, which are 5 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผื”ื•ึนื“","translit":"Malkhut sheb'Hod","en":"Majesty within Splendor"},"anaBekoachWord":"ื—ืงืดื‘ ืชื ืดืข","lamnatzeachWord":"ืขึทืžึดึผื™ื","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืณ"}},{"title":"ื›ืดื ืึดื™ึธึผื™ืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"hebdate","title_orig":"21 Iyyar 5786","hebrew":"ื›ืดื ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 8","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Chullin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ืœืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-08","hdate":"21 Iyyar 5786","category":"omer","title_orig":"Omer 36","hebrew":"ืขื•ืžืจ ื™ื•ื 36","link":"https://hebcal.com/o/5786/36?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 36 days, which are 5 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Chesed shebiYesod","en":"Lovingkindness within Foundation"},"anaBekoachWord":"ื™ึธื—ึดื™ื“","lamnatzeachWord":"ื›ึปึผืœึธึผื","lamnatzeachLetter":"ืž"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืึดื™ึธึผื™ืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"hebdate","title_orig":"22 Iyyar 5786","hebrew":"ื›ืดื‘ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืจึพื‘ึฐึผื—ึปืงึนึผืชึทื™","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"parashat","title_orig":"Parashat Behar-Bechukotai","hebrew":"ืคืจืฉืช ื‘ื”ืจึพื‘ื—ืงืชื™","leyning":{"1":"Leviticus 25:1-25:18","2":"Leviticus 25:19-25:28","3":"Leviticus 25:29-25:38","4":"Leviticus 25:39-26:9","5":"Leviticus 26:10-26:46","6":"Leviticus 27:1-27:15","7":"Leviticus 27:16-27:34","torah":"Leviticus 25:1-27:34","haftarah":"Jeremiah 16:19-17:14","maftir":"Leviticus 27:32-27:34","triennial":{"1":"Leviticus 25:1-25:3","2":"Leviticus 25:4-25:7","3":"Leviticus 25:8-25:13","4":"Leviticus 25:14-25:18","5":"Leviticus 25:19-25:24","6":"Leviticus 25:25-25:28","7":"Leviticus 25:29-25:38","maftir":"Leviticus 25:35-25:38"}},"link":"https://hebcal.com/s/5786i/32d?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 9","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Chullin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ืœืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-09","hdate":"22 Iyyar 5786","category":"omer","title_orig":"Omer 37","hebrew":"ืขื•ืžืจ ื™ื•ื 37","link":"https://hebcal.com/o/5786/37?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 37 days, which are 5 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Gevurah shebiYesod","en":"Might within Foundation"},"anaBekoachWord":"ื’ึตึผืึถื”","lamnatzeachWord":"ืึถืจึถืฅ","lamnatzeachLetter":"ื™"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืึดื™ึธึผื™ืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"hebdate","title_orig":"23 Iyyar 5786","hebrew":"ื›ืดื’ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 10","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Chullin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ืœืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-10","hdate":"23 Iyyar 5786","category":"omer","title_orig":"Omer 38","hebrew":"ืขื•ืžืจ ื™ื•ื 38","link":"https://hebcal.com/o/5786/38?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 38 days, which are 5 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Tiferet shebiYesod","en":"Beauty within Foundation"},"anaBekoachWord":"ืœึฐืขึทืžึฐึผืšึธ","lamnatzeachWord":"ื ึธืชึฐื ึธื”","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืึดื™ึธึผื™ืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"hebdate","title_orig":"24 Iyyar 5786","hebrew":"ื›ืดื“ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 11","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Chullin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ืœืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-11","hdate":"24 Iyyar 5786","category":"omer","title_orig":"Omer 39","hebrew":"ืขื•ืžืจ ื™ื•ื 39","link":"https://hebcal.com/o/5786/39?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึผืฉึฐืืœื•ึนืฉึดืื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 39 days, which are 5 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Netzach shebiYesod","en":"Eternity within Foundation"},"anaBekoachWord":"ืคึฐึผื ึตื”","lamnatzeachWord":"ื™ึฐื‘ื•ึผืœึธื”ึผ","lamnatzeachLetter":"ื‘"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืึดื™ึธึผื™ืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"hebdate","title_orig":"25 Iyyar 5786","hebrew":"ื›ืดื” ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 12","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Chullin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ืžืณ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-12","hdate":"25 Iyyar 5786","category":"omer","title_orig":"Omer 40","hebrew":"ืขื•ืžืจ ื™ื•ื 40","link":"https://hebcal.com/o/5786/40?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 40 days, which are 5 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Hod shebiYesod","en":"Splendor within Foundation"},"anaBekoachWord":"ื–ื•ึนื›ึฐืจึตื™","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืึดื™ึธึผื™ืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"hebdate","title_orig":"26 Iyyar 5786","hebrew":"ื›ืดื• ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 13","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Chullin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ืžืดื ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-13","hdate":"26 Iyyar 5786","category":"omer","title_orig":"Omer 41","hebrew":"ืขื•ืžืจ ื™ื•ื 41","link":"https://hebcal.com/o/5786/41?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึถื—ึธื“ ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ื—ึฒืžึดืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 41 days, which are 5 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Yesod shebiYesod","en":"Foundation within Foundation"},"anaBekoachWord":"ืงึฐื“ึปืฉึธึผืืชึถืšึธ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ืจ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืึดื™ึธึผื™ืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"hebdate","title_orig":"27 Iyyar 5786","hebrew":"ื›ืดื– ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 14","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Chullin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ืžืดื‘ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-14","hdate":"27 Iyyar 5786","category":"omer","title_orig":"Omer 42","hebrew":"ืขื•ืžืจ ื™ื•ื 42","link":"https://hebcal.com/o/5786/42?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืื ึทื™ึดื ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 42 days, which are 6 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึดึผื™ึฐืกื•ึนื“","translit":"Malkhut shebiYesod","en":"Majesty within Foundation"},"anaBekoachWord":"ื™ื’ืดืœ ืคื–ืดืง","lamnatzeachWord":"ืึฑืœึนื”ึตื™ื ื•ึผ","lamnatzeachLetter":"ืฅ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืึดื™ึธึผื™ืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"hebdate","title_orig":"28 Iyyar 5786","hebrew":"ื›ืดื— ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 15","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Chullin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ืžืดื’ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-15","hdate":"28 Iyyar 5786","category":"omer","title_orig":"Omer 43","hebrew":"ืขื•ืžืจ ื™ื•ื 43","link":"https://hebcal.com/o/5786/43?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืœื•ึนืฉึธืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐื™ื•ึนื ืึถื—ึธื“ ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 43 days, which are 6 weeks and 1 day of the Omer"},"sefira":{"he":"ื—ึถึฝืกึถื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Chesed sheb'Malkhut","en":"Lovingkindness within Majesty"},"anaBekoachWord":"ืฉึทืื•ึฐืขึธืชึตื ื•ึผ","lamnatzeachWord":"ื™ึฐื‘ึธืจึฐื›ึตื ื•ึผ","lamnatzeachLetter":"ืช"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืึดื™ึธึผื™ืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"hebdate","title_orig":"29 Iyyar 5786","hebrew":"ื›ืดื˜ ืื™ื™ืจ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผืžึดื“ึฐื‘ึทึผืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"parashat","title_orig":"Parashat Bamidbar","hebrew":"ืคืจืฉืช ื‘ืžื“ื‘ืจ","leyning":{"1":"Numbers 1:1-1:19","2":"Numbers 1:20-1:54","3":"Numbers 2:1-2:34","4":"Numbers 3:1-3:13","5":"Numbers 3:14-3:39","6":"Numbers 3:40-3:51","7":"Numbers 4:1-4:20","torah":"Numbers 1:1-4:20","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Numbers 4:17-4:20","triennial":{"1":"Numbers 1:1-1:4","2":"Numbers 1:5-1:16","3":"Numbers 1:17-1:19","4":"Numbers 1:20-1:27","5":"Numbers 1:28-1:35","6":"Numbers 1:36-1:43","7":"Numbers 1:44-1:54","maftir":"Numbers 1:52-1:54"}},"link":"https://hebcal.com/s/5786i/34?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืกึดื™ื•ึธืŸ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Sivan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกึดื™ื•ึธืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืกึดื™ื•ึธืŸ ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึทืื‘ึธึผืช ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 18 ื‘ึธึผืขึถืจึถื‘, ื•-2 ื“ึทึผืงึผื•ึนืช ื•-15 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"dafyomi","title_orig":"Chullin 16","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Chullin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืžืดื“ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-16","hdate":"29 Iyyar 5786","category":"omer","title_orig":"Omer 44","hebrew":"ืขื•ืžืจ ื™ื•ื 44","link":"https://hebcal.com/o/5786/44?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืึทืจึฐื‘ึธึผืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืื ึตื™ ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 44 days, which are 6 weeks and 2 days of the Omer"},"sefira":{"he":"ื’ึฐึผื‘ื•ึผืจึธื” ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Gevurah sheb'Malkhut","en":"Might within Majesty"},"anaBekoachWord":"ืงึทื‘ึตึผืœ","lamnatzeachWord":"ืึฑืœึนื”ึดื™ื","lamnatzeachLetter":"ื "},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืื™ื™ืจ","d":"ื›ืดื˜"}},{"title":"ืืณ ืกึดื™ื•ึธืŸ ืชืฉืคืดื•","date":"2026-05-17","hdate":"1 Sivan 5786","category":"hebdate","title_orig":"1 Sivan 5786","hebrew":"ืืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","date":"2026-05-17","hdate":"1 Sivan 5786","category":"dafyomi","title_orig":"Chullin 17","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Chullin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ืžืดื” ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-17","hdate":"1 Sivan 5786","category":"omer","title_orig":"Omer 45","hebrew":"ืขื•ืžืจ ื™ื•ื 45","link":"https://hebcal.com/o/5786/45?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ื—ึฒืžึดืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึผืฉึฐืืœื•ึนืฉึธืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 45 days, which are 6 weeks and 3 days of the Omer"},"sefira":{"he":"ืชึดึผืคึฐืึถึฝืจึถืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Tiferet sheb'Malkhut","en":"Beauty within Majesty"},"anaBekoachWord":"ื•ึผืฉึฐืืžึทืข","lamnatzeachWord":"ื•ึฐื™ึดื™ืจึฐืื•ึผ","lamnatzeachLetter":"ื—"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืืณ"}},{"title":"ื‘ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"hebdate","title_orig":"2 Sivan 5786","hebrew":"ื‘ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","date":"2026-05-18","hdate":"2 Sivan 5786","category":"dafyomi","title_orig":"Chullin 18","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Chullin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ืžืดื• ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-18","hdate":"2 Sivan 5786","category":"omer","title_orig":"Omer 46","hebrew":"ืขื•ืžืจ ื™ื•ื 46","link":"https://hebcal.com/o/5786/46?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืืฉึธึผืื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืึทืจึฐื‘ึธึผืขึธื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 46 days, which are 6 weeks and 4 days of the Omer"},"sefira":{"he":"ื ึถึผึฝืฆึทื— ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Netzach sheb'Malkhut","en":"Eternity within Majesty"},"anaBekoachWord":"ืฆึทืขึฒืงึธืชึตื ื•ึผ","lamnatzeachWord":"ืื•ึนืชื•ึน","lamnatzeachLetter":"ื"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื‘ืณ"}},{"title":"ื’ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"hebdate","title_orig":"3 Sivan 5786","hebrew":"ื’ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","date":"2026-05-19","hdate":"3 Sivan 5786","category":"dafyomi","title_orig":"Chullin 19","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Chullin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ืžืดื– ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-19","hdate":"3 Sivan 5786","category":"omer","title_orig":"Omer 47","hebrew":"ืขื•ืžืจ ื™ื•ื 47","link":"https://hebcal.com/o/5786/47?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึดืื‘ึฐืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึทื—ึฒืžึดืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 47 days, which are 6 weeks and 5 days of the Omer"},"sefira":{"he":"ื”ื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Hod sheb'Malkhut","en":"Splendor within Majesty"},"anaBekoachWord":"ื™ื•ึนื“ึตืขึท","lamnatzeachWord":"ื›ึธึผืœ","lamnatzeachLetter":"ืก"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื’ืณ"}},{"title":"ื“ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"hebdate","title_orig":"4 Sivan 5786","hebrew":"ื“ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"dafyomi","title_orig":"Chullin 20","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Chullin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ืžืดื— ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-20","hdate":"4 Sivan 5786","category":"omer","title_orig":"Omer 48","hebrew":"ืขื•ืžืจ ื™ื•ื 48","link":"https://hebcal.com/o/5786/48?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืฉึฐืืžื•ึนื ึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืืฉึธึผืื” ืฉึธืื‘ื•ึผืขื•ึนืช ื•ึฐืฉึดืืฉึธึผืื” ื™ึธืžึดื™ื ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 48 days, which are 6 weeks and 6 days of the Omer"},"sefira":{"he":"ื™ึฐึผืกื•ึนื“ ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Yesod sheb'Malkhut","en":"Foundation within Majesty"},"anaBekoachWord":"ืชึทึผืขึฒืœื•ึผืžื•ึนืช","lamnatzeachWord":"ืึทืคึฐืกึตื™","lamnatzeachLetter":"ืœ"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื“ืณ"}},{"title":"ื”ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"hebdate","title_orig":"5 Sivan 5786","hebrew":"ื”ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืขึถืจึถื‘ ืฉึธืื‘ื•ึผืขื•ึนืช","date":"2026-05-21","hdate":"5 Sivan 5786","category":"holiday","subcat":"major","title_orig":"Erev Shavuot","hebrew":"ืขืจื‘ ืฉื‘ื•ืขื•ืช","link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","date":"2026-05-21","hdate":"5 Sivan 5786","category":"dafyomi","title_orig":"Chullin 21","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Chullin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ืžืดื˜ ื‘ึธึผืขื•ึนืžึถืจ","date":"2026-05-21","hdate":"5 Sivan 5786","category":"omer","title_orig":"Omer 49","hebrew":"ืขื•ืžืจ ื™ื•ื 49","link":"https://hebcal.com/o/5786/49?i=on&us=js&um=api","omer":{"count":{"he":"ื”ึทื™ึผื•ึนื ืชึดึผืฉึฐืืขึธื” ื•ึฐืึทืจึฐื‘ึธึผืขึดื™ื ื™ื•ึนื, ืฉึถืื”ึตื ืฉึดืื‘ึฐืขึธื” ืฉึธืื‘ื•ึผืขื•ึนืช ืœึธืขึฝื•ึนืžึถืจ","en":"Today is 49 days, which are 7 weeks of the Omer"},"sefira":{"he":"ืžึทืœึฐื›ื•ึผืช ืฉึถืื‘ึฐึผืžึทืœึฐื›ื•ึผืช","translit":"Malkhut sheb'Malkhut","en":"Majesty within Majesty"},"anaBekoachWord":"ืฉืงืดื• ืฆื™ืดืช","lamnatzeachWord":"ืึธืจึถืฅ","lamnatzeachLetter":"ื”"},"heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื”ืณ"}},{"title":"ื•ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-22","hdate":"6 Sivan 5786","category":"hebdate","title_orig":"6 Sivan 5786","hebrew":"ื•ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ืฉึธืื‘ื•ึผืขื•ึนืช","date":"2026-05-22","hdate":"6 Sivan 5786","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shavuot","hebrew":"ืฉื‘ื•ืขื•ืช","leyning":{"1":"Exodus 19:1-19:6","2":"Exodus 19:7-19:13","3":"Exodus 19:14-19:19","4":"Exodus 19:20-20:14","5":"Exodus 20:15-20:23","torah":"Exodus 19:1-20:23; Numbers 28:26-31; Ruth 1:1-4:22","haftarah":"Ezekiel 1:1-28, 3:12","maftir":"Numbers 28:26-28:31"},"link":"https://hebcal.com/h/shavuot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"},"memo":"ื—ื’ ื”ืฉื‘ื•ืขื•ืช. ืžืฆื™ื™ืŸ ืืช ืžืชืŸ ืชื•ืจื” ื‘ื”ืจ ืกื™ื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","date":"2026-05-22","hdate":"6 Sivan 5786","category":"dafyomi","title_orig":"Chullin 22","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Chullin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื•ืณ"}},{"title":"ื–ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-23","hdate":"7 Sivan 5786","category":"hebdate","title_orig":"7 Sivan 5786","hebrew":"ื–ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ื ึธืฉื‚ื","date":"2026-05-23","hdate":"7 Sivan 5786","category":"parashat","title_orig":"Parashat Nasso","hebrew":"ืคืจืฉืช ื ืฉื","leyning":{"1":"Numbers 4:21-4:37","2":"Numbers 4:38-4:49","3":"Numbers 5:1-5:10","4":"Numbers 5:11-6:27","5":"Numbers 7:1-7:41","6":"Numbers 7:42-7:71","7":"Numbers 7:72-7:89","torah":"Numbers 4:21-7:89","haftarah":"Judges 13:2-25","maftir":"Numbers 7:87-7:89","triennial":{"1":"Numbers 4:21-4:24","2":"Numbers 4:25-4:28","3":"Numbers 4:29-4:33","4":"Numbers 4:34-4:37","5":"Numbers 4:38-4:49","6":"Numbers 5:1-5:4","7":"Numbers 5:5-5:10","maftir":"Numbers 5:8-5:10"}},"link":"https://hebcal.com/s/5786i/35?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","date":"2026-05-23","hdate":"7 Sivan 5786","category":"dafyomi","title_orig":"Chullin 23","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Chullin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื–ืณ"}},{"title":"ื—ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-24","hdate":"8 Sivan 5786","category":"hebdate","title_orig":"8 Sivan 5786","hebrew":"ื—ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","date":"2026-05-24","hdate":"8 Sivan 5786","category":"dafyomi","title_orig":"Chullin 24","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Chullin.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื—ืณ"}},{"title":"ื˜ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-25","hdate":"9 Sivan 5786","category":"hebdate","title_orig":"9 Sivan 5786","hebrew":"ื˜ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","date":"2026-05-25","hdate":"9 Sivan 5786","category":"dafyomi","title_orig":"Chullin 25","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Chullin.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืณ"}},{"title":"ื™ืณ ืกึดื™ื•ึธืŸ","date":"2026-05-26","hdate":"10 Sivan 5786","category":"hebdate","title_orig":"10 Sivan 5786","hebrew":"ื™ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","date":"2026-05-26","hdate":"10 Sivan 5786","category":"dafyomi","title_orig":"Chullin 26","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Chullin.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืณ"}},{"title":"ื™ืดื ืกึดื™ื•ึธืŸ","date":"2026-05-27","hdate":"11 Sivan 5786","category":"hebdate","title_orig":"11 Sivan 5786","hebrew":"ื™ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","date":"2026-05-27","hdate":"11 Sivan 5786","category":"dafyomi","title_orig":"Chullin 27","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Chullin.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-05-28","hdate":"12 Sivan 5786","category":"hebdate","title_orig":"12 Sivan 5786","hebrew":"ื™ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","date":"2026-05-28","hdate":"12 Sivan 5786","category":"dafyomi","title_orig":"Chullin 28","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Chullin.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-05-29","hdate":"13 Sivan 5786","category":"hebdate","title_orig":"13 Sivan 5786","hebrew":"ื™ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","date":"2026-05-29","hdate":"13 Sivan 5786","category":"dafyomi","title_orig":"Chullin 29","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Chullin.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"hebdate","title_orig":"14 Sivan 5786","hebrew":"ื™ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึฐึผื”ึทืขึฒืœึนืชึฐืšึธ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"parashat","title_orig":"Parashat Beha'alotcha","hebrew":"ืคืจืฉืช ื‘ื”ืขืœืชืš","leyning":{"1":"Numbers 8:1-8:14","2":"Numbers 8:15-8:26","3":"Numbers 9:1-9:14","4":"Numbers 9:15-10:10","5":"Numbers 10:11-10:34","6":"Numbers 10:35-11:29","7":"Numbers 11:30-12:16","torah":"Numbers 8:1-12:16","haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 12:14-12:16","triennial":{"1":"Numbers 8:1-8:4","2":"Numbers 8:5-8:9","3":"Numbers 8:10-8:14","4":"Numbers 8:15-8:22","5":"Numbers 8:23-8:26","6":"Numbers 9:1-9:8","7":"Numbers 9:9-9:14","maftir":"Numbers 9:12-9:14"}},"link":"https://hebcal.com/s/5786i/36?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","date":"2026-05-30","hdate":"14 Sivan 5786","category":"dafyomi","title_orig":"Chullin 30","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Chullin.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืกึดื™ื•ึธืŸ","date":"2026-05-31","hdate":"15 Sivan 5786","category":"hebdate","title_orig":"15 Sivan 5786","hebrew":"ื˜ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","date":"2026-05-31","hdate":"15 Sivan 5786","category":"dafyomi","title_orig":"Chullin 31","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Chullin.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-01","hdate":"16 Sivan 5786","category":"hebdate","title_orig":"16 Sivan 5786","hebrew":"ื˜ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","date":"2026-06-01","hdate":"16 Sivan 5786","category":"dafyomi","title_orig":"Chullin 32","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Chullin.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-02","hdate":"17 Sivan 5786","category":"hebdate","title_orig":"17 Sivan 5786","hebrew":"ื™ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","date":"2026-06-02","hdate":"17 Sivan 5786","category":"dafyomi","title_orig":"Chullin 33","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Chullin.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืกึดื™ื•ึธืŸ","date":"2026-06-03","hdate":"18 Sivan 5786","category":"hebdate","title_orig":"18 Sivan 5786","hebrew":"ื™ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","date":"2026-06-03","hdate":"18 Sivan 5786","category":"dafyomi","title_orig":"Chullin 34","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Chullin.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืกึดื™ื•ึธืŸ","date":"2026-06-04","hdate":"19 Sivan 5786","category":"hebdate","title_orig":"19 Sivan 5786","hebrew":"ื™ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","date":"2026-06-04","hdate":"19 Sivan 5786","category":"dafyomi","title_orig":"Chullin 35","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Chullin.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืกึดื™ื•ึธืŸ","date":"2026-06-05","hdate":"20 Sivan 5786","category":"hebdate","title_orig":"20 Sivan 5786","hebrew":"ื›ืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","date":"2026-06-05","hdate":"20 Sivan 5786","category":"dafyomi","title_orig":"Chullin 36","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Chullin.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืณ"}},{"title":"ื›ืดื ืกึดื™ื•ึธืŸ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"hebdate","title_orig":"21 Sivan 5786","hebrew":"ื›ืดื ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ืคึธึผืจึธืฉึทืืช ืฉึฐืืœึทื—ึพืœึฐืšึธ","date":"2026-06-06","hdate":"21 Sivan 5786","category":"parashat","title_orig":"Parashat Sh'lach","hebrew":"ืคืจืฉืช ืฉืœื—ึพืœืš","leyning":{"1":"Numbers 13:1-13:20","2":"Numbers 13:21-14:7","3":"Numbers 14:8-14:25","4":"Numbers 14:26-15:7","5":"Numbers 15:8-15:16","6":"Numbers 15:17-15:26","7":"Numbers 15:27-15:41","torah":"Numbers 13:1-15:41","haftarah":"Joshua 2:1-24","maftir":"Numbers 15:37-15:41","triennial":{"1":"Numbers 13:1-13:3","2":"Numbers 13:4-13:16","3":"Numbers 13:17-13:20","4":"Numbers 13:21-13:24","5":"Numbers 13:25-13:30","6":"Numbers 13:31-13:33","7":"Numbers 14:1-14:7","maftir":"Numbers 14:5-14:7"}},"link":"https://hebcal.com/s/5786i/37?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","date":"2026-06-06","hdate":"21 Sivan 5786","category":"dafyomi","title_orig":"Chullin 37","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Chullin.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืกึดื™ื•ึธืŸ","date":"2026-06-07","hdate":"22 Sivan 5786","category":"hebdate","title_orig":"22 Sivan 5786","hebrew":"ื›ืดื‘ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","date":"2026-06-07","hdate":"22 Sivan 5786","category":"dafyomi","title_orig":"Chullin 38","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Chullin.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืกึดื™ื•ึธืŸ","date":"2026-06-08","hdate":"23 Sivan 5786","category":"hebdate","title_orig":"23 Sivan 5786","hebrew":"ื›ืดื’ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","date":"2026-06-08","hdate":"23 Sivan 5786","category":"dafyomi","title_orig":"Chullin 39","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Chullin.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืกึดื™ื•ึธืŸ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"hebdate","title_orig":"24 Sivan 5786","hebrew":"ื›ืดื“ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","date":"2026-06-09","hdate":"24 Sivan 5786","category":"dafyomi","title_orig":"Chullin 40","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Chullin.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืกึดื™ื•ึธืŸ","date":"2026-06-10","hdate":"25 Sivan 5786","category":"hebdate","title_orig":"25 Sivan 5786","hebrew":"ื›ืดื” ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื","date":"2026-06-10","hdate":"25 Sivan 5786","category":"dafyomi","title_orig":"Chullin 41","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Chullin.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื”"}},{"title":"ื›ืดื• ืกึดื™ื•ึธืŸ","date":"2026-06-11","hdate":"26 Sivan 5786","category":"hebdate","title_orig":"26 Sivan 5786","hebrew":"ื›ืดื• ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื‘","date":"2026-06-11","hdate":"26 Sivan 5786","category":"dafyomi","title_orig":"Chullin 42","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Chullin.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื•"}},{"title":"ื›ืดื– ืกึดื™ื•ึธืŸ","date":"2026-06-12","hdate":"27 Sivan 5786","category":"hebdate","title_orig":"27 Sivan 5786","hebrew":"ื›ืดื– ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื’","date":"2026-06-12","hdate":"27 Sivan 5786","category":"dafyomi","title_orig":"Chullin 43","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Chullin.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื–"}},{"title":"ื›ืดื— ืกึดื™ื•ึธืŸ","date":"2026-06-13","hdate":"28 Sivan 5786","category":"hebdate","title_orig":"28 Sivan 5786","hebrew":"ื›ืดื— ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ืคึธึผืจึธืฉึทืืช ืงึนืจึทื—","date":"2026-06-13","hdate":"28 Sivan 5786","category":"parashat","title_orig":"Parashat Korach","hebrew":"ืคืจืฉืช ืงื•ืจื—","leyning":{"1":"Numbers 16:1-16:13","2":"Numbers 16:14-16:19","3":"Numbers 16:20-17:8","4":"Numbers 17:9-17:15","5":"Numbers 17:16-17:24","6":"Numbers 17:25-18:20","7":"Numbers 18:21-18:32","torah":"Numbers 16:1-18:32","haftarah":"I Samuel 11:14-12:22","maftir":"Numbers 18:30-18:32","triennial":{"1":"Numbers 16:1-16:3","2":"Numbers 16:4-16:7","3":"Numbers 16:8-16:13","4":"Numbers 16:14-16:19","5":"Numbers 16:20-16:35","6":"Numbers 17:1-17:8","7":"Numbers 17:9-17:15","maftir":"Numbers 17:9-17:15"}},"link":"https://hebcal.com/s/5786i/38?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ืžึฐื‘ึธืจึฐื›ึดื™ื ื—ื•ึนื“ึถืฉื ืชึทึผืžึผื•ึผื–","date":"2026-06-13","hdate":"28 Sivan 5786","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tamuz","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืชึทึผืžึผื•ึผื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"},"memo":"ืžื•ึนืœึธื“ ื”ึธืœึฐึผื‘ึธื ึธื” ืชึทึผืžึผื•ึผื– ื™ึดื”ึฐื™ึถื” ื‘ึทึผื™ึผื•ึนื ืฉึตืื ึดื™ ื‘ืฉึธืื‘ื•ึผืขึท, ื‘ึฐึผืฉึธืืขึธื” 6 ื‘ึทึผื‘ึนึผืงึถืจ, ื•-46 ื“ึทึผืงึผื•ึนืช ื•-16 ื—ึฒืœึธืงึดื™ื"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื“","date":"2026-06-13","hdate":"28 Sivan 5786","category":"dafyomi","title_orig":"Chullin 44","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Chullin.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื—"}},{"title":"ื›ืดื˜ ืกึดื™ื•ึธืŸ","date":"2026-06-14","hdate":"29 Sivan 5786","category":"hebdate","title_orig":"29 Sivan 5786","hebrew":"ื›ืดื˜ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื”","date":"2026-06-14","hdate":"29 Sivan 5786","category":"dafyomi","title_orig":"Chullin 45","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Chullin.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ื›ืดื˜"}},{"title":"ืœืณ ืกึดื™ื•ึธืŸ","date":"2026-06-15","hdate":"30 Sivan 5786","category":"hebdate","title_orig":"30 Sivan 5786","hebrew":"ืœืณ ืกื™ื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืœืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื•","date":"2026-06-15","hdate":"30 Sivan 5786","category":"dafyomi","title_orig":"Chullin 46","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Chullin.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืกื™ื•ืŸ","d":"ืœืณ"}},{"title":"ืืณ ืชึทึผืžึผื•ึผื– ืชืฉืคืดื•","date":"2026-06-16","hdate":"1 Tamuz 5786","category":"hebdate","title_orig":"1 Tamuz 5786","hebrew":"ืืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ืืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื–","date":"2026-06-16","hdate":"1 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 47","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Chullin.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ืืณ"}},{"title":"ื‘ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-17","hdate":"2 Tamuz 5786","category":"hebdate","title_orig":"2 Tamuz 5786","hebrew":"ื‘ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื‘ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื—","date":"2026-06-17","hdate":"2 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 48","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Chullin.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื‘ืณ"}},{"title":"ื’ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-18","hdate":"3 Tamuz 5786","category":"hebdate","title_orig":"3 Tamuz 5786","hebrew":"ื’ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื’ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื˜","date":"2026-06-18","hdate":"3 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 49","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Chullin.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื’ืณ"}},{"title":"ื“ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-19","hdate":"4 Tamuz 5786","category":"hebdate","title_orig":"4 Tamuz 5786","hebrew":"ื“ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื“ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืณ","date":"2026-06-19","hdate":"4 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 50","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Chullin.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื“ืณ"}},{"title":"ื”ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"hebdate","title_orig":"5 Tamuz 5786","hebrew":"ื”ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ืคึธึผืจึธืฉึทืืช ื—ึปืงึทึผืช","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"parashat","title_orig":"Parashat Chukat","hebrew":"ืคืจืฉืช ื—ื•ืงืช","leyning":{"1":"Numbers 19:1-19:17","2":"Numbers 19:18-20:6","3":"Numbers 20:7-20:13","4":"Numbers 20:14-20:21","5":"Numbers 20:22-21:9","6":"Numbers 21:10-21:20","7":"Numbers 21:21-22:1","torah":"Numbers 19:1-22:1","haftarah":"Judges 11:1-33","maftir":"Numbers 21:34-22:1","triennial":{"1":"Numbers 19:1-19:6","2":"Numbers 19:7-19:9","3":"Numbers 19:10-19:13","4":"Numbers 19:14-19:17","5":"Numbers 19:18-19:22","6":"Numbers 20:1-20:6","7":"Numbers 20:7-20:13","maftir":"Numbers 20:7-20:13"}},"link":"https://hebcal.com/s/5786i/39?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื","date":"2026-06-20","hdate":"5 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 51","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Chullin.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื”ืณ"}},{"title":"ื•ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-21","hdate":"6 Tamuz 5786","category":"hebdate","title_orig":"6 Tamuz 5786","hebrew":"ื•ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื•ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื‘","date":"2026-06-21","hdate":"6 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 52","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Chullin.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื•ืณ"}},{"title":"ื–ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-22","hdate":"7 Tamuz 5786","category":"hebdate","title_orig":"7 Tamuz 5786","hebrew":"ื–ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื–ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื’","date":"2026-06-22","hdate":"7 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 53","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Chullin.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื–ืณ"}},{"title":"ื—ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-23","hdate":"8 Tamuz 5786","category":"hebdate","title_orig":"8 Tamuz 5786","hebrew":"ื—ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื—ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื“","date":"2026-06-23","hdate":"8 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 54","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Chullin.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื—ืณ"}},{"title":"ื˜ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-24","hdate":"9 Tamuz 5786","category":"hebdate","title_orig":"9 Tamuz 5786","hebrew":"ื˜ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื”","date":"2026-06-24","hdate":"9 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 55","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Chullin.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืณ"}},{"title":"ื™ืณ ืชึทึผืžึผื•ึผื–","date":"2026-06-25","hdate":"10 Tamuz 5786","category":"hebdate","title_orig":"10 Tamuz 5786","hebrew":"ื™ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื•","date":"2026-06-25","hdate":"10 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 56","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Chullin.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืณ"}},{"title":"ื™ืดื ืชึทึผืžึผื•ึผื–","date":"2026-06-26","hdate":"11 Tamuz 5786","category":"hebdate","title_orig":"11 Tamuz 5786","hebrew":"ื™ืดื ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื–","date":"2026-06-26","hdate":"11 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 57","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Chullin.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื"}},{"title":"ื™ืดื‘ ืชึทึผืžึผื•ึผื–","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"hebdate","title_orig":"12 Tamuz 5786","hebrew":"ื™ืดื‘ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ืคึธึผืจึธืฉึทืืช ื‘ึธึผืœึธืง","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"parashat","title_orig":"Parashat Balak","hebrew":"ืคืจืฉืช ื‘ืœืง","leyning":{"1":"Numbers 22:2-22:12","2":"Numbers 22:13-22:20","3":"Numbers 22:21-22:38","4":"Numbers 22:39-23:12","5":"Numbers 23:13-23:26","6":"Numbers 23:27-24:13","7":"Numbers 24:14-25:9","torah":"Numbers 22:2-25:9","haftarah":"Micah 5:6-6:8","maftir":"Numbers 25:7-25:9","triennial":{"1":"Numbers 22:2-22:4","2":"Numbers 22:5-22:7","3":"Numbers 22:8-22:12","4":"Numbers 22:13-22:20","5":"Numbers 22:21-22:27","6":"Numbers 22:28-22:30","7":"Numbers 22:31-22:38","maftir":"Numbers 22:36-22:38"}},"link":"https://hebcal.com/s/5786i/40?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื—","date":"2026-06-27","hdate":"12 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 58","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Chullin.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื‘"}},{"title":"ื™ืดื’ ืชึทึผืžึผื•ึผื–","date":"2026-06-28","hdate":"13 Tamuz 5786","category":"hebdate","title_orig":"13 Tamuz 5786","hebrew":"ื™ืดื’ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื˜","date":"2026-06-28","hdate":"13 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 59","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Chullin.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื’"}},{"title":"ื™ืดื“ ืชึทึผืžึผื•ึผื–","date":"2026-06-29","hdate":"14 Tamuz 5786","category":"hebdate","title_orig":"14 Tamuz 5786","hebrew":"ื™ืดื“ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืณ","date":"2026-06-29","hdate":"14 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 60","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Chullin.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื“"}},{"title":"ื˜ืดื• ืชึทึผืžึผื•ึผื–","date":"2026-06-30","hdate":"15 Tamuz 5786","category":"hebdate","title_orig":"15 Tamuz 5786","hebrew":"ื˜ืดื• ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื•"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื","date":"2026-06-30","hdate":"15 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 61","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Chullin.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื•"}},{"title":"ื˜ืดื– ืชึทึผืžึผื•ึผื–","date":"2026-07-01","hdate":"16 Tamuz 5786","category":"hebdate","title_orig":"16 Tamuz 5786","hebrew":"ื˜ืดื– ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื–"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื‘","date":"2026-07-01","hdate":"16 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 62","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื‘","link":"https://www.sefaria.org/Chullin.62a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื˜ืดื–"}},{"title":"ื™ืดื– ืชึทึผืžึผื•ึผื–","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"hebdate","title_orig":"17 Tamuz 5786","hebrew":"ื™ืดื– ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"}},{"title":"ืฆื•ึนื ื™ืดื– ื‘ึฐึผืชึทืžึผื•ึผื–","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"holiday","subcat":"fast","title_orig":"Tzom Tammuz","hebrew":"ืฆื•ื ื™ืดื– ื‘ืชืžื•ื–","leyning":{"1":"Exodus 32:11-32:14","2":"Exodus 34:1-34:3","3":"Exodus 34:4-34:10","torah":"Exodus 32:11-14, 34:1-10"},"link":"https://hebcal.com/h/tzom-tammuz-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"},"memo":"ืฆื•ื ื™ืดื– ื‘ืชืžื•ื–. ืฆื•ื ื”ืžืฆื™ื™ืŸ ืืช ื”ื‘ืงืขืช ื—ื•ืžื•ืช ื™ืจื•ืฉืœื™ื ืœืคื ื™ ื—ื•ืจื‘ืŸ ื‘ื™ืช ื”ืžืงื“ืฉ ื”ืฉื ื™"},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื’","date":"2026-07-02","hdate":"17 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 63","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื’","link":"https://www.sefaria.org/Chullin.63a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื–"}},{"title":"ื™ืดื— ืชึทึผืžึผื•ึผื–","date":"2026-07-03","hdate":"18 Tamuz 5786","category":"hebdate","title_orig":"18 Tamuz 5786","hebrew":"ื™ืดื— ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื—"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื“","date":"2026-07-03","hdate":"18 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 64","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื“","link":"https://www.sefaria.org/Chullin.64a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื—"}},{"title":"ื™ืดื˜ ืชึทึผืžึผื•ึผื–","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"hebdate","title_orig":"19 Tamuz 5786","hebrew":"ื™ืดื˜ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ืคึธึผืจึธืฉึทืืช ืคึดึผื™ื ึฐื—ึธืก","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"parashat","title_orig":"Parashat Pinchas","hebrew":"ืคืจืฉืช ืคื™ื ื—ืก","leyning":{"1":"Numbers 25:10-26:4","2":"Numbers 26:5-26:51","3":"Numbers 26:52-27:5","4":"Numbers 27:6-27:23","5":"Numbers 28:1-28:15","6":"Numbers 28:16-29:11","7":"Numbers 29:12-30:1","torah":"Numbers 25:10-30:1","haftarah":"Jeremiah 1:1-2:3 | Pinchas occurring after 17 Tammuz","maftir":"Numbers 29:35-30:1","triennial":{"1":"Numbers 25:10-25:12","2":"Numbers 25:13-25:15","3":"Numbers 25:16-26:4","4":"Numbers 26:5-26:11","5":"Numbers 26:12-26:22","6":"Numbers 26:23-26:34","7":"Numbers 26:35-26:51","maftir":"Numbers 26:48-26:51"}},"link":"https://hebcal.com/s/5786i/41?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื”","date":"2026-07-04","hdate":"19 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 65","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื”","link":"https://www.sefaria.org/Chullin.65a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื™ืดื˜"}},{"title":"ื›ืณ ืชึทึผืžึผื•ึผื–","date":"2026-07-05","hdate":"20 Tamuz 5786","category":"hebdate","title_orig":"20 Tamuz 5786","hebrew":"ื›ืณ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืณ"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื•","date":"2026-07-05","hdate":"20 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 66","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื•","link":"https://www.sefaria.org/Chullin.66a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืณ"}},{"title":"ื›ืดื ืชึทึผืžึผื•ึผื–","date":"2026-07-06","hdate":"21 Tamuz 5786","category":"hebdate","title_orig":"21 Tamuz 5786","hebrew":"ื›ืดื ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื–","date":"2026-07-06","hdate":"21 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 67","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื–","link":"https://www.sefaria.org/Chullin.67a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื"}},{"title":"ื›ืดื‘ ืชึทึผืžึผื•ึผื–","date":"2026-07-07","hdate":"22 Tamuz 5786","category":"hebdate","title_orig":"22 Tamuz 5786","hebrew":"ื›ืดื‘ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื‘"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื—","date":"2026-07-07","hdate":"22 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 68","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื—","link":"https://www.sefaria.org/Chullin.68a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื‘"}},{"title":"ื›ืดื’ ืชึทึผืžึผื•ึผื–","date":"2026-07-08","hdate":"23 Tamuz 5786","category":"hebdate","title_orig":"23 Tamuz 5786","hebrew":"ื›ืดื’ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื’"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื˜","date":"2026-07-08","hdate":"23 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 69","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืกืดื˜","link":"https://www.sefaria.org/Chullin.69a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื’"}},{"title":"ื›ืดื“ ืชึทึผืžึผื•ึผื–","date":"2026-07-09","hdate":"24 Tamuz 5786","category":"hebdate","title_orig":"24 Tamuz 5786","hebrew":"ื›ืดื“ ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื“"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืณ","date":"2026-07-09","hdate":"24 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 70","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืณ","link":"https://www.sefaria.org/Chullin.70a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื“"}},{"title":"ื›ืดื” ืชึทึผืžึผื•ึผื–","date":"2026-07-10","hdate":"25 Tamuz 5786","category":"hebdate","title_orig":"25 Tamuz 5786","hebrew":"ื›ืดื” ืชืžื•ื–","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื”"}},{"title":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืดื","date":"2026-07-10","hdate":"25 Tamuz 5786","category":"dafyomi","title_orig":"Chullin 71","hebrew":"ื—ื•ืœื™ืŸ ื“ืฃ ืขืดื","link":"https://www.sefaria.org/Chullin.71a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื•","m":"ืชืžื•ื–","d":"ื›ืดื”"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-en.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-en.json new file mode 100644 index 0000000..2c59fcf --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-en.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:49.742Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"12th of Tishrei","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"Bechorot 5","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"13th of Tishrei","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"Bechorot 6","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"14th of Tishrei","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"Erev Sukkot","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 7","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"15th of Tishrei","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"Sukkot I","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 8","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"16th of Tishrei","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"Sukkot II (CHโ€™โ€™M)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 9","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"17th of Tishrei","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"Sukkot III (CHโ€™โ€™M)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 10","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"18th of Tishrei","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"Sukkot IV (CHโ€™โ€™M)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 11","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"19th of Tishrei","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"Sukkot V (CHโ€™โ€™M)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 12","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"20th of Tishrei","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"Sukkot VI (CHโ€™โ€™M)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 13","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"21st of Tishrei","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"Sukkot VII (Hoshana Raba)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 14","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"22nd of Tishrei","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"Shmini Atzeret","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"Eighth Day of Assembly. Immediately following Sukkot, it is observed as a separate holiday in the Diaspora and is combined with Simchat Torah in Israel"},{"title":"Bechorot 15","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"23rd of Tishrei","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"Bechorot 16","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"24th of Tishrei","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"Bechorot 17","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"25th of Tishrei","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"Bechorot 18","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"26th of Tishrei","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"Bechorot 19","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"27th of Tishrei","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"Bechorot 20","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"28th of Tishrei","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"Bechorot 21","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"29th of Tishrei","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Parashat Bereshit","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Cheshvan","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"Molad Cheshvan: Sunday, 9:43 and 2 chalakim"},{"title":"Bechorot 22","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"30th of Tishrei","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"Bechorot 23","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"1st of Cheshvan, 5787","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"Bechorot 24","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"2nd of Cheshvan","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"Bechorot 25","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"3rd of Cheshvan","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"Bechorot 26","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"4th of Cheshvan","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"Bechorot 27","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"5th of Cheshvan","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"Bechorot 28","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"6th of Cheshvan","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Parashat Noach","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Bechorot 29","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"7th of Cheshvan","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"Bechorot 30","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"8th of Cheshvan","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"Bechorot 31","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"9th of Cheshvan","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"Bechorot 32","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"10th of Cheshvan","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"Bechorot 33","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"11th of Cheshvan","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"Bechorot 34","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"12th of Cheshvan","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"Bechorot 35","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"13th of Cheshvan","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Parashat Lech-Lecha","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Bechorot 36","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"14th of Cheshvan","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"Bechorot 37","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"15th of Cheshvan","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"Bechorot 38","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"16th of Cheshvan","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"Bechorot 39","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"17th of Cheshvan","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"Bechorot 40","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"18th of Cheshvan","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"Bechorot 41","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"19th of Cheshvan","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"Bechorot 42","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"20th of Cheshvan","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Parashat Vayera","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Bechorot 43","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"21st of Cheshvan","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"Bechorot 44","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"22nd of Cheshvan","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"Bechorot 45","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"23rd of Cheshvan","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"Bechorot 46","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"24th of Cheshvan","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"Bechorot 47","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"25th of Cheshvan","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"Bechorot 48","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"26th of Cheshvan","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"Bechorot 49","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"27th of Cheshvan","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Parashat Chayei Sara","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Mevarchim Chodesh Kislev","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"Molad Kislev: Monday, 22:27 and 3 chalakim"},{"title":"Bechorot 50","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"28th of Cheshvan","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"Bechorot 51","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"29th of Cheshvan","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"Bechorot 52","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"30th of Cheshvan","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"Bechorot 53","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"1st of Kislev, 5787","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"Bechorot 54","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"2nd of Kislev","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"Bechorot 55","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"3rd of Kislev","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"Bechorot 56","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"4th of Kislev","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Parashat Toldot","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Bechorot 57","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"5th of Kislev","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"Bechorot 58","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"6th of Kislev","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"Bechorot 59","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"7th of Kislev","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"Bechorot 60","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"8th of Kislev","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"Bechorot 61","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"9th of Kislev","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"Arachin 2","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"10th of Kislev","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"Arachin 3","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"11th of Kislev","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Parashat Vayetzei","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Arachin 4","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"12th of Kislev","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"Arachin 5","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"13th of Kislev","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"Arachin 6","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"14th of Kislev","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"Arachin 7","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"15th of Kislev","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"Arachin 8","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"16th of Kislev","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"Arachin 9","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"17th of Kislev","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"Arachin 10","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"18th of Kislev","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Parashat Vayishlach","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Arachin 11","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"19th of Kislev","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"Arachin 12","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"20th of Kislev","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"Arachin 13","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"21st of Kislev","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"Arachin 14","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"22nd of Kislev","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"Arachin 15","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"23rd of Kislev","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"Arachin 16","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"24th of Kislev","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"Chanukah: 1 Candle","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 17","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"25th of Kislev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Parashat Vayeshev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Mevarchim Chodesh Tevet","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื˜ื‘ืช","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Molad Tevet: Wednesday, 11:11 and 4 chalakim"},{"title":"Chanukah: 2 Candles","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 18","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"26th of Kislev","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"Chanukah: 3 Candles","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 19","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"27th of Kislev","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"Chanukah: 4 Candles","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 20","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"28th of Kislev","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"Chanukah: 5 Candles","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 21","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"29th of Kislev","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"Chanukah: 6 Candles","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 22","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"30th of Kislev","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"Chag HaBanot","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"North African Chanukah festival of daughters. Called Eid al-Banat in Arabic, the holiday was most preserved in Tunisia"},{"title":"Chanukah: 7 Candles","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 23","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-es.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-es.json new file mode 100644 index 0000000..bdc5ff9 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-es.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:55.368Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"12ยบ Tishrei","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"Bejorot 5","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 5","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"13ยบ Tishrei","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"Bejorot 6","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 6","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"14ยบ Tishrei","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"Erev Sukot","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Erev Sukkot","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 7","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 7","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"15ยบ Tishrei","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"Sukot I","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Sukkot I","hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 8","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 8","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"16ยบ Tishrei","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"Sukot II (Jโ€™โ€™M)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 9","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 9","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"17ยบ Tishrei","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"Sukot III (Jโ€™โ€™M)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 10","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 10","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"18ยบ Tishrei","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"Sukot IV (Jโ€™โ€™M)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 11","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 11","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"19ยบ Tishrei","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"Sukot V (Jโ€™โ€™M)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 12","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 12","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"20ยบ Tishrei","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"Sukot VI (Jโ€™โ€™M)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 13","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 13","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"21ยบ Tishrei","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"Sukot VII (Hoshana Raba)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VII (Hoshana Raba)","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bejorot 14","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 14","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"22ยบ Tishrei","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"Shmini Atzeret","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"Eighth Day of Assembly. Immediately following Sukkot, it is observed as a separate holiday in the Diaspora and is combined with Simchat Torah in Israel"},{"title":"Bejorot 15","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 15","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"23ยบ Tishrei","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"Bejorot 16","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 16","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"24ยบ Tishrei","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"Bejorot 17","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 17","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"25ยบ Tishrei","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"Bejorot 18","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 18","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"26ยบ Tishrei","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"Bejorot 19","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 19","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"27ยบ Tishrei","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"Bejorot 20","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 20","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"28ยบ Tishrei","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"Bejorot 21","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 21","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"29ยบ Tishrei","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Parashรก Bereshit","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","title_orig":"Parashat Bereshit","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Jeshvan","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Jeshvan","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"Molad Jeshvan: Sunday, 9:43 and 2 chalakim"},{"title":"Bejorot 22","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 22","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"30ยบ Tishrei","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"Bejorot 23","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 23","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"1ยบ Jeshvan, 5787","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"Bejorot 24","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 24","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"2ยบ Jeshvan","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"Bejorot 25","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 25","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"3ยบ Jeshvan","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"Bejorot 26","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 26","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"4ยบ Jeshvan","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"Bejorot 27","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 27","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"5ยบ Jeshvan","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"Bejorot 28","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 28","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"6ยบ Jeshvan","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Parashรก Noaj","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","title_orig":"Parashat Noach","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Bejorot 29","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 29","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"7ยบ Jeshvan","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"Bejorot 30","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 30","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"8ยบ Jeshvan","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"Bejorot 31","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 31","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"9ยบ Jeshvan","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"Bejorot 32","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 32","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"10ยบ Jeshvan","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"Bejorot 33","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 33","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"11ยบ Jeshvan","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"Bejorot 34","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 34","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"12ยบ Jeshvan","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"Bejorot 35","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 35","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"13ยบ Jeshvan","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Parashรก Lej Leja","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","title_orig":"Parashat Lech-Lecha","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Bejorot 36","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 36","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"14ยบ Jeshvan","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"Bejorot 37","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 37","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"15ยบ Jeshvan","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"Bejorot 38","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 38","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"16ยบ Jeshvan","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"Bejorot 39","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 39","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"17ยบ Jeshvan","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"Bejorot 40","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 40","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"18ยบ Jeshvan","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"Bejorot 41","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 41","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"19ยบ Jeshvan","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"Bejorot 42","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 42","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"20ยบ Jeshvan","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Parashรก Vayera","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","title_orig":"Parashat Vayera","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Bejorot 43","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 43","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"21ยบ Jeshvan","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"Bejorot 44","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 44","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"22ยบ Jeshvan","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"Bejorot 45","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 45","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"23ยบ Jeshvan","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"Bejorot 46","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 46","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"24ยบ Jeshvan","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"Bejorot 47","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 47","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"25ยบ Jeshvan","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"Bejorot 48","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 48","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"26ยบ Jeshvan","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"Bejorot 49","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 49","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"27ยบ Jeshvan","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Parashรก Jayei Sara","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","title_orig":"Parashat Chayei Sara","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Mevarchim Chodesh Kislev","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"Molad Kislev: Monday, 22:27 and 3 chalakim"},{"title":"Bejorot 50","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 50","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"28ยบ Jeshvan","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"Bejorot 51","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 51","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"29ยบ Jeshvan","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"Bejorot 52","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 52","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"30ยบ Jeshvan","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"Bejorot 53","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 53","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"1ยบ Kislev, 5787","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"Bejorot 54","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 54","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"2ยบ Kislev","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"Bejorot 55","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 55","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"3ยบ Kislev","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"Bejorot 56","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 56","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"4ยบ Kislev","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Parashรก Toldot","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","title_orig":"Parashat Toldot","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Bejorot 57","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 57","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"5ยบ Kislev","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"Bejorot 58","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 58","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"6ยบ Kislev","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"Bejorot 59","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 59","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"7ยบ Kislev","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"Bejorot 60","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 60","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"8ยบ Kislev","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"Bejorot 61","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 61","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"9ยบ Kislev","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"Arajin 2","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","title_orig":"Arachin 2","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"10ยบ Kislev","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"Arajin 3","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","title_orig":"Arachin 3","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"11ยบ Kislev","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Parashรก Vayetze","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","title_orig":"Parashat Vayetzei","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Arajin 4","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","title_orig":"Arachin 4","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"12ยบ Kislev","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"Arajin 5","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","title_orig":"Arachin 5","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"13ยบ Kislev","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"Arajin 6","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","title_orig":"Arachin 6","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"14ยบ Kislev","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"Arajin 7","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","title_orig":"Arachin 7","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"15ยบ Kislev","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"Arajin 8","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","title_orig":"Arachin 8","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"16ยบ Kislev","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"Arajin 9","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","title_orig":"Arachin 9","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"17ยบ Kislev","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"Arajin 10","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","title_orig":"Arachin 10","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"18ยบ Kislev","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Parashรก Vaishlaj","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","title_orig":"Parashat Vayishlach","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Arajin 11","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","title_orig":"Arachin 11","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"19ยบ Kislev","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"Arajin 12","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","title_orig":"Arachin 12","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"20ยบ Kislev","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"Arajin 13","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","title_orig":"Arachin 13","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"21ยบ Kislev","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"Arajin 14","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","title_orig":"Arachin 14","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"22ยบ Kislev","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"Arajin 15","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","title_orig":"Arachin 15","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"23ยบ Kislev","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"Arajin 16","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","title_orig":"Arachin 16","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"24ยบ Kislev","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"Janucรก: 1 vela","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 1 Candle","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 17","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","title_orig":"Arachin 17","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"25ยบ Kislev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Parashรก Vayeshev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","title_orig":"Parashat Vayeshev","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Mevarchim Chodesh Tevet","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื˜ื‘ืช","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Molad Tevet: Wednesday, 11:11 and 4 chalakim"},{"title":"Janucรก: 2 velas","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 2 Candles","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 18","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","title_orig":"Arachin 18","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"26ยบ Kislev","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"Janucรก: 3 velas","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 3 Candles","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 19","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","title_orig":"Arachin 19","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"27ยบ Kislev","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"Janucรก: 4 velas","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 4 Candles","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 20","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","title_orig":"Arachin 20","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"28ยบ Kislev","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"Janucรก: 5 velas","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 5 Candles","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 21","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","title_orig":"Arachin 21","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"29ยบ Kislev","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"Janucรก: 6 velas","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 6 Candles","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 22","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","title_orig":"Arachin 22","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"30ยบ Kislev","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"Jag HaBanot","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","title_orig":"Chag HaBanot","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"North African Chanukah festival of daughters. Called Eid al-Banat in Arabic, the holiday was most preserved in Tunisia"},{"title":"Janucรก: 7 velas","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 7 Candles","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arajin 23","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","title_orig":"Arachin 23","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-fr.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-fr.json new file mode 100644 index 0000000..2b7dffa --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-fr.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:53.882Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"12. Tishrei","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"Bekhorot 5","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 5","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"13. Tishrei","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"Bekhorot 6","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 6","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"14. Tishrei","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"Erev Soukkot","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Erev Sukkot","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 7","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 7","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"15. Tishrei","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"Soukkot I","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Sukkot I","hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 8","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 8","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"16. Tishrei","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"Soukkot II (Hฬฒโ€™โ€™M)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 9","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 9","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"17. Tishrei","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"Soukkot III (Hฬฒโ€™โ€™M)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 10","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 10","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"18. Tishrei","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"Soukkot IV (Hฬฒโ€™โ€™M)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 11","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 11","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"19. Tishrei","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"Soukkot V (Hฬฒโ€™โ€™M)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 12","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 12","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"20. Tishrei","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"Soukkot VI (Hฬฒโ€™โ€™M)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 13","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 13","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"21. Tishrei","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"Soukkot VII (Hochanah Rabbah)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VII (Hoshana Raba)","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bekhorot 14","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 14","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"22. Tishrei","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"Chemini Atzรฉret","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shmini Atzeret","hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"Eighth Day of Assembly. Immediately following Sukkot, it is observed as a separate holiday in the Diaspora and is combined with Simchat Torah in Israel"},{"title":"Bekhorot 15","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 15","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"23. Tishrei","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"Bekhorot 16","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 16","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"24. Tishrei","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"Bekhorot 17","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 17","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"25. Tishrei","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"Bekhorot 18","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 18","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"26. Tishrei","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"Bekhorot 19","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 19","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"27. Tishrei","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"Bekhorot 20","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 20","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"28. Tishrei","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"Bekhorot 21","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 21","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"29. Tishrei","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Parachah Berechit","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","title_orig":"Parashat Bereshit","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Hฬฒechvan","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Hฬฒechvan","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"Molad Hฬฒechvan: Dimanche, 9:43 et 2 'halakim"},{"title":"Bekhorot 22","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 22","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"30. Tishrei","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"Bekhorot 23","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 23","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"1. Hฬฒechvan, 5787","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"Bekhorot 24","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 24","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"2. Hฬฒechvan","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"Bekhorot 25","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 25","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"3. Hฬฒechvan","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"Bekhorot 26","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 26","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"4. Hฬฒechvan","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"Bekhorot 27","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 27","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"5. Hฬฒechvan","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"Bekhorot 28","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 28","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"6. Hฬฒechvan","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Parachah Noahฬฒ","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","title_orig":"Parashat Noach","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Bekhorot 29","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 29","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"7. Hฬฒechvan","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"Bekhorot 30","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 30","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"8. Hฬฒechvan","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"Bekhorot 31","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 31","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"9. Hฬฒechvan","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"Bekhorot 32","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 32","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"10. Hฬฒechvan","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"Bekhorot 33","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 33","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"11. Hฬฒechvan","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"Bekhorot 34","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 34","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"12. Hฬฒechvan","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"Bekhorot 35","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 35","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. Hฬฒechvan","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Parachah Lekh Lekha","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","title_orig":"Parashat Lech-Lecha","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Bekhorot 36","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 36","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"14. Hฬฒechvan","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"Bekhorot 37","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 37","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"15. Hฬฒechvan","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"Bekhorot 38","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 38","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. Hฬฒechvan","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"Bekhorot 39","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 39","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. Hฬฒechvan","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"Bekhorot 40","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 40","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"18. Hฬฒechvan","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"Bekhorot 41","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 41","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"19. Hฬฒechvan","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"Bekhorot 42","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 42","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. Hฬฒechvan","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Parachah Vayรฉra","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","title_orig":"Parashat Vayera","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Bekhorot 43","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 43","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"21. Hฬฒechvan","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"Bekhorot 44","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 44","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"22. Hฬฒechvan","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"Bekhorot 45","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 45","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. Hฬฒechvan","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"Bekhorot 46","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 46","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"24. Hฬฒechvan","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"Bekhorot 47","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 47","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"25. Hฬฒechvan","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"Bekhorot 48","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 48","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"26. Hฬฒechvan","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"Bekhorot 49","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 49","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"27. Hฬฒechvan","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Parachah Hฬฒayรฉ Sarah","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","title_orig":"Parashat Chayei Sara","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Mevarchim Chodesh Kislav","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Kislav","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"Molad Kislav: Lundi, 22:27 et 3 'halakim"},{"title":"Bekhorot 50","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 50","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"28. Hฬฒechvan","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"Bekhorot 51","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 51","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"29. Hฬฒechvan","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"Bekhorot 52","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 52","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"30. Hฬฒechvan","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"Bekhorot 53","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 53","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"1. Kislav, 5787","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"Bekhorot 54","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 54","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"2. Kislav","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"Bekhorot 55","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 55","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"3. Kislav","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"Bekhorot 56","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 56","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"4. Kislav","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Parachah Toledot","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","title_orig":"Parashat Toldot","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Bekhorot 57","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 57","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"5. Kislav","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"Bekhorot 58","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 58","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"6. Kislav","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"Bekhorot 59","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 59","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"7. Kislav","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"Bekhorot 60","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 60","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"8. Kislav","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"Bekhorot 61","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 61","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"9. Kislav","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"Arahฬฒin 2","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","title_orig":"Arachin 2","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"10. Kislav","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"Arahฬฒin 3","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","title_orig":"Arachin 3","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"11. Kislav","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Parachah Vayรฉtzรฉ","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","title_orig":"Parashat Vayetzei","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Arahฬฒin 4","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","title_orig":"Arachin 4","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"12. Kislav","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"Arahฬฒin 5","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","title_orig":"Arachin 5","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"13. Kislav","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"Arahฬฒin 6","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","title_orig":"Arachin 6","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"14. Kislav","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"Arahฬฒin 7","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","title_orig":"Arachin 7","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"15. Kislav","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"Arahฬฒin 8","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","title_orig":"Arachin 8","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"16. Kislav","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"Arahฬฒin 9","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","title_orig":"Arachin 9","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"17. Kislav","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"Arahฬฒin 10","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","title_orig":"Arachin 10","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"18. Kislav","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Parachah Vayichlahฬฒ","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","title_orig":"Parashat Vayishlach","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Arahฬฒin 11","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","title_orig":"Arachin 11","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"19. Kislav","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"Arahฬฒin 12","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","title_orig":"Arachin 12","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"20. Kislav","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"Arahฬฒin 13","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","title_orig":"Arachin 13","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"21. Kislav","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"Arahฬฒin 14","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","title_orig":"Arachin 14","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"22. Kislav","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"Arahฬฒin 15","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","title_orig":"Arachin 15","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"23. Kislav","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"Arahฬฒin 16","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","title_orig":"Arachin 16","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"24. Kislav","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"Hฬฒanoukah: 1 Bougie","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 1 Candle","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 17","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","title_orig":"Arachin 17","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"25. Kislav","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Parachah Vayรฉchev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","title_orig":"Parashat Vayeshev","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Mevarchim Chodesh Tรฉvet","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ Tรฉvet","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Molad Tรฉvet: Mercredi, 11:11 et 4 'halakim"},{"title":"Hฬฒanoukah: 2 Bougies","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 2 Candles","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 18","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","title_orig":"Arachin 18","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"26. Kislav","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"Hฬฒanoukah: 3 Bougies","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 3 Candles","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 19","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","title_orig":"Arachin 19","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"27. Kislav","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"Hฬฒanoukah: 4 Bougies","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 4 Candles","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 20","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","title_orig":"Arachin 20","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"28. Kislav","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"Hฬฒanoukah: 5 Bougies","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 5 Candles","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 21","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","title_orig":"Arachin 21","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"29. Kislav","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"Hฬฒanoukah: 6 Bougies","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 6 Candles","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 22","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","title_orig":"Arachin 22","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"30. Kislav","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"Hag HaBanot","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","title_orig":"Chag HaBanot","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"North African Chanukah festival of daughters. Called Eid al-Banat in Arabic, the holiday was most preserved in Tunisia"},{"title":"Hฬฒanoukah: 7 Bougies","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 7 Candles","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arahฬฒin 23","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","title_orig":"Arachin 23","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-pt-br.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-pt-br.json new file mode 100644 index 0000000..08e31eb --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-pt-br.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:57.212Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"12. Tishrei","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"Bechorot 5","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"13. Tishrei","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"Bechorot 6","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"14. Tishrei","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"Vรฉspera de Sukot","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Erev Sukkot","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 7","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"15. Tishrei","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"Sukot I","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Sukkot I","hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 8","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"16. Tishrei","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"Sukot II (Chol haMoed)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 9","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"17. Tishrei","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"Sukot III (Chol haMoed)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 10","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"18. Tishrei","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"Sukot IV (Chol haMoed)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 11","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"19. Tishrei","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"Sukot V (Chol haMoed)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 12","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"20. Tishrei","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"Sukot VI (Chol haMoed)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 13","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"21. Tishrei","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"Sukot VII (Hoshana Raba)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VII (Hoshana Raba)","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"Bechorot 14","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"22. Tishrei","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"Shmini Atzeret","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"Eighth Day of Assembly. Immediately following Sukkot, it is observed as a separate holiday in the Diaspora and is combined with Simchat Torah in Israel"},{"title":"Bechorot 15","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"23. Tishrei","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"Bechorot 16","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"24. Tishrei","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"Bechorot 17","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"25. Tishrei","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"Bechorot 18","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"26. Tishrei","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"Bechorot 19","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"27. Tishrei","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"Bechorot 20","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"28. Tishrei","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"Bechorot 21","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"29. Tishrei","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Parashรก Bereshit","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","title_orig":"Parashat Bereshit","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"Mevarchim Chodesh Cheshvan","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"Molad Cheshvan: Sunday, 9:43 and 2 chalakim"},{"title":"Bechorot 22","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"30. Tishrei","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"Bechorot 23","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"1. Cheshvan, 5787","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"Bechorot 24","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"2. Cheshvan","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"Bechorot 25","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"3. Cheshvan","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"Bechorot 26","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"4. Cheshvan","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"Bechorot 27","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"5. Cheshvan","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"Bechorot 28","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"6. Cheshvan","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Parashรก Noach","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","title_orig":"Parashat Noach","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"Bechorot 29","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"7. Cheshvan","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"Bechorot 30","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"8. Cheshvan","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"Bechorot 31","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"9. Cheshvan","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"Bechorot 32","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"10. Cheshvan","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"Bechorot 33","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"11. Cheshvan","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"Bechorot 34","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"12. Cheshvan","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"Bechorot 35","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. Cheshvan","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Parashรก Lech Lecha","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","title_orig":"Parashat Lech-Lecha","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"Bechorot 36","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"14. Cheshvan","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"Bechorot 37","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"15. Cheshvan","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"Bechorot 38","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. Cheshvan","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"Bechorot 39","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. Cheshvan","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"Bechorot 40","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"18. Cheshvan","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"Bechorot 41","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"19. Cheshvan","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"Bechorot 42","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. Cheshvan","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Parashรก Vayera","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","title_orig":"Parashat Vayera","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"Bechorot 43","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"21. Cheshvan","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"Bechorot 44","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"22. Cheshvan","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"Bechorot 45","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. Cheshvan","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"Bechorot 46","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"24. Cheshvan","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"Bechorot 47","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"25. Cheshvan","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"Bechorot 48","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"26. Cheshvan","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"Bechorot 49","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"27. Cheshvan","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Parashรก Chayei Sara","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","title_orig":"Parashat Chayei Sara","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"Mevarchim Chodesh Kislev","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"Molad Kislev: Monday, 22:27 and 3 chalakim"},{"title":"Bechorot 50","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"28. Cheshvan","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"Bechorot 51","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"29. Cheshvan","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"Bechorot 52","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"30. Cheshvan","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"Bechorot 53","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"1. Kislev, 5787","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"Bechorot 54","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"2. Kislev","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"Bechorot 55","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"3. Kislev","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"Bechorot 56","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"4. Kislev","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Parashรก Toldot","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","title_orig":"Parashat Toldot","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"Bechorot 57","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"5. Kislev","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"Bechorot 58","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"6. Kislev","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"Bechorot 59","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"7. Kislev","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"Bechorot 60","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"8. Kislev","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"Bechorot 61","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"9. Kislev","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"Arachin 2","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"10. Kislev","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"Arachin 3","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"11. Kislev","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Parashรก Vayetze","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","title_orig":"Parashat Vayetzei","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"Arachin 4","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"12. Kislev","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"Arachin 5","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"13. Kislev","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"Arachin 6","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"14. Kislev","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"Arachin 7","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"15. Kislev","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"Arachin 8","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"16. Kislev","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"Arachin 9","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"17. Kislev","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"Arachin 10","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"18. Kislev","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Parashรก Vaishlach","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","title_orig":"Parashat Vayishlach","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"Arachin 11","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"19. Kislev","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"Arachin 12","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"20. Kislev","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"Arachin 13","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"21. Kislev","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"Arachin 14","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"22. Kislev","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"Arachin 15","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"23. Kislev","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"Arachin 16","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"24. Kislev","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"Chanucรก: 1 vela","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 1 Candle","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 17","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"25. Kislev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Parashรก Vayeshev","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","title_orig":"Parashat Vayeshev","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"Mevarchim Chodesh Tevet","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ื˜ื‘ืช","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Molad Tevet: Wednesday, 11:11 and 4 chalakim"},{"title":"Chanucรก: 2 velas","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 2 Candles","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 18","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"26. Kislev","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"Chanucรก: 3 velas","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 3 Candles","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 19","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"27. Kislev","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"Chanucรก: 4 velas","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 4 Candles","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 20","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"28. Kislev","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"Chanucรก: 5 velas","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 5 Candles","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 21","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"29. Kislev","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"Chanucรก: 6 velas","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 6 Candles","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 22","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"30. Kislev","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"Chag HaBanot","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"North African Chanukah festival of daughters. Called Eid al-Banat in Arabic, the holiday was most preserved in Tunisia"},{"title":"Chanucรก: 7 velas","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 7 Candles","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"Arachin 23","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-ru.json b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-ru.json new file mode 100644 index 0000000..a4494ca --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-calendar-2026-10-ru.json @@ -0,0 +1 @@ +{"title":"Hebcal Israel 2026","date":"2026-10-14T22:43:52.420Z","version":"6.14.3-4.2.2","location":{"geo":"none"},"range":{"start":"2026-09-23","end":"2026-12-10"},"items":[{"title":"12. ะขะธัˆั€ะตะน","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"hebdate","title_orig":"12 Tishrei 5787","hebrew":"ื™ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 5","date":"2026-09-23","hdate":"12 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 5","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Bekhorot.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื‘"}},{"title":"13. ะขะธัˆั€ะตะน","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"hebdate","title_orig":"13 Tishrei 5787","hebrew":"ื™ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 6","date":"2026-09-24","hdate":"13 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 6","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Bekhorot.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื’"}},{"title":"14. ะขะธัˆั€ะตะน","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"hebdate","title_orig":"14 Tishrei 5787","hebrew":"ื™ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"ะ’ะตั‡ะตั€ ะกัƒะบะบะพั‚ะฐ","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Erev Sukkot","hebrew":"ืขืจื‘ ืกื•ื›ื•ืช","link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 7","date":"2026-09-25","hdate":"14 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 7","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Bekhorot.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื“"}},{"title":"15. ะขะธัˆั€ะตะน","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"hebdate","title_orig":"15 Tishrei 5787","hebrew":"ื˜ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"ะกัƒะบะบะพั‚ I","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Sukkot I","hebrew":"ืกื•ื›ื•ืช ืืณ","leyning":{"1":"Leviticus 22:26-22:33","2":"Leviticus 23:1-23:3","3":"Leviticus 23:4-23:8","4":"Leviticus 23:9-23:14","5":"Leviticus 23:15-23:22","6":"Leviticus 23:23-23:32","7":"Leviticus 23:33-23:44","torah":"Leviticus 22:26-23:44; Numbers 29:12-16; Ecclesiastes 1:1-12:14","haftarah":"Zechariah 14:1-21","maftir":"Numbers 29:12-29:16"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 8","date":"2026-09-26","hdate":"15 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 8","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Bekhorot.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื•"}},{"title":"16. ะขะธัˆั€ะตะน","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"hebdate","title_orig":"16 Tishrei 5787","hebrew":"ื˜ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"ะ’ั‚ะพั€ะพะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะฅะพะปัŒ ะะผะพัะด)","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot II (CH''M)","hebrew":"ืกื•ื›ื•ืช ื‘ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:17-29:19","2":"Numbers 29:20-29:22","3":"Numbers 29:23-29:25","4":"Numbers 29:17-29:22","torah":"Numbers 29:17-25"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 9","date":"2026-09-27","hdate":"16 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 9","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Bekhorot.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื˜ืดื–"}},{"title":"17. ะขะธัˆั€ะตะน","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"hebdate","title_orig":"17 Tishrei 5787","hebrew":"ื™ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"ะขั€ะตั‚ะธะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะฅะพะปัŒ ะะผะพัะด)","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot III (CH''M)","hebrew":"ืกื•ื›ื•ืช ื’ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:20-29:22","2":"Numbers 29:23-29:25","3":"Numbers 29:26-29:28","4":"Numbers 29:20-29:25","torah":"Numbers 29:20-28"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 10","date":"2026-09-28","hdate":"17 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 10","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Bekhorot.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื–"}},{"title":"18. ะขะธัˆั€ะตะน","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"hebdate","title_orig":"18 Tishrei 5787","hebrew":"ื™ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"ะงะตั‚ะฒะตั€ั‚ั‹ะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะฅะพะปัŒ ะะผะพัะด)","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot IV (CH''M)","hebrew":"ืกื•ื›ื•ืช ื“ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:23-29:25","2":"Numbers 29:26-29:28","3":"Numbers 29:29-29:31","4":"Numbers 29:23-29:28","torah":"Numbers 29:23-31"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 11","date":"2026-09-29","hdate":"18 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 11","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Bekhorot.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื—"}},{"title":"19. ะขะธัˆั€ะตะน","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"hebdate","title_orig":"19 Tishrei 5787","hebrew":"ื™ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"ะŸัั‚ั‹ะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะฅะพะปัŒ ะะผะพัะด)","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot V (CH''M)","hebrew":"ืกื•ื›ื•ืช ื”ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:26-29:31","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 12","date":"2026-09-30","hdate":"19 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 12","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Bekhorot.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื™ืดื˜"}},{"title":"20. ะขะธัˆั€ะตะน","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"hebdate","title_orig":"20 Tishrei 5787","hebrew":"ื›ืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"ะจะตัั‚ะพะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะฅะพะปัŒ ะะผะพัะด)","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VI (CH''M)","hebrew":"ืกื•ื›ื•ืช ื•ืณ (ื—ื•ื”ืดืž)","leyning":{"1":"Numbers 29:29-29:31","2":"Numbers 29:32-29:34","3":"Numbers 29:35-29:37","4":"Numbers 29:29-29:34","torah":"Numbers 29:29-37"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 13","date":"2026-10-01","hdate":"20 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 13","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Bekhorot.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืณ"}},{"title":"21. ะขะธัˆั€ะตะน","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"hebdate","title_orig":"21 Tishrei 5787","hebrew":"ื›ืดื ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"ะกะตะดัŒะผะพะน ะดะตะฝัŒ ะกัƒะบะบะพั‚ะฐ (ะžัˆะฐะฝะฐ ะ ะฐะฑะฐ)","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"holiday","subcat":"major","title_orig":"Sukkot VII (Hoshana Raba)","hebrew":"ืกื•ื›ื•ืช ื–ืณ (ื”ื•ืฉืขื ื ืจื‘ื”)","leyning":{"1":"Numbers 29:26-29:28","2":"Numbers 29:29-29:31","3":"Numbers 29:32-29:34","4":"Numbers 29:29-29:34","torah":"Numbers 29:26-34"},"link":"https://hebcal.com/h/sukkot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"},"memo":"Feast of Booths. Also called the Feast of Tabernacles, the seven-day holiday is one of the Three Pilgrimage Festivals (Hebrew: ืฉืœื•ืฉ ืจื’ืœื™ื, shalosh regalim)"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 14","date":"2026-10-02","hdate":"21 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 14","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Bekhorot.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื"}},{"title":"22. ะขะธัˆั€ะตะน","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"hebdate","title_orig":"22 Tishrei 5787","hebrew":"ื›ืดื‘ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"ะจะผะธะฝะธ ะั†ะตั€ะตั‚","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"holiday","subcat":"major","yomtov":true,"title_orig":"Shmini Atzeret","hebrew":"ืฉืžื™ื ื™ ืขืฆืจืช","leyning":{"1":"Deuteronomy 33:1-33:7","2":"Deuteronomy 33:8-33:12","3":"Deuteronomy 33:13-33:17","4":"Deuteronomy 33:18-33:21","5":"Deuteronomy 33:22-33:26","6":"Deuteronomy 33:27-33:29","7":"Deuteronomy 34:1-34:12","8":"Genesis 1:1-2:3","torah":"Deuteronomy 33:1-34:12; Genesis 1:1-2:3; Numbers 29:35-30:1","haftarah":"Joshua 1:1-18","maftir":"Numbers 29:35-30:1"},"link":"https://hebcal.com/h/shmini-atzeret-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"},"memo":"Eighth Day of Assembly. Immediately following Sukkot, it is observed as a separate holiday in the Diaspora and is combined with Simchat Torah in Israel"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 15","date":"2026-10-03","hdate":"22 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 15","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Bekhorot.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื‘"}},{"title":"23. ะขะธัˆั€ะตะน","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"hebdate","title_orig":"23 Tishrei 5787","hebrew":"ื›ืดื’ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 16","date":"2026-10-04","hdate":"23 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 16","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Bekhorot.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื’"}},{"title":"24. ะขะธัˆั€ะตะน","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"hebdate","title_orig":"24 Tishrei 5787","hebrew":"ื›ืดื“ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 17","date":"2026-10-05","hdate":"24 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 17","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Bekhorot.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื“"}},{"title":"25. ะขะธัˆั€ะตะน","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"hebdate","title_orig":"25 Tishrei 5787","hebrew":"ื›ืดื” ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 18","date":"2026-10-06","hdate":"25 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 18","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Bekhorot.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื”"}},{"title":"26. ะขะธัˆั€ะตะน","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"hebdate","title_orig":"26 Tishrei 5787","hebrew":"ื›ืดื• ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 19","date":"2026-10-07","hdate":"26 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 19","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Bekhorot.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื•"}},{"title":"27. ะขะธัˆั€ะตะน","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"hebdate","title_orig":"27 Tishrei 5787","hebrew":"ื›ืดื– ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 20","date":"2026-10-08","hdate":"27 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 20","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Bekhorot.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื–"}},{"title":"28. ะขะธัˆั€ะตะน","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"hebdate","title_orig":"28 Tishrei 5787","hebrew":"ื›ืดื— ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 21","date":"2026-10-09","hdate":"28 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 21","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Bekhorot.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื—"}},{"title":"29. ะขะธัˆั€ะตะน","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"hebdate","title_orig":"29 Tishrei 5787","hebrew":"ื›ืดื˜ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"ะ“ะปะฐะฒะฐ ะ‘ะตั€ะตัˆะธั‚","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"parashat","title_orig":"Parashat Bereshit","hebrew":"ืคืจืฉืช ื‘ืจืืฉื™ืช","leyning":{"1":"Genesis 1:1-2:3","2":"Genesis 2:4-2:19","3":"Genesis 2:20-3:21","4":"Genesis 3:22-4:18","5":"Genesis 4:19-4:22","6":"Genesis 4:23-5:24","7":"Genesis 5:25-6:8","torah":"Genesis 1:1-6:8","haftarah":"I Samuel 20:18-42 | Shabbat Machar Chodesh","maftir":"Genesis 6:5-6:8","triennial":{"1":"Genesis 2:4-2:9","2":"Genesis 2:10-2:19","3":"Genesis 2:20-2:25","4":"Genesis 3:1-3:21","5":"Genesis 3:22-3:24","6":"Genesis 4:1-4:18","7":"Genesis 4:19-4:26","maftir":"Genesis 4:23-4:26"}},"link":"https://hebcal.com/s/5787i/1?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"ะผะตะฒะฐั€ั…ะธะผ ะะพะฒะพะผะตััั‡ัŒะตะผ ะฅะตัˆะฒะฐะฝ","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Cheshvan","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ะฅะตัˆะฒะฐะฝ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"},"memo":"Molad ะฅะตัˆะฒะฐะฝ: Sunday, 9:43 and 2 chalakim"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 22","date":"2026-10-10","hdate":"29 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 22","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Bekhorot.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ื›ืดื˜"}},{"title":"30. ะขะธัˆั€ะตะน","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"hebdate","title_orig":"30 Tishrei 5787","hebrew":"ืœืณ ืชืฉืจื™","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 23","date":"2026-10-11","hdate":"30 Tishrei 5787","category":"dafyomi","title_orig":"Bechorot 23","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Bekhorot.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ืชืฉืจื™","d":"ืœืณ"}},{"title":"1. ะฅะตัˆะฒะฐะฝ, 5787","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"hebdate","title_orig":"1 Cheshvan 5787","hebrew":"ืืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 24","date":"2026-10-12","hdate":"1 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 24","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื“","link":"https://www.sefaria.org/Bekhorot.24a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืืณ"}},{"title":"2. ะฅะตัˆะฒะฐะฝ","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"hebdate","title_orig":"2 Cheshvan 5787","hebrew":"ื‘ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 25","date":"2026-10-13","hdate":"2 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 25","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื”","link":"https://www.sefaria.org/Bekhorot.25a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื‘ืณ"}},{"title":"3. ะฅะตัˆะฒะฐะฝ","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"hebdate","title_orig":"3 Cheshvan 5787","hebrew":"ื’ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 26","date":"2026-10-14","hdate":"3 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 26","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื•","link":"https://www.sefaria.org/Bekhorot.26a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื’ืณ"}},{"title":"4. ะฅะตัˆะฒะฐะฝ","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"hebdate","title_orig":"4 Cheshvan 5787","hebrew":"ื“ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 27","date":"2026-10-15","hdate":"4 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 27","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื–","link":"https://www.sefaria.org/Bekhorot.27a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื“ืณ"}},{"title":"5. ะฅะตัˆะฒะฐะฝ","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"hebdate","title_orig":"5 Cheshvan 5787","hebrew":"ื”ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 28","date":"2026-10-16","hdate":"5 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 28","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื—","link":"https://www.sefaria.org/Bekhorot.28a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื”ืณ"}},{"title":"6. ะฅะตัˆะฒะฐะฝ","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"hebdate","title_orig":"6 Cheshvan 5787","hebrew":"ื•ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะะพะฐั…","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"parashat","title_orig":"Parashat Noach","hebrew":"ืคืจืฉืช ื ื—","leyning":{"1":"Genesis 6:9-6:22","2":"Genesis 7:1-7:16","3":"Genesis 7:17-8:14","4":"Genesis 8:15-9:7","5":"Genesis 9:8-9:17","6":"Genesis 9:18-10:32","7":"Genesis 11:1-11:32","torah":"Genesis 6:9-11:32","haftarah":"Isaiah 54:1-55:5","haftarah_sephardic":"Isaiah 54:1-10","maftir":"Genesis 11:29-11:32","triennial":{"1":"Genesis 8:15-8:22","2":"Genesis 9:1-9:7","3":"Genesis 9:8-9:17","4":"Genesis 9:18-9:29","5":"Genesis 10:1-10:14","6":"Genesis 10:15-10:20","7":"Genesis 10:21-10:32","maftir":"Genesis 10:26-10:32"}},"link":"https://hebcal.com/s/5787i/2?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 29","date":"2026-10-17","hdate":"6 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 29","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื›ืดื˜","link":"https://www.sefaria.org/Bekhorot.29a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื•ืณ"}},{"title":"7. ะฅะตัˆะฒะฐะฝ","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"hebdate","title_orig":"7 Cheshvan 5787","hebrew":"ื–ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 30","date":"2026-10-18","hdate":"7 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 30","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืณ","link":"https://www.sefaria.org/Bekhorot.30a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื–ืณ"}},{"title":"8. ะฅะตัˆะฒะฐะฝ","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"hebdate","title_orig":"8 Cheshvan 5787","hebrew":"ื—ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 31","date":"2026-10-19","hdate":"8 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 31","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื","link":"https://www.sefaria.org/Bekhorot.31a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื—ืณ"}},{"title":"9. ะฅะตัˆะฒะฐะฝ","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"hebdate","title_orig":"9 Cheshvan 5787","hebrew":"ื˜ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 32","date":"2026-10-20","hdate":"9 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 32","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื‘","link":"https://www.sefaria.org/Bekhorot.32a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืณ"}},{"title":"10. ะฅะตัˆะฒะฐะฝ","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"hebdate","title_orig":"10 Cheshvan 5787","hebrew":"ื™ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 33","date":"2026-10-21","hdate":"10 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 33","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื’","link":"https://www.sefaria.org/Bekhorot.33a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืณ"}},{"title":"11. ะฅะตัˆะฒะฐะฝ","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"hebdate","title_orig":"11 Cheshvan 5787","hebrew":"ื™ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 34","date":"2026-10-22","hdate":"11 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 34","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื“","link":"https://www.sefaria.org/Bekhorot.34a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื"}},{"title":"12. ะฅะตัˆะฒะฐะฝ","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"hebdate","title_orig":"12 Cheshvan 5787","hebrew":"ื™ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 35","date":"2026-10-23","hdate":"12 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 35","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื”","link":"https://www.sefaria.org/Bekhorot.35a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื‘"}},{"title":"13. ะฅะตัˆะฒะฐะฝ","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"hebdate","title_orig":"13 Cheshvan 5787","hebrew":"ื™ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"ะ“ะปะฐะฒะฐ ะ›ะตั…-ะ›ะตั…ะฐ","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"parashat","title_orig":"Parashat Lech-Lecha","hebrew":"ืคืจืฉืช ืœืšึพืœืš","leyning":{"1":"Genesis 12:1-12:13","2":"Genesis 12:14-13:4","3":"Genesis 13:5-13:18","4":"Genesis 14:1-14:20","5":"Genesis 14:21-15:6","6":"Genesis 15:7-17:6","7":"Genesis 17:7-17:27","torah":"Genesis 12:1-17:27","haftarah":"Isaiah 40:27-41:16","maftir":"Genesis 17:24-17:27","triennial":{"1":"Genesis 14:1-14:9","2":"Genesis 14:10-14:16","3":"Genesis 14:17-14:20","4":"Genesis 14:21-14:24","5":"Genesis 15:1-15:6","6":"Genesis 15:7-15:16","7":"Genesis 15:17-15:21","maftir":"Genesis 15:17-15:21"}},"link":"https://hebcal.com/s/5787i/3?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 36","date":"2026-10-24","hdate":"13 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 36","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื•","link":"https://www.sefaria.org/Bekhorot.36a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื’"}},{"title":"14. ะฅะตัˆะฒะฐะฝ","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"hebdate","title_orig":"14 Cheshvan 5787","hebrew":"ื™ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 37","date":"2026-10-25","hdate":"14 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 37","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื–","link":"https://www.sefaria.org/Bekhorot.37a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื“"}},{"title":"15. ะฅะตัˆะฒะฐะฝ","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"hebdate","title_orig":"15 Cheshvan 5787","hebrew":"ื˜ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 38","date":"2026-10-26","hdate":"15 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 38","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื—","link":"https://www.sefaria.org/Bekhorot.38a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื•"}},{"title":"16. ะฅะตัˆะฒะฐะฝ","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"hebdate","title_orig":"16 Cheshvan 5787","hebrew":"ื˜ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 39","date":"2026-10-27","hdate":"16 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 39","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืœืดื˜","link":"https://www.sefaria.org/Bekhorot.39a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื˜ืดื–"}},{"title":"17. ะฅะตัˆะฒะฐะฝ","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"hebdate","title_orig":"17 Cheshvan 5787","hebrew":"ื™ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 40","date":"2026-10-28","hdate":"17 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 40","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืณ","link":"https://www.sefaria.org/Bekhorot.40a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื–"}},{"title":"18. ะฅะตัˆะฒะฐะฝ","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"hebdate","title_orig":"18 Cheshvan 5787","hebrew":"ื™ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 41","date":"2026-10-29","hdate":"18 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 41","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื","link":"https://www.sefaria.org/Bekhorot.41a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื—"}},{"title":"19. ะฅะตัˆะฒะฐะฝ","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"hebdate","title_orig":"19 Cheshvan 5787","hebrew":"ื™ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 42","date":"2026-10-30","hdate":"19 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 42","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื‘","link":"https://www.sefaria.org/Bekhorot.42a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื™ืดื˜"}},{"title":"20. ะฅะตัˆะฒะฐะฝ","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"hebdate","title_orig":"20 Cheshvan 5787","hebrew":"ื›ืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะ’ะฐะตั€ะฐ","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"parashat","title_orig":"Parashat Vayera","hebrew":"ืคืจืฉืช ื•ื™ืจื","leyning":{"1":"Genesis 18:1-18:14","2":"Genesis 18:15-18:33","3":"Genesis 19:1-19:20","4":"Genesis 19:21-21:4","5":"Genesis 21:5-21:21","6":"Genesis 21:22-21:34","7":"Genesis 22:1-22:24","torah":"Genesis 18:1-22:24","haftarah":"II Kings 4:1-37","haftarah_sephardic":"II Kings 4:1-23","haftarah_chabad":"II Kings 4:1-37","maftir":"Genesis 22:20-22:24","triennial":{"1":"Genesis 19:1-19:11","2":"Genesis 19:12-19:20","3":"Genesis 19:21-19:29","4":"Genesis 19:30-19:38","5":"Genesis 20:1-20:8","6":"Genesis 20:9-20:14","7":"Genesis 20:15-20:18","maftir":"Genesis 20:15-20:18"}},"link":"https://hebcal.com/s/5787i/4?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 43","date":"2026-10-31","hdate":"20 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 43","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื’","link":"https://www.sefaria.org/Bekhorot.43a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืณ"}},{"title":"21. ะฅะตัˆะฒะฐะฝ","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"hebdate","title_orig":"21 Cheshvan 5787","hebrew":"ื›ืดื ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 44","date":"2026-11-01","hdate":"21 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 44","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื“","link":"https://www.sefaria.org/Bekhorot.44a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื"}},{"title":"22. ะฅะตัˆะฒะฐะฝ","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"hebdate","title_orig":"22 Cheshvan 5787","hebrew":"ื›ืดื‘ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 45","date":"2026-11-02","hdate":"22 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 45","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื”","link":"https://www.sefaria.org/Bekhorot.45a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื‘"}},{"title":"23. ะฅะตัˆะฒะฐะฝ","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"hebdate","title_orig":"23 Cheshvan 5787","hebrew":"ื›ืดื’ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 46","date":"2026-11-03","hdate":"23 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 46","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื•","link":"https://www.sefaria.org/Bekhorot.46a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื’"}},{"title":"24. ะฅะตัˆะฒะฐะฝ","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"hebdate","title_orig":"24 Cheshvan 5787","hebrew":"ื›ืดื“ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 47","date":"2026-11-04","hdate":"24 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 47","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื–","link":"https://www.sefaria.org/Bekhorot.47a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื“"}},{"title":"25. ะฅะตัˆะฒะฐะฝ","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"hebdate","title_orig":"25 Cheshvan 5787","hebrew":"ื›ืดื” ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 48","date":"2026-11-05","hdate":"25 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 48","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื—","link":"https://www.sefaria.org/Bekhorot.48a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื”"}},{"title":"26. ะฅะตัˆะฒะฐะฝ","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"hebdate","title_orig":"26 Cheshvan 5787","hebrew":"ื›ืดื• ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 49","date":"2026-11-06","hdate":"26 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 49","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืžืดื˜","link":"https://www.sefaria.org/Bekhorot.49a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื•"}},{"title":"27. ะฅะตัˆะฒะฐะฝ","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"hebdate","title_orig":"27 Cheshvan 5787","hebrew":"ื›ืดื– ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"ะ“ะปะฐะฒะฐ ะฅะฐะตะน ะกะฐั€ะฐ","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"parashat","title_orig":"Parashat Chayei Sara","hebrew":"ืคืจืฉืช ื—ื™ื™ ืฉืจื”","leyning":{"1":"Genesis 23:1-23:16","2":"Genesis 23:17-24:9","3":"Genesis 24:10-24:26","4":"Genesis 24:27-24:52","5":"Genesis 24:53-24:67","6":"Genesis 25:1-25:11","7":"Genesis 25:12-25:18","torah":"Genesis 23:1-25:18","haftarah":"I Kings 1:1-31","maftir":"Genesis 25:16-25:18","triennial":{"1":"Genesis 24:10-24:14","2":"Genesis 24:15-24:20","3":"Genesis 24:21-24:26","4":"Genesis 24:27-24:33","5":"Genesis 24:34-24:41","6":"Genesis 24:42-24:49","7":"Genesis 24:50-24:52","maftir":"Genesis 24:50-24:52"}},"link":"https://hebcal.com/s/5787i/5?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"ะผะตะฒะฐั€ั…ะธะผ ะะพะฒะพะผะตััั‡ัŒะตะผ ะšะธัะปะตะฒ","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Kislev","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ะšะธัะปะตะฒ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"},"memo":"Molad ะšะธัะปะตะฒ: Monday, 22:27 and 3 chalakim"},{"title":"ะ‘ะตั…ะพั€ะพั‚ 50","date":"2026-11-07","hdate":"27 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 50","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืณ","link":"https://www.sefaria.org/Bekhorot.50a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื–"}},{"title":"28. ะฅะตัˆะฒะฐะฝ","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"hebdate","title_orig":"28 Cheshvan 5787","hebrew":"ื›ืดื— ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 51","date":"2026-11-08","hdate":"28 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 51","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื","link":"https://www.sefaria.org/Bekhorot.51a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื—"}},{"title":"29. ะฅะตัˆะฒะฐะฝ","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"hebdate","title_orig":"29 Cheshvan 5787","hebrew":"ื›ืดื˜ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 52","date":"2026-11-09","hdate":"29 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 52","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื‘","link":"https://www.sefaria.org/Bekhorot.52a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ื›ืดื˜"}},{"title":"30. ะฅะตัˆะฒะฐะฝ","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"hebdate","title_orig":"30 Cheshvan 5787","hebrew":"ืœืณ ื—ืฉื•ืŸ","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 53","date":"2026-11-10","hdate":"30 Cheshvan 5787","category":"dafyomi","title_orig":"Bechorot 53","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื’","link":"https://www.sefaria.org/Bekhorot.53a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื—ืฉื•ืŸ","d":"ืœืณ"}},{"title":"1. ะšะธัะปะตะฒ, 5787","date":"2026-11-11","hdate":"1 Kislev 5787","category":"hebdate","title_orig":"1 Kislev 5787","hebrew":"ืืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 54","date":"2026-11-11","hdate":"1 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 54","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื“","link":"https://www.sefaria.org/Bekhorot.54a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืืณ"}},{"title":"2. ะšะธัะปะตะฒ","date":"2026-11-12","hdate":"2 Kislev 5787","category":"hebdate","title_orig":"2 Kislev 5787","hebrew":"ื‘ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 55","date":"2026-11-12","hdate":"2 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 55","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื”","link":"https://www.sefaria.org/Bekhorot.55a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื‘ืณ"}},{"title":"3. ะšะธัะปะตะฒ","date":"2026-11-13","hdate":"3 Kislev 5787","category":"hebdate","title_orig":"3 Kislev 5787","hebrew":"ื’ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 56","date":"2026-11-13","hdate":"3 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 56","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื•","link":"https://www.sefaria.org/Bekhorot.56a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื’ืณ"}},{"title":"4. ะšะธัะปะตะฒ","date":"2026-11-14","hdate":"4 Kislev 5787","category":"hebdate","title_orig":"4 Kislev 5787","hebrew":"ื“ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"ะ“ะปะฐะฒะฐ ะขะพะปะดะพั‚","date":"2026-11-14","hdate":"4 Kislev 5787","category":"parashat","title_orig":"Parashat Toldot","hebrew":"ืคืจืฉืช ืชื•ืœื“ื•ืช","leyning":{"1":"Genesis 25:19-26:5","2":"Genesis 26:6-26:12","3":"Genesis 26:13-26:22","4":"Genesis 26:23-26:29","5":"Genesis 26:30-27:27","6":"Genesis 27:28-28:4","7":"Genesis 28:5-28:9","torah":"Genesis 25:19-28:9","haftarah":"Malachi 1:1-2:7","maftir":"Genesis 28:7-28:9","triennial":{"1":"Genesis 26:23-26:29","2":"Genesis 26:30-26:33","3":"Genesis 26:34-27:4","4":"Genesis 27:5-27:13","5":"Genesis 27:14-27:17","6":"Genesis 27:18-27:23","7":"Genesis 27:24-27:27","maftir":"Genesis 27:24-27:27"}},"link":"https://hebcal.com/s/5787i/6?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 57","date":"2026-11-14","hdate":"4 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 57","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื–","link":"https://www.sefaria.org/Bekhorot.57a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื“ืณ"}},{"title":"5. ะšะธัะปะตะฒ","date":"2026-11-15","hdate":"5 Kislev 5787","category":"hebdate","title_orig":"5 Kislev 5787","hebrew":"ื”ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 58","date":"2026-11-15","hdate":"5 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 58","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื—","link":"https://www.sefaria.org/Bekhorot.58a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื”ืณ"}},{"title":"6. ะšะธัะปะตะฒ","date":"2026-11-16","hdate":"6 Kislev 5787","category":"hebdate","title_orig":"6 Kislev 5787","hebrew":"ื•ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 59","date":"2026-11-16","hdate":"6 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 59","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ื ืดื˜","link":"https://www.sefaria.org/Bekhorot.59a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื•ืณ"}},{"title":"7. ะšะธัะปะตะฒ","date":"2026-11-17","hdate":"7 Kislev 5787","category":"hebdate","title_orig":"7 Kislev 5787","hebrew":"ื–ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 60","date":"2026-11-17","hdate":"7 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 60","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืณ","link":"https://www.sefaria.org/Bekhorot.60a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื–ืณ"}},{"title":"8. ะšะธัะปะตะฒ","date":"2026-11-18","hdate":"8 Kislev 5787","category":"hebdate","title_orig":"8 Kislev 5787","hebrew":"ื—ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"ะ‘ะตั…ะพั€ะพั‚ 61","date":"2026-11-18","hdate":"8 Kislev 5787","category":"dafyomi","title_orig":"Bechorot 61","hebrew":"ื‘ื›ื•ืจื•ืช ื“ืฃ ืกืดื","link":"https://www.sefaria.org/Bekhorot.61a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื—ืณ"}},{"title":"9. ะšะธัะปะตะฒ","date":"2026-11-19","hdate":"9 Kislev 5787","category":"hebdate","title_orig":"9 Kislev 5787","hebrew":"ื˜ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"ะั€ะฐั…ะธะฝ 2","date":"2026-11-19","hdate":"9 Kislev 5787","category":"dafyomi","title_orig":"Arachin 2","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื‘ืณ","link":"https://www.sefaria.org/Arakhin.2a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืณ"}},{"title":"10. ะšะธัะปะตะฒ","date":"2026-11-20","hdate":"10 Kislev 5787","category":"hebdate","title_orig":"10 Kislev 5787","hebrew":"ื™ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"ะั€ะฐั…ะธะฝ 3","date":"2026-11-20","hdate":"10 Kislev 5787","category":"dafyomi","title_orig":"Arachin 3","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื’ืณ","link":"https://www.sefaria.org/Arakhin.3a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืณ"}},{"title":"11. ะšะธัะปะตะฒ","date":"2026-11-21","hdate":"11 Kislev 5787","category":"hebdate","title_orig":"11 Kislev 5787","hebrew":"ื™ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"ะ“ะปะฐะฒะฐ ะ’ะฐะตั†ะต","date":"2026-11-21","hdate":"11 Kislev 5787","category":"parashat","title_orig":"Parashat Vayetzei","hebrew":"ืคืจืฉืช ื•ื™ืฆื","leyning":{"1":"Genesis 28:10-28:22","2":"Genesis 29:1-29:17","3":"Genesis 29:18-30:13","4":"Genesis 30:14-30:27","5":"Genesis 30:28-31:16","6":"Genesis 31:17-31:42","7":"Genesis 31:43-32:3","torah":"Genesis 28:10-32:3","haftarah":"Hosea 12:13-14:10","haftarah_sephardic":"Hosea 11:7-12:12","maftir":"Genesis 32:1-32:3","triennial":{"1":"Genesis 30:14-30:16","2":"Genesis 30:17-30:21","3":"Genesis 30:22-30:27","4":"Genesis 30:28-30:36","5":"Genesis 30:37-30:43","6":"Genesis 31:1-31:9","7":"Genesis 31:10-31:16","maftir":"Genesis 31:14-31:16"}},"link":"https://hebcal.com/s/5787i/7?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"ะั€ะฐั…ะธะฝ 4","date":"2026-11-21","hdate":"11 Kislev 5787","category":"dafyomi","title_orig":"Arachin 4","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื“ืณ","link":"https://www.sefaria.org/Arakhin.4a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื"}},{"title":"12. ะšะธัะปะตะฒ","date":"2026-11-22","hdate":"12 Kislev 5787","category":"hebdate","title_orig":"12 Kislev 5787","hebrew":"ื™ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"ะั€ะฐั…ะธะฝ 5","date":"2026-11-22","hdate":"12 Kislev 5787","category":"dafyomi","title_orig":"Arachin 5","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื”ืณ","link":"https://www.sefaria.org/Arakhin.5a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื‘"}},{"title":"13. ะšะธัะปะตะฒ","date":"2026-11-23","hdate":"13 Kislev 5787","category":"hebdate","title_orig":"13 Kislev 5787","hebrew":"ื™ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"ะั€ะฐั…ะธะฝ 6","date":"2026-11-23","hdate":"13 Kislev 5787","category":"dafyomi","title_orig":"Arachin 6","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื•ืณ","link":"https://www.sefaria.org/Arakhin.6a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื’"}},{"title":"14. ะšะธัะปะตะฒ","date":"2026-11-24","hdate":"14 Kislev 5787","category":"hebdate","title_orig":"14 Kislev 5787","hebrew":"ื™ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"ะั€ะฐั…ะธะฝ 7","date":"2026-11-24","hdate":"14 Kislev 5787","category":"dafyomi","title_orig":"Arachin 7","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื–ืณ","link":"https://www.sefaria.org/Arakhin.7a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื“"}},{"title":"15. ะšะธัะปะตะฒ","date":"2026-11-25","hdate":"15 Kislev 5787","category":"hebdate","title_orig":"15 Kislev 5787","hebrew":"ื˜ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"ะั€ะฐั…ะธะฝ 8","date":"2026-11-25","hdate":"15 Kislev 5787","category":"dafyomi","title_orig":"Arachin 8","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื—ืณ","link":"https://www.sefaria.org/Arakhin.8a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื•"}},{"title":"16. ะšะธัะปะตะฒ","date":"2026-11-26","hdate":"16 Kislev 5787","category":"hebdate","title_orig":"16 Kislev 5787","hebrew":"ื˜ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"ะั€ะฐั…ะธะฝ 9","date":"2026-11-26","hdate":"16 Kislev 5787","category":"dafyomi","title_orig":"Arachin 9","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืณ","link":"https://www.sefaria.org/Arakhin.9a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื˜ืดื–"}},{"title":"17. ะšะธัะปะตะฒ","date":"2026-11-27","hdate":"17 Kislev 5787","category":"hebdate","title_orig":"17 Kislev 5787","hebrew":"ื™ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"ะั€ะฐั…ะธะฝ 10","date":"2026-11-27","hdate":"17 Kislev 5787","category":"dafyomi","title_orig":"Arachin 10","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืณ","link":"https://www.sefaria.org/Arakhin.10a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื–"}},{"title":"18. ะšะธัะปะตะฒ","date":"2026-11-28","hdate":"18 Kislev 5787","category":"hebdate","title_orig":"18 Kislev 5787","hebrew":"ื™ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"ะ“ะปะฐะฒะฐ ะ’ะฐะธัˆะปะฐั…","date":"2026-11-28","hdate":"18 Kislev 5787","category":"parashat","title_orig":"Parashat Vayishlach","hebrew":"ืคืจืฉืช ื•ื™ืฉืœื—","leyning":{"1":"Genesis 32:4-32:13","2":"Genesis 32:14-32:30","3":"Genesis 32:31-33:5","4":"Genesis 33:6-33:20","5":"Genesis 34:1-35:11","6":"Genesis 35:12-36:19","7":"Genesis 36:20-36:43","torah":"Genesis 32:4-36:43","haftarah":"Obadiah 1:1-21","maftir":"Genesis 36:40-36:43","triennial":{"1":"Genesis 34:1-34:4","2":"Genesis 34:5-34:12","3":"Genesis 34:13-34:17","4":"Genesis 34:18-34:23","5":"Genesis 34:24-34:31","6":"Genesis 35:1-35:11","7":"Genesis 35:12-35:15","maftir":"Genesis 35:12-35:15"}},"link":"https://hebcal.com/s/5787i/8?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"ะั€ะฐั…ะธะฝ 11","date":"2026-11-28","hdate":"18 Kislev 5787","category":"dafyomi","title_orig":"Arachin 11","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื","link":"https://www.sefaria.org/Arakhin.11a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื—"}},{"title":"19. ะšะธัะปะตะฒ","date":"2026-11-29","hdate":"19 Kislev 5787","category":"hebdate","title_orig":"19 Kislev 5787","hebrew":"ื™ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"ะั€ะฐั…ะธะฝ 12","date":"2026-11-29","hdate":"19 Kislev 5787","category":"dafyomi","title_orig":"Arachin 12","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื‘","link":"https://www.sefaria.org/Arakhin.12a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื™ืดื˜"}},{"title":"20. ะšะธัะปะตะฒ","date":"2026-11-30","hdate":"20 Kislev 5787","category":"hebdate","title_orig":"20 Kislev 5787","hebrew":"ื›ืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"ะั€ะฐั…ะธะฝ 13","date":"2026-11-30","hdate":"20 Kislev 5787","category":"dafyomi","title_orig":"Arachin 13","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื’","link":"https://www.sefaria.org/Arakhin.13a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืณ"}},{"title":"21. ะšะธัะปะตะฒ","date":"2026-12-01","hdate":"21 Kislev 5787","category":"hebdate","title_orig":"21 Kislev 5787","hebrew":"ื›ืดื ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"ะั€ะฐั…ะธะฝ 14","date":"2026-12-01","hdate":"21 Kislev 5787","category":"dafyomi","title_orig":"Arachin 14","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื“","link":"https://www.sefaria.org/Arakhin.14a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื"}},{"title":"22. ะšะธัะปะตะฒ","date":"2026-12-02","hdate":"22 Kislev 5787","category":"hebdate","title_orig":"22 Kislev 5787","hebrew":"ื›ืดื‘ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"ะั€ะฐั…ะธะฝ 15","date":"2026-12-02","hdate":"22 Kislev 5787","category":"dafyomi","title_orig":"Arachin 15","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื•","link":"https://www.sefaria.org/Arakhin.15a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื‘"}},{"title":"23. ะšะธัะปะตะฒ","date":"2026-12-03","hdate":"23 Kislev 5787","category":"hebdate","title_orig":"23 Kislev 5787","hebrew":"ื›ืดื’ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"ะั€ะฐั…ะธะฝ 16","date":"2026-12-03","hdate":"23 Kislev 5787","category":"dafyomi","title_orig":"Arachin 16","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื˜ืดื–","link":"https://www.sefaria.org/Arakhin.16a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื’"}},{"title":"24. ะšะธัะปะตะฒ","date":"2026-12-04","hdate":"24 Kislev 5787","category":"hebdate","title_orig":"24 Kislev 5787","hebrew":"ื›ืดื“ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"ะฅะฐะฝัƒะบะฐ: 1 ะกะฒะตั‡ะฐ","date":"2026-12-04","hdate":"24 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 1 Candle","hebrew":"ื—ื ื•ื›ื”: ืืณ ื ืจ","link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 17","date":"2026-12-04","hdate":"24 Kislev 5787","category":"dafyomi","title_orig":"Arachin 17","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื–","link":"https://www.sefaria.org/Arakhin.17a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื“"}},{"title":"25. ะšะธัะปะตะฒ","date":"2026-12-05","hdate":"25 Kislev 5787","category":"hebdate","title_orig":"25 Kislev 5787","hebrew":"ื›ืดื” ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"ะ“ะปะฐะฒะฐ ะ’ะฐะตัˆะตะฒ","date":"2026-12-05","hdate":"25 Kislev 5787","category":"parashat","title_orig":"Parashat Vayeshev","hebrew":"ืคืจืฉืช ื•ื™ืฉื‘","leyning":{"1":"Genesis 37:1-37:11","2":"Genesis 37:12-37:22","3":"Genesis 37:23-37:36","4":"Genesis 38:1-38:30","5":"Genesis 39:1-39:6","6":"Genesis 39:7-39:23","7":"Genesis 40:1-40:23","torah":"Genesis 37:1-40:23; Numbers 7:1-17","haftarah":"Zechariah 2:14-4:7 | Chanukah Day 1 (on Shabbat)","maftir":"Numbers 7:1-7:17 | Chanukah Day 1 (on Shabbat)","triennial":{"1":"Genesis 38:1-38:5","2":"Genesis 38:6-38:11","3":"Genesis 38:12-38:14","4":"Genesis 38:15-38:19","5":"Genesis 38:20-38:23","6":"Genesis 38:24-38:26","7":"Genesis 38:27-38:30","maftir":"Numbers 7:1-7:17"}},"link":"https://hebcal.com/s/5787i/9?us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"ะผะตะฒะฐั€ั…ะธะผ ะะพะฒะพะผะตััั‡ัŒะตะผ ะขะตะฒะตั‚","date":"2026-12-05","hdate":"25 Kislev 5787","category":"mevarchim","title_orig":"Shabbat Mevarchim Chodesh Tevet","hebrew":"ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ะขะตะฒะตั‚","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Molad ะขะตะฒะตั‚: Wednesday, 11:11 and 4 chalakim"},{"title":"ะฅะฐะฝัƒะบะฐ: 2 ะกะฒะตั‡ะธ","date":"2026-12-05","hdate":"25 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 2 Candles","hebrew":"ื—ื ื•ื›ื”: ื‘ืณ ื ืจื•ืช","leyning":{"haftarah":"Zechariah 2:14-4:7","maftir":"Numbers 7:1-7:17"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 18","date":"2026-12-05","hdate":"25 Kislev 5787","category":"dafyomi","title_orig":"Arachin 18","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื—","link":"https://www.sefaria.org/Arakhin.18a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื”"}},{"title":"26. ะšะธัะปะตะฒ","date":"2026-12-06","hdate":"26 Kislev 5787","category":"hebdate","title_orig":"26 Kislev 5787","hebrew":"ื›ืดื• ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"ะฅะฐะฝัƒะบะฐ: 3 ะกะฒะตั‡ะธ","date":"2026-12-06","hdate":"26 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 3 Candles","hebrew":"ื—ื ื•ื›ื”: ื’ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:18-7:20","2":"Numbers 7:21-7:23","3":"Numbers 7:24-7:29","torah":"Numbers 7:18-29"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 19","date":"2026-12-06","hdate":"26 Kislev 5787","category":"dafyomi","title_orig":"Arachin 19","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื™ืดื˜","link":"https://www.sefaria.org/Arakhin.19a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื•"}},{"title":"27. ะšะธัะปะตะฒ","date":"2026-12-07","hdate":"27 Kislev 5787","category":"hebdate","title_orig":"27 Kislev 5787","hebrew":"ื›ืดื– ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"ะฅะฐะฝัƒะบะฐ: 4 ะกะฒะตั‡ะธ","date":"2026-12-07","hdate":"27 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 4 Candles","hebrew":"ื—ื ื•ื›ื”: ื“ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:24-7:26","2":"Numbers 7:27-7:29","3":"Numbers 7:30-7:35","torah":"Numbers 7:24-35"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 20","date":"2026-12-07","hdate":"27 Kislev 5787","category":"dafyomi","title_orig":"Arachin 20","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืณ","link":"https://www.sefaria.org/Arakhin.20a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื–"}},{"title":"28. ะšะธัะปะตะฒ","date":"2026-12-08","hdate":"28 Kislev 5787","category":"hebdate","title_orig":"28 Kislev 5787","hebrew":"ื›ืดื— ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"ะฅะฐะฝัƒะบะฐ: 5 ะกะฒะตั‡ะตะน","date":"2026-12-08","hdate":"28 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 5 Candles","hebrew":"ื—ื ื•ื›ื”: ื”ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:30-7:32","2":"Numbers 7:33-7:35","3":"Numbers 7:36-7:41","torah":"Numbers 7:30-41"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 21","date":"2026-12-08","hdate":"28 Kislev 5787","category":"dafyomi","title_orig":"Arachin 21","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื","link":"https://www.sefaria.org/Arakhin.21a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื—"}},{"title":"29. ะšะธัะปะตะฒ","date":"2026-12-09","hdate":"29 Kislev 5787","category":"hebdate","title_orig":"29 Kislev 5787","hebrew":"ื›ืดื˜ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"ะฅะฐะฝัƒะบะฐ: 6 ะกะฒะตั‡ะตะน","date":"2026-12-09","hdate":"29 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 6 Candles","hebrew":"ื—ื ื•ื›ื”: ื•ืณ ื ืจื•ืช","leyning":{"1":"Numbers 7:36-7:38","2":"Numbers 7:39-7:41","3":"Numbers 7:42-7:47","torah":"Numbers 7:36-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 22","date":"2026-12-09","hdate":"29 Kislev 5787","category":"dafyomi","title_orig":"Arachin 22","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื‘","link":"https://www.sefaria.org/Arakhin.22a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ื›ืดื˜"}},{"title":"30. ะšะธัะปะตะฒ","date":"2026-12-10","hdate":"30 Kislev 5787","category":"hebdate","title_orig":"30 Kislev 5787","hebrew":"ืœืณ ื›ืกืœื•","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}},{"title":"ะฅะฐะณ ั…ะฐ-ะ‘ะฐะฝะพั‚","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"minor","title_orig":"Chag HaBanot","hebrew":"ื—ื’ ื”ื‘ื ื•ืช","link":"https://hebcal.com/h/chag-habanot-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"North African Chanukah festival of daughters. Called Eid al-Banat in Arabic, the holiday was most preserved in Tunisia"},{"title":"ะฅะฐะฝัƒะบะฐ: 7 ะกะฒะตั‡ะตะน","date":"2026-12-10","hdate":"30 Kislev 5787","category":"holiday","subcat":"major","title_orig":"Chanukah: 7 Candles","hebrew":"ื—ื ื•ื›ื”: ื–ืณ ื ืจื•ืช","leyning":{"1":"Numbers 28:1-28:5","2":"Numbers 28:6-28:10","3":"Numbers 28:11-28:15","4":"Numbers 7:42-7:47","torah":"Numbers 28:1-15, 7:42-47"},"link":"https://hebcal.com/h/chanukah-2026?i=on&us=js&um=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"},"memo":"Hanukkah, the Jewish festival of rededication. Also known as the Festival of Lights, the eight-day festival is observed by lighting the candles of a hanukkiah (menorah)"},{"title":"ะั€ะฐั…ะธะฝ 23","date":"2026-12-10","hdate":"30 Kislev 5787","category":"dafyomi","title_orig":"Arachin 23","hebrew":"ืขืจื›ื™ืŸ ื“ืฃ ื›ืดื’","link":"https://www.sefaria.org/Arakhin.23a?lang=bi&utm_source=hebcal.com&utm_medium=api","heDateParts":{"y":"ืชืฉืคืดื–","m":"ื›ืกืœื•","d":"ืœืณ"}}]} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-solar-2026-04.json b/tests/Fixtures/hebcal-jerusalem-full-solar-2026-04.json new file mode 100644 index 0000000..cf3d413 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-solar-2026-04.json @@ -0,0 +1 @@ +{"date":{"start":"2026-03-24","end":"2026-06-09"},"version":"v1.14.2","location":{"title":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","city":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","tzid":"Asia/Jerusalem","latitude":31.76904,"longitude":35.21633,"geo":"pos"},"times":{"chatzotNight":{"2026-03-24":"2026-03-23T23:45:00+02:00","2026-03-25":"2026-03-24T23:45:00+02:00","2026-03-26":"2026-03-25T23:45:00+02:00","2026-03-27":"2026-03-26T23:44:00+02:00","2026-03-28":"2026-03-28T00:44:00+03:00","2026-03-29":"2026-03-29T00:44:00+03:00","2026-03-30":"2026-03-30T00:44:00+03:00","2026-03-31":"2026-03-31T00:43:00+03:00","2026-04-01":"2026-04-01T00:43:00+03:00","2026-04-02":"2026-04-02T00:43:00+03:00","2026-04-03":"2026-04-03T00:42:00+03:00","2026-04-04":"2026-04-04T00:42:00+03:00","2026-04-05":"2026-04-05T00:42:00+03:00","2026-04-06":"2026-04-06T00:41:00+03:00","2026-04-07":"2026-04-07T00:41:00+03:00","2026-04-08":"2026-04-08T00:41:00+03:00","2026-04-09":"2026-04-09T00:41:00+03:00","2026-04-10":"2026-04-10T00:40:00+03:00","2026-04-11":"2026-04-11T00:40:00+03:00","2026-04-12":"2026-04-12T00:40:00+03:00","2026-04-13":"2026-04-13T00:40:00+03:00","2026-04-14":"2026-04-14T00:39:00+03:00","2026-04-15":"2026-04-15T00:39:00+03:00","2026-04-16":"2026-04-16T00:39:00+03:00","2026-04-17":"2026-04-17T00:39:00+03:00","2026-04-18":"2026-04-18T00:38:00+03:00","2026-04-19":"2026-04-19T00:38:00+03:00","2026-04-20":"2026-04-20T00:38:00+03:00","2026-04-21":"2026-04-21T00:38:00+03:00","2026-04-22":"2026-04-22T00:38:00+03:00","2026-04-23":"2026-04-23T00:37:00+03:00","2026-04-24":"2026-04-24T00:37:00+03:00","2026-04-25":"2026-04-25T00:37:00+03:00","2026-04-26":"2026-04-26T00:37:00+03:00","2026-04-27":"2026-04-27T00:37:00+03:00","2026-04-28":"2026-04-28T00:37:00+03:00","2026-04-29":"2026-04-29T00:36:00+03:00","2026-04-30":"2026-04-30T00:36:00+03:00","2026-05-01":"2026-05-01T00:36:00+03:00","2026-05-02":"2026-05-02T00:36:00+03:00","2026-05-03":"2026-05-03T00:36:00+03:00","2026-05-04":"2026-05-04T00:36:00+03:00","2026-05-05":"2026-05-05T00:36:00+03:00","2026-05-06":"2026-05-06T00:36:00+03:00","2026-05-07":"2026-05-07T00:36:00+03:00","2026-05-08":"2026-05-08T00:36:00+03:00","2026-05-09":"2026-05-09T00:35:00+03:00","2026-05-10":"2026-05-10T00:35:00+03:00","2026-05-11":"2026-05-11T00:35:00+03:00","2026-05-12":"2026-05-12T00:35:00+03:00","2026-05-13":"2026-05-13T00:35:00+03:00","2026-05-14":"2026-05-14T00:35:00+03:00","2026-05-15":"2026-05-15T00:35:00+03:00","2026-05-16":"2026-05-16T00:35:00+03:00","2026-05-17":"2026-05-17T00:35:00+03:00","2026-05-18":"2026-05-18T00:35:00+03:00","2026-05-19":"2026-05-19T00:35:00+03:00","2026-05-20":"2026-05-20T00:36:00+03:00","2026-05-21":"2026-05-21T00:36:00+03:00","2026-05-22":"2026-05-22T00:36:00+03:00","2026-05-23":"2026-05-23T00:36:00+03:00","2026-05-24":"2026-05-24T00:36:00+03:00","2026-05-25":"2026-05-25T00:36:00+03:00","2026-05-26":"2026-05-26T00:36:00+03:00","2026-05-27":"2026-05-27T00:36:00+03:00","2026-05-28":"2026-05-28T00:36:00+03:00","2026-05-29":"2026-05-29T00:36:00+03:00","2026-05-30":"2026-05-30T00:37:00+03:00","2026-05-31":"2026-05-31T00:37:00+03:00","2026-06-01":"2026-06-01T00:37:00+03:00","2026-06-02":"2026-06-02T00:37:00+03:00","2026-06-03":"2026-06-03T00:37:00+03:00","2026-06-04":"2026-06-04T00:37:00+03:00","2026-06-05":"2026-06-05T00:37:00+03:00","2026-06-06":"2026-06-06T00:38:00+03:00","2026-06-07":"2026-06-07T00:38:00+03:00","2026-06-08":"2026-06-08T00:38:00+03:00","2026-06-09":"2026-06-09T00:38:00+03:00"},"alosBaalHatanya":{"2026-03-24":"2026-03-24T04:22:00+02:00","2026-03-25":"2026-03-25T04:20:00+02:00","2026-03-26":"2026-03-26T04:19:00+02:00","2026-03-27":"2026-03-27T05:18:00+03:00","2026-03-28":"2026-03-28T05:16:00+03:00","2026-03-29":"2026-03-29T05:15:00+03:00","2026-03-30":"2026-03-30T05:14:00+03:00","2026-03-31":"2026-03-31T05:12:00+03:00","2026-04-01":"2026-04-01T05:11:00+03:00","2026-04-02":"2026-04-02T05:09:00+03:00","2026-04-03":"2026-04-03T05:08:00+03:00","2026-04-04":"2026-04-04T05:07:00+03:00","2026-04-05":"2026-04-05T05:05:00+03:00","2026-04-06":"2026-04-06T05:04:00+03:00","2026-04-07":"2026-04-07T05:03:00+03:00","2026-04-08":"2026-04-08T05:01:00+03:00","2026-04-09":"2026-04-09T05:00:00+03:00","2026-04-10":"2026-04-10T04:58:00+03:00","2026-04-11":"2026-04-11T04:57:00+03:00","2026-04-12":"2026-04-12T04:56:00+03:00","2026-04-13":"2026-04-13T04:54:00+03:00","2026-04-14":"2026-04-14T04:53:00+03:00","2026-04-15":"2026-04-15T04:52:00+03:00","2026-04-16":"2026-04-16T04:50:00+03:00","2026-04-17":"2026-04-17T04:49:00+03:00","2026-04-18":"2026-04-18T04:47:00+03:00","2026-04-19":"2026-04-19T04:46:00+03:00","2026-04-20":"2026-04-20T04:45:00+03:00","2026-04-21":"2026-04-21T04:43:00+03:00","2026-04-22":"2026-04-22T04:42:00+03:00","2026-04-23":"2026-04-23T04:41:00+03:00","2026-04-24":"2026-04-24T04:40:00+03:00","2026-04-25":"2026-04-25T04:38:00+03:00","2026-04-26":"2026-04-26T04:37:00+03:00","2026-04-27":"2026-04-27T04:36:00+03:00","2026-04-28":"2026-04-28T04:35:00+03:00","2026-04-29":"2026-04-29T04:33:00+03:00","2026-04-30":"2026-04-30T04:32:00+03:00","2026-05-01":"2026-05-01T04:31:00+03:00","2026-05-02":"2026-05-02T04:30:00+03:00","2026-05-03":"2026-05-03T04:28:00+03:00","2026-05-04":"2026-05-04T04:27:00+03:00","2026-05-05":"2026-05-05T04:26:00+03:00","2026-05-06":"2026-05-06T04:25:00+03:00","2026-05-07":"2026-05-07T04:24:00+03:00","2026-05-08":"2026-05-08T04:23:00+03:00","2026-05-09":"2026-05-09T04:22:00+03:00","2026-05-10":"2026-05-10T04:21:00+03:00","2026-05-11":"2026-05-11T04:20:00+03:00","2026-05-12":"2026-05-12T04:19:00+03:00","2026-05-13":"2026-05-13T04:18:00+03:00","2026-05-14":"2026-05-14T04:17:00+03:00","2026-05-15":"2026-05-15T04:16:00+03:00","2026-05-16":"2026-05-16T04:15:00+03:00","2026-05-17":"2026-05-17T04:14:00+03:00","2026-05-18":"2026-05-18T04:13:00+03:00","2026-05-19":"2026-05-19T04:12:00+03:00","2026-05-20":"2026-05-20T04:11:00+03:00","2026-05-21":"2026-05-21T04:11:00+03:00","2026-05-22":"2026-05-22T04:10:00+03:00","2026-05-23":"2026-05-23T04:09:00+03:00","2026-05-24":"2026-05-24T04:08:00+03:00","2026-05-25":"2026-05-25T04:08:00+03:00","2026-05-26":"2026-05-26T04:07:00+03:00","2026-05-27":"2026-05-27T04:06:00+03:00","2026-05-28":"2026-05-28T04:06:00+03:00","2026-05-29":"2026-05-29T04:05:00+03:00","2026-05-30":"2026-05-30T04:05:00+03:00","2026-05-31":"2026-05-31T04:04:00+03:00","2026-06-01":"2026-06-01T04:04:00+03:00","2026-06-02":"2026-06-02T04:03:00+03:00","2026-06-03":"2026-06-03T04:03:00+03:00","2026-06-04":"2026-06-04T04:03:00+03:00","2026-06-05":"2026-06-05T04:02:00+03:00","2026-06-06":"2026-06-06T04:02:00+03:00","2026-06-07":"2026-06-07T04:02:00+03:00","2026-06-08":"2026-06-08T04:01:00+03:00","2026-06-09":"2026-06-09T04:01:00+03:00"},"alotHaShachar":{"2026-03-24":"2026-03-24T04:26:00+02:00","2026-03-25":"2026-03-25T04:24:00+02:00","2026-03-26":"2026-03-26T04:23:00+02:00","2026-03-27":"2026-03-27T05:22:00+03:00","2026-03-28":"2026-03-28T05:20:00+03:00","2026-03-29":"2026-03-29T05:19:00+03:00","2026-03-30":"2026-03-30T05:18:00+03:00","2026-03-31":"2026-03-31T05:16:00+03:00","2026-04-01":"2026-04-01T05:15:00+03:00","2026-04-02":"2026-04-02T05:13:00+03:00","2026-04-03":"2026-04-03T05:12:00+03:00","2026-04-04":"2026-04-04T05:11:00+03:00","2026-04-05":"2026-04-05T05:09:00+03:00","2026-04-06":"2026-04-06T05:08:00+03:00","2026-04-07":"2026-04-07T05:06:00+03:00","2026-04-08":"2026-04-08T05:05:00+03:00","2026-04-09":"2026-04-09T05:04:00+03:00","2026-04-10":"2026-04-10T05:02:00+03:00","2026-04-11":"2026-04-11T05:01:00+03:00","2026-04-12":"2026-04-12T05:00:00+03:00","2026-04-13":"2026-04-13T04:58:00+03:00","2026-04-14":"2026-04-14T04:57:00+03:00","2026-04-15":"2026-04-15T04:56:00+03:00","2026-04-16":"2026-04-16T04:54:00+03:00","2026-04-17":"2026-04-17T04:53:00+03:00","2026-04-18":"2026-04-18T04:52:00+03:00","2026-04-19":"2026-04-19T04:50:00+03:00","2026-04-20":"2026-04-20T04:49:00+03:00","2026-04-21":"2026-04-21T04:48:00+03:00","2026-04-22":"2026-04-22T04:46:00+03:00","2026-04-23":"2026-04-23T04:45:00+03:00","2026-04-24":"2026-04-24T04:44:00+03:00","2026-04-25":"2026-04-25T04:43:00+03:00","2026-04-26":"2026-04-26T04:41:00+03:00","2026-04-27":"2026-04-27T04:40:00+03:00","2026-04-28":"2026-04-28T04:39:00+03:00","2026-04-29":"2026-04-29T04:38:00+03:00","2026-04-30":"2026-04-30T04:36:00+03:00","2026-05-01":"2026-05-01T04:35:00+03:00","2026-05-02":"2026-05-02T04:34:00+03:00","2026-05-03":"2026-05-03T04:33:00+03:00","2026-05-04":"2026-05-04T04:32:00+03:00","2026-05-05":"2026-05-05T04:31:00+03:00","2026-05-06":"2026-05-06T04:29:00+03:00","2026-05-07":"2026-05-07T04:28:00+03:00","2026-05-08":"2026-05-08T04:27:00+03:00","2026-05-09":"2026-05-09T04:26:00+03:00","2026-05-10":"2026-05-10T04:25:00+03:00","2026-05-11":"2026-05-11T04:24:00+03:00","2026-05-12":"2026-05-12T04:23:00+03:00","2026-05-13":"2026-05-13T04:22:00+03:00","2026-05-14":"2026-05-14T04:21:00+03:00","2026-05-15":"2026-05-15T04:20:00+03:00","2026-05-16":"2026-05-16T04:20:00+03:00","2026-05-17":"2026-05-17T04:19:00+03:00","2026-05-18":"2026-05-18T04:18:00+03:00","2026-05-19":"2026-05-19T04:17:00+03:00","2026-05-20":"2026-05-20T04:16:00+03:00","2026-05-21":"2026-05-21T04:15:00+03:00","2026-05-22":"2026-05-22T04:15:00+03:00","2026-05-23":"2026-05-23T04:14:00+03:00","2026-05-24":"2026-05-24T04:13:00+03:00","2026-05-25":"2026-05-25T04:13:00+03:00","2026-05-26":"2026-05-26T04:12:00+03:00","2026-05-27":"2026-05-27T04:11:00+03:00","2026-05-28":"2026-05-28T04:11:00+03:00","2026-05-29":"2026-05-29T04:10:00+03:00","2026-05-30":"2026-05-30T04:10:00+03:00","2026-05-31":"2026-05-31T04:09:00+03:00","2026-06-01":"2026-06-01T04:09:00+03:00","2026-06-02":"2026-06-02T04:08:00+03:00","2026-06-03":"2026-06-03T04:08:00+03:00","2026-06-04":"2026-06-04T04:08:00+03:00","2026-06-05":"2026-06-05T04:07:00+03:00","2026-06-06":"2026-06-06T04:07:00+03:00","2026-06-07":"2026-06-07T04:07:00+03:00","2026-06-08":"2026-06-08T04:06:00+03:00","2026-06-09":"2026-06-09T04:06:00+03:00"},"misheyakir":{"2026-03-24":"2026-03-24T04:48:00+02:00","2026-03-25":"2026-03-25T04:46:00+02:00","2026-03-26":"2026-03-26T04:45:00+02:00","2026-03-27":"2026-03-27T05:44:00+03:00","2026-03-28":"2026-03-28T05:42:00+03:00","2026-03-29":"2026-03-29T05:41:00+03:00","2026-03-30":"2026-03-30T05:40:00+03:00","2026-03-31":"2026-03-31T05:38:00+03:00","2026-04-01":"2026-04-01T05:37:00+03:00","2026-04-02":"2026-04-02T05:36:00+03:00","2026-04-03":"2026-04-03T05:34:00+03:00","2026-04-04":"2026-04-04T05:33:00+03:00","2026-04-05":"2026-04-05T05:32:00+03:00","2026-04-06":"2026-04-06T05:30:00+03:00","2026-04-07":"2026-04-07T05:29:00+03:00","2026-04-08":"2026-04-08T05:28:00+03:00","2026-04-09":"2026-04-09T05:27:00+03:00","2026-04-10":"2026-04-10T05:25:00+03:00","2026-04-11":"2026-04-11T05:24:00+03:00","2026-04-12":"2026-04-12T05:23:00+03:00","2026-04-13":"2026-04-13T05:21:00+03:00","2026-04-14":"2026-04-14T05:20:00+03:00","2026-04-15":"2026-04-15T05:19:00+03:00","2026-04-16":"2026-04-16T05:17:00+03:00","2026-04-17":"2026-04-17T05:16:00+03:00","2026-04-18":"2026-04-18T05:15:00+03:00","2026-04-19":"2026-04-19T05:14:00+03:00","2026-04-20":"2026-04-20T05:12:00+03:00","2026-04-21":"2026-04-21T05:11:00+03:00","2026-04-22":"2026-04-22T05:10:00+03:00","2026-04-23":"2026-04-23T05:09:00+03:00","2026-04-24":"2026-04-24T05:08:00+03:00","2026-04-25":"2026-04-25T05:06:00+03:00","2026-04-26":"2026-04-26T05:05:00+03:00","2026-04-27":"2026-04-27T05:04:00+03:00","2026-04-28":"2026-04-28T05:03:00+03:00","2026-04-29":"2026-04-29T05:02:00+03:00","2026-04-30":"2026-04-30T05:01:00+03:00","2026-05-01":"2026-05-01T05:00:00+03:00","2026-05-02":"2026-05-02T04:59:00+03:00","2026-05-03":"2026-05-03T04:58:00+03:00","2026-05-04":"2026-05-04T04:56:00+03:00","2026-05-05":"2026-05-05T04:55:00+03:00","2026-05-06":"2026-05-06T04:54:00+03:00","2026-05-07":"2026-05-07T04:53:00+03:00","2026-05-08":"2026-05-08T04:52:00+03:00","2026-05-09":"2026-05-09T04:51:00+03:00","2026-05-10":"2026-05-10T04:51:00+03:00","2026-05-11":"2026-05-11T04:50:00+03:00","2026-05-12":"2026-05-12T04:49:00+03:00","2026-05-13":"2026-05-13T04:48:00+03:00","2026-05-14":"2026-05-14T04:47:00+03:00","2026-05-15":"2026-05-15T04:46:00+03:00","2026-05-16":"2026-05-16T04:45:00+03:00","2026-05-17":"2026-05-17T04:45:00+03:00","2026-05-18":"2026-05-18T04:44:00+03:00","2026-05-19":"2026-05-19T04:43:00+03:00","2026-05-20":"2026-05-20T04:42:00+03:00","2026-05-21":"2026-05-21T04:42:00+03:00","2026-05-22":"2026-05-22T04:41:00+03:00","2026-05-23":"2026-05-23T04:40:00+03:00","2026-05-24":"2026-05-24T04:40:00+03:00","2026-05-25":"2026-05-25T04:39:00+03:00","2026-05-26":"2026-05-26T04:39:00+03:00","2026-05-27":"2026-05-27T04:38:00+03:00","2026-05-28":"2026-05-28T04:38:00+03:00","2026-05-29":"2026-05-29T04:37:00+03:00","2026-05-30":"2026-05-30T04:37:00+03:00","2026-05-31":"2026-05-31T04:36:00+03:00","2026-06-01":"2026-06-01T04:36:00+03:00","2026-06-02":"2026-06-02T04:36:00+03:00","2026-06-03":"2026-06-03T04:35:00+03:00","2026-06-04":"2026-06-04T04:35:00+03:00","2026-06-05":"2026-06-05T04:35:00+03:00","2026-06-06":"2026-06-06T04:34:00+03:00","2026-06-07":"2026-06-07T04:34:00+03:00","2026-06-08":"2026-06-08T04:34:00+03:00","2026-06-09":"2026-06-09T04:34:00+03:00"},"misheyakirMachmir":{"2026-03-24":"2026-03-24T04:54:00+02:00","2026-03-25":"2026-03-25T04:53:00+02:00","2026-03-26":"2026-03-26T04:51:00+02:00","2026-03-27":"2026-03-27T05:50:00+03:00","2026-03-28":"2026-03-28T05:49:00+03:00","2026-03-29":"2026-03-29T05:47:00+03:00","2026-03-30":"2026-03-30T05:46:00+03:00","2026-03-31":"2026-03-31T05:45:00+03:00","2026-04-01":"2026-04-01T05:43:00+03:00","2026-04-02":"2026-04-02T05:42:00+03:00","2026-04-03":"2026-04-03T05:41:00+03:00","2026-04-04":"2026-04-04T05:39:00+03:00","2026-04-05":"2026-04-05T05:38:00+03:00","2026-04-06":"2026-04-06T05:37:00+03:00","2026-04-07":"2026-04-07T05:35:00+03:00","2026-04-08":"2026-04-08T05:34:00+03:00","2026-04-09":"2026-04-09T05:33:00+03:00","2026-04-10":"2026-04-10T05:32:00+03:00","2026-04-11":"2026-04-11T05:30:00+03:00","2026-04-12":"2026-04-12T05:29:00+03:00","2026-04-13":"2026-04-13T05:28:00+03:00","2026-04-14":"2026-04-14T05:26:00+03:00","2026-04-15":"2026-04-15T05:25:00+03:00","2026-04-16":"2026-04-16T05:24:00+03:00","2026-04-17":"2026-04-17T05:23:00+03:00","2026-04-18":"2026-04-18T05:21:00+03:00","2026-04-19":"2026-04-19T05:20:00+03:00","2026-04-20":"2026-04-20T05:19:00+03:00","2026-04-21":"2026-04-21T05:18:00+03:00","2026-04-22":"2026-04-22T05:17:00+03:00","2026-04-23":"2026-04-23T05:15:00+03:00","2026-04-24":"2026-04-24T05:14:00+03:00","2026-04-25":"2026-04-25T05:13:00+03:00","2026-04-26":"2026-04-26T05:12:00+03:00","2026-04-27":"2026-04-27T05:11:00+03:00","2026-04-28":"2026-04-28T05:10:00+03:00","2026-04-29":"2026-04-29T05:09:00+03:00","2026-04-30":"2026-04-30T05:07:00+03:00","2026-05-01":"2026-05-01T05:06:00+03:00","2026-05-02":"2026-05-02T05:05:00+03:00","2026-05-03":"2026-05-03T05:04:00+03:00","2026-05-04":"2026-05-04T05:03:00+03:00","2026-05-05":"2026-05-05T05:02:00+03:00","2026-05-06":"2026-05-06T05:01:00+03:00","2026-05-07":"2026-05-07T05:00:00+03:00","2026-05-08":"2026-05-08T04:59:00+03:00","2026-05-09":"2026-05-09T04:58:00+03:00","2026-05-10":"2026-05-10T04:58:00+03:00","2026-05-11":"2026-05-11T04:57:00+03:00","2026-05-12":"2026-05-12T04:56:00+03:00","2026-05-13":"2026-05-13T04:55:00+03:00","2026-05-14":"2026-05-14T04:54:00+03:00","2026-05-15":"2026-05-15T04:53:00+03:00","2026-05-16":"2026-05-16T04:52:00+03:00","2026-05-17":"2026-05-17T04:52:00+03:00","2026-05-18":"2026-05-18T04:51:00+03:00","2026-05-19":"2026-05-19T04:50:00+03:00","2026-05-20":"2026-05-20T04:50:00+03:00","2026-05-21":"2026-05-21T04:49:00+03:00","2026-05-22":"2026-05-22T04:48:00+03:00","2026-05-23":"2026-05-23T04:48:00+03:00","2026-05-24":"2026-05-24T04:47:00+03:00","2026-05-25":"2026-05-25T04:47:00+03:00","2026-05-26":"2026-05-26T04:46:00+03:00","2026-05-27":"2026-05-27T04:46:00+03:00","2026-05-28":"2026-05-28T04:45:00+03:00","2026-05-29":"2026-05-29T04:45:00+03:00","2026-05-30":"2026-05-30T04:44:00+03:00","2026-05-31":"2026-05-31T04:44:00+03:00","2026-06-01":"2026-06-01T04:43:00+03:00","2026-06-02":"2026-06-02T04:43:00+03:00","2026-06-03":"2026-06-03T04:43:00+03:00","2026-06-04":"2026-06-04T04:42:00+03:00","2026-06-05":"2026-06-05T04:42:00+03:00","2026-06-06":"2026-06-06T04:42:00+03:00","2026-06-07":"2026-06-07T04:42:00+03:00","2026-06-08":"2026-06-08T04:42:00+03:00","2026-06-09":"2026-06-09T04:41:00+03:00"},"dawn":{"2026-03-24":"2026-03-24T05:14:00+02:00","2026-03-25":"2026-03-25T05:12:00+02:00","2026-03-26":"2026-03-26T05:11:00+02:00","2026-03-27":"2026-03-27T06:10:00+03:00","2026-03-28":"2026-03-28T06:09:00+03:00","2026-03-29":"2026-03-29T06:07:00+03:00","2026-03-30":"2026-03-30T06:06:00+03:00","2026-03-31":"2026-03-31T06:05:00+03:00","2026-04-01":"2026-04-01T06:03:00+03:00","2026-04-02":"2026-04-02T06:02:00+03:00","2026-04-03":"2026-04-03T06:01:00+03:00","2026-04-04":"2026-04-04T06:00:00+03:00","2026-04-05":"2026-04-05T05:58:00+03:00","2026-04-06":"2026-04-06T05:57:00+03:00","2026-04-07":"2026-04-07T05:56:00+03:00","2026-04-08":"2026-04-08T05:54:00+03:00","2026-04-09":"2026-04-09T05:53:00+03:00","2026-04-10":"2026-04-10T05:52:00+03:00","2026-04-11":"2026-04-11T05:51:00+03:00","2026-04-12":"2026-04-12T05:49:00+03:00","2026-04-13":"2026-04-13T05:48:00+03:00","2026-04-14":"2026-04-14T05:47:00+03:00","2026-04-15":"2026-04-15T05:46:00+03:00","2026-04-16":"2026-04-16T05:45:00+03:00","2026-04-17":"2026-04-17T05:43:00+03:00","2026-04-18":"2026-04-18T05:42:00+03:00","2026-04-19":"2026-04-19T05:41:00+03:00","2026-04-20":"2026-04-20T05:40:00+03:00","2026-04-21":"2026-04-21T05:39:00+03:00","2026-04-22":"2026-04-22T05:38:00+03:00","2026-04-23":"2026-04-23T05:36:00+03:00","2026-04-24":"2026-04-24T05:35:00+03:00","2026-04-25":"2026-04-25T05:34:00+03:00","2026-04-26":"2026-04-26T05:33:00+03:00","2026-04-27":"2026-04-27T05:32:00+03:00","2026-04-28":"2026-04-28T05:31:00+03:00","2026-04-29":"2026-04-29T05:30:00+03:00","2026-04-30":"2026-04-30T05:29:00+03:00","2026-05-01":"2026-05-01T05:28:00+03:00","2026-05-02":"2026-05-02T05:27:00+03:00","2026-05-03":"2026-05-03T05:26:00+03:00","2026-05-04":"2026-05-04T05:25:00+03:00","2026-05-05":"2026-05-05T05:24:00+03:00","2026-05-06":"2026-05-06T05:23:00+03:00","2026-05-07":"2026-05-07T05:22:00+03:00","2026-05-08":"2026-05-08T05:21:00+03:00","2026-05-09":"2026-05-09T05:20:00+03:00","2026-05-10":"2026-05-10T05:20:00+03:00","2026-05-11":"2026-05-11T05:19:00+03:00","2026-05-12":"2026-05-12T05:18:00+03:00","2026-05-13":"2026-05-13T05:17:00+03:00","2026-05-14":"2026-05-14T05:16:00+03:00","2026-05-15":"2026-05-15T05:16:00+03:00","2026-05-16":"2026-05-16T05:15:00+03:00","2026-05-17":"2026-05-17T05:14:00+03:00","2026-05-18":"2026-05-18T05:14:00+03:00","2026-05-19":"2026-05-19T05:13:00+03:00","2026-05-20":"2026-05-20T05:12:00+03:00","2026-05-21":"2026-05-21T05:12:00+03:00","2026-05-22":"2026-05-22T05:11:00+03:00","2026-05-23":"2026-05-23T05:11:00+03:00","2026-05-24":"2026-05-24T05:10:00+03:00","2026-05-25":"2026-05-25T05:10:00+03:00","2026-05-26":"2026-05-26T05:09:00+03:00","2026-05-27":"2026-05-27T05:09:00+03:00","2026-05-28":"2026-05-28T05:08:00+03:00","2026-05-29":"2026-05-29T05:08:00+03:00","2026-05-30":"2026-05-30T05:07:00+03:00","2026-05-31":"2026-05-31T05:07:00+03:00","2026-06-01":"2026-06-01T05:07:00+03:00","2026-06-02":"2026-06-02T05:06:00+03:00","2026-06-03":"2026-06-03T05:06:00+03:00","2026-06-04":"2026-06-04T05:06:00+03:00","2026-06-05":"2026-06-05T05:06:00+03:00","2026-06-06":"2026-06-06T05:06:00+03:00","2026-06-07":"2026-06-07T05:05:00+03:00","2026-06-08":"2026-06-08T05:05:00+03:00","2026-06-09":"2026-06-09T05:05:00+03:00"},"sunrise":{"2026-03-24":"2026-03-24T05:38:00+02:00","2026-03-25":"2026-03-25T05:37:00+02:00","2026-03-26":"2026-03-26T05:36:00+02:00","2026-03-27":"2026-03-27T06:34:00+03:00","2026-03-28":"2026-03-28T06:33:00+03:00","2026-03-29":"2026-03-29T06:32:00+03:00","2026-03-30":"2026-03-30T06:30:00+03:00","2026-03-31":"2026-03-31T06:29:00+03:00","2026-04-01":"2026-04-01T06:28:00+03:00","2026-04-02":"2026-04-02T06:27:00+03:00","2026-04-03":"2026-04-03T06:25:00+03:00","2026-04-04":"2026-04-04T06:24:00+03:00","2026-04-05":"2026-04-05T06:23:00+03:00","2026-04-06":"2026-04-06T06:22:00+03:00","2026-04-07":"2026-04-07T06:20:00+03:00","2026-04-08":"2026-04-08T06:19:00+03:00","2026-04-09":"2026-04-09T06:18:00+03:00","2026-04-10":"2026-04-10T06:17:00+03:00","2026-04-11":"2026-04-11T06:16:00+03:00","2026-04-12":"2026-04-12T06:14:00+03:00","2026-04-13":"2026-04-13T06:13:00+03:00","2026-04-14":"2026-04-14T06:12:00+03:00","2026-04-15":"2026-04-15T06:11:00+03:00","2026-04-16":"2026-04-16T06:10:00+03:00","2026-04-17":"2026-04-17T06:09:00+03:00","2026-04-18":"2026-04-18T06:07:00+03:00","2026-04-19":"2026-04-19T06:06:00+03:00","2026-04-20":"2026-04-20T06:05:00+03:00","2026-04-21":"2026-04-21T06:04:00+03:00","2026-04-22":"2026-04-22T06:03:00+03:00","2026-04-23":"2026-04-23T06:02:00+03:00","2026-04-24":"2026-04-24T06:01:00+03:00","2026-04-25":"2026-04-25T06:00:00+03:00","2026-04-26":"2026-04-26T05:59:00+03:00","2026-04-27":"2026-04-27T05:58:00+03:00","2026-04-28":"2026-04-28T05:57:00+03:00","2026-04-29":"2026-04-29T05:56:00+03:00","2026-04-30":"2026-04-30T05:55:00+03:00","2026-05-01":"2026-05-01T05:54:00+03:00","2026-05-02":"2026-05-02T05:53:00+03:00","2026-05-03":"2026-05-03T05:52:00+03:00","2026-05-04":"2026-05-04T05:51:00+03:00","2026-05-05":"2026-05-05T05:50:00+03:00","2026-05-06":"2026-05-06T05:49:00+03:00","2026-05-07":"2026-05-07T05:48:00+03:00","2026-05-08":"2026-05-08T05:48:00+03:00","2026-05-09":"2026-05-09T05:47:00+03:00","2026-05-10":"2026-05-10T05:46:00+03:00","2026-05-11":"2026-05-11T05:45:00+03:00","2026-05-12":"2026-05-12T05:45:00+03:00","2026-05-13":"2026-05-13T05:44:00+03:00","2026-05-14":"2026-05-14T05:43:00+03:00","2026-05-15":"2026-05-15T05:42:00+03:00","2026-05-16":"2026-05-16T05:42:00+03:00","2026-05-17":"2026-05-17T05:41:00+03:00","2026-05-18":"2026-05-18T05:41:00+03:00","2026-05-19":"2026-05-19T05:40:00+03:00","2026-05-20":"2026-05-20T05:39:00+03:00","2026-05-21":"2026-05-21T05:39:00+03:00","2026-05-22":"2026-05-22T05:38:00+03:00","2026-05-23":"2026-05-23T05:38:00+03:00","2026-05-24":"2026-05-24T05:37:00+03:00","2026-05-25":"2026-05-25T05:37:00+03:00","2026-05-26":"2026-05-26T05:37:00+03:00","2026-05-27":"2026-05-27T05:36:00+03:00","2026-05-28":"2026-05-28T05:36:00+03:00","2026-05-29":"2026-05-29T05:35:00+03:00","2026-05-30":"2026-05-30T05:35:00+03:00","2026-05-31":"2026-05-31T05:35:00+03:00","2026-06-01":"2026-06-01T05:34:00+03:00","2026-06-02":"2026-06-02T05:34:00+03:00","2026-06-03":"2026-06-03T05:34:00+03:00","2026-06-04":"2026-06-04T05:34:00+03:00","2026-06-05":"2026-06-05T05:34:00+03:00","2026-06-06":"2026-06-06T05:34:00+03:00","2026-06-07":"2026-06-07T05:33:00+03:00","2026-06-08":"2026-06-08T05:33:00+03:00","2026-06-09":"2026-06-09T05:33:00+03:00"},"seaLevelSunrise":{},"sofZmanShmaMGA19Point8":{"2026-03-24":"2026-03-24T07:57:00+02:00","2026-03-25":"2026-03-25T07:56:00+02:00","2026-03-26":"2026-03-26T07:55:00+02:00","2026-03-27":"2026-03-27T08:54:00+03:00","2026-03-28":"2026-03-28T08:53:00+03:00","2026-03-29":"2026-03-29T08:53:00+03:00","2026-03-30":"2026-03-30T08:52:00+03:00","2026-03-31":"2026-03-31T08:51:00+03:00","2026-04-01":"2026-04-01T08:50:00+03:00","2026-04-02":"2026-04-02T08:49:00+03:00","2026-04-03":"2026-04-03T08:48:00+03:00","2026-04-04":"2026-04-04T08:47:00+03:00","2026-04-05":"2026-04-05T08:47:00+03:00","2026-04-06":"2026-04-06T08:46:00+03:00","2026-04-07":"2026-04-07T08:45:00+03:00","2026-04-08":"2026-04-08T08:44:00+03:00","2026-04-09":"2026-04-09T08:43:00+03:00","2026-04-10":"2026-04-10T08:42:00+03:00","2026-04-11":"2026-04-11T08:41:00+03:00","2026-04-12":"2026-04-12T08:41:00+03:00","2026-04-13":"2026-04-13T08:40:00+03:00","2026-04-14":"2026-04-14T08:39:00+03:00","2026-04-15":"2026-04-15T08:38:00+03:00","2026-04-16":"2026-04-16T08:37:00+03:00","2026-04-17":"2026-04-17T08:36:00+03:00","2026-04-18":"2026-04-18T08:36:00+03:00","2026-04-19":"2026-04-19T08:35:00+03:00","2026-04-20":"2026-04-20T08:34:00+03:00","2026-04-21":"2026-04-21T08:33:00+03:00","2026-04-22":"2026-04-22T08:32:00+03:00","2026-04-23":"2026-04-23T08:32:00+03:00","2026-04-24":"2026-04-24T08:31:00+03:00","2026-04-25":"2026-04-25T08:30:00+03:00","2026-04-26":"2026-04-26T08:29:00+03:00","2026-04-27":"2026-04-27T08:29:00+03:00","2026-04-28":"2026-04-28T08:28:00+03:00","2026-04-29":"2026-04-29T08:27:00+03:00","2026-04-30":"2026-04-30T08:26:00+03:00","2026-05-01":"2026-05-01T08:26:00+03:00","2026-05-02":"2026-05-02T08:25:00+03:00","2026-05-03":"2026-05-03T08:24:00+03:00","2026-05-04":"2026-05-04T08:24:00+03:00","2026-05-05":"2026-05-05T08:23:00+03:00","2026-05-06":"2026-05-06T08:22:00+03:00","2026-05-07":"2026-05-07T08:22:00+03:00","2026-05-08":"2026-05-08T08:21:00+03:00","2026-05-09":"2026-05-09T08:21:00+03:00","2026-05-10":"2026-05-10T08:20:00+03:00","2026-05-11":"2026-05-11T08:19:00+03:00","2026-05-12":"2026-05-12T08:19:00+03:00","2026-05-13":"2026-05-13T08:18:00+03:00","2026-05-14":"2026-05-14T08:18:00+03:00","2026-05-15":"2026-05-15T08:17:00+03:00","2026-05-16":"2026-05-16T08:17:00+03:00","2026-05-17":"2026-05-17T08:16:00+03:00","2026-05-18":"2026-05-18T08:16:00+03:00","2026-05-19":"2026-05-19T08:15:00+03:00","2026-05-20":"2026-05-20T08:15:00+03:00","2026-05-21":"2026-05-21T08:15:00+03:00","2026-05-22":"2026-05-22T08:14:00+03:00","2026-05-23":"2026-05-23T08:14:00+03:00","2026-05-24":"2026-05-24T08:13:00+03:00","2026-05-25":"2026-05-25T08:13:00+03:00","2026-05-26":"2026-05-26T08:13:00+03:00","2026-05-27":"2026-05-27T08:13:00+03:00","2026-05-28":"2026-05-28T08:12:00+03:00","2026-05-29":"2026-05-29T08:12:00+03:00","2026-05-30":"2026-05-30T08:12:00+03:00","2026-05-31":"2026-05-31T08:12:00+03:00","2026-06-01":"2026-06-01T08:11:00+03:00","2026-06-02":"2026-06-02T08:11:00+03:00","2026-06-03":"2026-06-03T08:11:00+03:00","2026-06-04":"2026-06-04T08:11:00+03:00","2026-06-05":"2026-06-05T08:11:00+03:00","2026-06-06":"2026-06-06T08:11:00+03:00","2026-06-07":"2026-06-07T08:11:00+03:00","2026-06-08":"2026-06-08T08:11:00+03:00","2026-06-09":"2026-06-09T08:10:00+03:00"},"sofZmanShmaMGA16Point1":{"2026-03-24":"2026-03-24T08:06:00+02:00","2026-03-25":"2026-03-25T08:05:00+02:00","2026-03-26":"2026-03-26T08:04:00+02:00","2026-03-27":"2026-03-27T09:03:00+03:00","2026-03-28":"2026-03-28T09:02:00+03:00","2026-03-29":"2026-03-29T09:02:00+03:00","2026-03-30":"2026-03-30T09:01:00+03:00","2026-03-31":"2026-03-31T09:00:00+03:00","2026-04-01":"2026-04-01T08:59:00+03:00","2026-04-02":"2026-04-02T08:58:00+03:00","2026-04-03":"2026-04-03T08:57:00+03:00","2026-04-04":"2026-04-04T08:57:00+03:00","2026-04-05":"2026-04-05T08:56:00+03:00","2026-04-06":"2026-04-06T08:55:00+03:00","2026-04-07":"2026-04-07T08:54:00+03:00","2026-04-08":"2026-04-08T08:53:00+03:00","2026-04-09":"2026-04-09T08:52:00+03:00","2026-04-10":"2026-04-10T08:52:00+03:00","2026-04-11":"2026-04-11T08:51:00+03:00","2026-04-12":"2026-04-12T08:50:00+03:00","2026-04-13":"2026-04-13T08:49:00+03:00","2026-04-14":"2026-04-14T08:48:00+03:00","2026-04-15":"2026-04-15T08:48:00+03:00","2026-04-16":"2026-04-16T08:47:00+03:00","2026-04-17":"2026-04-17T08:46:00+03:00","2026-04-18":"2026-04-18T08:45:00+03:00","2026-04-19":"2026-04-19T08:44:00+03:00","2026-04-20":"2026-04-20T08:44:00+03:00","2026-04-21":"2026-04-21T08:43:00+03:00","2026-04-22":"2026-04-22T08:42:00+03:00","2026-04-23":"2026-04-23T08:41:00+03:00","2026-04-24":"2026-04-24T08:41:00+03:00","2026-04-25":"2026-04-25T08:40:00+03:00","2026-04-26":"2026-04-26T08:39:00+03:00","2026-04-27":"2026-04-27T08:39:00+03:00","2026-04-28":"2026-04-28T08:38:00+03:00","2026-04-29":"2026-04-29T08:37:00+03:00","2026-04-30":"2026-04-30T08:37:00+03:00","2026-05-01":"2026-05-01T08:36:00+03:00","2026-05-02":"2026-05-02T08:35:00+03:00","2026-05-03":"2026-05-03T08:35:00+03:00","2026-05-04":"2026-05-04T08:34:00+03:00","2026-05-05":"2026-05-05T08:33:00+03:00","2026-05-06":"2026-05-06T08:33:00+03:00","2026-05-07":"2026-05-07T08:32:00+03:00","2026-05-08":"2026-05-08T08:32:00+03:00","2026-05-09":"2026-05-09T08:31:00+03:00","2026-05-10":"2026-05-10T08:31:00+03:00","2026-05-11":"2026-05-11T08:30:00+03:00","2026-05-12":"2026-05-12T08:30:00+03:00","2026-05-13":"2026-05-13T08:29:00+03:00","2026-05-14":"2026-05-14T08:29:00+03:00","2026-05-15":"2026-05-15T08:28:00+03:00","2026-05-16":"2026-05-16T08:28:00+03:00","2026-05-17":"2026-05-17T08:27:00+03:00","2026-05-18":"2026-05-18T08:27:00+03:00","2026-05-19":"2026-05-19T08:26:00+03:00","2026-05-20":"2026-05-20T08:26:00+03:00","2026-05-21":"2026-05-21T08:26:00+03:00","2026-05-22":"2026-05-22T08:25:00+03:00","2026-05-23":"2026-05-23T08:25:00+03:00","2026-05-24":"2026-05-24T08:25:00+03:00","2026-05-25":"2026-05-25T08:24:00+03:00","2026-05-26":"2026-05-26T08:24:00+03:00","2026-05-27":"2026-05-27T08:24:00+03:00","2026-05-28":"2026-05-28T08:24:00+03:00","2026-05-29":"2026-05-29T08:23:00+03:00","2026-05-30":"2026-05-30T08:23:00+03:00","2026-05-31":"2026-05-31T08:23:00+03:00","2026-06-01":"2026-06-01T08:23:00+03:00","2026-06-02":"2026-06-02T08:23:00+03:00","2026-06-03":"2026-06-03T08:23:00+03:00","2026-06-04":"2026-06-04T08:23:00+03:00","2026-06-05":"2026-06-05T08:23:00+03:00","2026-06-06":"2026-06-06T08:22:00+03:00","2026-06-07":"2026-06-07T08:22:00+03:00","2026-06-08":"2026-06-08T08:22:00+03:00","2026-06-09":"2026-06-09T08:22:00+03:00"},"sofZmanShmaMGA":{"2026-03-24":"2026-03-24T08:06:00+02:00","2026-03-25":"2026-03-25T08:05:00+02:00","2026-03-26":"2026-03-26T08:04:00+02:00","2026-03-27":"2026-03-27T09:03:00+03:00","2026-03-28":"2026-03-28T09:03:00+03:00","2026-03-29":"2026-03-29T09:02:00+03:00","2026-03-30":"2026-03-30T09:01:00+03:00","2026-03-31":"2026-03-31T09:00:00+03:00","2026-04-01":"2026-04-01T09:00:00+03:00","2026-04-02":"2026-04-02T08:59:00+03:00","2026-04-03":"2026-04-03T08:58:00+03:00","2026-04-04":"2026-04-04T08:57:00+03:00","2026-04-05":"2026-04-05T08:56:00+03:00","2026-04-06":"2026-04-06T08:56:00+03:00","2026-04-07":"2026-04-07T08:55:00+03:00","2026-04-08":"2026-04-08T08:54:00+03:00","2026-04-09":"2026-04-09T08:53:00+03:00","2026-04-10":"2026-04-10T08:53:00+03:00","2026-04-11":"2026-04-11T08:52:00+03:00","2026-04-12":"2026-04-12T08:51:00+03:00","2026-04-13":"2026-04-13T08:51:00+03:00","2026-04-14":"2026-04-14T08:50:00+03:00","2026-04-15":"2026-04-15T08:49:00+03:00","2026-04-16":"2026-04-16T08:48:00+03:00","2026-04-17":"2026-04-17T08:48:00+03:00","2026-04-18":"2026-04-18T08:47:00+03:00","2026-04-19":"2026-04-19T08:46:00+03:00","2026-04-20":"2026-04-20T08:46:00+03:00","2026-04-21":"2026-04-21T08:45:00+03:00","2026-04-22":"2026-04-22T08:44:00+03:00","2026-04-23":"2026-04-23T08:44:00+03:00","2026-04-24":"2026-04-24T08:43:00+03:00","2026-04-25":"2026-04-25T08:43:00+03:00","2026-04-26":"2026-04-26T08:42:00+03:00","2026-04-27":"2026-04-27T08:41:00+03:00","2026-04-28":"2026-04-28T08:41:00+03:00","2026-04-29":"2026-04-29T08:40:00+03:00","2026-04-30":"2026-04-30T08:40:00+03:00","2026-05-01":"2026-05-01T08:39:00+03:00","2026-05-02":"2026-05-02T08:39:00+03:00","2026-05-03":"2026-05-03T08:38:00+03:00","2026-05-04":"2026-05-04T08:38:00+03:00","2026-05-05":"2026-05-05T08:37:00+03:00","2026-05-06":"2026-05-06T08:37:00+03:00","2026-05-07":"2026-05-07T08:36:00+03:00","2026-05-08":"2026-05-08T08:36:00+03:00","2026-05-09":"2026-05-09T08:35:00+03:00","2026-05-10":"2026-05-10T08:35:00+03:00","2026-05-11":"2026-05-11T08:35:00+03:00","2026-05-12":"2026-05-12T08:34:00+03:00","2026-05-13":"2026-05-13T08:34:00+03:00","2026-05-14":"2026-05-14T08:33:00+03:00","2026-05-15":"2026-05-15T08:33:00+03:00","2026-05-16":"2026-05-16T08:33:00+03:00","2026-05-17":"2026-05-17T08:32:00+03:00","2026-05-18":"2026-05-18T08:32:00+03:00","2026-05-19":"2026-05-19T08:32:00+03:00","2026-05-20":"2026-05-20T08:32:00+03:00","2026-05-21":"2026-05-21T08:31:00+03:00","2026-05-22":"2026-05-22T08:31:00+03:00","2026-05-23":"2026-05-23T08:31:00+03:00","2026-05-24":"2026-05-24T08:31:00+03:00","2026-05-25":"2026-05-25T08:31:00+03:00","2026-05-26":"2026-05-26T08:30:00+03:00","2026-05-27":"2026-05-27T08:30:00+03:00","2026-05-28":"2026-05-28T08:30:00+03:00","2026-05-29":"2026-05-29T08:30:00+03:00","2026-05-30":"2026-05-30T08:30:00+03:00","2026-05-31":"2026-05-31T08:30:00+03:00","2026-06-01":"2026-06-01T08:30:00+03:00","2026-06-02":"2026-06-02T08:30:00+03:00","2026-06-03":"2026-06-03T08:30:00+03:00","2026-06-04":"2026-06-04T08:30:00+03:00","2026-06-05":"2026-06-05T08:30:00+03:00","2026-06-06":"2026-06-06T08:30:00+03:00","2026-06-07":"2026-06-07T08:30:00+03:00","2026-06-08":"2026-06-08T08:30:00+03:00","2026-06-09":"2026-06-09T08:30:00+03:00"},"sofZmanShmaBaalHatanya":{"2026-03-24":"2026-03-24T08:40:00+02:00","2026-03-25":"2026-03-25T08:39:00+02:00","2026-03-26":"2026-03-26T08:39:00+02:00","2026-03-27":"2026-03-27T09:38:00+03:00","2026-03-28":"2026-03-28T09:37:00+03:00","2026-03-29":"2026-03-29T09:36:00+03:00","2026-03-30":"2026-03-30T09:35:00+03:00","2026-03-31":"2026-03-31T09:35:00+03:00","2026-04-01":"2026-04-01T09:34:00+03:00","2026-04-02":"2026-04-02T09:33:00+03:00","2026-04-03":"2026-04-03T09:32:00+03:00","2026-04-04":"2026-04-04T09:31:00+03:00","2026-04-05":"2026-04-05T09:31:00+03:00","2026-04-06":"2026-04-06T09:30:00+03:00","2026-04-07":"2026-04-07T09:29:00+03:00","2026-04-08":"2026-04-08T09:28:00+03:00","2026-04-09":"2026-04-09T09:28:00+03:00","2026-04-10":"2026-04-10T09:27:00+03:00","2026-04-11":"2026-04-11T09:26:00+03:00","2026-04-12":"2026-04-12T09:25:00+03:00","2026-04-13":"2026-04-13T09:25:00+03:00","2026-04-14":"2026-04-14T09:24:00+03:00","2026-04-15":"2026-04-15T09:23:00+03:00","2026-04-16":"2026-04-16T09:23:00+03:00","2026-04-17":"2026-04-17T09:22:00+03:00","2026-04-18":"2026-04-18T09:21:00+03:00","2026-04-19":"2026-04-19T09:21:00+03:00","2026-04-20":"2026-04-20T09:20:00+03:00","2026-04-21":"2026-04-21T09:19:00+03:00","2026-04-22":"2026-04-22T09:19:00+03:00","2026-04-23":"2026-04-23T09:18:00+03:00","2026-04-24":"2026-04-24T09:17:00+03:00","2026-04-25":"2026-04-25T09:17:00+03:00","2026-04-26":"2026-04-26T09:16:00+03:00","2026-04-27":"2026-04-27T09:16:00+03:00","2026-04-28":"2026-04-28T09:15:00+03:00","2026-04-29":"2026-04-29T09:14:00+03:00","2026-04-30":"2026-04-30T09:14:00+03:00","2026-05-01":"2026-05-01T09:13:00+03:00","2026-05-02":"2026-05-02T09:13:00+03:00","2026-05-03":"2026-05-03T09:12:00+03:00","2026-05-04":"2026-05-04T09:12:00+03:00","2026-05-05":"2026-05-05T09:11:00+03:00","2026-05-06":"2026-05-06T09:11:00+03:00","2026-05-07":"2026-05-07T09:10:00+03:00","2026-05-08":"2026-05-08T09:10:00+03:00","2026-05-09":"2026-05-09T09:09:00+03:00","2026-05-10":"2026-05-10T09:09:00+03:00","2026-05-11":"2026-05-11T09:09:00+03:00","2026-05-12":"2026-05-12T09:08:00+03:00","2026-05-13":"2026-05-13T09:08:00+03:00","2026-05-14":"2026-05-14T09:07:00+03:00","2026-05-15":"2026-05-15T09:07:00+03:00","2026-05-16":"2026-05-16T09:07:00+03:00","2026-05-17":"2026-05-17T09:07:00+03:00","2026-05-18":"2026-05-18T09:06:00+03:00","2026-05-19":"2026-05-19T09:06:00+03:00","2026-05-20":"2026-05-20T09:06:00+03:00","2026-05-21":"2026-05-21T09:05:00+03:00","2026-05-22":"2026-05-22T09:05:00+03:00","2026-05-23":"2026-05-23T09:05:00+03:00","2026-05-24":"2026-05-24T09:05:00+03:00","2026-05-25":"2026-05-25T09:05:00+03:00","2026-05-26":"2026-05-26T09:04:00+03:00","2026-05-27":"2026-05-27T09:04:00+03:00","2026-05-28":"2026-05-28T09:04:00+03:00","2026-05-29":"2026-05-29T09:04:00+03:00","2026-05-30":"2026-05-30T09:04:00+03:00","2026-05-31":"2026-05-31T09:04:00+03:00","2026-06-01":"2026-06-01T09:04:00+03:00","2026-06-02":"2026-06-02T09:04:00+03:00","2026-06-03":"2026-06-03T09:04:00+03:00","2026-06-04":"2026-06-04T09:04:00+03:00","2026-06-05":"2026-06-05T09:04:00+03:00","2026-06-06":"2026-06-06T09:04:00+03:00","2026-06-07":"2026-06-07T09:04:00+03:00","2026-06-08":"2026-06-08T09:04:00+03:00","2026-06-09":"2026-06-09T09:04:00+03:00"},"sofZmanShma":{"2026-03-24":"2026-03-24T08:42:00+02:00","2026-03-25":"2026-03-25T08:41:00+02:00","2026-03-26":"2026-03-26T08:40:00+02:00","2026-03-27":"2026-03-27T09:39:00+03:00","2026-03-28":"2026-03-28T09:39:00+03:00","2026-03-29":"2026-03-29T09:38:00+03:00","2026-03-30":"2026-03-30T09:37:00+03:00","2026-03-31":"2026-03-31T09:36:00+03:00","2026-04-01":"2026-04-01T09:36:00+03:00","2026-04-02":"2026-04-02T09:35:00+03:00","2026-04-03":"2026-04-03T09:34:00+03:00","2026-04-04":"2026-04-04T09:33:00+03:00","2026-04-05":"2026-04-05T09:32:00+03:00","2026-04-06":"2026-04-06T09:32:00+03:00","2026-04-07":"2026-04-07T09:31:00+03:00","2026-04-08":"2026-04-08T09:30:00+03:00","2026-04-09":"2026-04-09T09:29:00+03:00","2026-04-10":"2026-04-10T09:29:00+03:00","2026-04-11":"2026-04-11T09:28:00+03:00","2026-04-12":"2026-04-12T09:27:00+03:00","2026-04-13":"2026-04-13T09:27:00+03:00","2026-04-14":"2026-04-14T09:26:00+03:00","2026-04-15":"2026-04-15T09:25:00+03:00","2026-04-16":"2026-04-16T09:24:00+03:00","2026-04-17":"2026-04-17T09:24:00+03:00","2026-04-18":"2026-04-18T09:23:00+03:00","2026-04-19":"2026-04-19T09:22:00+03:00","2026-04-20":"2026-04-20T09:22:00+03:00","2026-04-21":"2026-04-21T09:21:00+03:00","2026-04-22":"2026-04-22T09:20:00+03:00","2026-04-23":"2026-04-23T09:20:00+03:00","2026-04-24":"2026-04-24T09:19:00+03:00","2026-04-25":"2026-04-25T09:19:00+03:00","2026-04-26":"2026-04-26T09:18:00+03:00","2026-04-27":"2026-04-27T09:17:00+03:00","2026-04-28":"2026-04-28T09:17:00+03:00","2026-04-29":"2026-04-29T09:16:00+03:00","2026-04-30":"2026-04-30T09:16:00+03:00","2026-05-01":"2026-05-01T09:15:00+03:00","2026-05-02":"2026-05-02T09:15:00+03:00","2026-05-03":"2026-05-03T09:14:00+03:00","2026-05-04":"2026-05-04T09:14:00+03:00","2026-05-05":"2026-05-05T09:13:00+03:00","2026-05-06":"2026-05-06T09:13:00+03:00","2026-05-07":"2026-05-07T09:12:00+03:00","2026-05-08":"2026-05-08T09:12:00+03:00","2026-05-09":"2026-05-09T09:11:00+03:00","2026-05-10":"2026-05-10T09:11:00+03:00","2026-05-11":"2026-05-11T09:11:00+03:00","2026-05-12":"2026-05-12T09:10:00+03:00","2026-05-13":"2026-05-13T09:10:00+03:00","2026-05-14":"2026-05-14T09:09:00+03:00","2026-05-15":"2026-05-15T09:09:00+03:00","2026-05-16":"2026-05-16T09:09:00+03:00","2026-05-17":"2026-05-17T09:08:00+03:00","2026-05-18":"2026-05-18T09:08:00+03:00","2026-05-19":"2026-05-19T09:08:00+03:00","2026-05-20":"2026-05-20T09:08:00+03:00","2026-05-21":"2026-05-21T09:07:00+03:00","2026-05-22":"2026-05-22T09:07:00+03:00","2026-05-23":"2026-05-23T09:07:00+03:00","2026-05-24":"2026-05-24T09:07:00+03:00","2026-05-25":"2026-05-25T09:07:00+03:00","2026-05-26":"2026-05-26T09:06:00+03:00","2026-05-27":"2026-05-27T09:06:00+03:00","2026-05-28":"2026-05-28T09:06:00+03:00","2026-05-29":"2026-05-29T09:06:00+03:00","2026-05-30":"2026-05-30T09:06:00+03:00","2026-05-31":"2026-05-31T09:06:00+03:00","2026-06-01":"2026-06-01T09:06:00+03:00","2026-06-02":"2026-06-02T09:06:00+03:00","2026-06-03":"2026-06-03T09:06:00+03:00","2026-06-04":"2026-06-04T09:06:00+03:00","2026-06-05":"2026-06-05T09:06:00+03:00","2026-06-06":"2026-06-06T09:06:00+03:00","2026-06-07":"2026-06-07T09:06:00+03:00","2026-06-08":"2026-06-08T09:06:00+03:00","2026-06-09":"2026-06-09T09:06:00+03:00"},"sofZmanTfillaMGA19Point8":{"2026-03-24":"2026-03-24T09:13:00+02:00","2026-03-25":"2026-03-25T09:12:00+02:00","2026-03-26":"2026-03-26T09:12:00+02:00","2026-03-27":"2026-03-27T10:11:00+03:00","2026-03-28":"2026-03-28T10:10:00+03:00","2026-03-29":"2026-03-29T10:10:00+03:00","2026-03-30":"2026-03-30T10:09:00+03:00","2026-03-31":"2026-03-31T10:08:00+03:00","2026-04-01":"2026-04-01T10:08:00+03:00","2026-04-02":"2026-04-02T10:07:00+03:00","2026-04-03":"2026-04-03T10:06:00+03:00","2026-04-04":"2026-04-04T10:06:00+03:00","2026-04-05":"2026-04-05T10:05:00+03:00","2026-04-06":"2026-04-06T10:04:00+03:00","2026-04-07":"2026-04-07T10:04:00+03:00","2026-04-08":"2026-04-08T10:03:00+03:00","2026-04-09":"2026-04-09T10:02:00+03:00","2026-04-10":"2026-04-10T10:02:00+03:00","2026-04-11":"2026-04-11T10:01:00+03:00","2026-04-12":"2026-04-12T10:00:00+03:00","2026-04-13":"2026-04-13T10:00:00+03:00","2026-04-14":"2026-04-14T09:59:00+03:00","2026-04-15":"2026-04-15T09:59:00+03:00","2026-04-16":"2026-04-16T09:58:00+03:00","2026-04-17":"2026-04-17T09:57:00+03:00","2026-04-18":"2026-04-18T09:57:00+03:00","2026-04-19":"2026-04-19T09:56:00+03:00","2026-04-20":"2026-04-20T09:55:00+03:00","2026-04-21":"2026-04-21T09:55:00+03:00","2026-04-22":"2026-04-22T09:54:00+03:00","2026-04-23":"2026-04-23T09:54:00+03:00","2026-04-24":"2026-04-24T09:53:00+03:00","2026-04-25":"2026-04-25T09:53:00+03:00","2026-04-26":"2026-04-26T09:52:00+03:00","2026-04-27":"2026-04-27T09:51:00+03:00","2026-04-28":"2026-04-28T09:51:00+03:00","2026-04-29":"2026-04-29T09:50:00+03:00","2026-04-30":"2026-04-30T09:50:00+03:00","2026-05-01":"2026-05-01T09:49:00+03:00","2026-05-02":"2026-05-02T09:49:00+03:00","2026-05-03":"2026-05-03T09:48:00+03:00","2026-05-04":"2026-05-04T09:48:00+03:00","2026-05-05":"2026-05-05T09:47:00+03:00","2026-05-06":"2026-05-06T09:47:00+03:00","2026-05-07":"2026-05-07T09:47:00+03:00","2026-05-08":"2026-05-08T09:46:00+03:00","2026-05-09":"2026-05-09T09:46:00+03:00","2026-05-10":"2026-05-10T09:45:00+03:00","2026-05-11":"2026-05-11T09:45:00+03:00","2026-05-12":"2026-05-12T09:45:00+03:00","2026-05-13":"2026-05-13T09:44:00+03:00","2026-05-14":"2026-05-14T09:44:00+03:00","2026-05-15":"2026-05-15T09:43:00+03:00","2026-05-16":"2026-05-16T09:43:00+03:00","2026-05-17":"2026-05-17T09:43:00+03:00","2026-05-18":"2026-05-18T09:43:00+03:00","2026-05-19":"2026-05-19T09:42:00+03:00","2026-05-20":"2026-05-20T09:42:00+03:00","2026-05-21":"2026-05-21T09:42:00+03:00","2026-05-22":"2026-05-22T09:41:00+03:00","2026-05-23":"2026-05-23T09:41:00+03:00","2026-05-24":"2026-05-24T09:41:00+03:00","2026-05-25":"2026-05-25T09:41:00+03:00","2026-05-26":"2026-05-26T09:41:00+03:00","2026-05-27":"2026-05-27T09:41:00+03:00","2026-05-28":"2026-05-28T09:40:00+03:00","2026-05-29":"2026-05-29T09:40:00+03:00","2026-05-30":"2026-05-30T09:40:00+03:00","2026-05-31":"2026-05-31T09:40:00+03:00","2026-06-01":"2026-06-01T09:40:00+03:00","2026-06-02":"2026-06-02T09:40:00+03:00","2026-06-03":"2026-06-03T09:40:00+03:00","2026-06-04":"2026-06-04T09:40:00+03:00","2026-06-05":"2026-06-05T09:40:00+03:00","2026-06-06":"2026-06-06T09:40:00+03:00","2026-06-07":"2026-06-07T09:40:00+03:00","2026-06-08":"2026-06-08T09:40:00+03:00","2026-06-09":"2026-06-09T09:40:00+03:00"},"sofZmanTfillaMGA16Point1":{"2026-03-24":"2026-03-24T09:19:00+02:00","2026-03-25":"2026-03-25T09:18:00+02:00","2026-03-26":"2026-03-26T09:18:00+02:00","2026-03-27":"2026-03-27T10:17:00+03:00","2026-03-28":"2026-03-28T10:16:00+03:00","2026-03-29":"2026-03-29T10:16:00+03:00","2026-03-30":"2026-03-30T10:15:00+03:00","2026-03-31":"2026-03-31T10:14:00+03:00","2026-04-01":"2026-04-01T10:14:00+03:00","2026-04-02":"2026-04-02T10:13:00+03:00","2026-04-03":"2026-04-03T10:12:00+03:00","2026-04-04":"2026-04-04T10:12:00+03:00","2026-04-05":"2026-04-05T10:11:00+03:00","2026-04-06":"2026-04-06T10:11:00+03:00","2026-04-07":"2026-04-07T10:10:00+03:00","2026-04-08":"2026-04-08T10:09:00+03:00","2026-04-09":"2026-04-09T10:09:00+03:00","2026-04-10":"2026-04-10T10:08:00+03:00","2026-04-11":"2026-04-11T10:07:00+03:00","2026-04-12":"2026-04-12T10:07:00+03:00","2026-04-13":"2026-04-13T10:06:00+03:00","2026-04-14":"2026-04-14T10:05:00+03:00","2026-04-15":"2026-04-15T10:05:00+03:00","2026-04-16":"2026-04-16T10:04:00+03:00","2026-04-17":"2026-04-17T10:04:00+03:00","2026-04-18":"2026-04-18T10:03:00+03:00","2026-04-19":"2026-04-19T10:02:00+03:00","2026-04-20":"2026-04-20T10:02:00+03:00","2026-04-21":"2026-04-21T10:01:00+03:00","2026-04-22":"2026-04-22T10:01:00+03:00","2026-04-23":"2026-04-23T10:00:00+03:00","2026-04-24":"2026-04-24T10:00:00+03:00","2026-04-25":"2026-04-25T09:59:00+03:00","2026-04-26":"2026-04-26T09:59:00+03:00","2026-04-27":"2026-04-27T09:58:00+03:00","2026-04-28":"2026-04-28T09:58:00+03:00","2026-04-29":"2026-04-29T09:57:00+03:00","2026-04-30":"2026-04-30T09:57:00+03:00","2026-05-01":"2026-05-01T09:56:00+03:00","2026-05-02":"2026-05-02T09:56:00+03:00","2026-05-03":"2026-05-03T09:55:00+03:00","2026-05-04":"2026-05-04T09:55:00+03:00","2026-05-05":"2026-05-05T09:54:00+03:00","2026-05-06":"2026-05-06T09:54:00+03:00","2026-05-07":"2026-05-07T09:53:00+03:00","2026-05-08":"2026-05-08T09:53:00+03:00","2026-05-09":"2026-05-09T09:53:00+03:00","2026-05-10":"2026-05-10T09:52:00+03:00","2026-05-11":"2026-05-11T09:52:00+03:00","2026-05-12":"2026-05-12T09:52:00+03:00","2026-05-13":"2026-05-13T09:51:00+03:00","2026-05-14":"2026-05-14T09:51:00+03:00","2026-05-15":"2026-05-15T09:51:00+03:00","2026-05-16":"2026-05-16T09:50:00+03:00","2026-05-17":"2026-05-17T09:50:00+03:00","2026-05-18":"2026-05-18T09:50:00+03:00","2026-05-19":"2026-05-19T09:50:00+03:00","2026-05-20":"2026-05-20T09:49:00+03:00","2026-05-21":"2026-05-21T09:49:00+03:00","2026-05-22":"2026-05-22T09:49:00+03:00","2026-05-23":"2026-05-23T09:49:00+03:00","2026-05-24":"2026-05-24T09:49:00+03:00","2026-05-25":"2026-05-25T09:48:00+03:00","2026-05-26":"2026-05-26T09:48:00+03:00","2026-05-27":"2026-05-27T09:48:00+03:00","2026-05-28":"2026-05-28T09:48:00+03:00","2026-05-29":"2026-05-29T09:48:00+03:00","2026-05-30":"2026-05-30T09:48:00+03:00","2026-05-31":"2026-05-31T09:48:00+03:00","2026-06-01":"2026-06-01T09:48:00+03:00","2026-06-02":"2026-06-02T09:48:00+03:00","2026-06-03":"2026-06-03T09:48:00+03:00","2026-06-04":"2026-06-04T09:48:00+03:00","2026-06-05":"2026-06-05T09:48:00+03:00","2026-06-06":"2026-06-06T09:48:00+03:00","2026-06-07":"2026-06-07T09:48:00+03:00","2026-06-08":"2026-06-08T09:48:00+03:00","2026-06-09":"2026-06-09T09:48:00+03:00"},"sofZmanTfillaMGA":{"2026-03-24":"2026-03-24T09:19:00+02:00","2026-03-25":"2026-03-25T09:18:00+02:00","2026-03-26":"2026-03-26T09:18:00+02:00","2026-03-27":"2026-03-27T10:17:00+03:00","2026-03-28":"2026-03-28T10:17:00+03:00","2026-03-29":"2026-03-29T10:16:00+03:00","2026-03-30":"2026-03-30T10:15:00+03:00","2026-03-31":"2026-03-31T10:15:00+03:00","2026-04-01":"2026-04-01T10:14:00+03:00","2026-04-02":"2026-04-02T10:14:00+03:00","2026-04-03":"2026-04-03T10:13:00+03:00","2026-04-04":"2026-04-04T10:12:00+03:00","2026-04-05":"2026-04-05T10:12:00+03:00","2026-04-06":"2026-04-06T10:11:00+03:00","2026-04-07":"2026-04-07T10:10:00+03:00","2026-04-08":"2026-04-08T10:10:00+03:00","2026-04-09":"2026-04-09T10:09:00+03:00","2026-04-10":"2026-04-10T10:09:00+03:00","2026-04-11":"2026-04-11T10:08:00+03:00","2026-04-12":"2026-04-12T10:08:00+03:00","2026-04-13":"2026-04-13T10:07:00+03:00","2026-04-14":"2026-04-14T10:06:00+03:00","2026-04-15":"2026-04-15T10:06:00+03:00","2026-04-16":"2026-04-16T10:05:00+03:00","2026-04-17":"2026-04-17T10:05:00+03:00","2026-04-18":"2026-04-18T10:04:00+03:00","2026-04-19":"2026-04-19T10:04:00+03:00","2026-04-20":"2026-04-20T10:03:00+03:00","2026-04-21":"2026-04-21T10:03:00+03:00","2026-04-22":"2026-04-22T10:02:00+03:00","2026-04-23":"2026-04-23T10:02:00+03:00","2026-04-24":"2026-04-24T10:01:00+03:00","2026-04-25":"2026-04-25T10:01:00+03:00","2026-04-26":"2026-04-26T10:00:00+03:00","2026-04-27":"2026-04-27T10:00:00+03:00","2026-04-28":"2026-04-28T09:59:00+03:00","2026-04-29":"2026-04-29T09:59:00+03:00","2026-04-30":"2026-04-30T09:59:00+03:00","2026-05-01":"2026-05-01T09:58:00+03:00","2026-05-02":"2026-05-02T09:58:00+03:00","2026-05-03":"2026-05-03T09:57:00+03:00","2026-05-04":"2026-05-04T09:57:00+03:00","2026-05-05":"2026-05-05T09:57:00+03:00","2026-05-06":"2026-05-06T09:56:00+03:00","2026-05-07":"2026-05-07T09:56:00+03:00","2026-05-08":"2026-05-08T09:56:00+03:00","2026-05-09":"2026-05-09T09:55:00+03:00","2026-05-10":"2026-05-10T09:55:00+03:00","2026-05-11":"2026-05-11T09:55:00+03:00","2026-05-12":"2026-05-12T09:55:00+03:00","2026-05-13":"2026-05-13T09:54:00+03:00","2026-05-14":"2026-05-14T09:54:00+03:00","2026-05-15":"2026-05-15T09:54:00+03:00","2026-05-16":"2026-05-16T09:54:00+03:00","2026-05-17":"2026-05-17T09:54:00+03:00","2026-05-18":"2026-05-18T09:53:00+03:00","2026-05-19":"2026-05-19T09:53:00+03:00","2026-05-20":"2026-05-20T09:53:00+03:00","2026-05-21":"2026-05-21T09:53:00+03:00","2026-05-22":"2026-05-22T09:53:00+03:00","2026-05-23":"2026-05-23T09:53:00+03:00","2026-05-24":"2026-05-24T09:53:00+03:00","2026-05-25":"2026-05-25T09:52:00+03:00","2026-05-26":"2026-05-26T09:52:00+03:00","2026-05-27":"2026-05-27T09:52:00+03:00","2026-05-28":"2026-05-28T09:52:00+03:00","2026-05-29":"2026-05-29T09:52:00+03:00","2026-05-30":"2026-05-30T09:52:00+03:00","2026-05-31":"2026-05-31T09:52:00+03:00","2026-06-01":"2026-06-01T09:52:00+03:00","2026-06-02":"2026-06-02T09:52:00+03:00","2026-06-03":"2026-06-03T09:52:00+03:00","2026-06-04":"2026-06-04T09:52:00+03:00","2026-06-05":"2026-06-05T09:52:00+03:00","2026-06-06":"2026-06-06T09:52:00+03:00","2026-06-07":"2026-06-07T09:53:00+03:00","2026-06-08":"2026-06-08T09:53:00+03:00","2026-06-09":"2026-06-09T09:53:00+03:00"},"sofZmanTfilaBaalHatanya":{"2026-03-24":"2026-03-24T09:42:00+02:00","2026-03-25":"2026-03-25T09:41:00+02:00","2026-03-26":"2026-03-26T09:41:00+02:00","2026-03-27":"2026-03-27T10:40:00+03:00","2026-03-28":"2026-03-28T10:39:00+03:00","2026-03-29":"2026-03-29T10:39:00+03:00","2026-03-30":"2026-03-30T10:38:00+03:00","2026-03-31":"2026-03-31T10:38:00+03:00","2026-04-01":"2026-04-01T10:37:00+03:00","2026-04-02":"2026-04-02T10:36:00+03:00","2026-04-03":"2026-04-03T10:36:00+03:00","2026-04-04":"2026-04-04T10:35:00+03:00","2026-04-05":"2026-04-05T10:34:00+03:00","2026-04-06":"2026-04-06T10:34:00+03:00","2026-04-07":"2026-04-07T10:33:00+03:00","2026-04-08":"2026-04-08T10:33:00+03:00","2026-04-09":"2026-04-09T10:32:00+03:00","2026-04-10":"2026-04-10T10:32:00+03:00","2026-04-11":"2026-04-11T10:31:00+03:00","2026-04-12":"2026-04-12T10:30:00+03:00","2026-04-13":"2026-04-13T10:30:00+03:00","2026-04-14":"2026-04-14T10:29:00+03:00","2026-04-15":"2026-04-15T10:29:00+03:00","2026-04-16":"2026-04-16T10:28:00+03:00","2026-04-17":"2026-04-17T10:28:00+03:00","2026-04-18":"2026-04-18T10:27:00+03:00","2026-04-19":"2026-04-19T10:27:00+03:00","2026-04-20":"2026-04-20T10:26:00+03:00","2026-04-21":"2026-04-21T10:26:00+03:00","2026-04-22":"2026-04-22T10:25:00+03:00","2026-04-23":"2026-04-23T10:25:00+03:00","2026-04-24":"2026-04-24T10:24:00+03:00","2026-04-25":"2026-04-25T10:24:00+03:00","2026-04-26":"2026-04-26T10:23:00+03:00","2026-04-27":"2026-04-27T10:23:00+03:00","2026-04-28":"2026-04-28T10:22:00+03:00","2026-04-29":"2026-04-29T10:22:00+03:00","2026-04-30":"2026-04-30T10:21:00+03:00","2026-05-01":"2026-05-01T10:21:00+03:00","2026-05-02":"2026-05-02T10:21:00+03:00","2026-05-03":"2026-05-03T10:20:00+03:00","2026-05-04":"2026-05-04T10:20:00+03:00","2026-05-05":"2026-05-05T10:20:00+03:00","2026-05-06":"2026-05-06T10:19:00+03:00","2026-05-07":"2026-05-07T10:19:00+03:00","2026-05-08":"2026-05-08T10:19:00+03:00","2026-05-09":"2026-05-09T10:18:00+03:00","2026-05-10":"2026-05-10T10:18:00+03:00","2026-05-11":"2026-05-11T10:18:00+03:00","2026-05-12":"2026-05-12T10:17:00+03:00","2026-05-13":"2026-05-13T10:17:00+03:00","2026-05-14":"2026-05-14T10:17:00+03:00","2026-05-15":"2026-05-15T10:17:00+03:00","2026-05-16":"2026-05-16T10:16:00+03:00","2026-05-17":"2026-05-17T10:16:00+03:00","2026-05-18":"2026-05-18T10:16:00+03:00","2026-05-19":"2026-05-19T10:16:00+03:00","2026-05-20":"2026-05-20T10:16:00+03:00","2026-05-21":"2026-05-21T10:16:00+03:00","2026-05-22":"2026-05-22T10:15:00+03:00","2026-05-23":"2026-05-23T10:15:00+03:00","2026-05-24":"2026-05-24T10:15:00+03:00","2026-05-25":"2026-05-25T10:15:00+03:00","2026-05-26":"2026-05-26T10:15:00+03:00","2026-05-27":"2026-05-27T10:15:00+03:00","2026-05-28":"2026-05-28T10:15:00+03:00","2026-05-29":"2026-05-29T10:15:00+03:00","2026-05-30":"2026-05-30T10:15:00+03:00","2026-05-31":"2026-05-31T10:15:00+03:00","2026-06-01":"2026-06-01T10:15:00+03:00","2026-06-02":"2026-06-02T10:15:00+03:00","2026-06-03":"2026-06-03T10:15:00+03:00","2026-06-04":"2026-06-04T10:15:00+03:00","2026-06-05":"2026-06-05T10:15:00+03:00","2026-06-06":"2026-06-06T10:15:00+03:00","2026-06-07":"2026-06-07T10:15:00+03:00","2026-06-08":"2026-06-08T10:15:00+03:00","2026-06-09":"2026-06-09T10:15:00+03:00"},"sofZmanTfilla":{"2026-03-24":"2026-03-24T09:43:00+02:00","2026-03-25":"2026-03-25T09:42:00+02:00","2026-03-26":"2026-03-26T09:42:00+02:00","2026-03-27":"2026-03-27T10:41:00+03:00","2026-03-28":"2026-03-28T10:41:00+03:00","2026-03-29":"2026-03-29T10:40:00+03:00","2026-03-30":"2026-03-30T10:39:00+03:00","2026-03-31":"2026-03-31T10:39:00+03:00","2026-04-01":"2026-04-01T10:38:00+03:00","2026-04-02":"2026-04-02T10:38:00+03:00","2026-04-03":"2026-04-03T10:37:00+03:00","2026-04-04":"2026-04-04T10:36:00+03:00","2026-04-05":"2026-04-05T10:36:00+03:00","2026-04-06":"2026-04-06T10:35:00+03:00","2026-04-07":"2026-04-07T10:34:00+03:00","2026-04-08":"2026-04-08T10:34:00+03:00","2026-04-09":"2026-04-09T10:33:00+03:00","2026-04-10":"2026-04-10T10:33:00+03:00","2026-04-11":"2026-04-11T10:32:00+03:00","2026-04-12":"2026-04-12T10:32:00+03:00","2026-04-13":"2026-04-13T10:31:00+03:00","2026-04-14":"2026-04-14T10:30:00+03:00","2026-04-15":"2026-04-15T10:30:00+03:00","2026-04-16":"2026-04-16T10:29:00+03:00","2026-04-17":"2026-04-17T10:29:00+03:00","2026-04-18":"2026-04-18T10:28:00+03:00","2026-04-19":"2026-04-19T10:28:00+03:00","2026-04-20":"2026-04-20T10:27:00+03:00","2026-04-21":"2026-04-21T10:27:00+03:00","2026-04-22":"2026-04-22T10:26:00+03:00","2026-04-23":"2026-04-23T10:26:00+03:00","2026-04-24":"2026-04-24T10:25:00+03:00","2026-04-25":"2026-04-25T10:25:00+03:00","2026-04-26":"2026-04-26T10:24:00+03:00","2026-04-27":"2026-04-27T10:24:00+03:00","2026-04-28":"2026-04-28T10:23:00+03:00","2026-04-29":"2026-04-29T10:23:00+03:00","2026-04-30":"2026-04-30T10:23:00+03:00","2026-05-01":"2026-05-01T10:22:00+03:00","2026-05-02":"2026-05-02T10:22:00+03:00","2026-05-03":"2026-05-03T10:21:00+03:00","2026-05-04":"2026-05-04T10:21:00+03:00","2026-05-05":"2026-05-05T10:21:00+03:00","2026-05-06":"2026-05-06T10:20:00+03:00","2026-05-07":"2026-05-07T10:20:00+03:00","2026-05-08":"2026-05-08T10:20:00+03:00","2026-05-09":"2026-05-09T10:19:00+03:00","2026-05-10":"2026-05-10T10:19:00+03:00","2026-05-11":"2026-05-11T10:19:00+03:00","2026-05-12":"2026-05-12T10:19:00+03:00","2026-05-13":"2026-05-13T10:18:00+03:00","2026-05-14":"2026-05-14T10:18:00+03:00","2026-05-15":"2026-05-15T10:18:00+03:00","2026-05-16":"2026-05-16T10:18:00+03:00","2026-05-17":"2026-05-17T10:18:00+03:00","2026-05-18":"2026-05-18T10:17:00+03:00","2026-05-19":"2026-05-19T10:17:00+03:00","2026-05-20":"2026-05-20T10:17:00+03:00","2026-05-21":"2026-05-21T10:17:00+03:00","2026-05-22":"2026-05-22T10:17:00+03:00","2026-05-23":"2026-05-23T10:17:00+03:00","2026-05-24":"2026-05-24T10:17:00+03:00","2026-05-25":"2026-05-25T10:16:00+03:00","2026-05-26":"2026-05-26T10:16:00+03:00","2026-05-27":"2026-05-27T10:16:00+03:00","2026-05-28":"2026-05-28T10:16:00+03:00","2026-05-29":"2026-05-29T10:16:00+03:00","2026-05-30":"2026-05-30T10:16:00+03:00","2026-05-31":"2026-05-31T10:16:00+03:00","2026-06-01":"2026-06-01T10:16:00+03:00","2026-06-02":"2026-06-02T10:16:00+03:00","2026-06-03":"2026-06-03T10:16:00+03:00","2026-06-04":"2026-06-04T10:16:00+03:00","2026-06-05":"2026-06-05T10:16:00+03:00","2026-06-06":"2026-06-06T10:16:00+03:00","2026-06-07":"2026-06-07T10:17:00+03:00","2026-06-08":"2026-06-08T10:17:00+03:00","2026-06-09":"2026-06-09T10:17:00+03:00"},"chatzot":{"2026-03-24":"2026-03-24T11:46:00+02:00","2026-03-25":"2026-03-25T11:45:00+02:00","2026-03-26":"2026-03-26T11:45:00+02:00","2026-03-27":"2026-03-27T12:45:00+03:00","2026-03-28":"2026-03-28T12:44:00+03:00","2026-03-29":"2026-03-29T12:44:00+03:00","2026-03-30":"2026-03-30T12:44:00+03:00","2026-03-31":"2026-03-31T12:44:00+03:00","2026-04-01":"2026-04-01T12:43:00+03:00","2026-04-02":"2026-04-02T12:43:00+03:00","2026-04-03":"2026-04-03T12:43:00+03:00","2026-04-04":"2026-04-04T12:42:00+03:00","2026-04-05":"2026-04-05T12:42:00+03:00","2026-04-06":"2026-04-06T12:42:00+03:00","2026-04-07":"2026-04-07T12:42:00+03:00","2026-04-08":"2026-04-08T12:41:00+03:00","2026-04-09":"2026-04-09T12:41:00+03:00","2026-04-10":"2026-04-10T12:41:00+03:00","2026-04-11":"2026-04-11T12:40:00+03:00","2026-04-12":"2026-04-12T12:40:00+03:00","2026-04-13":"2026-04-13T12:40:00+03:00","2026-04-14":"2026-04-14T12:40:00+03:00","2026-04-15":"2026-04-15T12:39:00+03:00","2026-04-16":"2026-04-16T12:39:00+03:00","2026-04-17":"2026-04-17T12:39:00+03:00","2026-04-18":"2026-04-18T12:39:00+03:00","2026-04-19":"2026-04-19T12:39:00+03:00","2026-04-20":"2026-04-20T12:38:00+03:00","2026-04-21":"2026-04-21T12:38:00+03:00","2026-04-22":"2026-04-22T12:38:00+03:00","2026-04-23":"2026-04-23T12:38:00+03:00","2026-04-24":"2026-04-24T12:38:00+03:00","2026-04-25":"2026-04-25T12:37:00+03:00","2026-04-26":"2026-04-26T12:37:00+03:00","2026-04-27":"2026-04-27T12:37:00+03:00","2026-04-28":"2026-04-28T12:37:00+03:00","2026-04-29":"2026-04-29T12:37:00+03:00","2026-04-30":"2026-04-30T12:37:00+03:00","2026-05-01":"2026-05-01T12:36:00+03:00","2026-05-02":"2026-05-02T12:36:00+03:00","2026-05-03":"2026-05-03T12:36:00+03:00","2026-05-04":"2026-05-04T12:36:00+03:00","2026-05-05":"2026-05-05T12:36:00+03:00","2026-05-06":"2026-05-06T12:36:00+03:00","2026-05-07":"2026-05-07T12:36:00+03:00","2026-05-08":"2026-05-08T12:36:00+03:00","2026-05-09":"2026-05-09T12:36:00+03:00","2026-05-10":"2026-05-10T12:36:00+03:00","2026-05-11":"2026-05-11T12:36:00+03:00","2026-05-12":"2026-05-12T12:36:00+03:00","2026-05-13":"2026-05-13T12:36:00+03:00","2026-05-14":"2026-05-14T12:36:00+03:00","2026-05-15":"2026-05-15T12:36:00+03:00","2026-05-16":"2026-05-16T12:36:00+03:00","2026-05-17":"2026-05-17T12:36:00+03:00","2026-05-18":"2026-05-18T12:36:00+03:00","2026-05-19":"2026-05-19T12:36:00+03:00","2026-05-20":"2026-05-20T12:36:00+03:00","2026-05-21":"2026-05-21T12:36:00+03:00","2026-05-22":"2026-05-22T12:36:00+03:00","2026-05-23":"2026-05-23T12:36:00+03:00","2026-05-24":"2026-05-24T12:36:00+03:00","2026-05-25":"2026-05-25T12:36:00+03:00","2026-05-26":"2026-05-26T12:36:00+03:00","2026-05-27":"2026-05-27T12:36:00+03:00","2026-05-28":"2026-05-28T12:37:00+03:00","2026-05-29":"2026-05-29T12:37:00+03:00","2026-05-30":"2026-05-30T12:37:00+03:00","2026-05-31":"2026-05-31T12:37:00+03:00","2026-06-01":"2026-06-01T12:37:00+03:00","2026-06-02":"2026-06-02T12:37:00+03:00","2026-06-03":"2026-06-03T12:37:00+03:00","2026-06-04":"2026-06-04T12:38:00+03:00","2026-06-05":"2026-06-05T12:38:00+03:00","2026-06-06":"2026-06-06T12:38:00+03:00","2026-06-07":"2026-06-07T12:38:00+03:00","2026-06-08":"2026-06-08T12:38:00+03:00","2026-06-09":"2026-06-09T12:38:00+03:00"},"minchaGedola":{"2026-03-24":"2026-03-24T12:16:00+02:00","2026-03-25":"2026-03-25T12:16:00+02:00","2026-03-26":"2026-03-26T12:16:00+02:00","2026-03-27":"2026-03-27T13:16:00+03:00","2026-03-28":"2026-03-28T13:15:00+03:00","2026-03-29":"2026-03-29T13:15:00+03:00","2026-03-30":"2026-03-30T13:15:00+03:00","2026-03-31":"2026-03-31T13:15:00+03:00","2026-04-01":"2026-04-01T13:15:00+03:00","2026-04-02":"2026-04-02T13:14:00+03:00","2026-04-03":"2026-04-03T13:14:00+03:00","2026-04-04":"2026-04-04T13:14:00+03:00","2026-04-05":"2026-04-05T13:14:00+03:00","2026-04-06":"2026-04-06T13:13:00+03:00","2026-04-07":"2026-04-07T13:13:00+03:00","2026-04-08":"2026-04-08T13:13:00+03:00","2026-04-09":"2026-04-09T13:13:00+03:00","2026-04-10":"2026-04-10T13:13:00+03:00","2026-04-11":"2026-04-11T13:12:00+03:00","2026-04-12":"2026-04-12T13:12:00+03:00","2026-04-13":"2026-04-13T13:12:00+03:00","2026-04-14":"2026-04-14T13:12:00+03:00","2026-04-15":"2026-04-15T13:12:00+03:00","2026-04-16":"2026-04-16T13:12:00+03:00","2026-04-17":"2026-04-17T13:11:00+03:00","2026-04-18":"2026-04-18T13:11:00+03:00","2026-04-19":"2026-04-19T13:11:00+03:00","2026-04-20":"2026-04-20T13:11:00+03:00","2026-04-21":"2026-04-21T13:11:00+03:00","2026-04-22":"2026-04-22T13:11:00+03:00","2026-04-23":"2026-04-23T13:11:00+03:00","2026-04-24":"2026-04-24T13:11:00+03:00","2026-04-25":"2026-04-25T13:10:00+03:00","2026-04-26":"2026-04-26T13:10:00+03:00","2026-04-27":"2026-04-27T13:10:00+03:00","2026-04-28":"2026-04-28T13:10:00+03:00","2026-04-29":"2026-04-29T13:10:00+03:00","2026-04-30":"2026-04-30T13:10:00+03:00","2026-05-01":"2026-05-01T13:10:00+03:00","2026-05-02":"2026-05-02T13:10:00+03:00","2026-05-03":"2026-05-03T13:10:00+03:00","2026-05-04":"2026-05-04T13:10:00+03:00","2026-05-05":"2026-05-05T13:10:00+03:00","2026-05-06":"2026-05-06T13:10:00+03:00","2026-05-07":"2026-05-07T13:10:00+03:00","2026-05-08":"2026-05-08T13:10:00+03:00","2026-05-09":"2026-05-09T13:10:00+03:00","2026-05-10":"2026-05-10T13:10:00+03:00","2026-05-11":"2026-05-11T13:10:00+03:00","2026-05-12":"2026-05-12T13:10:00+03:00","2026-05-13":"2026-05-13T13:10:00+03:00","2026-05-14":"2026-05-14T13:10:00+03:00","2026-05-15":"2026-05-15T13:10:00+03:00","2026-05-16":"2026-05-16T13:10:00+03:00","2026-05-17":"2026-05-17T13:10:00+03:00","2026-05-18":"2026-05-18T13:10:00+03:00","2026-05-19":"2026-05-19T13:10:00+03:00","2026-05-20":"2026-05-20T13:11:00+03:00","2026-05-21":"2026-05-21T13:11:00+03:00","2026-05-22":"2026-05-22T13:11:00+03:00","2026-05-23":"2026-05-23T13:11:00+03:00","2026-05-24":"2026-05-24T13:11:00+03:00","2026-05-25":"2026-05-25T13:11:00+03:00","2026-05-26":"2026-05-26T13:11:00+03:00","2026-05-27":"2026-05-27T13:11:00+03:00","2026-05-28":"2026-05-28T13:12:00+03:00","2026-05-29":"2026-05-29T13:12:00+03:00","2026-05-30":"2026-05-30T13:12:00+03:00","2026-05-31":"2026-05-31T13:12:00+03:00","2026-06-01":"2026-06-01T13:12:00+03:00","2026-06-02":"2026-06-02T13:13:00+03:00","2026-06-03":"2026-06-03T13:13:00+03:00","2026-06-04":"2026-06-04T13:13:00+03:00","2026-06-05":"2026-06-05T13:13:00+03:00","2026-06-06":"2026-06-06T13:13:00+03:00","2026-06-07":"2026-06-07T13:13:00+03:00","2026-06-08":"2026-06-08T13:14:00+03:00","2026-06-09":"2026-06-09T13:14:00+03:00"},"minchaGedolaBaalHatanya":{"2026-03-24":"2026-03-24T12:17:00+02:00","2026-03-25":"2026-03-25T12:16:00+02:00","2026-03-26":"2026-03-26T12:16:00+02:00","2026-03-27":"2026-03-27T13:16:00+03:00","2026-03-28":"2026-03-28T13:16:00+03:00","2026-03-29":"2026-03-29T13:15:00+03:00","2026-03-30":"2026-03-30T13:15:00+03:00","2026-03-31":"2026-03-31T13:15:00+03:00","2026-04-01":"2026-04-01T13:15:00+03:00","2026-04-02":"2026-04-02T13:15:00+03:00","2026-04-03":"2026-04-03T13:14:00+03:00","2026-04-04":"2026-04-04T13:14:00+03:00","2026-04-05":"2026-04-05T13:14:00+03:00","2026-04-06":"2026-04-06T13:14:00+03:00","2026-04-07":"2026-04-07T13:14:00+03:00","2026-04-08":"2026-04-08T13:13:00+03:00","2026-04-09":"2026-04-09T13:13:00+03:00","2026-04-10":"2026-04-10T13:13:00+03:00","2026-04-11":"2026-04-11T13:13:00+03:00","2026-04-12":"2026-04-12T13:13:00+03:00","2026-04-13":"2026-04-13T13:12:00+03:00","2026-04-14":"2026-04-14T13:12:00+03:00","2026-04-15":"2026-04-15T13:12:00+03:00","2026-04-16":"2026-04-16T13:12:00+03:00","2026-04-17":"2026-04-17T13:12:00+03:00","2026-04-18":"2026-04-18T13:12:00+03:00","2026-04-19":"2026-04-19T13:12:00+03:00","2026-04-20":"2026-04-20T13:11:00+03:00","2026-04-21":"2026-04-21T13:11:00+03:00","2026-04-22":"2026-04-22T13:11:00+03:00","2026-04-23":"2026-04-23T13:11:00+03:00","2026-04-24":"2026-04-24T13:11:00+03:00","2026-04-25":"2026-04-25T13:11:00+03:00","2026-04-26":"2026-04-26T13:11:00+03:00","2026-04-27":"2026-04-27T13:11:00+03:00","2026-04-28":"2026-04-28T13:11:00+03:00","2026-04-29":"2026-04-29T13:10:00+03:00","2026-04-30":"2026-04-30T13:10:00+03:00","2026-05-01":"2026-05-01T13:10:00+03:00","2026-05-02":"2026-05-02T13:10:00+03:00","2026-05-03":"2026-05-03T13:10:00+03:00","2026-05-04":"2026-05-04T13:10:00+03:00","2026-05-05":"2026-05-05T13:10:00+03:00","2026-05-06":"2026-05-06T13:10:00+03:00","2026-05-07":"2026-05-07T13:10:00+03:00","2026-05-08":"2026-05-08T13:10:00+03:00","2026-05-09":"2026-05-09T13:10:00+03:00","2026-05-10":"2026-05-10T13:10:00+03:00","2026-05-11":"2026-05-11T13:10:00+03:00","2026-05-12":"2026-05-12T13:10:00+03:00","2026-05-13":"2026-05-13T13:10:00+03:00","2026-05-14":"2026-05-14T13:10:00+03:00","2026-05-15":"2026-05-15T13:10:00+03:00","2026-05-16":"2026-05-16T13:11:00+03:00","2026-05-17":"2026-05-17T13:11:00+03:00","2026-05-18":"2026-05-18T13:11:00+03:00","2026-05-19":"2026-05-19T13:11:00+03:00","2026-05-20":"2026-05-20T13:11:00+03:00","2026-05-21":"2026-05-21T13:11:00+03:00","2026-05-22":"2026-05-22T13:11:00+03:00","2026-05-23":"2026-05-23T13:11:00+03:00","2026-05-24":"2026-05-24T13:11:00+03:00","2026-05-25":"2026-05-25T13:12:00+03:00","2026-05-26":"2026-05-26T13:12:00+03:00","2026-05-27":"2026-05-27T13:12:00+03:00","2026-05-28":"2026-05-28T13:12:00+03:00","2026-05-29":"2026-05-29T13:12:00+03:00","2026-05-30":"2026-05-30T13:12:00+03:00","2026-05-31":"2026-05-31T13:12:00+03:00","2026-06-01":"2026-06-01T13:13:00+03:00","2026-06-02":"2026-06-02T13:13:00+03:00","2026-06-03":"2026-06-03T13:13:00+03:00","2026-06-04":"2026-06-04T13:13:00+03:00","2026-06-05":"2026-06-05T13:13:00+03:00","2026-06-06":"2026-06-06T13:14:00+03:00","2026-06-07":"2026-06-07T13:14:00+03:00","2026-06-08":"2026-06-08T13:14:00+03:00","2026-06-09":"2026-06-09T13:14:00+03:00"},"minchaGedolaMGA":{"2026-03-24":"2026-03-24T12:22:00+02:00","2026-03-25":"2026-03-25T12:22:00+02:00","2026-03-26":"2026-03-26T12:22:00+02:00","2026-03-27":"2026-03-27T13:22:00+03:00","2026-03-28":"2026-03-28T13:21:00+03:00","2026-03-29":"2026-03-29T13:21:00+03:00","2026-03-30":"2026-03-30T13:21:00+03:00","2026-03-31":"2026-03-31T13:21:00+03:00","2026-04-01":"2026-04-01T13:21:00+03:00","2026-04-02":"2026-04-02T13:20:00+03:00","2026-04-03":"2026-04-03T13:20:00+03:00","2026-04-04":"2026-04-04T13:20:00+03:00","2026-04-05":"2026-04-05T13:20:00+03:00","2026-04-06":"2026-04-06T13:19:00+03:00","2026-04-07":"2026-04-07T13:19:00+03:00","2026-04-08":"2026-04-08T13:19:00+03:00","2026-04-09":"2026-04-09T13:19:00+03:00","2026-04-10":"2026-04-10T13:19:00+03:00","2026-04-11":"2026-04-11T13:18:00+03:00","2026-04-12":"2026-04-12T13:18:00+03:00","2026-04-13":"2026-04-13T13:18:00+03:00","2026-04-14":"2026-04-14T13:18:00+03:00","2026-04-15":"2026-04-15T13:18:00+03:00","2026-04-16":"2026-04-16T13:18:00+03:00","2026-04-17":"2026-04-17T13:17:00+03:00","2026-04-18":"2026-04-18T13:17:00+03:00","2026-04-19":"2026-04-19T13:17:00+03:00","2026-04-20":"2026-04-20T13:17:00+03:00","2026-04-21":"2026-04-21T13:17:00+03:00","2026-04-22":"2026-04-22T13:17:00+03:00","2026-04-23":"2026-04-23T13:17:00+03:00","2026-04-24":"2026-04-24T13:17:00+03:00","2026-04-25":"2026-04-25T13:16:00+03:00","2026-04-26":"2026-04-26T13:16:00+03:00","2026-04-27":"2026-04-27T13:16:00+03:00","2026-04-28":"2026-04-28T13:16:00+03:00","2026-04-29":"2026-04-29T13:16:00+03:00","2026-04-30":"2026-04-30T13:16:00+03:00","2026-05-01":"2026-05-01T13:16:00+03:00","2026-05-02":"2026-05-02T13:16:00+03:00","2026-05-03":"2026-05-03T13:16:00+03:00","2026-05-04":"2026-05-04T13:16:00+03:00","2026-05-05":"2026-05-05T13:16:00+03:00","2026-05-06":"2026-05-06T13:16:00+03:00","2026-05-07":"2026-05-07T13:16:00+03:00","2026-05-08":"2026-05-08T13:16:00+03:00","2026-05-09":"2026-05-09T13:16:00+03:00","2026-05-10":"2026-05-10T13:16:00+03:00","2026-05-11":"2026-05-11T13:16:00+03:00","2026-05-12":"2026-05-12T13:16:00+03:00","2026-05-13":"2026-05-13T13:16:00+03:00","2026-05-14":"2026-05-14T13:16:00+03:00","2026-05-15":"2026-05-15T13:16:00+03:00","2026-05-16":"2026-05-16T13:16:00+03:00","2026-05-17":"2026-05-17T13:16:00+03:00","2026-05-18":"2026-05-18T13:16:00+03:00","2026-05-19":"2026-05-19T13:16:00+03:00","2026-05-20":"2026-05-20T13:17:00+03:00","2026-05-21":"2026-05-21T13:17:00+03:00","2026-05-22":"2026-05-22T13:17:00+03:00","2026-05-23":"2026-05-23T13:17:00+03:00","2026-05-24":"2026-05-24T13:17:00+03:00","2026-05-25":"2026-05-25T13:17:00+03:00","2026-05-26":"2026-05-26T13:17:00+03:00","2026-05-27":"2026-05-27T13:17:00+03:00","2026-05-28":"2026-05-28T13:18:00+03:00","2026-05-29":"2026-05-29T13:18:00+03:00","2026-05-30":"2026-05-30T13:18:00+03:00","2026-05-31":"2026-05-31T13:18:00+03:00","2026-06-01":"2026-06-01T13:18:00+03:00","2026-06-02":"2026-06-02T13:19:00+03:00","2026-06-03":"2026-06-03T13:19:00+03:00","2026-06-04":"2026-06-04T13:19:00+03:00","2026-06-05":"2026-06-05T13:19:00+03:00","2026-06-06":"2026-06-06T13:19:00+03:00","2026-06-07":"2026-06-07T13:19:00+03:00","2026-06-08":"2026-06-08T13:20:00+03:00","2026-06-09":"2026-06-09T13:20:00+03:00"},"minchaKetana":{"2026-03-24":"2026-03-24T15:20:00+02:00","2026-03-25":"2026-03-25T15:20:00+02:00","2026-03-26":"2026-03-26T15:21:00+02:00","2026-03-27":"2026-03-27T16:21:00+03:00","2026-03-28":"2026-03-28T16:21:00+03:00","2026-03-29":"2026-03-29T16:21:00+03:00","2026-03-30":"2026-03-30T16:22:00+03:00","2026-03-31":"2026-03-31T16:22:00+03:00","2026-04-01":"2026-04-01T16:22:00+03:00","2026-04-02":"2026-04-02T16:22:00+03:00","2026-04-03":"2026-04-03T16:23:00+03:00","2026-04-04":"2026-04-04T16:23:00+03:00","2026-04-05":"2026-04-05T16:23:00+03:00","2026-04-06":"2026-04-06T16:24:00+03:00","2026-04-07":"2026-04-07T16:24:00+03:00","2026-04-08":"2026-04-08T16:24:00+03:00","2026-04-09":"2026-04-09T16:24:00+03:00","2026-04-10":"2026-04-10T16:25:00+03:00","2026-04-11":"2026-04-11T16:25:00+03:00","2026-04-12":"2026-04-12T16:25:00+03:00","2026-04-13":"2026-04-13T16:26:00+03:00","2026-04-14":"2026-04-14T16:26:00+03:00","2026-04-15":"2026-04-15T16:26:00+03:00","2026-04-16":"2026-04-16T16:26:00+03:00","2026-04-17":"2026-04-17T16:27:00+03:00","2026-04-18":"2026-04-18T16:27:00+03:00","2026-04-19":"2026-04-19T16:27:00+03:00","2026-04-20":"2026-04-20T16:28:00+03:00","2026-04-21":"2026-04-21T16:28:00+03:00","2026-04-22":"2026-04-22T16:28:00+03:00","2026-04-23":"2026-04-23T16:29:00+03:00","2026-04-24":"2026-04-24T16:29:00+03:00","2026-04-25":"2026-04-25T16:29:00+03:00","2026-04-26":"2026-04-26T16:30:00+03:00","2026-04-27":"2026-04-27T16:30:00+03:00","2026-04-28":"2026-04-28T16:30:00+03:00","2026-04-29":"2026-04-29T16:31:00+03:00","2026-04-30":"2026-04-30T16:31:00+03:00","2026-05-01":"2026-05-01T16:31:00+03:00","2026-05-02":"2026-05-02T16:32:00+03:00","2026-05-03":"2026-05-03T16:32:00+03:00","2026-05-04":"2026-05-04T16:32:00+03:00","2026-05-05":"2026-05-05T16:33:00+03:00","2026-05-06":"2026-05-06T16:33:00+03:00","2026-05-07":"2026-05-07T16:34:00+03:00","2026-05-08":"2026-05-08T16:34:00+03:00","2026-05-09":"2026-05-09T16:34:00+03:00","2026-05-10":"2026-05-10T16:35:00+03:00","2026-05-11":"2026-05-11T16:35:00+03:00","2026-05-12":"2026-05-12T16:36:00+03:00","2026-05-13":"2026-05-13T16:36:00+03:00","2026-05-14":"2026-05-14T16:36:00+03:00","2026-05-15":"2026-05-15T16:37:00+03:00","2026-05-16":"2026-05-16T16:37:00+03:00","2026-05-17":"2026-05-17T16:38:00+03:00","2026-05-18":"2026-05-18T16:38:00+03:00","2026-05-19":"2026-05-19T16:38:00+03:00","2026-05-20":"2026-05-20T16:39:00+03:00","2026-05-21":"2026-05-21T16:39:00+03:00","2026-05-22":"2026-05-22T16:40:00+03:00","2026-05-23":"2026-05-23T16:40:00+03:00","2026-05-24":"2026-05-24T16:40:00+03:00","2026-05-25":"2026-05-25T16:41:00+03:00","2026-05-26":"2026-05-26T16:41:00+03:00","2026-05-27":"2026-05-27T16:42:00+03:00","2026-05-28":"2026-05-28T16:42:00+03:00","2026-05-29":"2026-05-29T16:42:00+03:00","2026-05-30":"2026-05-30T16:43:00+03:00","2026-05-31":"2026-05-31T16:43:00+03:00","2026-06-01":"2026-06-01T16:44:00+03:00","2026-06-02":"2026-06-02T16:44:00+03:00","2026-06-03":"2026-06-03T16:44:00+03:00","2026-06-04":"2026-06-04T16:45:00+03:00","2026-06-05":"2026-06-05T16:45:00+03:00","2026-06-06":"2026-06-06T16:45:00+03:00","2026-06-07":"2026-06-07T16:46:00+03:00","2026-06-08":"2026-06-08T16:46:00+03:00","2026-06-09":"2026-06-09T16:47:00+03:00"},"minchaKetanaBaalHatanya":{"2026-03-24":"2026-03-24T15:22:00+02:00","2026-03-25":"2026-03-25T15:22:00+02:00","2026-03-26":"2026-03-26T15:23:00+02:00","2026-03-27":"2026-03-27T16:23:00+03:00","2026-03-28":"2026-03-28T16:23:00+03:00","2026-03-29":"2026-03-29T16:23:00+03:00","2026-03-30":"2026-03-30T16:24:00+03:00","2026-03-31":"2026-03-31T16:24:00+03:00","2026-04-01":"2026-04-01T16:24:00+03:00","2026-04-02":"2026-04-02T16:25:00+03:00","2026-04-03":"2026-04-03T16:25:00+03:00","2026-04-04":"2026-04-04T16:25:00+03:00","2026-04-05":"2026-04-05T16:25:00+03:00","2026-04-06":"2026-04-06T16:26:00+03:00","2026-04-07":"2026-04-07T16:26:00+03:00","2026-04-08":"2026-04-08T16:26:00+03:00","2026-04-09":"2026-04-09T16:26:00+03:00","2026-04-10":"2026-04-10T16:27:00+03:00","2026-04-11":"2026-04-11T16:27:00+03:00","2026-04-12":"2026-04-12T16:27:00+03:00","2026-04-13":"2026-04-13T16:28:00+03:00","2026-04-14":"2026-04-14T16:28:00+03:00","2026-04-15":"2026-04-15T16:28:00+03:00","2026-04-16":"2026-04-16T16:29:00+03:00","2026-04-17":"2026-04-17T16:29:00+03:00","2026-04-18":"2026-04-18T16:29:00+03:00","2026-04-19":"2026-04-19T16:29:00+03:00","2026-04-20":"2026-04-20T16:30:00+03:00","2026-04-21":"2026-04-21T16:30:00+03:00","2026-04-22":"2026-04-22T16:30:00+03:00","2026-04-23":"2026-04-23T16:31:00+03:00","2026-04-24":"2026-04-24T16:31:00+03:00","2026-04-25":"2026-04-25T16:31:00+03:00","2026-04-26":"2026-04-26T16:32:00+03:00","2026-04-27":"2026-04-27T16:32:00+03:00","2026-04-28":"2026-04-28T16:32:00+03:00","2026-04-29":"2026-04-29T16:33:00+03:00","2026-04-30":"2026-04-30T16:33:00+03:00","2026-05-01":"2026-05-01T16:34:00+03:00","2026-05-02":"2026-05-02T16:34:00+03:00","2026-05-03":"2026-05-03T16:34:00+03:00","2026-05-04":"2026-05-04T16:35:00+03:00","2026-05-05":"2026-05-05T16:35:00+03:00","2026-05-06":"2026-05-06T16:35:00+03:00","2026-05-07":"2026-05-07T16:36:00+03:00","2026-05-08":"2026-05-08T16:36:00+03:00","2026-05-09":"2026-05-09T16:37:00+03:00","2026-05-10":"2026-05-10T16:37:00+03:00","2026-05-11":"2026-05-11T16:37:00+03:00","2026-05-12":"2026-05-12T16:38:00+03:00","2026-05-13":"2026-05-13T16:38:00+03:00","2026-05-14":"2026-05-14T16:39:00+03:00","2026-05-15":"2026-05-15T16:39:00+03:00","2026-05-16":"2026-05-16T16:39:00+03:00","2026-05-17":"2026-05-17T16:40:00+03:00","2026-05-18":"2026-05-18T16:40:00+03:00","2026-05-19":"2026-05-19T16:41:00+03:00","2026-05-20":"2026-05-20T16:41:00+03:00","2026-05-21":"2026-05-21T16:41:00+03:00","2026-05-22":"2026-05-22T16:42:00+03:00","2026-05-23":"2026-05-23T16:42:00+03:00","2026-05-24":"2026-05-24T16:43:00+03:00","2026-05-25":"2026-05-25T16:43:00+03:00","2026-05-26":"2026-05-26T16:44:00+03:00","2026-05-27":"2026-05-27T16:44:00+03:00","2026-05-28":"2026-05-28T16:44:00+03:00","2026-05-29":"2026-05-29T16:45:00+03:00","2026-05-30":"2026-05-30T16:45:00+03:00","2026-05-31":"2026-05-31T16:46:00+03:00","2026-06-01":"2026-06-01T16:46:00+03:00","2026-06-02":"2026-06-02T16:46:00+03:00","2026-06-03":"2026-06-03T16:47:00+03:00","2026-06-04":"2026-06-04T16:47:00+03:00","2026-06-05":"2026-06-05T16:47:00+03:00","2026-06-06":"2026-06-06T16:48:00+03:00","2026-06-07":"2026-06-07T16:48:00+03:00","2026-06-08":"2026-06-08T16:49:00+03:00","2026-06-09":"2026-06-09T16:49:00+03:00"},"minchaKetanaMGA":{"2026-03-24":"2026-03-24T16:02:00+02:00","2026-03-25":"2026-03-25T16:02:00+02:00","2026-03-26":"2026-03-26T16:03:00+02:00","2026-03-27":"2026-03-27T17:03:00+03:00","2026-03-28":"2026-03-28T17:03:00+03:00","2026-03-29":"2026-03-29T17:03:00+03:00","2026-03-30":"2026-03-30T17:04:00+03:00","2026-03-31":"2026-03-31T17:04:00+03:00","2026-04-01":"2026-04-01T17:04:00+03:00","2026-04-02":"2026-04-02T17:04:00+03:00","2026-04-03":"2026-04-03T17:05:00+03:00","2026-04-04":"2026-04-04T17:05:00+03:00","2026-04-05":"2026-04-05T17:05:00+03:00","2026-04-06":"2026-04-06T17:06:00+03:00","2026-04-07":"2026-04-07T17:06:00+03:00","2026-04-08":"2026-04-08T17:06:00+03:00","2026-04-09":"2026-04-09T17:06:00+03:00","2026-04-10":"2026-04-10T17:07:00+03:00","2026-04-11":"2026-04-11T17:07:00+03:00","2026-04-12":"2026-04-12T17:07:00+03:00","2026-04-13":"2026-04-13T17:08:00+03:00","2026-04-14":"2026-04-14T17:08:00+03:00","2026-04-15":"2026-04-15T17:08:00+03:00","2026-04-16":"2026-04-16T17:08:00+03:00","2026-04-17":"2026-04-17T17:09:00+03:00","2026-04-18":"2026-04-18T17:09:00+03:00","2026-04-19":"2026-04-19T17:09:00+03:00","2026-04-20":"2026-04-20T17:10:00+03:00","2026-04-21":"2026-04-21T17:10:00+03:00","2026-04-22":"2026-04-22T17:10:00+03:00","2026-04-23":"2026-04-23T17:11:00+03:00","2026-04-24":"2026-04-24T17:11:00+03:00","2026-04-25":"2026-04-25T17:11:00+03:00","2026-04-26":"2026-04-26T17:12:00+03:00","2026-04-27":"2026-04-27T17:12:00+03:00","2026-04-28":"2026-04-28T17:12:00+03:00","2026-04-29":"2026-04-29T17:13:00+03:00","2026-04-30":"2026-04-30T17:13:00+03:00","2026-05-01":"2026-05-01T17:13:00+03:00","2026-05-02":"2026-05-02T17:14:00+03:00","2026-05-03":"2026-05-03T17:14:00+03:00","2026-05-04":"2026-05-04T17:14:00+03:00","2026-05-05":"2026-05-05T17:15:00+03:00","2026-05-06":"2026-05-06T17:15:00+03:00","2026-05-07":"2026-05-07T17:16:00+03:00","2026-05-08":"2026-05-08T17:16:00+03:00","2026-05-09":"2026-05-09T17:16:00+03:00","2026-05-10":"2026-05-10T17:17:00+03:00","2026-05-11":"2026-05-11T17:17:00+03:00","2026-05-12":"2026-05-12T17:18:00+03:00","2026-05-13":"2026-05-13T17:18:00+03:00","2026-05-14":"2026-05-14T17:18:00+03:00","2026-05-15":"2026-05-15T17:19:00+03:00","2026-05-16":"2026-05-16T17:19:00+03:00","2026-05-17":"2026-05-17T17:20:00+03:00","2026-05-18":"2026-05-18T17:20:00+03:00","2026-05-19":"2026-05-19T17:20:00+03:00","2026-05-20":"2026-05-20T17:21:00+03:00","2026-05-21":"2026-05-21T17:21:00+03:00","2026-05-22":"2026-05-22T17:22:00+03:00","2026-05-23":"2026-05-23T17:22:00+03:00","2026-05-24":"2026-05-24T17:22:00+03:00","2026-05-25":"2026-05-25T17:23:00+03:00","2026-05-26":"2026-05-26T17:23:00+03:00","2026-05-27":"2026-05-27T17:24:00+03:00","2026-05-28":"2026-05-28T17:24:00+03:00","2026-05-29":"2026-05-29T17:24:00+03:00","2026-05-30":"2026-05-30T17:25:00+03:00","2026-05-31":"2026-05-31T17:25:00+03:00","2026-06-01":"2026-06-01T17:26:00+03:00","2026-06-02":"2026-06-02T17:26:00+03:00","2026-06-03":"2026-06-03T17:26:00+03:00","2026-06-04":"2026-06-04T17:27:00+03:00","2026-06-05":"2026-06-05T17:27:00+03:00","2026-06-06":"2026-06-06T17:27:00+03:00","2026-06-07":"2026-06-07T17:28:00+03:00","2026-06-08":"2026-06-08T17:28:00+03:00","2026-06-09":"2026-06-09T17:29:00+03:00"},"plagHaMincha":{"2026-03-24":"2026-03-24T16:37:00+02:00","2026-03-25":"2026-03-25T16:37:00+02:00","2026-03-26":"2026-03-26T16:38:00+02:00","2026-03-27":"2026-03-27T17:38:00+03:00","2026-03-28":"2026-03-28T17:39:00+03:00","2026-03-29":"2026-03-29T17:39:00+03:00","2026-03-30":"2026-03-30T17:39:00+03:00","2026-03-31":"2026-03-31T17:40:00+03:00","2026-04-01":"2026-04-01T17:40:00+03:00","2026-04-02":"2026-04-02T17:41:00+03:00","2026-04-03":"2026-04-03T17:41:00+03:00","2026-04-04":"2026-04-04T17:42:00+03:00","2026-04-05":"2026-04-05T17:42:00+03:00","2026-04-06":"2026-04-06T17:43:00+03:00","2026-04-07":"2026-04-07T17:43:00+03:00","2026-04-08":"2026-04-08T17:44:00+03:00","2026-04-09":"2026-04-09T17:44:00+03:00","2026-04-10":"2026-04-10T17:45:00+03:00","2026-04-11":"2026-04-11T17:45:00+03:00","2026-04-12":"2026-04-12T17:46:00+03:00","2026-04-13":"2026-04-13T17:46:00+03:00","2026-04-14":"2026-04-14T17:47:00+03:00","2026-04-15":"2026-04-15T17:47:00+03:00","2026-04-16":"2026-04-16T17:48:00+03:00","2026-04-17":"2026-04-17T17:48:00+03:00","2026-04-18":"2026-04-18T17:49:00+03:00","2026-04-19":"2026-04-19T17:49:00+03:00","2026-04-20":"2026-04-20T17:50:00+03:00","2026-04-21":"2026-04-21T17:50:00+03:00","2026-04-22":"2026-04-22T17:51:00+03:00","2026-04-23":"2026-04-23T17:51:00+03:00","2026-04-24":"2026-04-24T17:52:00+03:00","2026-04-25":"2026-04-25T17:52:00+03:00","2026-04-26":"2026-04-26T17:53:00+03:00","2026-04-27":"2026-04-27T17:53:00+03:00","2026-04-28":"2026-04-28T17:54:00+03:00","2026-04-29":"2026-04-29T17:54:00+03:00","2026-04-30":"2026-04-30T17:55:00+03:00","2026-05-01":"2026-05-01T17:55:00+03:00","2026-05-02":"2026-05-02T17:56:00+03:00","2026-05-03":"2026-05-03T17:56:00+03:00","2026-05-04":"2026-05-04T17:57:00+03:00","2026-05-05":"2026-05-05T17:57:00+03:00","2026-05-06":"2026-05-06T17:58:00+03:00","2026-05-07":"2026-05-07T17:58:00+03:00","2026-05-08":"2026-05-08T17:59:00+03:00","2026-05-09":"2026-05-09T18:00:00+03:00","2026-05-10":"2026-05-10T18:00:00+03:00","2026-05-11":"2026-05-11T18:01:00+03:00","2026-05-12":"2026-05-12T18:01:00+03:00","2026-05-13":"2026-05-13T18:02:00+03:00","2026-05-14":"2026-05-14T18:02:00+03:00","2026-05-15":"2026-05-15T18:03:00+03:00","2026-05-16":"2026-05-16T18:03:00+03:00","2026-05-17":"2026-05-17T18:04:00+03:00","2026-05-18":"2026-05-18T18:04:00+03:00","2026-05-19":"2026-05-19T18:05:00+03:00","2026-05-20":"2026-05-20T18:06:00+03:00","2026-05-21":"2026-05-21T18:06:00+03:00","2026-05-22":"2026-05-22T18:07:00+03:00","2026-05-23":"2026-05-23T18:07:00+03:00","2026-05-24":"2026-05-24T18:08:00+03:00","2026-05-25":"2026-05-25T18:08:00+03:00","2026-05-26":"2026-05-26T18:09:00+03:00","2026-05-27":"2026-05-27T18:09:00+03:00","2026-05-28":"2026-05-28T18:10:00+03:00","2026-05-29":"2026-05-29T18:10:00+03:00","2026-05-30":"2026-05-30T18:11:00+03:00","2026-05-31":"2026-05-31T18:11:00+03:00","2026-06-01":"2026-06-01T18:12:00+03:00","2026-06-02":"2026-06-02T18:12:00+03:00","2026-06-03":"2026-06-03T18:13:00+03:00","2026-06-04":"2026-06-04T18:13:00+03:00","2026-06-05":"2026-06-05T18:13:00+03:00","2026-06-06":"2026-06-06T18:14:00+03:00","2026-06-07":"2026-06-07T18:14:00+03:00","2026-06-08":"2026-06-08T18:15:00+03:00","2026-06-09":"2026-06-09T18:15:00+03:00"},"plagHaminchaBaalHatanya":{"2026-03-24":"2026-03-24T16:39:00+02:00","2026-03-25":"2026-03-25T16:40:00+02:00","2026-03-26":"2026-03-26T16:40:00+02:00","2026-03-27":"2026-03-27T17:41:00+03:00","2026-03-28":"2026-03-28T17:41:00+03:00","2026-03-29":"2026-03-29T17:42:00+03:00","2026-03-30":"2026-03-30T17:42:00+03:00","2026-03-31":"2026-03-31T17:43:00+03:00","2026-04-01":"2026-04-01T17:43:00+03:00","2026-04-02":"2026-04-02T17:44:00+03:00","2026-04-03":"2026-04-03T17:44:00+03:00","2026-04-04":"2026-04-04T17:45:00+03:00","2026-04-05":"2026-04-05T17:45:00+03:00","2026-04-06":"2026-04-06T17:46:00+03:00","2026-04-07":"2026-04-07T17:46:00+03:00","2026-04-08":"2026-04-08T17:47:00+03:00","2026-04-09":"2026-04-09T17:47:00+03:00","2026-04-10":"2026-04-10T17:47:00+03:00","2026-04-11":"2026-04-11T17:48:00+03:00","2026-04-12":"2026-04-12T17:48:00+03:00","2026-04-13":"2026-04-13T17:49:00+03:00","2026-04-14":"2026-04-14T17:49:00+03:00","2026-04-15":"2026-04-15T17:50:00+03:00","2026-04-16":"2026-04-16T17:50:00+03:00","2026-04-17":"2026-04-17T17:51:00+03:00","2026-04-18":"2026-04-18T17:51:00+03:00","2026-04-19":"2026-04-19T17:52:00+03:00","2026-04-20":"2026-04-20T17:52:00+03:00","2026-04-21":"2026-04-21T17:53:00+03:00","2026-04-22":"2026-04-22T17:53:00+03:00","2026-04-23":"2026-04-23T17:54:00+03:00","2026-04-24":"2026-04-24T17:55:00+03:00","2026-04-25":"2026-04-25T17:55:00+03:00","2026-04-26":"2026-04-26T17:56:00+03:00","2026-04-27":"2026-04-27T17:56:00+03:00","2026-04-28":"2026-04-28T17:57:00+03:00","2026-04-29":"2026-04-29T17:57:00+03:00","2026-04-30":"2026-04-30T17:58:00+03:00","2026-05-01":"2026-05-01T17:58:00+03:00","2026-05-02":"2026-05-02T17:59:00+03:00","2026-05-03":"2026-05-03T17:59:00+03:00","2026-05-04":"2026-05-04T18:00:00+03:00","2026-05-05":"2026-05-05T18:00:00+03:00","2026-05-06":"2026-05-06T18:01:00+03:00","2026-05-07":"2026-05-07T18:01:00+03:00","2026-05-08":"2026-05-08T18:02:00+03:00","2026-05-09":"2026-05-09T18:03:00+03:00","2026-05-10":"2026-05-10T18:03:00+03:00","2026-05-11":"2026-05-11T18:04:00+03:00","2026-05-12":"2026-05-12T18:04:00+03:00","2026-05-13":"2026-05-13T18:05:00+03:00","2026-05-14":"2026-05-14T18:05:00+03:00","2026-05-15":"2026-05-15T18:06:00+03:00","2026-05-16":"2026-05-16T18:06:00+03:00","2026-05-17":"2026-05-17T18:07:00+03:00","2026-05-18":"2026-05-18T18:08:00+03:00","2026-05-19":"2026-05-19T18:08:00+03:00","2026-05-20":"2026-05-20T18:09:00+03:00","2026-05-21":"2026-05-21T18:09:00+03:00","2026-05-22":"2026-05-22T18:10:00+03:00","2026-05-23":"2026-05-23T18:10:00+03:00","2026-05-24":"2026-05-24T18:11:00+03:00","2026-05-25":"2026-05-25T18:11:00+03:00","2026-05-26":"2026-05-26T18:12:00+03:00","2026-05-27":"2026-05-27T18:12:00+03:00","2026-05-28":"2026-05-28T18:13:00+03:00","2026-05-29":"2026-05-29T18:13:00+03:00","2026-05-30":"2026-05-30T18:14:00+03:00","2026-05-31":"2026-05-31T18:14:00+03:00","2026-06-01":"2026-06-01T18:15:00+03:00","2026-06-02":"2026-06-02T18:15:00+03:00","2026-06-03":"2026-06-03T18:16:00+03:00","2026-06-04":"2026-06-04T18:16:00+03:00","2026-06-05":"2026-06-05T18:17:00+03:00","2026-06-06":"2026-06-06T18:17:00+03:00","2026-06-07":"2026-06-07T18:18:00+03:00","2026-06-08":"2026-06-08T18:18:00+03:00","2026-06-09":"2026-06-09T18:18:00+03:00"},"seaLevelSunset":{},"sunset":{"2026-03-24":"2026-03-24T17:53:00+02:00","2026-03-25":"2026-03-25T17:54:00+02:00","2026-03-26":"2026-03-26T17:55:00+02:00","2026-03-27":"2026-03-27T18:55:00+03:00","2026-03-28":"2026-03-28T18:56:00+03:00","2026-03-29":"2026-03-29T18:57:00+03:00","2026-03-30":"2026-03-30T18:57:00+03:00","2026-03-31":"2026-03-31T18:58:00+03:00","2026-04-01":"2026-04-01T18:59:00+03:00","2026-04-02":"2026-04-02T18:59:00+03:00","2026-04-03":"2026-04-03T19:00:00+03:00","2026-04-04":"2026-04-04T19:01:00+03:00","2026-04-05":"2026-04-05T19:01:00+03:00","2026-04-06":"2026-04-06T19:02:00+03:00","2026-04-07":"2026-04-07T19:03:00+03:00","2026-04-08":"2026-04-08T19:03:00+03:00","2026-04-09":"2026-04-09T19:04:00+03:00","2026-04-10":"2026-04-10T19:05:00+03:00","2026-04-11":"2026-04-11T19:05:00+03:00","2026-04-12":"2026-04-12T19:06:00+03:00","2026-04-13":"2026-04-13T19:07:00+03:00","2026-04-14":"2026-04-14T19:07:00+03:00","2026-04-15":"2026-04-15T19:08:00+03:00","2026-04-16":"2026-04-16T19:09:00+03:00","2026-04-17":"2026-04-17T19:09:00+03:00","2026-04-18":"2026-04-18T19:10:00+03:00","2026-04-19":"2026-04-19T19:11:00+03:00","2026-04-20":"2026-04-20T19:11:00+03:00","2026-04-21":"2026-04-21T19:12:00+03:00","2026-04-22":"2026-04-22T19:13:00+03:00","2026-04-23":"2026-04-23T19:14:00+03:00","2026-04-24":"2026-04-24T19:14:00+03:00","2026-04-25":"2026-04-25T19:15:00+03:00","2026-04-26":"2026-04-26T19:16:00+03:00","2026-04-27":"2026-04-27T19:16:00+03:00","2026-04-28":"2026-04-28T19:17:00+03:00","2026-04-29":"2026-04-29T19:18:00+03:00","2026-04-30":"2026-04-30T19:18:00+03:00","2026-05-01":"2026-05-01T19:19:00+03:00","2026-05-02":"2026-05-02T19:20:00+03:00","2026-05-03":"2026-05-03T19:21:00+03:00","2026-05-04":"2026-05-04T19:21:00+03:00","2026-05-05":"2026-05-05T19:22:00+03:00","2026-05-06":"2026-05-06T19:23:00+03:00","2026-05-07":"2026-05-07T19:23:00+03:00","2026-05-08":"2026-05-08T19:24:00+03:00","2026-05-09":"2026-05-09T19:25:00+03:00","2026-05-10":"2026-05-10T19:25:00+03:00","2026-05-11":"2026-05-11T19:26:00+03:00","2026-05-12":"2026-05-12T19:27:00+03:00","2026-05-13":"2026-05-13T19:28:00+03:00","2026-05-14":"2026-05-14T19:28:00+03:00","2026-05-15":"2026-05-15T19:29:00+03:00","2026-05-16":"2026-05-16T19:30:00+03:00","2026-05-17":"2026-05-17T19:30:00+03:00","2026-05-18":"2026-05-18T19:31:00+03:00","2026-05-19":"2026-05-19T19:32:00+03:00","2026-05-20":"2026-05-20T19:32:00+03:00","2026-05-21":"2026-05-21T19:33:00+03:00","2026-05-22":"2026-05-22T19:34:00+03:00","2026-05-23":"2026-05-23T19:34:00+03:00","2026-05-24":"2026-05-24T19:35:00+03:00","2026-05-25":"2026-05-25T19:36:00+03:00","2026-05-26":"2026-05-26T19:36:00+03:00","2026-05-27":"2026-05-27T19:37:00+03:00","2026-05-28":"2026-05-28T19:37:00+03:00","2026-05-29":"2026-05-29T19:38:00+03:00","2026-05-30":"2026-05-30T19:39:00+03:00","2026-05-31":"2026-05-31T19:39:00+03:00","2026-06-01":"2026-06-01T19:40:00+03:00","2026-06-02":"2026-06-02T19:40:00+03:00","2026-06-03":"2026-06-03T19:41:00+03:00","2026-06-04":"2026-06-04T19:41:00+03:00","2026-06-05":"2026-06-05T19:42:00+03:00","2026-06-06":"2026-06-06T19:42:00+03:00","2026-06-07":"2026-06-07T19:43:00+03:00","2026-06-08":"2026-06-08T19:43:00+03:00","2026-06-09":"2026-06-09T19:44:00+03:00"},"beinHaShmashos":{"2026-03-24":"2026-03-24T18:09:00+02:00","2026-03-25":"2026-03-25T18:10:00+02:00","2026-03-26":"2026-03-26T18:11:00+02:00","2026-03-27":"2026-03-27T19:11:00+03:00","2026-03-28":"2026-03-28T19:12:00+03:00","2026-03-29":"2026-03-29T19:13:00+03:00","2026-03-30":"2026-03-30T19:13:00+03:00","2026-03-31":"2026-03-31T19:14:00+03:00","2026-04-01":"2026-04-01T19:15:00+03:00","2026-04-02":"2026-04-02T19:16:00+03:00","2026-04-03":"2026-04-03T19:16:00+03:00","2026-04-04":"2026-04-04T19:17:00+03:00","2026-04-05":"2026-04-05T19:18:00+03:00","2026-04-06":"2026-04-06T19:18:00+03:00","2026-04-07":"2026-04-07T19:19:00+03:00","2026-04-08":"2026-04-08T19:20:00+03:00","2026-04-09":"2026-04-09T19:21:00+03:00","2026-04-10":"2026-04-10T19:21:00+03:00","2026-04-11":"2026-04-11T19:22:00+03:00","2026-04-12":"2026-04-12T19:23:00+03:00","2026-04-13":"2026-04-13T19:23:00+03:00","2026-04-14":"2026-04-14T19:24:00+03:00","2026-04-15":"2026-04-15T19:25:00+03:00","2026-04-16":"2026-04-16T19:26:00+03:00","2026-04-17":"2026-04-17T19:26:00+03:00","2026-04-18":"2026-04-18T19:27:00+03:00","2026-04-19":"2026-04-19T19:28:00+03:00","2026-04-20":"2026-04-20T19:29:00+03:00","2026-04-21":"2026-04-21T19:29:00+03:00","2026-04-22":"2026-04-22T19:30:00+03:00","2026-04-23":"2026-04-23T19:31:00+03:00","2026-04-24":"2026-04-24T19:32:00+03:00","2026-04-25":"2026-04-25T19:32:00+03:00","2026-04-26":"2026-04-26T19:33:00+03:00","2026-04-27":"2026-04-27T19:34:00+03:00","2026-04-28":"2026-04-28T19:35:00+03:00","2026-04-29":"2026-04-29T19:36:00+03:00","2026-04-30":"2026-04-30T19:36:00+03:00","2026-05-01":"2026-05-01T19:37:00+03:00","2026-05-02":"2026-05-02T19:38:00+03:00","2026-05-03":"2026-05-03T19:39:00+03:00","2026-05-04":"2026-05-04T19:39:00+03:00","2026-05-05":"2026-05-05T19:40:00+03:00","2026-05-06":"2026-05-06T19:41:00+03:00","2026-05-07":"2026-05-07T19:42:00+03:00","2026-05-08":"2026-05-08T19:43:00+03:00","2026-05-09":"2026-05-09T19:43:00+03:00","2026-05-10":"2026-05-10T19:44:00+03:00","2026-05-11":"2026-05-11T19:45:00+03:00","2026-05-12":"2026-05-12T19:46:00+03:00","2026-05-13":"2026-05-13T19:46:00+03:00","2026-05-14":"2026-05-14T19:47:00+03:00","2026-05-15":"2026-05-15T19:48:00+03:00","2026-05-16":"2026-05-16T19:49:00+03:00","2026-05-17":"2026-05-17T19:50:00+03:00","2026-05-18":"2026-05-18T19:50:00+03:00","2026-05-19":"2026-05-19T19:51:00+03:00","2026-05-20":"2026-05-20T19:52:00+03:00","2026-05-21":"2026-05-21T19:52:00+03:00","2026-05-22":"2026-05-22T19:53:00+03:00","2026-05-23":"2026-05-23T19:54:00+03:00","2026-05-24":"2026-05-24T19:55:00+03:00","2026-05-25":"2026-05-25T19:55:00+03:00","2026-05-26":"2026-05-26T19:56:00+03:00","2026-05-27":"2026-05-27T19:57:00+03:00","2026-05-28":"2026-05-28T19:57:00+03:00","2026-05-29":"2026-05-29T19:58:00+03:00","2026-05-30":"2026-05-30T19:59:00+03:00","2026-05-31":"2026-05-31T19:59:00+03:00","2026-06-01":"2026-06-01T20:00:00+03:00","2026-06-02":"2026-06-02T20:01:00+03:00","2026-06-03":"2026-06-03T20:01:00+03:00","2026-06-04":"2026-06-04T20:02:00+03:00","2026-06-05":"2026-06-05T20:02:00+03:00","2026-06-06":"2026-06-06T20:03:00+03:00","2026-06-07":"2026-06-07T20:03:00+03:00","2026-06-08":"2026-06-08T20:04:00+03:00","2026-06-09":"2026-06-09T20:04:00+03:00"},"dusk":{"2026-03-24":"2026-03-24T18:18:00+02:00","2026-03-25":"2026-03-25T18:18:00+02:00","2026-03-26":"2026-03-26T18:19:00+02:00","2026-03-27":"2026-03-27T19:20:00+03:00","2026-03-28":"2026-03-28T19:20:00+03:00","2026-03-29":"2026-03-29T19:21:00+03:00","2026-03-30":"2026-03-30T19:22:00+03:00","2026-03-31":"2026-03-31T19:22:00+03:00","2026-04-01":"2026-04-01T19:23:00+03:00","2026-04-02":"2026-04-02T19:24:00+03:00","2026-04-03":"2026-04-03T19:25:00+03:00","2026-04-04":"2026-04-04T19:25:00+03:00","2026-04-05":"2026-04-05T19:26:00+03:00","2026-04-06":"2026-04-06T19:27:00+03:00","2026-04-07":"2026-04-07T19:27:00+03:00","2026-04-08":"2026-04-08T19:28:00+03:00","2026-04-09":"2026-04-09T19:29:00+03:00","2026-04-10":"2026-04-10T19:29:00+03:00","2026-04-11":"2026-04-11T19:30:00+03:00","2026-04-12":"2026-04-12T19:31:00+03:00","2026-04-13":"2026-04-13T19:32:00+03:00","2026-04-14":"2026-04-14T19:32:00+03:00","2026-04-15":"2026-04-15T19:33:00+03:00","2026-04-16":"2026-04-16T19:34:00+03:00","2026-04-17":"2026-04-17T19:35:00+03:00","2026-04-18":"2026-04-18T19:35:00+03:00","2026-04-19":"2026-04-19T19:36:00+03:00","2026-04-20":"2026-04-20T19:37:00+03:00","2026-04-21":"2026-04-21T19:38:00+03:00","2026-04-22":"2026-04-22T19:38:00+03:00","2026-04-23":"2026-04-23T19:39:00+03:00","2026-04-24":"2026-04-24T19:40:00+03:00","2026-04-25":"2026-04-25T19:41:00+03:00","2026-04-26":"2026-04-26T19:41:00+03:00","2026-04-27":"2026-04-27T19:42:00+03:00","2026-04-28":"2026-04-28T19:43:00+03:00","2026-04-29":"2026-04-29T19:44:00+03:00","2026-04-30":"2026-04-30T19:44:00+03:00","2026-05-01":"2026-05-01T19:45:00+03:00","2026-05-02":"2026-05-02T19:46:00+03:00","2026-05-03":"2026-05-03T19:47:00+03:00","2026-05-04":"2026-05-04T19:47:00+03:00","2026-05-05":"2026-05-05T19:48:00+03:00","2026-05-06":"2026-05-06T19:49:00+03:00","2026-05-07":"2026-05-07T19:50:00+03:00","2026-05-08":"2026-05-08T19:50:00+03:00","2026-05-09":"2026-05-09T19:51:00+03:00","2026-05-10":"2026-05-10T19:52:00+03:00","2026-05-11":"2026-05-11T19:53:00+03:00","2026-05-12":"2026-05-12T19:54:00+03:00","2026-05-13":"2026-05-13T19:54:00+03:00","2026-05-14":"2026-05-14T19:55:00+03:00","2026-05-15":"2026-05-15T19:56:00+03:00","2026-05-16":"2026-05-16T19:57:00+03:00","2026-05-17":"2026-05-17T19:57:00+03:00","2026-05-18":"2026-05-18T19:58:00+03:00","2026-05-19":"2026-05-19T19:59:00+03:00","2026-05-20":"2026-05-20T19:59:00+03:00","2026-05-21":"2026-05-21T20:00:00+03:00","2026-05-22":"2026-05-22T20:01:00+03:00","2026-05-23":"2026-05-23T20:02:00+03:00","2026-05-24":"2026-05-24T20:02:00+03:00","2026-05-25":"2026-05-25T20:03:00+03:00","2026-05-26":"2026-05-26T20:04:00+03:00","2026-05-27":"2026-05-27T20:04:00+03:00","2026-05-28":"2026-05-28T20:05:00+03:00","2026-05-29":"2026-05-29T20:06:00+03:00","2026-05-30":"2026-05-30T20:06:00+03:00","2026-05-31":"2026-05-31T20:07:00+03:00","2026-06-01":"2026-06-01T20:07:00+03:00","2026-06-02":"2026-06-02T20:08:00+03:00","2026-06-03":"2026-06-03T20:09:00+03:00","2026-06-04":"2026-06-04T20:09:00+03:00","2026-06-05":"2026-06-05T20:10:00+03:00","2026-06-06":"2026-06-06T20:10:00+03:00","2026-06-07":"2026-06-07T20:11:00+03:00","2026-06-08":"2026-06-08T20:11:00+03:00","2026-06-09":"2026-06-09T20:12:00+03:00"},"tzaisBaalHatanya":{"2026-03-24":"2026-03-24T18:18:00+02:00","2026-03-25":"2026-03-25T18:18:00+02:00","2026-03-26":"2026-03-26T18:19:00+02:00","2026-03-27":"2026-03-27T19:20:00+03:00","2026-03-28":"2026-03-28T19:20:00+03:00","2026-03-29":"2026-03-29T19:21:00+03:00","2026-03-30":"2026-03-30T19:22:00+03:00","2026-03-31":"2026-03-31T19:22:00+03:00","2026-04-01":"2026-04-01T19:23:00+03:00","2026-04-02":"2026-04-02T19:24:00+03:00","2026-04-03":"2026-04-03T19:25:00+03:00","2026-04-04":"2026-04-04T19:25:00+03:00","2026-04-05":"2026-04-05T19:26:00+03:00","2026-04-06":"2026-04-06T19:27:00+03:00","2026-04-07":"2026-04-07T19:27:00+03:00","2026-04-08":"2026-04-08T19:28:00+03:00","2026-04-09":"2026-04-09T19:29:00+03:00","2026-04-10":"2026-04-10T19:29:00+03:00","2026-04-11":"2026-04-11T19:30:00+03:00","2026-04-12":"2026-04-12T19:31:00+03:00","2026-04-13":"2026-04-13T19:32:00+03:00","2026-04-14":"2026-04-14T19:32:00+03:00","2026-04-15":"2026-04-15T19:33:00+03:00","2026-04-16":"2026-04-16T19:34:00+03:00","2026-04-17":"2026-04-17T19:35:00+03:00","2026-04-18":"2026-04-18T19:35:00+03:00","2026-04-19":"2026-04-19T19:36:00+03:00","2026-04-20":"2026-04-20T19:37:00+03:00","2026-04-21":"2026-04-21T19:38:00+03:00","2026-04-22":"2026-04-22T19:38:00+03:00","2026-04-23":"2026-04-23T19:39:00+03:00","2026-04-24":"2026-04-24T19:40:00+03:00","2026-04-25":"2026-04-25T19:41:00+03:00","2026-04-26":"2026-04-26T19:41:00+03:00","2026-04-27":"2026-04-27T19:42:00+03:00","2026-04-28":"2026-04-28T19:43:00+03:00","2026-04-29":"2026-04-29T19:44:00+03:00","2026-04-30":"2026-04-30T19:44:00+03:00","2026-05-01":"2026-05-01T19:45:00+03:00","2026-05-02":"2026-05-02T19:46:00+03:00","2026-05-03":"2026-05-03T19:47:00+03:00","2026-05-04":"2026-05-04T19:47:00+03:00","2026-05-05":"2026-05-05T19:48:00+03:00","2026-05-06":"2026-05-06T19:49:00+03:00","2026-05-07":"2026-05-07T19:50:00+03:00","2026-05-08":"2026-05-08T19:50:00+03:00","2026-05-09":"2026-05-09T19:51:00+03:00","2026-05-10":"2026-05-10T19:52:00+03:00","2026-05-11":"2026-05-11T19:53:00+03:00","2026-05-12":"2026-05-12T19:54:00+03:00","2026-05-13":"2026-05-13T19:54:00+03:00","2026-05-14":"2026-05-14T19:55:00+03:00","2026-05-15":"2026-05-15T19:56:00+03:00","2026-05-16":"2026-05-16T19:57:00+03:00","2026-05-17":"2026-05-17T19:57:00+03:00","2026-05-18":"2026-05-18T19:58:00+03:00","2026-05-19":"2026-05-19T19:59:00+03:00","2026-05-20":"2026-05-20T19:59:00+03:00","2026-05-21":"2026-05-21T20:00:00+03:00","2026-05-22":"2026-05-22T20:01:00+03:00","2026-05-23":"2026-05-23T20:02:00+03:00","2026-05-24":"2026-05-24T20:02:00+03:00","2026-05-25":"2026-05-25T20:03:00+03:00","2026-05-26":"2026-05-26T20:04:00+03:00","2026-05-27":"2026-05-27T20:04:00+03:00","2026-05-28":"2026-05-28T20:05:00+03:00","2026-05-29":"2026-05-29T20:06:00+03:00","2026-05-30":"2026-05-30T20:06:00+03:00","2026-05-31":"2026-05-31T20:07:00+03:00","2026-06-01":"2026-06-01T20:07:00+03:00","2026-06-02":"2026-06-02T20:08:00+03:00","2026-06-03":"2026-06-03T20:09:00+03:00","2026-06-04":"2026-06-04T20:09:00+03:00","2026-06-05":"2026-06-05T20:10:00+03:00","2026-06-06":"2026-06-06T20:10:00+03:00","2026-06-07":"2026-06-07T20:11:00+03:00","2026-06-08":"2026-06-08T20:11:00+03:00","2026-06-09":"2026-06-09T20:12:00+03:00"},"tzeit7083deg":{"2026-03-24":"2026-03-24T18:23:00+02:00","2026-03-25":"2026-03-25T18:23:00+02:00","2026-03-26":"2026-03-26T18:24:00+02:00","2026-03-27":"2026-03-27T19:25:00+03:00","2026-03-28":"2026-03-28T19:25:00+03:00","2026-03-29":"2026-03-29T19:26:00+03:00","2026-03-30":"2026-03-30T19:27:00+03:00","2026-03-31":"2026-03-31T19:28:00+03:00","2026-04-01":"2026-04-01T19:28:00+03:00","2026-04-02":"2026-04-02T19:29:00+03:00","2026-04-03":"2026-04-03T19:30:00+03:00","2026-04-04":"2026-04-04T19:30:00+03:00","2026-04-05":"2026-04-05T19:31:00+03:00","2026-04-06":"2026-04-06T19:32:00+03:00","2026-04-07":"2026-04-07T19:33:00+03:00","2026-04-08":"2026-04-08T19:33:00+03:00","2026-04-09":"2026-04-09T19:34:00+03:00","2026-04-10":"2026-04-10T19:35:00+03:00","2026-04-11":"2026-04-11T19:35:00+03:00","2026-04-12":"2026-04-12T19:36:00+03:00","2026-04-13":"2026-04-13T19:37:00+03:00","2026-04-14":"2026-04-14T19:38:00+03:00","2026-04-15":"2026-04-15T19:38:00+03:00","2026-04-16":"2026-04-16T19:39:00+03:00","2026-04-17":"2026-04-17T19:40:00+03:00","2026-04-18":"2026-04-18T19:41:00+03:00","2026-04-19":"2026-04-19T19:41:00+03:00","2026-04-20":"2026-04-20T19:42:00+03:00","2026-04-21":"2026-04-21T19:43:00+03:00","2026-04-22":"2026-04-22T19:44:00+03:00","2026-04-23":"2026-04-23T19:44:00+03:00","2026-04-24":"2026-04-24T19:45:00+03:00","2026-04-25":"2026-04-25T19:46:00+03:00","2026-04-26":"2026-04-26T19:47:00+03:00","2026-04-27":"2026-04-27T19:47:00+03:00","2026-04-28":"2026-04-28T19:48:00+03:00","2026-04-29":"2026-04-29T19:49:00+03:00","2026-04-30":"2026-04-30T19:50:00+03:00","2026-05-01":"2026-05-01T19:51:00+03:00","2026-05-02":"2026-05-02T19:51:00+03:00","2026-05-03":"2026-05-03T19:52:00+03:00","2026-05-04":"2026-05-04T19:53:00+03:00","2026-05-05":"2026-05-05T19:54:00+03:00","2026-05-06":"2026-05-06T19:55:00+03:00","2026-05-07":"2026-05-07T19:55:00+03:00","2026-05-08":"2026-05-08T19:56:00+03:00","2026-05-09":"2026-05-09T19:57:00+03:00","2026-05-10":"2026-05-10T19:58:00+03:00","2026-05-11":"2026-05-11T19:58:00+03:00","2026-05-12":"2026-05-12T19:59:00+03:00","2026-05-13":"2026-05-13T20:00:00+03:00","2026-05-14":"2026-05-14T20:01:00+03:00","2026-05-15":"2026-05-15T20:01:00+03:00","2026-05-16":"2026-05-16T20:02:00+03:00","2026-05-17":"2026-05-17T20:03:00+03:00","2026-05-18":"2026-05-18T20:04:00+03:00","2026-05-19":"2026-05-19T20:05:00+03:00","2026-05-20":"2026-05-20T20:05:00+03:00","2026-05-21":"2026-05-21T20:06:00+03:00","2026-05-22":"2026-05-22T20:07:00+03:00","2026-05-23":"2026-05-23T20:07:00+03:00","2026-05-24":"2026-05-24T20:08:00+03:00","2026-05-25":"2026-05-25T20:09:00+03:00","2026-05-26":"2026-05-26T20:10:00+03:00","2026-05-27":"2026-05-27T20:10:00+03:00","2026-05-28":"2026-05-28T20:11:00+03:00","2026-05-29":"2026-05-29T20:12:00+03:00","2026-05-30":"2026-05-30T20:12:00+03:00","2026-05-31":"2026-05-31T20:13:00+03:00","2026-06-01":"2026-06-01T20:13:00+03:00","2026-06-02":"2026-06-02T20:14:00+03:00","2026-06-03":"2026-06-03T20:15:00+03:00","2026-06-04":"2026-06-04T20:15:00+03:00","2026-06-05":"2026-06-05T20:16:00+03:00","2026-06-06":"2026-06-06T20:16:00+03:00","2026-06-07":"2026-06-07T20:17:00+03:00","2026-06-08":"2026-06-08T20:17:00+03:00","2026-06-09":"2026-06-09T20:18:00+03:00"},"tzeit85deg":{"2026-03-24":"2026-03-24T18:29:00+02:00","2026-03-25":"2026-03-25T18:30:00+02:00","2026-03-26":"2026-03-26T18:31:00+02:00","2026-03-27":"2026-03-27T19:32:00+03:00","2026-03-28":"2026-03-28T19:32:00+03:00","2026-03-29":"2026-03-29T19:33:00+03:00","2026-03-30":"2026-03-30T19:34:00+03:00","2026-03-31":"2026-03-31T19:34:00+03:00","2026-04-01":"2026-04-01T19:35:00+03:00","2026-04-02":"2026-04-02T19:36:00+03:00","2026-04-03":"2026-04-03T19:37:00+03:00","2026-04-04":"2026-04-04T19:37:00+03:00","2026-04-05":"2026-04-05T19:38:00+03:00","2026-04-06":"2026-04-06T19:39:00+03:00","2026-04-07":"2026-04-07T19:39:00+03:00","2026-04-08":"2026-04-08T19:40:00+03:00","2026-04-09":"2026-04-09T19:41:00+03:00","2026-04-10":"2026-04-10T19:42:00+03:00","2026-04-11":"2026-04-11T19:42:00+03:00","2026-04-12":"2026-04-12T19:43:00+03:00","2026-04-13":"2026-04-13T19:44:00+03:00","2026-04-14":"2026-04-14T19:45:00+03:00","2026-04-15":"2026-04-15T19:45:00+03:00","2026-04-16":"2026-04-16T19:46:00+03:00","2026-04-17":"2026-04-17T19:47:00+03:00","2026-04-18":"2026-04-18T19:48:00+03:00","2026-04-19":"2026-04-19T19:48:00+03:00","2026-04-20":"2026-04-20T19:49:00+03:00","2026-04-21":"2026-04-21T19:50:00+03:00","2026-04-22":"2026-04-22T19:51:00+03:00","2026-04-23":"2026-04-23T19:52:00+03:00","2026-04-24":"2026-04-24T19:52:00+03:00","2026-04-25":"2026-04-25T19:53:00+03:00","2026-04-26":"2026-04-26T19:54:00+03:00","2026-04-27":"2026-04-27T19:55:00+03:00","2026-04-28":"2026-04-28T19:55:00+03:00","2026-04-29":"2026-04-29T19:56:00+03:00","2026-04-30":"2026-04-30T19:57:00+03:00","2026-05-01":"2026-05-01T19:58:00+03:00","2026-05-02":"2026-05-02T19:59:00+03:00","2026-05-03":"2026-05-03T19:59:00+03:00","2026-05-04":"2026-05-04T20:00:00+03:00","2026-05-05":"2026-05-05T20:01:00+03:00","2026-05-06":"2026-05-06T20:02:00+03:00","2026-05-07":"2026-05-07T20:03:00+03:00","2026-05-08":"2026-05-08T20:03:00+03:00","2026-05-09":"2026-05-09T20:04:00+03:00","2026-05-10":"2026-05-10T20:05:00+03:00","2026-05-11":"2026-05-11T20:06:00+03:00","2026-05-12":"2026-05-12T20:07:00+03:00","2026-05-13":"2026-05-13T20:07:00+03:00","2026-05-14":"2026-05-14T20:08:00+03:00","2026-05-15":"2026-05-15T20:09:00+03:00","2026-05-16":"2026-05-16T20:10:00+03:00","2026-05-17":"2026-05-17T20:11:00+03:00","2026-05-18":"2026-05-18T20:11:00+03:00","2026-05-19":"2026-05-19T20:12:00+03:00","2026-05-20":"2026-05-20T20:13:00+03:00","2026-05-21":"2026-05-21T20:14:00+03:00","2026-05-22":"2026-05-22T20:14:00+03:00","2026-05-23":"2026-05-23T20:15:00+03:00","2026-05-24":"2026-05-24T20:16:00+03:00","2026-05-25":"2026-05-25T20:17:00+03:00","2026-05-26":"2026-05-26T20:17:00+03:00","2026-05-27":"2026-05-27T20:18:00+03:00","2026-05-28":"2026-05-28T20:19:00+03:00","2026-05-29":"2026-05-29T20:19:00+03:00","2026-05-30":"2026-05-30T20:20:00+03:00","2026-05-31":"2026-05-31T20:21:00+03:00","2026-06-01":"2026-06-01T20:21:00+03:00","2026-06-02":"2026-06-02T20:22:00+03:00","2026-06-03":"2026-06-03T20:23:00+03:00","2026-06-04":"2026-06-04T20:23:00+03:00","2026-06-05":"2026-06-05T20:24:00+03:00","2026-06-06":"2026-06-06T20:24:00+03:00","2026-06-07":"2026-06-07T20:25:00+03:00","2026-06-08":"2026-06-08T20:25:00+03:00","2026-06-09":"2026-06-09T20:26:00+03:00"},"tzeit42min":{"2026-03-24":"2026-03-24T18:35:00+02:00","2026-03-25":"2026-03-25T18:36:00+02:00","2026-03-26":"2026-03-26T18:37:00+02:00","2026-03-27":"2026-03-27T19:37:00+03:00","2026-03-28":"2026-03-28T19:38:00+03:00","2026-03-29":"2026-03-29T19:39:00+03:00","2026-03-30":"2026-03-30T19:39:00+03:00","2026-03-31":"2026-03-31T19:40:00+03:00","2026-04-01":"2026-04-01T19:41:00+03:00","2026-04-02":"2026-04-02T19:41:00+03:00","2026-04-03":"2026-04-03T19:42:00+03:00","2026-04-04":"2026-04-04T19:43:00+03:00","2026-04-05":"2026-04-05T19:43:00+03:00","2026-04-06":"2026-04-06T19:44:00+03:00","2026-04-07":"2026-04-07T19:45:00+03:00","2026-04-08":"2026-04-08T19:45:00+03:00","2026-04-09":"2026-04-09T19:46:00+03:00","2026-04-10":"2026-04-10T19:47:00+03:00","2026-04-11":"2026-04-11T19:47:00+03:00","2026-04-12":"2026-04-12T19:48:00+03:00","2026-04-13":"2026-04-13T19:49:00+03:00","2026-04-14":"2026-04-14T19:49:00+03:00","2026-04-15":"2026-04-15T19:50:00+03:00","2026-04-16":"2026-04-16T19:51:00+03:00","2026-04-17":"2026-04-17T19:51:00+03:00","2026-04-18":"2026-04-18T19:52:00+03:00","2026-04-19":"2026-04-19T19:53:00+03:00","2026-04-20":"2026-04-20T19:53:00+03:00","2026-04-21":"2026-04-21T19:54:00+03:00","2026-04-22":"2026-04-22T19:55:00+03:00","2026-04-23":"2026-04-23T19:56:00+03:00","2026-04-24":"2026-04-24T19:56:00+03:00","2026-04-25":"2026-04-25T19:57:00+03:00","2026-04-26":"2026-04-26T19:58:00+03:00","2026-04-27":"2026-04-27T19:58:00+03:00","2026-04-28":"2026-04-28T19:59:00+03:00","2026-04-29":"2026-04-29T20:00:00+03:00","2026-04-30":"2026-04-30T20:00:00+03:00","2026-05-01":"2026-05-01T20:01:00+03:00","2026-05-02":"2026-05-02T20:02:00+03:00","2026-05-03":"2026-05-03T20:03:00+03:00","2026-05-04":"2026-05-04T20:03:00+03:00","2026-05-05":"2026-05-05T20:04:00+03:00","2026-05-06":"2026-05-06T20:05:00+03:00","2026-05-07":"2026-05-07T20:05:00+03:00","2026-05-08":"2026-05-08T20:06:00+03:00","2026-05-09":"2026-05-09T20:07:00+03:00","2026-05-10":"2026-05-10T20:07:00+03:00","2026-05-11":"2026-05-11T20:08:00+03:00","2026-05-12":"2026-05-12T20:09:00+03:00","2026-05-13":"2026-05-13T20:10:00+03:00","2026-05-14":"2026-05-14T20:10:00+03:00","2026-05-15":"2026-05-15T20:11:00+03:00","2026-05-16":"2026-05-16T20:12:00+03:00","2026-05-17":"2026-05-17T20:12:00+03:00","2026-05-18":"2026-05-18T20:13:00+03:00","2026-05-19":"2026-05-19T20:14:00+03:00","2026-05-20":"2026-05-20T20:14:00+03:00","2026-05-21":"2026-05-21T20:15:00+03:00","2026-05-22":"2026-05-22T20:16:00+03:00","2026-05-23":"2026-05-23T20:16:00+03:00","2026-05-24":"2026-05-24T20:17:00+03:00","2026-05-25":"2026-05-25T20:18:00+03:00","2026-05-26":"2026-05-26T20:18:00+03:00","2026-05-27":"2026-05-27T20:19:00+03:00","2026-05-28":"2026-05-28T20:19:00+03:00","2026-05-29":"2026-05-29T20:20:00+03:00","2026-05-30":"2026-05-30T20:21:00+03:00","2026-05-31":"2026-05-31T20:21:00+03:00","2026-06-01":"2026-06-01T20:22:00+03:00","2026-06-02":"2026-06-02T20:22:00+03:00","2026-06-03":"2026-06-03T20:23:00+03:00","2026-06-04":"2026-06-04T20:23:00+03:00","2026-06-05":"2026-06-05T20:24:00+03:00","2026-06-06":"2026-06-06T20:24:00+03:00","2026-06-07":"2026-06-07T20:25:00+03:00","2026-06-08":"2026-06-08T20:25:00+03:00","2026-06-09":"2026-06-09T20:26:00+03:00"},"tzeit50min":{"2026-03-24":"2026-03-24T18:43:00+02:00","2026-03-25":"2026-03-25T18:44:00+02:00","2026-03-26":"2026-03-26T18:45:00+02:00","2026-03-27":"2026-03-27T19:45:00+03:00","2026-03-28":"2026-03-28T19:46:00+03:00","2026-03-29":"2026-03-29T19:47:00+03:00","2026-03-30":"2026-03-30T19:47:00+03:00","2026-03-31":"2026-03-31T19:48:00+03:00","2026-04-01":"2026-04-01T19:49:00+03:00","2026-04-02":"2026-04-02T19:49:00+03:00","2026-04-03":"2026-04-03T19:50:00+03:00","2026-04-04":"2026-04-04T19:51:00+03:00","2026-04-05":"2026-04-05T19:51:00+03:00","2026-04-06":"2026-04-06T19:52:00+03:00","2026-04-07":"2026-04-07T19:53:00+03:00","2026-04-08":"2026-04-08T19:53:00+03:00","2026-04-09":"2026-04-09T19:54:00+03:00","2026-04-10":"2026-04-10T19:55:00+03:00","2026-04-11":"2026-04-11T19:55:00+03:00","2026-04-12":"2026-04-12T19:56:00+03:00","2026-04-13":"2026-04-13T19:57:00+03:00","2026-04-14":"2026-04-14T19:57:00+03:00","2026-04-15":"2026-04-15T19:58:00+03:00","2026-04-16":"2026-04-16T19:59:00+03:00","2026-04-17":"2026-04-17T19:59:00+03:00","2026-04-18":"2026-04-18T20:00:00+03:00","2026-04-19":"2026-04-19T20:01:00+03:00","2026-04-20":"2026-04-20T20:01:00+03:00","2026-04-21":"2026-04-21T20:02:00+03:00","2026-04-22":"2026-04-22T20:03:00+03:00","2026-04-23":"2026-04-23T20:04:00+03:00","2026-04-24":"2026-04-24T20:04:00+03:00","2026-04-25":"2026-04-25T20:05:00+03:00","2026-04-26":"2026-04-26T20:06:00+03:00","2026-04-27":"2026-04-27T20:06:00+03:00","2026-04-28":"2026-04-28T20:07:00+03:00","2026-04-29":"2026-04-29T20:08:00+03:00","2026-04-30":"2026-04-30T20:08:00+03:00","2026-05-01":"2026-05-01T20:09:00+03:00","2026-05-02":"2026-05-02T20:10:00+03:00","2026-05-03":"2026-05-03T20:11:00+03:00","2026-05-04":"2026-05-04T20:11:00+03:00","2026-05-05":"2026-05-05T20:12:00+03:00","2026-05-06":"2026-05-06T20:13:00+03:00","2026-05-07":"2026-05-07T20:13:00+03:00","2026-05-08":"2026-05-08T20:14:00+03:00","2026-05-09":"2026-05-09T20:15:00+03:00","2026-05-10":"2026-05-10T20:15:00+03:00","2026-05-11":"2026-05-11T20:16:00+03:00","2026-05-12":"2026-05-12T20:17:00+03:00","2026-05-13":"2026-05-13T20:18:00+03:00","2026-05-14":"2026-05-14T20:18:00+03:00","2026-05-15":"2026-05-15T20:19:00+03:00","2026-05-16":"2026-05-16T20:20:00+03:00","2026-05-17":"2026-05-17T20:20:00+03:00","2026-05-18":"2026-05-18T20:21:00+03:00","2026-05-19":"2026-05-19T20:22:00+03:00","2026-05-20":"2026-05-20T20:22:00+03:00","2026-05-21":"2026-05-21T20:23:00+03:00","2026-05-22":"2026-05-22T20:24:00+03:00","2026-05-23":"2026-05-23T20:24:00+03:00","2026-05-24":"2026-05-24T20:25:00+03:00","2026-05-25":"2026-05-25T20:26:00+03:00","2026-05-26":"2026-05-26T20:26:00+03:00","2026-05-27":"2026-05-27T20:27:00+03:00","2026-05-28":"2026-05-28T20:27:00+03:00","2026-05-29":"2026-05-29T20:28:00+03:00","2026-05-30":"2026-05-30T20:29:00+03:00","2026-05-31":"2026-05-31T20:29:00+03:00","2026-06-01":"2026-06-01T20:30:00+03:00","2026-06-02":"2026-06-02T20:30:00+03:00","2026-06-03":"2026-06-03T20:31:00+03:00","2026-06-04":"2026-06-04T20:31:00+03:00","2026-06-05":"2026-06-05T20:32:00+03:00","2026-06-06":"2026-06-06T20:32:00+03:00","2026-06-07":"2026-06-07T20:33:00+03:00","2026-06-08":"2026-06-08T20:33:00+03:00","2026-06-09":"2026-06-09T20:34:00+03:00"},"tzeit72min":{"2026-03-24":"2026-03-24T19:05:00+02:00","2026-03-25":"2026-03-25T19:06:00+02:00","2026-03-26":"2026-03-26T19:07:00+02:00","2026-03-27":"2026-03-27T20:07:00+03:00","2026-03-28":"2026-03-28T20:08:00+03:00","2026-03-29":"2026-03-29T20:09:00+03:00","2026-03-30":"2026-03-30T20:09:00+03:00","2026-03-31":"2026-03-31T20:10:00+03:00","2026-04-01":"2026-04-01T20:11:00+03:00","2026-04-02":"2026-04-02T20:11:00+03:00","2026-04-03":"2026-04-03T20:12:00+03:00","2026-04-04":"2026-04-04T20:13:00+03:00","2026-04-05":"2026-04-05T20:13:00+03:00","2026-04-06":"2026-04-06T20:14:00+03:00","2026-04-07":"2026-04-07T20:15:00+03:00","2026-04-08":"2026-04-08T20:15:00+03:00","2026-04-09":"2026-04-09T20:16:00+03:00","2026-04-10":"2026-04-10T20:17:00+03:00","2026-04-11":"2026-04-11T20:17:00+03:00","2026-04-12":"2026-04-12T20:18:00+03:00","2026-04-13":"2026-04-13T20:19:00+03:00","2026-04-14":"2026-04-14T20:19:00+03:00","2026-04-15":"2026-04-15T20:20:00+03:00","2026-04-16":"2026-04-16T20:21:00+03:00","2026-04-17":"2026-04-17T20:21:00+03:00","2026-04-18":"2026-04-18T20:22:00+03:00","2026-04-19":"2026-04-19T20:23:00+03:00","2026-04-20":"2026-04-20T20:23:00+03:00","2026-04-21":"2026-04-21T20:24:00+03:00","2026-04-22":"2026-04-22T20:25:00+03:00","2026-04-23":"2026-04-23T20:26:00+03:00","2026-04-24":"2026-04-24T20:26:00+03:00","2026-04-25":"2026-04-25T20:27:00+03:00","2026-04-26":"2026-04-26T20:28:00+03:00","2026-04-27":"2026-04-27T20:28:00+03:00","2026-04-28":"2026-04-28T20:29:00+03:00","2026-04-29":"2026-04-29T20:30:00+03:00","2026-04-30":"2026-04-30T20:30:00+03:00","2026-05-01":"2026-05-01T20:31:00+03:00","2026-05-02":"2026-05-02T20:32:00+03:00","2026-05-03":"2026-05-03T20:33:00+03:00","2026-05-04":"2026-05-04T20:33:00+03:00","2026-05-05":"2026-05-05T20:34:00+03:00","2026-05-06":"2026-05-06T20:35:00+03:00","2026-05-07":"2026-05-07T20:35:00+03:00","2026-05-08":"2026-05-08T20:36:00+03:00","2026-05-09":"2026-05-09T20:37:00+03:00","2026-05-10":"2026-05-10T20:37:00+03:00","2026-05-11":"2026-05-11T20:38:00+03:00","2026-05-12":"2026-05-12T20:39:00+03:00","2026-05-13":"2026-05-13T20:40:00+03:00","2026-05-14":"2026-05-14T20:40:00+03:00","2026-05-15":"2026-05-15T20:41:00+03:00","2026-05-16":"2026-05-16T20:42:00+03:00","2026-05-17":"2026-05-17T20:42:00+03:00","2026-05-18":"2026-05-18T20:43:00+03:00","2026-05-19":"2026-05-19T20:44:00+03:00","2026-05-20":"2026-05-20T20:44:00+03:00","2026-05-21":"2026-05-21T20:45:00+03:00","2026-05-22":"2026-05-22T20:46:00+03:00","2026-05-23":"2026-05-23T20:46:00+03:00","2026-05-24":"2026-05-24T20:47:00+03:00","2026-05-25":"2026-05-25T20:48:00+03:00","2026-05-26":"2026-05-26T20:48:00+03:00","2026-05-27":"2026-05-27T20:49:00+03:00","2026-05-28":"2026-05-28T20:49:00+03:00","2026-05-29":"2026-05-29T20:50:00+03:00","2026-05-30":"2026-05-30T20:51:00+03:00","2026-05-31":"2026-05-31T20:51:00+03:00","2026-06-01":"2026-06-01T20:52:00+03:00","2026-06-02":"2026-06-02T20:52:00+03:00","2026-06-03":"2026-06-03T20:53:00+03:00","2026-06-04":"2026-06-04T20:53:00+03:00","2026-06-05":"2026-06-05T20:54:00+03:00","2026-06-06":"2026-06-06T20:54:00+03:00","2026-06-07":"2026-06-07T20:55:00+03:00","2026-06-08":"2026-06-08T20:55:00+03:00","2026-06-09":"2026-06-09T20:56:00+03:00"},"molad":{"2026-04-17":"2026-04-17T05:58:00+03:00","2026-05-16":"2026-05-16T18:42:00+03:00"},"tchilasZmanKidushLevana3Days":{"2026-04-20":"2026-04-20T05:58:00+03:00","2026-05-19":"2026-05-19T18:42:00+03:00"},"tchilasZmanKidushLevana7Days":{"2026-03-25":"2026-03-25T16:14:00+02:00","2026-04-24":"2026-04-24T05:58:00+03:00","2026-05-23":"2026-05-23T18:42:00+03:00"},"sofZmanKidushLevanaBetweenMoldos":{"2026-04-02":"2026-04-02T11:35:00+03:00","2026-05-02":"2026-05-02T00:19:00+03:00","2026-05-31":"2026-05-31T13:03:00+03:00"},"sofZmanKidushLevana15Days":{"2026-04-02":"2026-04-02T17:13:00+03:00","2026-05-02":"2026-05-02T05:57:00+03:00","2026-05-31":"2026-05-31T18:41:00+03:00"}}} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-full-solar-2026-05.json b/tests/Fixtures/hebcal-jerusalem-full-solar-2026-05.json new file mode 100644 index 0000000..f53f635 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-full-solar-2026-05.json @@ -0,0 +1 @@ +{"date":{"start":"2026-04-23","end":"2026-07-10"},"version":"v1.14.2","location":{"title":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","city":"31ยฐ46โ€ฒN 35ยฐ12โ€ฒE Asia/Jerusalem","tzid":"Asia/Jerusalem","latitude":31.76904,"longitude":35.21633,"geo":"pos"},"times":{"chatzotNight":{"2026-04-23":"2026-04-23T00:37:00+03:00","2026-04-24":"2026-04-24T00:37:00+03:00","2026-04-25":"2026-04-25T00:37:00+03:00","2026-04-26":"2026-04-26T00:37:00+03:00","2026-04-27":"2026-04-27T00:37:00+03:00","2026-04-28":"2026-04-28T00:37:00+03:00","2026-04-29":"2026-04-29T00:36:00+03:00","2026-04-30":"2026-04-30T00:36:00+03:00","2026-05-01":"2026-05-01T00:36:00+03:00","2026-05-02":"2026-05-02T00:36:00+03:00","2026-05-03":"2026-05-03T00:36:00+03:00","2026-05-04":"2026-05-04T00:36:00+03:00","2026-05-05":"2026-05-05T00:36:00+03:00","2026-05-06":"2026-05-06T00:36:00+03:00","2026-05-07":"2026-05-07T00:36:00+03:00","2026-05-08":"2026-05-08T00:36:00+03:00","2026-05-09":"2026-05-09T00:35:00+03:00","2026-05-10":"2026-05-10T00:35:00+03:00","2026-05-11":"2026-05-11T00:35:00+03:00","2026-05-12":"2026-05-12T00:35:00+03:00","2026-05-13":"2026-05-13T00:35:00+03:00","2026-05-14":"2026-05-14T00:35:00+03:00","2026-05-15":"2026-05-15T00:35:00+03:00","2026-05-16":"2026-05-16T00:35:00+03:00","2026-05-17":"2026-05-17T00:35:00+03:00","2026-05-18":"2026-05-18T00:35:00+03:00","2026-05-19":"2026-05-19T00:35:00+03:00","2026-05-20":"2026-05-20T00:36:00+03:00","2026-05-21":"2026-05-21T00:36:00+03:00","2026-05-22":"2026-05-22T00:36:00+03:00","2026-05-23":"2026-05-23T00:36:00+03:00","2026-05-24":"2026-05-24T00:36:00+03:00","2026-05-25":"2026-05-25T00:36:00+03:00","2026-05-26":"2026-05-26T00:36:00+03:00","2026-05-27":"2026-05-27T00:36:00+03:00","2026-05-28":"2026-05-28T00:36:00+03:00","2026-05-29":"2026-05-29T00:36:00+03:00","2026-05-30":"2026-05-30T00:37:00+03:00","2026-05-31":"2026-05-31T00:37:00+03:00","2026-06-01":"2026-06-01T00:37:00+03:00","2026-06-02":"2026-06-02T00:37:00+03:00","2026-06-03":"2026-06-03T00:37:00+03:00","2026-06-04":"2026-06-04T00:37:00+03:00","2026-06-05":"2026-06-05T00:37:00+03:00","2026-06-06":"2026-06-06T00:38:00+03:00","2026-06-07":"2026-06-07T00:38:00+03:00","2026-06-08":"2026-06-08T00:38:00+03:00","2026-06-09":"2026-06-09T00:38:00+03:00","2026-06-10":"2026-06-10T00:38:00+03:00","2026-06-11":"2026-06-11T00:39:00+03:00","2026-06-12":"2026-06-12T00:39:00+03:00","2026-06-13":"2026-06-13T00:39:00+03:00","2026-06-14":"2026-06-14T00:39:00+03:00","2026-06-15":"2026-06-15T00:40:00+03:00","2026-06-16":"2026-06-16T00:40:00+03:00","2026-06-17":"2026-06-17T00:40:00+03:00","2026-06-18":"2026-06-18T00:40:00+03:00","2026-06-19":"2026-06-19T00:40:00+03:00","2026-06-20":"2026-06-20T00:41:00+03:00","2026-06-21":"2026-06-21T00:41:00+03:00","2026-06-22":"2026-06-22T00:41:00+03:00","2026-06-23":"2026-06-23T00:41:00+03:00","2026-06-24":"2026-06-24T00:41:00+03:00","2026-06-25":"2026-06-25T00:42:00+03:00","2026-06-26":"2026-06-26T00:42:00+03:00","2026-06-27":"2026-06-27T00:42:00+03:00","2026-06-28":"2026-06-28T00:42:00+03:00","2026-06-29":"2026-06-29T00:43:00+03:00","2026-06-30":"2026-06-30T00:43:00+03:00","2026-07-01":"2026-07-01T00:43:00+03:00","2026-07-02":"2026-07-02T00:43:00+03:00","2026-07-03":"2026-07-03T00:43:00+03:00","2026-07-04":"2026-07-04T00:44:00+03:00","2026-07-05":"2026-07-05T00:44:00+03:00","2026-07-06":"2026-07-06T00:44:00+03:00","2026-07-07":"2026-07-07T00:44:00+03:00","2026-07-08":"2026-07-08T00:44:00+03:00","2026-07-09":"2026-07-09T00:44:00+03:00","2026-07-10":"2026-07-10T00:45:00+03:00"},"alosBaalHatanya":{"2026-04-23":"2026-04-23T04:41:00+03:00","2026-04-24":"2026-04-24T04:40:00+03:00","2026-04-25":"2026-04-25T04:38:00+03:00","2026-04-26":"2026-04-26T04:37:00+03:00","2026-04-27":"2026-04-27T04:36:00+03:00","2026-04-28":"2026-04-28T04:35:00+03:00","2026-04-29":"2026-04-29T04:33:00+03:00","2026-04-30":"2026-04-30T04:32:00+03:00","2026-05-01":"2026-05-01T04:31:00+03:00","2026-05-02":"2026-05-02T04:30:00+03:00","2026-05-03":"2026-05-03T04:28:00+03:00","2026-05-04":"2026-05-04T04:27:00+03:00","2026-05-05":"2026-05-05T04:26:00+03:00","2026-05-06":"2026-05-06T04:25:00+03:00","2026-05-07":"2026-05-07T04:24:00+03:00","2026-05-08":"2026-05-08T04:23:00+03:00","2026-05-09":"2026-05-09T04:22:00+03:00","2026-05-10":"2026-05-10T04:21:00+03:00","2026-05-11":"2026-05-11T04:20:00+03:00","2026-05-12":"2026-05-12T04:19:00+03:00","2026-05-13":"2026-05-13T04:18:00+03:00","2026-05-14":"2026-05-14T04:17:00+03:00","2026-05-15":"2026-05-15T04:16:00+03:00","2026-05-16":"2026-05-16T04:15:00+03:00","2026-05-17":"2026-05-17T04:14:00+03:00","2026-05-18":"2026-05-18T04:13:00+03:00","2026-05-19":"2026-05-19T04:12:00+03:00","2026-05-20":"2026-05-20T04:11:00+03:00","2026-05-21":"2026-05-21T04:11:00+03:00","2026-05-22":"2026-05-22T04:10:00+03:00","2026-05-23":"2026-05-23T04:09:00+03:00","2026-05-24":"2026-05-24T04:08:00+03:00","2026-05-25":"2026-05-25T04:08:00+03:00","2026-05-26":"2026-05-26T04:07:00+03:00","2026-05-27":"2026-05-27T04:06:00+03:00","2026-05-28":"2026-05-28T04:06:00+03:00","2026-05-29":"2026-05-29T04:05:00+03:00","2026-05-30":"2026-05-30T04:05:00+03:00","2026-05-31":"2026-05-31T04:04:00+03:00","2026-06-01":"2026-06-01T04:04:00+03:00","2026-06-02":"2026-06-02T04:03:00+03:00","2026-06-03":"2026-06-03T04:03:00+03:00","2026-06-04":"2026-06-04T04:03:00+03:00","2026-06-05":"2026-06-05T04:02:00+03:00","2026-06-06":"2026-06-06T04:02:00+03:00","2026-06-07":"2026-06-07T04:02:00+03:00","2026-06-08":"2026-06-08T04:01:00+03:00","2026-06-09":"2026-06-09T04:01:00+03:00","2026-06-10":"2026-06-10T04:01:00+03:00","2026-06-11":"2026-06-11T04:01:00+03:00","2026-06-12":"2026-06-12T04:01:00+03:00","2026-06-13":"2026-06-13T04:01:00+03:00","2026-06-14":"2026-06-14T04:01:00+03:00","2026-06-15":"2026-06-15T04:01:00+03:00","2026-06-16":"2026-06-16T04:01:00+03:00","2026-06-17":"2026-06-17T04:01:00+03:00","2026-06-18":"2026-06-18T04:01:00+03:00","2026-06-19":"2026-06-19T04:01:00+03:00","2026-06-20":"2026-06-20T04:01:00+03:00","2026-06-21":"2026-06-21T04:01:00+03:00","2026-06-22":"2026-06-22T04:02:00+03:00","2026-06-23":"2026-06-23T04:02:00+03:00","2026-06-24":"2026-06-24T04:02:00+03:00","2026-06-25":"2026-06-25T04:02:00+03:00","2026-06-26":"2026-06-26T04:03:00+03:00","2026-06-27":"2026-06-27T04:03:00+03:00","2026-06-28":"2026-06-28T04:04:00+03:00","2026-06-29":"2026-06-29T04:04:00+03:00","2026-06-30":"2026-06-30T04:04:00+03:00","2026-07-01":"2026-07-01T04:05:00+03:00","2026-07-02":"2026-07-02T04:05:00+03:00","2026-07-03":"2026-07-03T04:06:00+03:00","2026-07-04":"2026-07-04T04:07:00+03:00","2026-07-05":"2026-07-05T04:07:00+03:00","2026-07-06":"2026-07-06T04:08:00+03:00","2026-07-07":"2026-07-07T04:08:00+03:00","2026-07-08":"2026-07-08T04:09:00+03:00","2026-07-09":"2026-07-09T04:10:00+03:00","2026-07-10":"2026-07-10T04:10:00+03:00"},"alotHaShachar":{"2026-04-23":"2026-04-23T04:45:00+03:00","2026-04-24":"2026-04-24T04:44:00+03:00","2026-04-25":"2026-04-25T04:43:00+03:00","2026-04-26":"2026-04-26T04:41:00+03:00","2026-04-27":"2026-04-27T04:40:00+03:00","2026-04-28":"2026-04-28T04:39:00+03:00","2026-04-29":"2026-04-29T04:38:00+03:00","2026-04-30":"2026-04-30T04:36:00+03:00","2026-05-01":"2026-05-01T04:35:00+03:00","2026-05-02":"2026-05-02T04:34:00+03:00","2026-05-03":"2026-05-03T04:33:00+03:00","2026-05-04":"2026-05-04T04:32:00+03:00","2026-05-05":"2026-05-05T04:31:00+03:00","2026-05-06":"2026-05-06T04:29:00+03:00","2026-05-07":"2026-05-07T04:28:00+03:00","2026-05-08":"2026-05-08T04:27:00+03:00","2026-05-09":"2026-05-09T04:26:00+03:00","2026-05-10":"2026-05-10T04:25:00+03:00","2026-05-11":"2026-05-11T04:24:00+03:00","2026-05-12":"2026-05-12T04:23:00+03:00","2026-05-13":"2026-05-13T04:22:00+03:00","2026-05-14":"2026-05-14T04:21:00+03:00","2026-05-15":"2026-05-15T04:20:00+03:00","2026-05-16":"2026-05-16T04:20:00+03:00","2026-05-17":"2026-05-17T04:19:00+03:00","2026-05-18":"2026-05-18T04:18:00+03:00","2026-05-19":"2026-05-19T04:17:00+03:00","2026-05-20":"2026-05-20T04:16:00+03:00","2026-05-21":"2026-05-21T04:15:00+03:00","2026-05-22":"2026-05-22T04:15:00+03:00","2026-05-23":"2026-05-23T04:14:00+03:00","2026-05-24":"2026-05-24T04:13:00+03:00","2026-05-25":"2026-05-25T04:13:00+03:00","2026-05-26":"2026-05-26T04:12:00+03:00","2026-05-27":"2026-05-27T04:11:00+03:00","2026-05-28":"2026-05-28T04:11:00+03:00","2026-05-29":"2026-05-29T04:10:00+03:00","2026-05-30":"2026-05-30T04:10:00+03:00","2026-05-31":"2026-05-31T04:09:00+03:00","2026-06-01":"2026-06-01T04:09:00+03:00","2026-06-02":"2026-06-02T04:08:00+03:00","2026-06-03":"2026-06-03T04:08:00+03:00","2026-06-04":"2026-06-04T04:08:00+03:00","2026-06-05":"2026-06-05T04:07:00+03:00","2026-06-06":"2026-06-06T04:07:00+03:00","2026-06-07":"2026-06-07T04:07:00+03:00","2026-06-08":"2026-06-08T04:06:00+03:00","2026-06-09":"2026-06-09T04:06:00+03:00","2026-06-10":"2026-06-10T04:06:00+03:00","2026-06-11":"2026-06-11T04:06:00+03:00","2026-06-12":"2026-06-12T04:06:00+03:00","2026-06-13":"2026-06-13T04:06:00+03:00","2026-06-14":"2026-06-14T04:06:00+03:00","2026-06-15":"2026-06-15T04:06:00+03:00","2026-06-16":"2026-06-16T04:06:00+03:00","2026-06-17":"2026-06-17T04:06:00+03:00","2026-06-18":"2026-06-18T04:06:00+03:00","2026-06-19":"2026-06-19T04:06:00+03:00","2026-06-20":"2026-06-20T04:06:00+03:00","2026-06-21":"2026-06-21T04:06:00+03:00","2026-06-22":"2026-06-22T04:07:00+03:00","2026-06-23":"2026-06-23T04:07:00+03:00","2026-06-24":"2026-06-24T04:07:00+03:00","2026-06-25":"2026-06-25T04:08:00+03:00","2026-06-26":"2026-06-26T04:08:00+03:00","2026-06-27":"2026-06-27T04:08:00+03:00","2026-06-28":"2026-06-28T04:09:00+03:00","2026-06-29":"2026-06-29T04:09:00+03:00","2026-06-30":"2026-06-30T04:10:00+03:00","2026-07-01":"2026-07-01T04:10:00+03:00","2026-07-02":"2026-07-02T04:10:00+03:00","2026-07-03":"2026-07-03T04:11:00+03:00","2026-07-04":"2026-07-04T04:12:00+03:00","2026-07-05":"2026-07-05T04:12:00+03:00","2026-07-06":"2026-07-06T04:13:00+03:00","2026-07-07":"2026-07-07T04:13:00+03:00","2026-07-08":"2026-07-08T04:14:00+03:00","2026-07-09":"2026-07-09T04:15:00+03:00","2026-07-10":"2026-07-10T04:15:00+03:00"},"misheyakir":{"2026-04-23":"2026-04-23T05:09:00+03:00","2026-04-24":"2026-04-24T05:08:00+03:00","2026-04-25":"2026-04-25T05:06:00+03:00","2026-04-26":"2026-04-26T05:05:00+03:00","2026-04-27":"2026-04-27T05:04:00+03:00","2026-04-28":"2026-04-28T05:03:00+03:00","2026-04-29":"2026-04-29T05:02:00+03:00","2026-04-30":"2026-04-30T05:01:00+03:00","2026-05-01":"2026-05-01T05:00:00+03:00","2026-05-02":"2026-05-02T04:59:00+03:00","2026-05-03":"2026-05-03T04:58:00+03:00","2026-05-04":"2026-05-04T04:56:00+03:00","2026-05-05":"2026-05-05T04:55:00+03:00","2026-05-06":"2026-05-06T04:54:00+03:00","2026-05-07":"2026-05-07T04:53:00+03:00","2026-05-08":"2026-05-08T04:52:00+03:00","2026-05-09":"2026-05-09T04:51:00+03:00","2026-05-10":"2026-05-10T04:51:00+03:00","2026-05-11":"2026-05-11T04:50:00+03:00","2026-05-12":"2026-05-12T04:49:00+03:00","2026-05-13":"2026-05-13T04:48:00+03:00","2026-05-14":"2026-05-14T04:47:00+03:00","2026-05-15":"2026-05-15T04:46:00+03:00","2026-05-16":"2026-05-16T04:45:00+03:00","2026-05-17":"2026-05-17T04:45:00+03:00","2026-05-18":"2026-05-18T04:44:00+03:00","2026-05-19":"2026-05-19T04:43:00+03:00","2026-05-20":"2026-05-20T04:42:00+03:00","2026-05-21":"2026-05-21T04:42:00+03:00","2026-05-22":"2026-05-22T04:41:00+03:00","2026-05-23":"2026-05-23T04:40:00+03:00","2026-05-24":"2026-05-24T04:40:00+03:00","2026-05-25":"2026-05-25T04:39:00+03:00","2026-05-26":"2026-05-26T04:39:00+03:00","2026-05-27":"2026-05-27T04:38:00+03:00","2026-05-28":"2026-05-28T04:38:00+03:00","2026-05-29":"2026-05-29T04:37:00+03:00","2026-05-30":"2026-05-30T04:37:00+03:00","2026-05-31":"2026-05-31T04:36:00+03:00","2026-06-01":"2026-06-01T04:36:00+03:00","2026-06-02":"2026-06-02T04:36:00+03:00","2026-06-03":"2026-06-03T04:35:00+03:00","2026-06-04":"2026-06-04T04:35:00+03:00","2026-06-05":"2026-06-05T04:35:00+03:00","2026-06-06":"2026-06-06T04:34:00+03:00","2026-06-07":"2026-06-07T04:34:00+03:00","2026-06-08":"2026-06-08T04:34:00+03:00","2026-06-09":"2026-06-09T04:34:00+03:00","2026-06-10":"2026-06-10T04:34:00+03:00","2026-06-11":"2026-06-11T04:34:00+03:00","2026-06-12":"2026-06-12T04:34:00+03:00","2026-06-13":"2026-06-13T04:34:00+03:00","2026-06-14":"2026-06-14T04:34:00+03:00","2026-06-15":"2026-06-15T04:34:00+03:00","2026-06-16":"2026-06-16T04:34:00+03:00","2026-06-17":"2026-06-17T04:34:00+03:00","2026-06-18":"2026-06-18T04:34:00+03:00","2026-06-19":"2026-06-19T04:34:00+03:00","2026-06-20":"2026-06-20T04:34:00+03:00","2026-06-21":"2026-06-21T04:34:00+03:00","2026-06-22":"2026-06-22T04:35:00+03:00","2026-06-23":"2026-06-23T04:35:00+03:00","2026-06-24":"2026-06-24T04:35:00+03:00","2026-06-25":"2026-06-25T04:35:00+03:00","2026-06-26":"2026-06-26T04:36:00+03:00","2026-06-27":"2026-06-27T04:36:00+03:00","2026-06-28":"2026-06-28T04:37:00+03:00","2026-06-29":"2026-06-29T04:37:00+03:00","2026-06-30":"2026-06-30T04:37:00+03:00","2026-07-01":"2026-07-01T04:38:00+03:00","2026-07-02":"2026-07-02T04:38:00+03:00","2026-07-03":"2026-07-03T04:39:00+03:00","2026-07-04":"2026-07-04T04:39:00+03:00","2026-07-05":"2026-07-05T04:40:00+03:00","2026-07-06":"2026-07-06T04:40:00+03:00","2026-07-07":"2026-07-07T04:41:00+03:00","2026-07-08":"2026-07-08T04:42:00+03:00","2026-07-09":"2026-07-09T04:42:00+03:00","2026-07-10":"2026-07-10T04:43:00+03:00"},"misheyakirMachmir":{"2026-04-23":"2026-04-23T05:15:00+03:00","2026-04-24":"2026-04-24T05:14:00+03:00","2026-04-25":"2026-04-25T05:13:00+03:00","2026-04-26":"2026-04-26T05:12:00+03:00","2026-04-27":"2026-04-27T05:11:00+03:00","2026-04-28":"2026-04-28T05:10:00+03:00","2026-04-29":"2026-04-29T05:09:00+03:00","2026-04-30":"2026-04-30T05:07:00+03:00","2026-05-01":"2026-05-01T05:06:00+03:00","2026-05-02":"2026-05-02T05:05:00+03:00","2026-05-03":"2026-05-03T05:04:00+03:00","2026-05-04":"2026-05-04T05:03:00+03:00","2026-05-05":"2026-05-05T05:02:00+03:00","2026-05-06":"2026-05-06T05:01:00+03:00","2026-05-07":"2026-05-07T05:00:00+03:00","2026-05-08":"2026-05-08T04:59:00+03:00","2026-05-09":"2026-05-09T04:58:00+03:00","2026-05-10":"2026-05-10T04:58:00+03:00","2026-05-11":"2026-05-11T04:57:00+03:00","2026-05-12":"2026-05-12T04:56:00+03:00","2026-05-13":"2026-05-13T04:55:00+03:00","2026-05-14":"2026-05-14T04:54:00+03:00","2026-05-15":"2026-05-15T04:53:00+03:00","2026-05-16":"2026-05-16T04:52:00+03:00","2026-05-17":"2026-05-17T04:52:00+03:00","2026-05-18":"2026-05-18T04:51:00+03:00","2026-05-19":"2026-05-19T04:50:00+03:00","2026-05-20":"2026-05-20T04:50:00+03:00","2026-05-21":"2026-05-21T04:49:00+03:00","2026-05-22":"2026-05-22T04:48:00+03:00","2026-05-23":"2026-05-23T04:48:00+03:00","2026-05-24":"2026-05-24T04:47:00+03:00","2026-05-25":"2026-05-25T04:47:00+03:00","2026-05-26":"2026-05-26T04:46:00+03:00","2026-05-27":"2026-05-27T04:46:00+03:00","2026-05-28":"2026-05-28T04:45:00+03:00","2026-05-29":"2026-05-29T04:45:00+03:00","2026-05-30":"2026-05-30T04:44:00+03:00","2026-05-31":"2026-05-31T04:44:00+03:00","2026-06-01":"2026-06-01T04:43:00+03:00","2026-06-02":"2026-06-02T04:43:00+03:00","2026-06-03":"2026-06-03T04:43:00+03:00","2026-06-04":"2026-06-04T04:42:00+03:00","2026-06-05":"2026-06-05T04:42:00+03:00","2026-06-06":"2026-06-06T04:42:00+03:00","2026-06-07":"2026-06-07T04:42:00+03:00","2026-06-08":"2026-06-08T04:42:00+03:00","2026-06-09":"2026-06-09T04:41:00+03:00","2026-06-10":"2026-06-10T04:41:00+03:00","2026-06-11":"2026-06-11T04:41:00+03:00","2026-06-12":"2026-06-12T04:41:00+03:00","2026-06-13":"2026-06-13T04:41:00+03:00","2026-06-14":"2026-06-14T04:41:00+03:00","2026-06-15":"2026-06-15T04:41:00+03:00","2026-06-16":"2026-06-16T04:41:00+03:00","2026-06-17":"2026-06-17T04:41:00+03:00","2026-06-18":"2026-06-18T04:42:00+03:00","2026-06-19":"2026-06-19T04:42:00+03:00","2026-06-20":"2026-06-20T04:42:00+03:00","2026-06-21":"2026-06-21T04:42:00+03:00","2026-06-22":"2026-06-22T04:42:00+03:00","2026-06-23":"2026-06-23T04:43:00+03:00","2026-06-24":"2026-06-24T04:43:00+03:00","2026-06-25":"2026-06-25T04:43:00+03:00","2026-06-26":"2026-06-26T04:43:00+03:00","2026-06-27":"2026-06-27T04:44:00+03:00","2026-06-28":"2026-06-28T04:44:00+03:00","2026-06-29":"2026-06-29T04:45:00+03:00","2026-06-30":"2026-06-30T04:45:00+03:00","2026-07-01":"2026-07-01T04:45:00+03:00","2026-07-02":"2026-07-02T04:46:00+03:00","2026-07-03":"2026-07-03T04:46:00+03:00","2026-07-04":"2026-07-04T04:47:00+03:00","2026-07-05":"2026-07-05T04:47:00+03:00","2026-07-06":"2026-07-06T04:48:00+03:00","2026-07-07":"2026-07-07T04:48:00+03:00","2026-07-08":"2026-07-08T04:49:00+03:00","2026-07-09":"2026-07-09T04:50:00+03:00","2026-07-10":"2026-07-10T04:50:00+03:00"},"dawn":{"2026-04-23":"2026-04-23T05:36:00+03:00","2026-04-24":"2026-04-24T05:35:00+03:00","2026-04-25":"2026-04-25T05:34:00+03:00","2026-04-26":"2026-04-26T05:33:00+03:00","2026-04-27":"2026-04-27T05:32:00+03:00","2026-04-28":"2026-04-28T05:31:00+03:00","2026-04-29":"2026-04-29T05:30:00+03:00","2026-04-30":"2026-04-30T05:29:00+03:00","2026-05-01":"2026-05-01T05:28:00+03:00","2026-05-02":"2026-05-02T05:27:00+03:00","2026-05-03":"2026-05-03T05:26:00+03:00","2026-05-04":"2026-05-04T05:25:00+03:00","2026-05-05":"2026-05-05T05:24:00+03:00","2026-05-06":"2026-05-06T05:23:00+03:00","2026-05-07":"2026-05-07T05:22:00+03:00","2026-05-08":"2026-05-08T05:21:00+03:00","2026-05-09":"2026-05-09T05:20:00+03:00","2026-05-10":"2026-05-10T05:20:00+03:00","2026-05-11":"2026-05-11T05:19:00+03:00","2026-05-12":"2026-05-12T05:18:00+03:00","2026-05-13":"2026-05-13T05:17:00+03:00","2026-05-14":"2026-05-14T05:16:00+03:00","2026-05-15":"2026-05-15T05:16:00+03:00","2026-05-16":"2026-05-16T05:15:00+03:00","2026-05-17":"2026-05-17T05:14:00+03:00","2026-05-18":"2026-05-18T05:14:00+03:00","2026-05-19":"2026-05-19T05:13:00+03:00","2026-05-20":"2026-05-20T05:12:00+03:00","2026-05-21":"2026-05-21T05:12:00+03:00","2026-05-22":"2026-05-22T05:11:00+03:00","2026-05-23":"2026-05-23T05:11:00+03:00","2026-05-24":"2026-05-24T05:10:00+03:00","2026-05-25":"2026-05-25T05:10:00+03:00","2026-05-26":"2026-05-26T05:09:00+03:00","2026-05-27":"2026-05-27T05:09:00+03:00","2026-05-28":"2026-05-28T05:08:00+03:00","2026-05-29":"2026-05-29T05:08:00+03:00","2026-05-30":"2026-05-30T05:07:00+03:00","2026-05-31":"2026-05-31T05:07:00+03:00","2026-06-01":"2026-06-01T05:07:00+03:00","2026-06-02":"2026-06-02T05:06:00+03:00","2026-06-03":"2026-06-03T05:06:00+03:00","2026-06-04":"2026-06-04T05:06:00+03:00","2026-06-05":"2026-06-05T05:06:00+03:00","2026-06-06":"2026-06-06T05:06:00+03:00","2026-06-07":"2026-06-07T05:05:00+03:00","2026-06-08":"2026-06-08T05:05:00+03:00","2026-06-09":"2026-06-09T05:05:00+03:00","2026-06-10":"2026-06-10T05:05:00+03:00","2026-06-11":"2026-06-11T05:05:00+03:00","2026-06-12":"2026-06-12T05:05:00+03:00","2026-06-13":"2026-06-13T05:05:00+03:00","2026-06-14":"2026-06-14T05:05:00+03:00","2026-06-15":"2026-06-15T05:05:00+03:00","2026-06-16":"2026-06-16T05:05:00+03:00","2026-06-17":"2026-06-17T05:05:00+03:00","2026-06-18":"2026-06-18T05:05:00+03:00","2026-06-19":"2026-06-19T05:06:00+03:00","2026-06-20":"2026-06-20T05:06:00+03:00","2026-06-21":"2026-06-21T05:06:00+03:00","2026-06-22":"2026-06-22T05:06:00+03:00","2026-06-23":"2026-06-23T05:06:00+03:00","2026-06-24":"2026-06-24T05:07:00+03:00","2026-06-25":"2026-06-25T05:07:00+03:00","2026-06-26":"2026-06-26T05:07:00+03:00","2026-06-27":"2026-06-27T05:08:00+03:00","2026-06-28":"2026-06-28T05:08:00+03:00","2026-06-29":"2026-06-29T05:08:00+03:00","2026-06-30":"2026-06-30T05:09:00+03:00","2026-07-01":"2026-07-01T05:09:00+03:00","2026-07-02":"2026-07-02T05:10:00+03:00","2026-07-03":"2026-07-03T05:10:00+03:00","2026-07-04":"2026-07-04T05:11:00+03:00","2026-07-05":"2026-07-05T05:11:00+03:00","2026-07-06":"2026-07-06T05:11:00+03:00","2026-07-07":"2026-07-07T05:12:00+03:00","2026-07-08":"2026-07-08T05:13:00+03:00","2026-07-09":"2026-07-09T05:13:00+03:00","2026-07-10":"2026-07-10T05:14:00+03:00"},"sunrise":{"2026-04-23":"2026-04-23T06:02:00+03:00","2026-04-24":"2026-04-24T06:01:00+03:00","2026-04-25":"2026-04-25T06:00:00+03:00","2026-04-26":"2026-04-26T05:59:00+03:00","2026-04-27":"2026-04-27T05:58:00+03:00","2026-04-28":"2026-04-28T05:57:00+03:00","2026-04-29":"2026-04-29T05:56:00+03:00","2026-04-30":"2026-04-30T05:55:00+03:00","2026-05-01":"2026-05-01T05:54:00+03:00","2026-05-02":"2026-05-02T05:53:00+03:00","2026-05-03":"2026-05-03T05:52:00+03:00","2026-05-04":"2026-05-04T05:51:00+03:00","2026-05-05":"2026-05-05T05:50:00+03:00","2026-05-06":"2026-05-06T05:49:00+03:00","2026-05-07":"2026-05-07T05:48:00+03:00","2026-05-08":"2026-05-08T05:48:00+03:00","2026-05-09":"2026-05-09T05:47:00+03:00","2026-05-10":"2026-05-10T05:46:00+03:00","2026-05-11":"2026-05-11T05:45:00+03:00","2026-05-12":"2026-05-12T05:45:00+03:00","2026-05-13":"2026-05-13T05:44:00+03:00","2026-05-14":"2026-05-14T05:43:00+03:00","2026-05-15":"2026-05-15T05:42:00+03:00","2026-05-16":"2026-05-16T05:42:00+03:00","2026-05-17":"2026-05-17T05:41:00+03:00","2026-05-18":"2026-05-18T05:41:00+03:00","2026-05-19":"2026-05-19T05:40:00+03:00","2026-05-20":"2026-05-20T05:39:00+03:00","2026-05-21":"2026-05-21T05:39:00+03:00","2026-05-22":"2026-05-22T05:38:00+03:00","2026-05-23":"2026-05-23T05:38:00+03:00","2026-05-24":"2026-05-24T05:37:00+03:00","2026-05-25":"2026-05-25T05:37:00+03:00","2026-05-26":"2026-05-26T05:37:00+03:00","2026-05-27":"2026-05-27T05:36:00+03:00","2026-05-28":"2026-05-28T05:36:00+03:00","2026-05-29":"2026-05-29T05:35:00+03:00","2026-05-30":"2026-05-30T05:35:00+03:00","2026-05-31":"2026-05-31T05:35:00+03:00","2026-06-01":"2026-06-01T05:34:00+03:00","2026-06-02":"2026-06-02T05:34:00+03:00","2026-06-03":"2026-06-03T05:34:00+03:00","2026-06-04":"2026-06-04T05:34:00+03:00","2026-06-05":"2026-06-05T05:34:00+03:00","2026-06-06":"2026-06-06T05:34:00+03:00","2026-06-07":"2026-06-07T05:33:00+03:00","2026-06-08":"2026-06-08T05:33:00+03:00","2026-06-09":"2026-06-09T05:33:00+03:00","2026-06-10":"2026-06-10T05:33:00+03:00","2026-06-11":"2026-06-11T05:33:00+03:00","2026-06-12":"2026-06-12T05:33:00+03:00","2026-06-13":"2026-06-13T05:33:00+03:00","2026-06-14":"2026-06-14T05:33:00+03:00","2026-06-15":"2026-06-15T05:33:00+03:00","2026-06-16":"2026-06-16T05:33:00+03:00","2026-06-17":"2026-06-17T05:33:00+03:00","2026-06-18":"2026-06-18T05:34:00+03:00","2026-06-19":"2026-06-19T05:34:00+03:00","2026-06-20":"2026-06-20T05:34:00+03:00","2026-06-21":"2026-06-21T05:34:00+03:00","2026-06-22":"2026-06-22T05:34:00+03:00","2026-06-23":"2026-06-23T05:35:00+03:00","2026-06-24":"2026-06-24T05:35:00+03:00","2026-06-25":"2026-06-25T05:35:00+03:00","2026-06-26":"2026-06-26T05:35:00+03:00","2026-06-27":"2026-06-27T05:36:00+03:00","2026-06-28":"2026-06-28T05:36:00+03:00","2026-06-29":"2026-06-29T05:36:00+03:00","2026-06-30":"2026-06-30T05:37:00+03:00","2026-07-01":"2026-07-01T05:37:00+03:00","2026-07-02":"2026-07-02T05:38:00+03:00","2026-07-03":"2026-07-03T05:38:00+03:00","2026-07-04":"2026-07-04T05:39:00+03:00","2026-07-05":"2026-07-05T05:39:00+03:00","2026-07-06":"2026-07-06T05:39:00+03:00","2026-07-07":"2026-07-07T05:40:00+03:00","2026-07-08":"2026-07-08T05:40:00+03:00","2026-07-09":"2026-07-09T05:41:00+03:00","2026-07-10":"2026-07-10T05:41:00+03:00"},"seaLevelSunrise":{},"sofZmanShmaMGA19Point8":{"2026-04-23":"2026-04-23T08:32:00+03:00","2026-04-24":"2026-04-24T08:31:00+03:00","2026-04-25":"2026-04-25T08:30:00+03:00","2026-04-26":"2026-04-26T08:29:00+03:00","2026-04-27":"2026-04-27T08:29:00+03:00","2026-04-28":"2026-04-28T08:28:00+03:00","2026-04-29":"2026-04-29T08:27:00+03:00","2026-04-30":"2026-04-30T08:26:00+03:00","2026-05-01":"2026-05-01T08:26:00+03:00","2026-05-02":"2026-05-02T08:25:00+03:00","2026-05-03":"2026-05-03T08:24:00+03:00","2026-05-04":"2026-05-04T08:24:00+03:00","2026-05-05":"2026-05-05T08:23:00+03:00","2026-05-06":"2026-05-06T08:22:00+03:00","2026-05-07":"2026-05-07T08:22:00+03:00","2026-05-08":"2026-05-08T08:21:00+03:00","2026-05-09":"2026-05-09T08:21:00+03:00","2026-05-10":"2026-05-10T08:20:00+03:00","2026-05-11":"2026-05-11T08:19:00+03:00","2026-05-12":"2026-05-12T08:19:00+03:00","2026-05-13":"2026-05-13T08:18:00+03:00","2026-05-14":"2026-05-14T08:18:00+03:00","2026-05-15":"2026-05-15T08:17:00+03:00","2026-05-16":"2026-05-16T08:17:00+03:00","2026-05-17":"2026-05-17T08:16:00+03:00","2026-05-18":"2026-05-18T08:16:00+03:00","2026-05-19":"2026-05-19T08:15:00+03:00","2026-05-20":"2026-05-20T08:15:00+03:00","2026-05-21":"2026-05-21T08:15:00+03:00","2026-05-22":"2026-05-22T08:14:00+03:00","2026-05-23":"2026-05-23T08:14:00+03:00","2026-05-24":"2026-05-24T08:13:00+03:00","2026-05-25":"2026-05-25T08:13:00+03:00","2026-05-26":"2026-05-26T08:13:00+03:00","2026-05-27":"2026-05-27T08:13:00+03:00","2026-05-28":"2026-05-28T08:12:00+03:00","2026-05-29":"2026-05-29T08:12:00+03:00","2026-05-30":"2026-05-30T08:12:00+03:00","2026-05-31":"2026-05-31T08:12:00+03:00","2026-06-01":"2026-06-01T08:11:00+03:00","2026-06-02":"2026-06-02T08:11:00+03:00","2026-06-03":"2026-06-03T08:11:00+03:00","2026-06-04":"2026-06-04T08:11:00+03:00","2026-06-05":"2026-06-05T08:11:00+03:00","2026-06-06":"2026-06-06T08:11:00+03:00","2026-06-07":"2026-06-07T08:11:00+03:00","2026-06-08":"2026-06-08T08:11:00+03:00","2026-06-09":"2026-06-09T08:10:00+03:00","2026-06-10":"2026-06-10T08:10:00+03:00","2026-06-11":"2026-06-11T08:10:00+03:00","2026-06-12":"2026-06-12T08:10:00+03:00","2026-06-13":"2026-06-13T08:11:00+03:00","2026-06-14":"2026-06-14T08:11:00+03:00","2026-06-15":"2026-06-15T08:11:00+03:00","2026-06-16":"2026-06-16T08:11:00+03:00","2026-06-17":"2026-06-17T08:11:00+03:00","2026-06-18":"2026-06-18T08:11:00+03:00","2026-06-19":"2026-06-19T08:11:00+03:00","2026-06-20":"2026-06-20T08:11:00+03:00","2026-06-21":"2026-06-21T08:12:00+03:00","2026-06-22":"2026-06-22T08:12:00+03:00","2026-06-23":"2026-06-23T08:12:00+03:00","2026-06-24":"2026-06-24T08:12:00+03:00","2026-06-25":"2026-06-25T08:13:00+03:00","2026-06-26":"2026-06-26T08:13:00+03:00","2026-06-27":"2026-06-27T08:13:00+03:00","2026-06-28":"2026-06-28T08:13:00+03:00","2026-06-29":"2026-06-29T08:14:00+03:00","2026-06-30":"2026-06-30T08:14:00+03:00","2026-07-01":"2026-07-01T08:14:00+03:00","2026-07-02":"2026-07-02T08:15:00+03:00","2026-07-03":"2026-07-03T08:15:00+03:00","2026-07-04":"2026-07-04T08:16:00+03:00","2026-07-05":"2026-07-05T08:16:00+03:00","2026-07-06":"2026-07-06T08:16:00+03:00","2026-07-07":"2026-07-07T08:17:00+03:00","2026-07-08":"2026-07-08T08:17:00+03:00","2026-07-09":"2026-07-09T08:18:00+03:00","2026-07-10":"2026-07-10T08:18:00+03:00"},"sofZmanShmaMGA16Point1":{"2026-04-23":"2026-04-23T08:41:00+03:00","2026-04-24":"2026-04-24T08:41:00+03:00","2026-04-25":"2026-04-25T08:40:00+03:00","2026-04-26":"2026-04-26T08:39:00+03:00","2026-04-27":"2026-04-27T08:39:00+03:00","2026-04-28":"2026-04-28T08:38:00+03:00","2026-04-29":"2026-04-29T08:37:00+03:00","2026-04-30":"2026-04-30T08:37:00+03:00","2026-05-01":"2026-05-01T08:36:00+03:00","2026-05-02":"2026-05-02T08:35:00+03:00","2026-05-03":"2026-05-03T08:35:00+03:00","2026-05-04":"2026-05-04T08:34:00+03:00","2026-05-05":"2026-05-05T08:33:00+03:00","2026-05-06":"2026-05-06T08:33:00+03:00","2026-05-07":"2026-05-07T08:32:00+03:00","2026-05-08":"2026-05-08T08:32:00+03:00","2026-05-09":"2026-05-09T08:31:00+03:00","2026-05-10":"2026-05-10T08:31:00+03:00","2026-05-11":"2026-05-11T08:30:00+03:00","2026-05-12":"2026-05-12T08:30:00+03:00","2026-05-13":"2026-05-13T08:29:00+03:00","2026-05-14":"2026-05-14T08:29:00+03:00","2026-05-15":"2026-05-15T08:28:00+03:00","2026-05-16":"2026-05-16T08:28:00+03:00","2026-05-17":"2026-05-17T08:27:00+03:00","2026-05-18":"2026-05-18T08:27:00+03:00","2026-05-19":"2026-05-19T08:26:00+03:00","2026-05-20":"2026-05-20T08:26:00+03:00","2026-05-21":"2026-05-21T08:26:00+03:00","2026-05-22":"2026-05-22T08:25:00+03:00","2026-05-23":"2026-05-23T08:25:00+03:00","2026-05-24":"2026-05-24T08:25:00+03:00","2026-05-25":"2026-05-25T08:24:00+03:00","2026-05-26":"2026-05-26T08:24:00+03:00","2026-05-27":"2026-05-27T08:24:00+03:00","2026-05-28":"2026-05-28T08:24:00+03:00","2026-05-29":"2026-05-29T08:23:00+03:00","2026-05-30":"2026-05-30T08:23:00+03:00","2026-05-31":"2026-05-31T08:23:00+03:00","2026-06-01":"2026-06-01T08:23:00+03:00","2026-06-02":"2026-06-02T08:23:00+03:00","2026-06-03":"2026-06-03T08:23:00+03:00","2026-06-04":"2026-06-04T08:23:00+03:00","2026-06-05":"2026-06-05T08:23:00+03:00","2026-06-06":"2026-06-06T08:22:00+03:00","2026-06-07":"2026-06-07T08:22:00+03:00","2026-06-08":"2026-06-08T08:22:00+03:00","2026-06-09":"2026-06-09T08:22:00+03:00","2026-06-10":"2026-06-10T08:22:00+03:00","2026-06-11":"2026-06-11T08:22:00+03:00","2026-06-12":"2026-06-12T08:22:00+03:00","2026-06-13":"2026-06-13T08:23:00+03:00","2026-06-14":"2026-06-14T08:23:00+03:00","2026-06-15":"2026-06-15T08:23:00+03:00","2026-06-16":"2026-06-16T08:23:00+03:00","2026-06-17":"2026-06-17T08:23:00+03:00","2026-06-18":"2026-06-18T08:23:00+03:00","2026-06-19":"2026-06-19T08:23:00+03:00","2026-06-20":"2026-06-20T08:23:00+03:00","2026-06-21":"2026-06-21T08:24:00+03:00","2026-06-22":"2026-06-22T08:24:00+03:00","2026-06-23":"2026-06-23T08:24:00+03:00","2026-06-24":"2026-06-24T08:24:00+03:00","2026-06-25":"2026-06-25T08:25:00+03:00","2026-06-26":"2026-06-26T08:25:00+03:00","2026-06-27":"2026-06-27T08:25:00+03:00","2026-06-28":"2026-06-28T08:25:00+03:00","2026-06-29":"2026-06-29T08:26:00+03:00","2026-06-30":"2026-06-30T08:26:00+03:00","2026-07-01":"2026-07-01T08:26:00+03:00","2026-07-02":"2026-07-02T08:27:00+03:00","2026-07-03":"2026-07-03T08:27:00+03:00","2026-07-04":"2026-07-04T08:28:00+03:00","2026-07-05":"2026-07-05T08:28:00+03:00","2026-07-06":"2026-07-06T08:28:00+03:00","2026-07-07":"2026-07-07T08:29:00+03:00","2026-07-08":"2026-07-08T08:29:00+03:00","2026-07-09":"2026-07-09T08:29:00+03:00","2026-07-10":"2026-07-10T08:30:00+03:00"},"sofZmanShmaMGA":{"2026-04-23":"2026-04-23T08:44:00+03:00","2026-04-24":"2026-04-24T08:43:00+03:00","2026-04-25":"2026-04-25T08:43:00+03:00","2026-04-26":"2026-04-26T08:42:00+03:00","2026-04-27":"2026-04-27T08:41:00+03:00","2026-04-28":"2026-04-28T08:41:00+03:00","2026-04-29":"2026-04-29T08:40:00+03:00","2026-04-30":"2026-04-30T08:40:00+03:00","2026-05-01":"2026-05-01T08:39:00+03:00","2026-05-02":"2026-05-02T08:39:00+03:00","2026-05-03":"2026-05-03T08:38:00+03:00","2026-05-04":"2026-05-04T08:38:00+03:00","2026-05-05":"2026-05-05T08:37:00+03:00","2026-05-06":"2026-05-06T08:37:00+03:00","2026-05-07":"2026-05-07T08:36:00+03:00","2026-05-08":"2026-05-08T08:36:00+03:00","2026-05-09":"2026-05-09T08:35:00+03:00","2026-05-10":"2026-05-10T08:35:00+03:00","2026-05-11":"2026-05-11T08:35:00+03:00","2026-05-12":"2026-05-12T08:34:00+03:00","2026-05-13":"2026-05-13T08:34:00+03:00","2026-05-14":"2026-05-14T08:33:00+03:00","2026-05-15":"2026-05-15T08:33:00+03:00","2026-05-16":"2026-05-16T08:33:00+03:00","2026-05-17":"2026-05-17T08:32:00+03:00","2026-05-18":"2026-05-18T08:32:00+03:00","2026-05-19":"2026-05-19T08:32:00+03:00","2026-05-20":"2026-05-20T08:32:00+03:00","2026-05-21":"2026-05-21T08:31:00+03:00","2026-05-22":"2026-05-22T08:31:00+03:00","2026-05-23":"2026-05-23T08:31:00+03:00","2026-05-24":"2026-05-24T08:31:00+03:00","2026-05-25":"2026-05-25T08:31:00+03:00","2026-05-26":"2026-05-26T08:30:00+03:00","2026-05-27":"2026-05-27T08:30:00+03:00","2026-05-28":"2026-05-28T08:30:00+03:00","2026-05-29":"2026-05-29T08:30:00+03:00","2026-05-30":"2026-05-30T08:30:00+03:00","2026-05-31":"2026-05-31T08:30:00+03:00","2026-06-01":"2026-06-01T08:30:00+03:00","2026-06-02":"2026-06-02T08:30:00+03:00","2026-06-03":"2026-06-03T08:30:00+03:00","2026-06-04":"2026-06-04T08:30:00+03:00","2026-06-05":"2026-06-05T08:30:00+03:00","2026-06-06":"2026-06-06T08:30:00+03:00","2026-06-07":"2026-06-07T08:30:00+03:00","2026-06-08":"2026-06-08T08:30:00+03:00","2026-06-09":"2026-06-09T08:30:00+03:00","2026-06-10":"2026-06-10T08:30:00+03:00","2026-06-11":"2026-06-11T08:30:00+03:00","2026-06-12":"2026-06-12T08:30:00+03:00","2026-06-13":"2026-06-13T08:30:00+03:00","2026-06-14":"2026-06-14T08:30:00+03:00","2026-06-15":"2026-06-15T08:30:00+03:00","2026-06-16":"2026-06-16T08:31:00+03:00","2026-06-17":"2026-06-17T08:31:00+03:00","2026-06-18":"2026-06-18T08:31:00+03:00","2026-06-19":"2026-06-19T08:31:00+03:00","2026-06-20":"2026-06-20T08:31:00+03:00","2026-06-21":"2026-06-21T08:32:00+03:00","2026-06-22":"2026-06-22T08:32:00+03:00","2026-06-23":"2026-06-23T08:32:00+03:00","2026-06-24":"2026-06-24T08:32:00+03:00","2026-06-25":"2026-06-25T08:32:00+03:00","2026-06-26":"2026-06-26T08:33:00+03:00","2026-06-27":"2026-06-27T08:33:00+03:00","2026-06-28":"2026-06-28T08:33:00+03:00","2026-06-29":"2026-06-29T08:34:00+03:00","2026-06-30":"2026-06-30T08:34:00+03:00","2026-07-01":"2026-07-01T08:34:00+03:00","2026-07-02":"2026-07-02T08:34:00+03:00","2026-07-03":"2026-07-03T08:35:00+03:00","2026-07-04":"2026-07-04T08:35:00+03:00","2026-07-05":"2026-07-05T08:35:00+03:00","2026-07-06":"2026-07-06T08:36:00+03:00","2026-07-07":"2026-07-07T08:36:00+03:00","2026-07-08":"2026-07-08T08:36:00+03:00","2026-07-09":"2026-07-09T08:37:00+03:00","2026-07-10":"2026-07-10T08:37:00+03:00"},"sofZmanShmaBaalHatanya":{"2026-04-23":"2026-04-23T09:18:00+03:00","2026-04-24":"2026-04-24T09:17:00+03:00","2026-04-25":"2026-04-25T09:17:00+03:00","2026-04-26":"2026-04-26T09:16:00+03:00","2026-04-27":"2026-04-27T09:16:00+03:00","2026-04-28":"2026-04-28T09:15:00+03:00","2026-04-29":"2026-04-29T09:14:00+03:00","2026-04-30":"2026-04-30T09:14:00+03:00","2026-05-01":"2026-05-01T09:13:00+03:00","2026-05-02":"2026-05-02T09:13:00+03:00","2026-05-03":"2026-05-03T09:12:00+03:00","2026-05-04":"2026-05-04T09:12:00+03:00","2026-05-05":"2026-05-05T09:11:00+03:00","2026-05-06":"2026-05-06T09:11:00+03:00","2026-05-07":"2026-05-07T09:10:00+03:00","2026-05-08":"2026-05-08T09:10:00+03:00","2026-05-09":"2026-05-09T09:09:00+03:00","2026-05-10":"2026-05-10T09:09:00+03:00","2026-05-11":"2026-05-11T09:09:00+03:00","2026-05-12":"2026-05-12T09:08:00+03:00","2026-05-13":"2026-05-13T09:08:00+03:00","2026-05-14":"2026-05-14T09:07:00+03:00","2026-05-15":"2026-05-15T09:07:00+03:00","2026-05-16":"2026-05-16T09:07:00+03:00","2026-05-17":"2026-05-17T09:07:00+03:00","2026-05-18":"2026-05-18T09:06:00+03:00","2026-05-19":"2026-05-19T09:06:00+03:00","2026-05-20":"2026-05-20T09:06:00+03:00","2026-05-21":"2026-05-21T09:05:00+03:00","2026-05-22":"2026-05-22T09:05:00+03:00","2026-05-23":"2026-05-23T09:05:00+03:00","2026-05-24":"2026-05-24T09:05:00+03:00","2026-05-25":"2026-05-25T09:05:00+03:00","2026-05-26":"2026-05-26T09:04:00+03:00","2026-05-27":"2026-05-27T09:04:00+03:00","2026-05-28":"2026-05-28T09:04:00+03:00","2026-05-29":"2026-05-29T09:04:00+03:00","2026-05-30":"2026-05-30T09:04:00+03:00","2026-05-31":"2026-05-31T09:04:00+03:00","2026-06-01":"2026-06-01T09:04:00+03:00","2026-06-02":"2026-06-02T09:04:00+03:00","2026-06-03":"2026-06-03T09:04:00+03:00","2026-06-04":"2026-06-04T09:04:00+03:00","2026-06-05":"2026-06-05T09:04:00+03:00","2026-06-06":"2026-06-06T09:04:00+03:00","2026-06-07":"2026-06-07T09:04:00+03:00","2026-06-08":"2026-06-08T09:04:00+03:00","2026-06-09":"2026-06-09T09:04:00+03:00","2026-06-10":"2026-06-10T09:04:00+03:00","2026-06-11":"2026-06-11T09:04:00+03:00","2026-06-12":"2026-06-12T09:04:00+03:00","2026-06-13":"2026-06-13T09:04:00+03:00","2026-06-14":"2026-06-14T09:04:00+03:00","2026-06-15":"2026-06-15T09:04:00+03:00","2026-06-16":"2026-06-16T09:05:00+03:00","2026-06-17":"2026-06-17T09:05:00+03:00","2026-06-18":"2026-06-18T09:05:00+03:00","2026-06-19":"2026-06-19T09:05:00+03:00","2026-06-20":"2026-06-20T09:05:00+03:00","2026-06-21":"2026-06-21T09:06:00+03:00","2026-06-22":"2026-06-22T09:06:00+03:00","2026-06-23":"2026-06-23T09:06:00+03:00","2026-06-24":"2026-06-24T09:06:00+03:00","2026-06-25":"2026-06-25T09:06:00+03:00","2026-06-26":"2026-06-26T09:07:00+03:00","2026-06-27":"2026-06-27T09:07:00+03:00","2026-06-28":"2026-06-28T09:07:00+03:00","2026-06-29":"2026-06-29T09:08:00+03:00","2026-06-30":"2026-06-30T09:08:00+03:00","2026-07-01":"2026-07-01T09:08:00+03:00","2026-07-02":"2026-07-02T09:08:00+03:00","2026-07-03":"2026-07-03T09:09:00+03:00","2026-07-04":"2026-07-04T09:09:00+03:00","2026-07-05":"2026-07-05T09:09:00+03:00","2026-07-06":"2026-07-06T09:10:00+03:00","2026-07-07":"2026-07-07T09:10:00+03:00","2026-07-08":"2026-07-08T09:10:00+03:00","2026-07-09":"2026-07-09T09:11:00+03:00","2026-07-10":"2026-07-10T09:11:00+03:00"},"sofZmanShma":{"2026-04-23":"2026-04-23T09:20:00+03:00","2026-04-24":"2026-04-24T09:19:00+03:00","2026-04-25":"2026-04-25T09:19:00+03:00","2026-04-26":"2026-04-26T09:18:00+03:00","2026-04-27":"2026-04-27T09:17:00+03:00","2026-04-28":"2026-04-28T09:17:00+03:00","2026-04-29":"2026-04-29T09:16:00+03:00","2026-04-30":"2026-04-30T09:16:00+03:00","2026-05-01":"2026-05-01T09:15:00+03:00","2026-05-02":"2026-05-02T09:15:00+03:00","2026-05-03":"2026-05-03T09:14:00+03:00","2026-05-04":"2026-05-04T09:14:00+03:00","2026-05-05":"2026-05-05T09:13:00+03:00","2026-05-06":"2026-05-06T09:13:00+03:00","2026-05-07":"2026-05-07T09:12:00+03:00","2026-05-08":"2026-05-08T09:12:00+03:00","2026-05-09":"2026-05-09T09:11:00+03:00","2026-05-10":"2026-05-10T09:11:00+03:00","2026-05-11":"2026-05-11T09:11:00+03:00","2026-05-12":"2026-05-12T09:10:00+03:00","2026-05-13":"2026-05-13T09:10:00+03:00","2026-05-14":"2026-05-14T09:09:00+03:00","2026-05-15":"2026-05-15T09:09:00+03:00","2026-05-16":"2026-05-16T09:09:00+03:00","2026-05-17":"2026-05-17T09:08:00+03:00","2026-05-18":"2026-05-18T09:08:00+03:00","2026-05-19":"2026-05-19T09:08:00+03:00","2026-05-20":"2026-05-20T09:08:00+03:00","2026-05-21":"2026-05-21T09:07:00+03:00","2026-05-22":"2026-05-22T09:07:00+03:00","2026-05-23":"2026-05-23T09:07:00+03:00","2026-05-24":"2026-05-24T09:07:00+03:00","2026-05-25":"2026-05-25T09:07:00+03:00","2026-05-26":"2026-05-26T09:06:00+03:00","2026-05-27":"2026-05-27T09:06:00+03:00","2026-05-28":"2026-05-28T09:06:00+03:00","2026-05-29":"2026-05-29T09:06:00+03:00","2026-05-30":"2026-05-30T09:06:00+03:00","2026-05-31":"2026-05-31T09:06:00+03:00","2026-06-01":"2026-06-01T09:06:00+03:00","2026-06-02":"2026-06-02T09:06:00+03:00","2026-06-03":"2026-06-03T09:06:00+03:00","2026-06-04":"2026-06-04T09:06:00+03:00","2026-06-05":"2026-06-05T09:06:00+03:00","2026-06-06":"2026-06-06T09:06:00+03:00","2026-06-07":"2026-06-07T09:06:00+03:00","2026-06-08":"2026-06-08T09:06:00+03:00","2026-06-09":"2026-06-09T09:06:00+03:00","2026-06-10":"2026-06-10T09:06:00+03:00","2026-06-11":"2026-06-11T09:06:00+03:00","2026-06-12":"2026-06-12T09:06:00+03:00","2026-06-13":"2026-06-13T09:06:00+03:00","2026-06-14":"2026-06-14T09:06:00+03:00","2026-06-15":"2026-06-15T09:06:00+03:00","2026-06-16":"2026-06-16T09:07:00+03:00","2026-06-17":"2026-06-17T09:07:00+03:00","2026-06-18":"2026-06-18T09:07:00+03:00","2026-06-19":"2026-06-19T09:07:00+03:00","2026-06-20":"2026-06-20T09:07:00+03:00","2026-06-21":"2026-06-21T09:08:00+03:00","2026-06-22":"2026-06-22T09:08:00+03:00","2026-06-23":"2026-06-23T09:08:00+03:00","2026-06-24":"2026-06-24T09:08:00+03:00","2026-06-25":"2026-06-25T09:08:00+03:00","2026-06-26":"2026-06-26T09:09:00+03:00","2026-06-27":"2026-06-27T09:09:00+03:00","2026-06-28":"2026-06-28T09:09:00+03:00","2026-06-29":"2026-06-29T09:10:00+03:00","2026-06-30":"2026-06-30T09:10:00+03:00","2026-07-01":"2026-07-01T09:10:00+03:00","2026-07-02":"2026-07-02T09:10:00+03:00","2026-07-03":"2026-07-03T09:11:00+03:00","2026-07-04":"2026-07-04T09:11:00+03:00","2026-07-05":"2026-07-05T09:11:00+03:00","2026-07-06":"2026-07-06T09:12:00+03:00","2026-07-07":"2026-07-07T09:12:00+03:00","2026-07-08":"2026-07-08T09:12:00+03:00","2026-07-09":"2026-07-09T09:13:00+03:00","2026-07-10":"2026-07-10T09:13:00+03:00"},"sofZmanTfillaMGA19Point8":{"2026-04-23":"2026-04-23T09:54:00+03:00","2026-04-24":"2026-04-24T09:53:00+03:00","2026-04-25":"2026-04-25T09:53:00+03:00","2026-04-26":"2026-04-26T09:52:00+03:00","2026-04-27":"2026-04-27T09:51:00+03:00","2026-04-28":"2026-04-28T09:51:00+03:00","2026-04-29":"2026-04-29T09:50:00+03:00","2026-04-30":"2026-04-30T09:50:00+03:00","2026-05-01":"2026-05-01T09:49:00+03:00","2026-05-02":"2026-05-02T09:49:00+03:00","2026-05-03":"2026-05-03T09:48:00+03:00","2026-05-04":"2026-05-04T09:48:00+03:00","2026-05-05":"2026-05-05T09:47:00+03:00","2026-05-06":"2026-05-06T09:47:00+03:00","2026-05-07":"2026-05-07T09:47:00+03:00","2026-05-08":"2026-05-08T09:46:00+03:00","2026-05-09":"2026-05-09T09:46:00+03:00","2026-05-10":"2026-05-10T09:45:00+03:00","2026-05-11":"2026-05-11T09:45:00+03:00","2026-05-12":"2026-05-12T09:45:00+03:00","2026-05-13":"2026-05-13T09:44:00+03:00","2026-05-14":"2026-05-14T09:44:00+03:00","2026-05-15":"2026-05-15T09:43:00+03:00","2026-05-16":"2026-05-16T09:43:00+03:00","2026-05-17":"2026-05-17T09:43:00+03:00","2026-05-18":"2026-05-18T09:43:00+03:00","2026-05-19":"2026-05-19T09:42:00+03:00","2026-05-20":"2026-05-20T09:42:00+03:00","2026-05-21":"2026-05-21T09:42:00+03:00","2026-05-22":"2026-05-22T09:41:00+03:00","2026-05-23":"2026-05-23T09:41:00+03:00","2026-05-24":"2026-05-24T09:41:00+03:00","2026-05-25":"2026-05-25T09:41:00+03:00","2026-05-26":"2026-05-26T09:41:00+03:00","2026-05-27":"2026-05-27T09:41:00+03:00","2026-05-28":"2026-05-28T09:40:00+03:00","2026-05-29":"2026-05-29T09:40:00+03:00","2026-05-30":"2026-05-30T09:40:00+03:00","2026-05-31":"2026-05-31T09:40:00+03:00","2026-06-01":"2026-06-01T09:40:00+03:00","2026-06-02":"2026-06-02T09:40:00+03:00","2026-06-03":"2026-06-03T09:40:00+03:00","2026-06-04":"2026-06-04T09:40:00+03:00","2026-06-05":"2026-06-05T09:40:00+03:00","2026-06-06":"2026-06-06T09:40:00+03:00","2026-06-07":"2026-06-07T09:40:00+03:00","2026-06-08":"2026-06-08T09:40:00+03:00","2026-06-09":"2026-06-09T09:40:00+03:00","2026-06-10":"2026-06-10T09:40:00+03:00","2026-06-11":"2026-06-11T09:40:00+03:00","2026-06-12":"2026-06-12T09:40:00+03:00","2026-06-13":"2026-06-13T09:40:00+03:00","2026-06-14":"2026-06-14T09:40:00+03:00","2026-06-15":"2026-06-15T09:40:00+03:00","2026-06-16":"2026-06-16T09:41:00+03:00","2026-06-17":"2026-06-17T09:41:00+03:00","2026-06-18":"2026-06-18T09:41:00+03:00","2026-06-19":"2026-06-19T09:41:00+03:00","2026-06-20":"2026-06-20T09:41:00+03:00","2026-06-21":"2026-06-21T09:41:00+03:00","2026-06-22":"2026-06-22T09:42:00+03:00","2026-06-23":"2026-06-23T09:42:00+03:00","2026-06-24":"2026-06-24T09:42:00+03:00","2026-06-25":"2026-06-25T09:42:00+03:00","2026-06-26":"2026-06-26T09:43:00+03:00","2026-06-27":"2026-06-27T09:43:00+03:00","2026-06-28":"2026-06-28T09:43:00+03:00","2026-06-29":"2026-06-29T09:43:00+03:00","2026-06-30":"2026-06-30T09:44:00+03:00","2026-07-01":"2026-07-01T09:44:00+03:00","2026-07-02":"2026-07-02T09:44:00+03:00","2026-07-03":"2026-07-03T09:45:00+03:00","2026-07-04":"2026-07-04T09:45:00+03:00","2026-07-05":"2026-07-05T09:45:00+03:00","2026-07-06":"2026-07-06T09:45:00+03:00","2026-07-07":"2026-07-07T09:46:00+03:00","2026-07-08":"2026-07-08T09:46:00+03:00","2026-07-09":"2026-07-09T09:46:00+03:00","2026-07-10":"2026-07-10T09:47:00+03:00"},"sofZmanTfillaMGA16Point1":{"2026-04-23":"2026-04-23T10:00:00+03:00","2026-04-24":"2026-04-24T10:00:00+03:00","2026-04-25":"2026-04-25T09:59:00+03:00","2026-04-26":"2026-04-26T09:59:00+03:00","2026-04-27":"2026-04-27T09:58:00+03:00","2026-04-28":"2026-04-28T09:58:00+03:00","2026-04-29":"2026-04-29T09:57:00+03:00","2026-04-30":"2026-04-30T09:57:00+03:00","2026-05-01":"2026-05-01T09:56:00+03:00","2026-05-02":"2026-05-02T09:56:00+03:00","2026-05-03":"2026-05-03T09:55:00+03:00","2026-05-04":"2026-05-04T09:55:00+03:00","2026-05-05":"2026-05-05T09:54:00+03:00","2026-05-06":"2026-05-06T09:54:00+03:00","2026-05-07":"2026-05-07T09:53:00+03:00","2026-05-08":"2026-05-08T09:53:00+03:00","2026-05-09":"2026-05-09T09:53:00+03:00","2026-05-10":"2026-05-10T09:52:00+03:00","2026-05-11":"2026-05-11T09:52:00+03:00","2026-05-12":"2026-05-12T09:52:00+03:00","2026-05-13":"2026-05-13T09:51:00+03:00","2026-05-14":"2026-05-14T09:51:00+03:00","2026-05-15":"2026-05-15T09:51:00+03:00","2026-05-16":"2026-05-16T09:50:00+03:00","2026-05-17":"2026-05-17T09:50:00+03:00","2026-05-18":"2026-05-18T09:50:00+03:00","2026-05-19":"2026-05-19T09:50:00+03:00","2026-05-20":"2026-05-20T09:49:00+03:00","2026-05-21":"2026-05-21T09:49:00+03:00","2026-05-22":"2026-05-22T09:49:00+03:00","2026-05-23":"2026-05-23T09:49:00+03:00","2026-05-24":"2026-05-24T09:49:00+03:00","2026-05-25":"2026-05-25T09:48:00+03:00","2026-05-26":"2026-05-26T09:48:00+03:00","2026-05-27":"2026-05-27T09:48:00+03:00","2026-05-28":"2026-05-28T09:48:00+03:00","2026-05-29":"2026-05-29T09:48:00+03:00","2026-05-30":"2026-05-30T09:48:00+03:00","2026-05-31":"2026-05-31T09:48:00+03:00","2026-06-01":"2026-06-01T09:48:00+03:00","2026-06-02":"2026-06-02T09:48:00+03:00","2026-06-03":"2026-06-03T09:48:00+03:00","2026-06-04":"2026-06-04T09:48:00+03:00","2026-06-05":"2026-06-05T09:48:00+03:00","2026-06-06":"2026-06-06T09:48:00+03:00","2026-06-07":"2026-06-07T09:48:00+03:00","2026-06-08":"2026-06-08T09:48:00+03:00","2026-06-09":"2026-06-09T09:48:00+03:00","2026-06-10":"2026-06-10T09:48:00+03:00","2026-06-11":"2026-06-11T09:48:00+03:00","2026-06-12":"2026-06-12T09:48:00+03:00","2026-06-13":"2026-06-13T09:48:00+03:00","2026-06-14":"2026-06-14T09:48:00+03:00","2026-06-15":"2026-06-15T09:48:00+03:00","2026-06-16":"2026-06-16T09:49:00+03:00","2026-06-17":"2026-06-17T09:49:00+03:00","2026-06-18":"2026-06-18T09:49:00+03:00","2026-06-19":"2026-06-19T09:49:00+03:00","2026-06-20":"2026-06-20T09:49:00+03:00","2026-06-21":"2026-06-21T09:49:00+03:00","2026-06-22":"2026-06-22T09:50:00+03:00","2026-06-23":"2026-06-23T09:50:00+03:00","2026-06-24":"2026-06-24T09:50:00+03:00","2026-06-25":"2026-06-25T09:50:00+03:00","2026-06-26":"2026-06-26T09:51:00+03:00","2026-06-27":"2026-06-27T09:51:00+03:00","2026-06-28":"2026-06-28T09:51:00+03:00","2026-06-29":"2026-06-29T09:51:00+03:00","2026-06-30":"2026-06-30T09:52:00+03:00","2026-07-01":"2026-07-01T09:52:00+03:00","2026-07-02":"2026-07-02T09:52:00+03:00","2026-07-03":"2026-07-03T09:53:00+03:00","2026-07-04":"2026-07-04T09:53:00+03:00","2026-07-05":"2026-07-05T09:53:00+03:00","2026-07-06":"2026-07-06T09:53:00+03:00","2026-07-07":"2026-07-07T09:54:00+03:00","2026-07-08":"2026-07-08T09:54:00+03:00","2026-07-09":"2026-07-09T09:54:00+03:00","2026-07-10":"2026-07-10T09:55:00+03:00"},"sofZmanTfillaMGA":{"2026-04-23":"2026-04-23T10:02:00+03:00","2026-04-24":"2026-04-24T10:01:00+03:00","2026-04-25":"2026-04-25T10:01:00+03:00","2026-04-26":"2026-04-26T10:00:00+03:00","2026-04-27":"2026-04-27T10:00:00+03:00","2026-04-28":"2026-04-28T09:59:00+03:00","2026-04-29":"2026-04-29T09:59:00+03:00","2026-04-30":"2026-04-30T09:59:00+03:00","2026-05-01":"2026-05-01T09:58:00+03:00","2026-05-02":"2026-05-02T09:58:00+03:00","2026-05-03":"2026-05-03T09:57:00+03:00","2026-05-04":"2026-05-04T09:57:00+03:00","2026-05-05":"2026-05-05T09:57:00+03:00","2026-05-06":"2026-05-06T09:56:00+03:00","2026-05-07":"2026-05-07T09:56:00+03:00","2026-05-08":"2026-05-08T09:56:00+03:00","2026-05-09":"2026-05-09T09:55:00+03:00","2026-05-10":"2026-05-10T09:55:00+03:00","2026-05-11":"2026-05-11T09:55:00+03:00","2026-05-12":"2026-05-12T09:55:00+03:00","2026-05-13":"2026-05-13T09:54:00+03:00","2026-05-14":"2026-05-14T09:54:00+03:00","2026-05-15":"2026-05-15T09:54:00+03:00","2026-05-16":"2026-05-16T09:54:00+03:00","2026-05-17":"2026-05-17T09:54:00+03:00","2026-05-18":"2026-05-18T09:53:00+03:00","2026-05-19":"2026-05-19T09:53:00+03:00","2026-05-20":"2026-05-20T09:53:00+03:00","2026-05-21":"2026-05-21T09:53:00+03:00","2026-05-22":"2026-05-22T09:53:00+03:00","2026-05-23":"2026-05-23T09:53:00+03:00","2026-05-24":"2026-05-24T09:53:00+03:00","2026-05-25":"2026-05-25T09:52:00+03:00","2026-05-26":"2026-05-26T09:52:00+03:00","2026-05-27":"2026-05-27T09:52:00+03:00","2026-05-28":"2026-05-28T09:52:00+03:00","2026-05-29":"2026-05-29T09:52:00+03:00","2026-05-30":"2026-05-30T09:52:00+03:00","2026-05-31":"2026-05-31T09:52:00+03:00","2026-06-01":"2026-06-01T09:52:00+03:00","2026-06-02":"2026-06-02T09:52:00+03:00","2026-06-03":"2026-06-03T09:52:00+03:00","2026-06-04":"2026-06-04T09:52:00+03:00","2026-06-05":"2026-06-05T09:52:00+03:00","2026-06-06":"2026-06-06T09:52:00+03:00","2026-06-07":"2026-06-07T09:53:00+03:00","2026-06-08":"2026-06-08T09:53:00+03:00","2026-06-09":"2026-06-09T09:53:00+03:00","2026-06-10":"2026-06-10T09:53:00+03:00","2026-06-11":"2026-06-11T09:53:00+03:00","2026-06-12":"2026-06-12T09:53:00+03:00","2026-06-13":"2026-06-13T09:53:00+03:00","2026-06-14":"2026-06-14T09:53:00+03:00","2026-06-15":"2026-06-15T09:54:00+03:00","2026-06-16":"2026-06-16T09:54:00+03:00","2026-06-17":"2026-06-17T09:54:00+03:00","2026-06-18":"2026-06-18T09:54:00+03:00","2026-06-19":"2026-06-19T09:54:00+03:00","2026-06-20":"2026-06-20T09:54:00+03:00","2026-06-21":"2026-06-21T09:55:00+03:00","2026-06-22":"2026-06-22T09:55:00+03:00","2026-06-23":"2026-06-23T09:55:00+03:00","2026-06-24":"2026-06-24T09:55:00+03:00","2026-06-25":"2026-06-25T09:56:00+03:00","2026-06-26":"2026-06-26T09:56:00+03:00","2026-06-27":"2026-06-27T09:56:00+03:00","2026-06-28":"2026-06-28T09:56:00+03:00","2026-06-29":"2026-06-29T09:57:00+03:00","2026-06-30":"2026-06-30T09:57:00+03:00","2026-07-01":"2026-07-01T09:57:00+03:00","2026-07-02":"2026-07-02T09:57:00+03:00","2026-07-03":"2026-07-03T09:58:00+03:00","2026-07-04":"2026-07-04T09:58:00+03:00","2026-07-05":"2026-07-05T09:58:00+03:00","2026-07-06":"2026-07-06T09:58:00+03:00","2026-07-07":"2026-07-07T09:59:00+03:00","2026-07-08":"2026-07-08T09:59:00+03:00","2026-07-09":"2026-07-09T09:59:00+03:00","2026-07-10":"2026-07-10T09:59:00+03:00"},"sofZmanTfilaBaalHatanya":{"2026-04-23":"2026-04-23T10:25:00+03:00","2026-04-24":"2026-04-24T10:24:00+03:00","2026-04-25":"2026-04-25T10:24:00+03:00","2026-04-26":"2026-04-26T10:23:00+03:00","2026-04-27":"2026-04-27T10:23:00+03:00","2026-04-28":"2026-04-28T10:22:00+03:00","2026-04-29":"2026-04-29T10:22:00+03:00","2026-04-30":"2026-04-30T10:21:00+03:00","2026-05-01":"2026-05-01T10:21:00+03:00","2026-05-02":"2026-05-02T10:21:00+03:00","2026-05-03":"2026-05-03T10:20:00+03:00","2026-05-04":"2026-05-04T10:20:00+03:00","2026-05-05":"2026-05-05T10:20:00+03:00","2026-05-06":"2026-05-06T10:19:00+03:00","2026-05-07":"2026-05-07T10:19:00+03:00","2026-05-08":"2026-05-08T10:19:00+03:00","2026-05-09":"2026-05-09T10:18:00+03:00","2026-05-10":"2026-05-10T10:18:00+03:00","2026-05-11":"2026-05-11T10:18:00+03:00","2026-05-12":"2026-05-12T10:17:00+03:00","2026-05-13":"2026-05-13T10:17:00+03:00","2026-05-14":"2026-05-14T10:17:00+03:00","2026-05-15":"2026-05-15T10:17:00+03:00","2026-05-16":"2026-05-16T10:16:00+03:00","2026-05-17":"2026-05-17T10:16:00+03:00","2026-05-18":"2026-05-18T10:16:00+03:00","2026-05-19":"2026-05-19T10:16:00+03:00","2026-05-20":"2026-05-20T10:16:00+03:00","2026-05-21":"2026-05-21T10:16:00+03:00","2026-05-22":"2026-05-22T10:15:00+03:00","2026-05-23":"2026-05-23T10:15:00+03:00","2026-05-24":"2026-05-24T10:15:00+03:00","2026-05-25":"2026-05-25T10:15:00+03:00","2026-05-26":"2026-05-26T10:15:00+03:00","2026-05-27":"2026-05-27T10:15:00+03:00","2026-05-28":"2026-05-28T10:15:00+03:00","2026-05-29":"2026-05-29T10:15:00+03:00","2026-05-30":"2026-05-30T10:15:00+03:00","2026-05-31":"2026-05-31T10:15:00+03:00","2026-06-01":"2026-06-01T10:15:00+03:00","2026-06-02":"2026-06-02T10:15:00+03:00","2026-06-03":"2026-06-03T10:15:00+03:00","2026-06-04":"2026-06-04T10:15:00+03:00","2026-06-05":"2026-06-05T10:15:00+03:00","2026-06-06":"2026-06-06T10:15:00+03:00","2026-06-07":"2026-06-07T10:15:00+03:00","2026-06-08":"2026-06-08T10:15:00+03:00","2026-06-09":"2026-06-09T10:15:00+03:00","2026-06-10":"2026-06-10T10:15:00+03:00","2026-06-11":"2026-06-11T10:16:00+03:00","2026-06-12":"2026-06-12T10:16:00+03:00","2026-06-13":"2026-06-13T10:16:00+03:00","2026-06-14":"2026-06-14T10:16:00+03:00","2026-06-15":"2026-06-15T10:16:00+03:00","2026-06-16":"2026-06-16T10:16:00+03:00","2026-06-17":"2026-06-17T10:17:00+03:00","2026-06-18":"2026-06-18T10:17:00+03:00","2026-06-19":"2026-06-19T10:17:00+03:00","2026-06-20":"2026-06-20T10:17:00+03:00","2026-06-21":"2026-06-21T10:17:00+03:00","2026-06-22":"2026-06-22T10:18:00+03:00","2026-06-23":"2026-06-23T10:18:00+03:00","2026-06-24":"2026-06-24T10:18:00+03:00","2026-06-25":"2026-06-25T10:18:00+03:00","2026-06-26":"2026-06-26T10:18:00+03:00","2026-06-27":"2026-06-27T10:19:00+03:00","2026-06-28":"2026-06-28T10:19:00+03:00","2026-06-29":"2026-06-29T10:19:00+03:00","2026-06-30":"2026-06-30T10:19:00+03:00","2026-07-01":"2026-07-01T10:20:00+03:00","2026-07-02":"2026-07-02T10:20:00+03:00","2026-07-03":"2026-07-03T10:20:00+03:00","2026-07-04":"2026-07-04T10:20:00+03:00","2026-07-05":"2026-07-05T10:21:00+03:00","2026-07-06":"2026-07-06T10:21:00+03:00","2026-07-07":"2026-07-07T10:21:00+03:00","2026-07-08":"2026-07-08T10:22:00+03:00","2026-07-09":"2026-07-09T10:22:00+03:00","2026-07-10":"2026-07-10T10:22:00+03:00"},"sofZmanTfilla":{"2026-04-23":"2026-04-23T10:26:00+03:00","2026-04-24":"2026-04-24T10:25:00+03:00","2026-04-25":"2026-04-25T10:25:00+03:00","2026-04-26":"2026-04-26T10:24:00+03:00","2026-04-27":"2026-04-27T10:24:00+03:00","2026-04-28":"2026-04-28T10:23:00+03:00","2026-04-29":"2026-04-29T10:23:00+03:00","2026-04-30":"2026-04-30T10:23:00+03:00","2026-05-01":"2026-05-01T10:22:00+03:00","2026-05-02":"2026-05-02T10:22:00+03:00","2026-05-03":"2026-05-03T10:21:00+03:00","2026-05-04":"2026-05-04T10:21:00+03:00","2026-05-05":"2026-05-05T10:21:00+03:00","2026-05-06":"2026-05-06T10:20:00+03:00","2026-05-07":"2026-05-07T10:20:00+03:00","2026-05-08":"2026-05-08T10:20:00+03:00","2026-05-09":"2026-05-09T10:19:00+03:00","2026-05-10":"2026-05-10T10:19:00+03:00","2026-05-11":"2026-05-11T10:19:00+03:00","2026-05-12":"2026-05-12T10:19:00+03:00","2026-05-13":"2026-05-13T10:18:00+03:00","2026-05-14":"2026-05-14T10:18:00+03:00","2026-05-15":"2026-05-15T10:18:00+03:00","2026-05-16":"2026-05-16T10:18:00+03:00","2026-05-17":"2026-05-17T10:18:00+03:00","2026-05-18":"2026-05-18T10:17:00+03:00","2026-05-19":"2026-05-19T10:17:00+03:00","2026-05-20":"2026-05-20T10:17:00+03:00","2026-05-21":"2026-05-21T10:17:00+03:00","2026-05-22":"2026-05-22T10:17:00+03:00","2026-05-23":"2026-05-23T10:17:00+03:00","2026-05-24":"2026-05-24T10:17:00+03:00","2026-05-25":"2026-05-25T10:16:00+03:00","2026-05-26":"2026-05-26T10:16:00+03:00","2026-05-27":"2026-05-27T10:16:00+03:00","2026-05-28":"2026-05-28T10:16:00+03:00","2026-05-29":"2026-05-29T10:16:00+03:00","2026-05-30":"2026-05-30T10:16:00+03:00","2026-05-31":"2026-05-31T10:16:00+03:00","2026-06-01":"2026-06-01T10:16:00+03:00","2026-06-02":"2026-06-02T10:16:00+03:00","2026-06-03":"2026-06-03T10:16:00+03:00","2026-06-04":"2026-06-04T10:16:00+03:00","2026-06-05":"2026-06-05T10:16:00+03:00","2026-06-06":"2026-06-06T10:16:00+03:00","2026-06-07":"2026-06-07T10:17:00+03:00","2026-06-08":"2026-06-08T10:17:00+03:00","2026-06-09":"2026-06-09T10:17:00+03:00","2026-06-10":"2026-06-10T10:17:00+03:00","2026-06-11":"2026-06-11T10:17:00+03:00","2026-06-12":"2026-06-12T10:17:00+03:00","2026-06-13":"2026-06-13T10:17:00+03:00","2026-06-14":"2026-06-14T10:17:00+03:00","2026-06-15":"2026-06-15T10:18:00+03:00","2026-06-16":"2026-06-16T10:18:00+03:00","2026-06-17":"2026-06-17T10:18:00+03:00","2026-06-18":"2026-06-18T10:18:00+03:00","2026-06-19":"2026-06-19T10:18:00+03:00","2026-06-20":"2026-06-20T10:18:00+03:00","2026-06-21":"2026-06-21T10:19:00+03:00","2026-06-22":"2026-06-22T10:19:00+03:00","2026-06-23":"2026-06-23T10:19:00+03:00","2026-06-24":"2026-06-24T10:19:00+03:00","2026-06-25":"2026-06-25T10:20:00+03:00","2026-06-26":"2026-06-26T10:20:00+03:00","2026-06-27":"2026-06-27T10:20:00+03:00","2026-06-28":"2026-06-28T10:20:00+03:00","2026-06-29":"2026-06-29T10:21:00+03:00","2026-06-30":"2026-06-30T10:21:00+03:00","2026-07-01":"2026-07-01T10:21:00+03:00","2026-07-02":"2026-07-02T10:21:00+03:00","2026-07-03":"2026-07-03T10:22:00+03:00","2026-07-04":"2026-07-04T10:22:00+03:00","2026-07-05":"2026-07-05T10:22:00+03:00","2026-07-06":"2026-07-06T10:22:00+03:00","2026-07-07":"2026-07-07T10:23:00+03:00","2026-07-08":"2026-07-08T10:23:00+03:00","2026-07-09":"2026-07-09T10:23:00+03:00","2026-07-10":"2026-07-10T10:23:00+03:00"},"chatzot":{"2026-04-23":"2026-04-23T12:38:00+03:00","2026-04-24":"2026-04-24T12:38:00+03:00","2026-04-25":"2026-04-25T12:37:00+03:00","2026-04-26":"2026-04-26T12:37:00+03:00","2026-04-27":"2026-04-27T12:37:00+03:00","2026-04-28":"2026-04-28T12:37:00+03:00","2026-04-29":"2026-04-29T12:37:00+03:00","2026-04-30":"2026-04-30T12:37:00+03:00","2026-05-01":"2026-05-01T12:36:00+03:00","2026-05-02":"2026-05-02T12:36:00+03:00","2026-05-03":"2026-05-03T12:36:00+03:00","2026-05-04":"2026-05-04T12:36:00+03:00","2026-05-05":"2026-05-05T12:36:00+03:00","2026-05-06":"2026-05-06T12:36:00+03:00","2026-05-07":"2026-05-07T12:36:00+03:00","2026-05-08":"2026-05-08T12:36:00+03:00","2026-05-09":"2026-05-09T12:36:00+03:00","2026-05-10":"2026-05-10T12:36:00+03:00","2026-05-11":"2026-05-11T12:36:00+03:00","2026-05-12":"2026-05-12T12:36:00+03:00","2026-05-13":"2026-05-13T12:36:00+03:00","2026-05-14":"2026-05-14T12:36:00+03:00","2026-05-15":"2026-05-15T12:36:00+03:00","2026-05-16":"2026-05-16T12:36:00+03:00","2026-05-17":"2026-05-17T12:36:00+03:00","2026-05-18":"2026-05-18T12:36:00+03:00","2026-05-19":"2026-05-19T12:36:00+03:00","2026-05-20":"2026-05-20T12:36:00+03:00","2026-05-21":"2026-05-21T12:36:00+03:00","2026-05-22":"2026-05-22T12:36:00+03:00","2026-05-23":"2026-05-23T12:36:00+03:00","2026-05-24":"2026-05-24T12:36:00+03:00","2026-05-25":"2026-05-25T12:36:00+03:00","2026-05-26":"2026-05-26T12:36:00+03:00","2026-05-27":"2026-05-27T12:36:00+03:00","2026-05-28":"2026-05-28T12:37:00+03:00","2026-05-29":"2026-05-29T12:37:00+03:00","2026-05-30":"2026-05-30T12:37:00+03:00","2026-05-31":"2026-05-31T12:37:00+03:00","2026-06-01":"2026-06-01T12:37:00+03:00","2026-06-02":"2026-06-02T12:37:00+03:00","2026-06-03":"2026-06-03T12:37:00+03:00","2026-06-04":"2026-06-04T12:38:00+03:00","2026-06-05":"2026-06-05T12:38:00+03:00","2026-06-06":"2026-06-06T12:38:00+03:00","2026-06-07":"2026-06-07T12:38:00+03:00","2026-06-08":"2026-06-08T12:38:00+03:00","2026-06-09":"2026-06-09T12:38:00+03:00","2026-06-10":"2026-06-10T12:39:00+03:00","2026-06-11":"2026-06-11T12:39:00+03:00","2026-06-12":"2026-06-12T12:39:00+03:00","2026-06-13":"2026-06-13T12:39:00+03:00","2026-06-14":"2026-06-14T12:39:00+03:00","2026-06-15":"2026-06-15T12:40:00+03:00","2026-06-16":"2026-06-16T12:40:00+03:00","2026-06-17":"2026-06-17T12:40:00+03:00","2026-06-18":"2026-06-18T12:40:00+03:00","2026-06-19":"2026-06-19T12:41:00+03:00","2026-06-20":"2026-06-20T12:41:00+03:00","2026-06-21":"2026-06-21T12:41:00+03:00","2026-06-22":"2026-06-22T12:41:00+03:00","2026-06-23":"2026-06-23T12:41:00+03:00","2026-06-24":"2026-06-24T12:42:00+03:00","2026-06-25":"2026-06-25T12:42:00+03:00","2026-06-26":"2026-06-26T12:42:00+03:00","2026-06-27":"2026-06-27T12:42:00+03:00","2026-06-28":"2026-06-28T12:42:00+03:00","2026-06-29":"2026-06-29T12:43:00+03:00","2026-06-30":"2026-06-30T12:43:00+03:00","2026-07-01":"2026-07-01T12:43:00+03:00","2026-07-02":"2026-07-02T12:43:00+03:00","2026-07-03":"2026-07-03T12:43:00+03:00","2026-07-04":"2026-07-04T12:43:00+03:00","2026-07-05":"2026-07-05T12:44:00+03:00","2026-07-06":"2026-07-06T12:44:00+03:00","2026-07-07":"2026-07-07T12:44:00+03:00","2026-07-08":"2026-07-08T12:44:00+03:00","2026-07-09":"2026-07-09T12:44:00+03:00","2026-07-10":"2026-07-10T12:44:00+03:00"},"minchaGedola":{"2026-04-23":"2026-04-23T13:11:00+03:00","2026-04-24":"2026-04-24T13:11:00+03:00","2026-04-25":"2026-04-25T13:10:00+03:00","2026-04-26":"2026-04-26T13:10:00+03:00","2026-04-27":"2026-04-27T13:10:00+03:00","2026-04-28":"2026-04-28T13:10:00+03:00","2026-04-29":"2026-04-29T13:10:00+03:00","2026-04-30":"2026-04-30T13:10:00+03:00","2026-05-01":"2026-05-01T13:10:00+03:00","2026-05-02":"2026-05-02T13:10:00+03:00","2026-05-03":"2026-05-03T13:10:00+03:00","2026-05-04":"2026-05-04T13:10:00+03:00","2026-05-05":"2026-05-05T13:10:00+03:00","2026-05-06":"2026-05-06T13:10:00+03:00","2026-05-07":"2026-05-07T13:10:00+03:00","2026-05-08":"2026-05-08T13:10:00+03:00","2026-05-09":"2026-05-09T13:10:00+03:00","2026-05-10":"2026-05-10T13:10:00+03:00","2026-05-11":"2026-05-11T13:10:00+03:00","2026-05-12":"2026-05-12T13:10:00+03:00","2026-05-13":"2026-05-13T13:10:00+03:00","2026-05-14":"2026-05-14T13:10:00+03:00","2026-05-15":"2026-05-15T13:10:00+03:00","2026-05-16":"2026-05-16T13:10:00+03:00","2026-05-17":"2026-05-17T13:10:00+03:00","2026-05-18":"2026-05-18T13:10:00+03:00","2026-05-19":"2026-05-19T13:10:00+03:00","2026-05-20":"2026-05-20T13:11:00+03:00","2026-05-21":"2026-05-21T13:11:00+03:00","2026-05-22":"2026-05-22T13:11:00+03:00","2026-05-23":"2026-05-23T13:11:00+03:00","2026-05-24":"2026-05-24T13:11:00+03:00","2026-05-25":"2026-05-25T13:11:00+03:00","2026-05-26":"2026-05-26T13:11:00+03:00","2026-05-27":"2026-05-27T13:11:00+03:00","2026-05-28":"2026-05-28T13:12:00+03:00","2026-05-29":"2026-05-29T13:12:00+03:00","2026-05-30":"2026-05-30T13:12:00+03:00","2026-05-31":"2026-05-31T13:12:00+03:00","2026-06-01":"2026-06-01T13:12:00+03:00","2026-06-02":"2026-06-02T13:13:00+03:00","2026-06-03":"2026-06-03T13:13:00+03:00","2026-06-04":"2026-06-04T13:13:00+03:00","2026-06-05":"2026-06-05T13:13:00+03:00","2026-06-06":"2026-06-06T13:13:00+03:00","2026-06-07":"2026-06-07T13:13:00+03:00","2026-06-08":"2026-06-08T13:14:00+03:00","2026-06-09":"2026-06-09T13:14:00+03:00","2026-06-10":"2026-06-10T13:14:00+03:00","2026-06-11":"2026-06-11T13:14:00+03:00","2026-06-12":"2026-06-12T13:15:00+03:00","2026-06-13":"2026-06-13T13:15:00+03:00","2026-06-14":"2026-06-14T13:15:00+03:00","2026-06-15":"2026-06-15T13:15:00+03:00","2026-06-16":"2026-06-16T13:15:00+03:00","2026-06-17":"2026-06-17T13:16:00+03:00","2026-06-18":"2026-06-18T13:16:00+03:00","2026-06-19":"2026-06-19T13:16:00+03:00","2026-06-20":"2026-06-20T13:16:00+03:00","2026-06-21":"2026-06-21T13:16:00+03:00","2026-06-22":"2026-06-22T13:17:00+03:00","2026-06-23":"2026-06-23T13:17:00+03:00","2026-06-24":"2026-06-24T13:17:00+03:00","2026-06-25":"2026-06-25T13:17:00+03:00","2026-06-26":"2026-06-26T13:18:00+03:00","2026-06-27":"2026-06-27T13:18:00+03:00","2026-06-28":"2026-06-28T13:18:00+03:00","2026-06-29":"2026-06-29T13:18:00+03:00","2026-06-30":"2026-06-30T13:18:00+03:00","2026-07-01":"2026-07-01T13:18:00+03:00","2026-07-02":"2026-07-02T13:19:00+03:00","2026-07-03":"2026-07-03T13:19:00+03:00","2026-07-04":"2026-07-04T13:19:00+03:00","2026-07-05":"2026-07-05T13:19:00+03:00","2026-07-06":"2026-07-06T13:19:00+03:00","2026-07-07":"2026-07-07T13:19:00+03:00","2026-07-08":"2026-07-08T13:19:00+03:00","2026-07-09":"2026-07-09T13:20:00+03:00","2026-07-10":"2026-07-10T13:20:00+03:00"},"minchaGedolaBaalHatanya":{"2026-04-23":"2026-04-23T13:11:00+03:00","2026-04-24":"2026-04-24T13:11:00+03:00","2026-04-25":"2026-04-25T13:11:00+03:00","2026-04-26":"2026-04-26T13:11:00+03:00","2026-04-27":"2026-04-27T13:11:00+03:00","2026-04-28":"2026-04-28T13:11:00+03:00","2026-04-29":"2026-04-29T13:10:00+03:00","2026-04-30":"2026-04-30T13:10:00+03:00","2026-05-01":"2026-05-01T13:10:00+03:00","2026-05-02":"2026-05-02T13:10:00+03:00","2026-05-03":"2026-05-03T13:10:00+03:00","2026-05-04":"2026-05-04T13:10:00+03:00","2026-05-05":"2026-05-05T13:10:00+03:00","2026-05-06":"2026-05-06T13:10:00+03:00","2026-05-07":"2026-05-07T13:10:00+03:00","2026-05-08":"2026-05-08T13:10:00+03:00","2026-05-09":"2026-05-09T13:10:00+03:00","2026-05-10":"2026-05-10T13:10:00+03:00","2026-05-11":"2026-05-11T13:10:00+03:00","2026-05-12":"2026-05-12T13:10:00+03:00","2026-05-13":"2026-05-13T13:10:00+03:00","2026-05-14":"2026-05-14T13:10:00+03:00","2026-05-15":"2026-05-15T13:10:00+03:00","2026-05-16":"2026-05-16T13:11:00+03:00","2026-05-17":"2026-05-17T13:11:00+03:00","2026-05-18":"2026-05-18T13:11:00+03:00","2026-05-19":"2026-05-19T13:11:00+03:00","2026-05-20":"2026-05-20T13:11:00+03:00","2026-05-21":"2026-05-21T13:11:00+03:00","2026-05-22":"2026-05-22T13:11:00+03:00","2026-05-23":"2026-05-23T13:11:00+03:00","2026-05-24":"2026-05-24T13:11:00+03:00","2026-05-25":"2026-05-25T13:12:00+03:00","2026-05-26":"2026-05-26T13:12:00+03:00","2026-05-27":"2026-05-27T13:12:00+03:00","2026-05-28":"2026-05-28T13:12:00+03:00","2026-05-29":"2026-05-29T13:12:00+03:00","2026-05-30":"2026-05-30T13:12:00+03:00","2026-05-31":"2026-05-31T13:12:00+03:00","2026-06-01":"2026-06-01T13:13:00+03:00","2026-06-02":"2026-06-02T13:13:00+03:00","2026-06-03":"2026-06-03T13:13:00+03:00","2026-06-04":"2026-06-04T13:13:00+03:00","2026-06-05":"2026-06-05T13:13:00+03:00","2026-06-06":"2026-06-06T13:14:00+03:00","2026-06-07":"2026-06-07T13:14:00+03:00","2026-06-08":"2026-06-08T13:14:00+03:00","2026-06-09":"2026-06-09T13:14:00+03:00","2026-06-10":"2026-06-10T13:14:00+03:00","2026-06-11":"2026-06-11T13:15:00+03:00","2026-06-12":"2026-06-12T13:15:00+03:00","2026-06-13":"2026-06-13T13:15:00+03:00","2026-06-14":"2026-06-14T13:15:00+03:00","2026-06-15":"2026-06-15T13:16:00+03:00","2026-06-16":"2026-06-16T13:16:00+03:00","2026-06-17":"2026-06-17T13:16:00+03:00","2026-06-18":"2026-06-18T13:16:00+03:00","2026-06-19":"2026-06-19T13:16:00+03:00","2026-06-20":"2026-06-20T13:17:00+03:00","2026-06-21":"2026-06-21T13:17:00+03:00","2026-06-22":"2026-06-22T13:17:00+03:00","2026-06-23":"2026-06-23T13:17:00+03:00","2026-06-24":"2026-06-24T13:17:00+03:00","2026-06-25":"2026-06-25T13:18:00+03:00","2026-06-26":"2026-06-26T13:18:00+03:00","2026-06-27":"2026-06-27T13:18:00+03:00","2026-06-28":"2026-06-28T13:18:00+03:00","2026-06-29":"2026-06-29T13:18:00+03:00","2026-06-30":"2026-06-30T13:19:00+03:00","2026-07-01":"2026-07-01T13:19:00+03:00","2026-07-02":"2026-07-02T13:19:00+03:00","2026-07-03":"2026-07-03T13:19:00+03:00","2026-07-04":"2026-07-04T13:19:00+03:00","2026-07-05":"2026-07-05T13:19:00+03:00","2026-07-06":"2026-07-06T13:20:00+03:00","2026-07-07":"2026-07-07T13:20:00+03:00","2026-07-08":"2026-07-08T13:20:00+03:00","2026-07-09":"2026-07-09T13:20:00+03:00","2026-07-10":"2026-07-10T13:20:00+03:00"},"minchaGedolaMGA":{"2026-04-23":"2026-04-23T13:17:00+03:00","2026-04-24":"2026-04-24T13:17:00+03:00","2026-04-25":"2026-04-25T13:16:00+03:00","2026-04-26":"2026-04-26T13:16:00+03:00","2026-04-27":"2026-04-27T13:16:00+03:00","2026-04-28":"2026-04-28T13:16:00+03:00","2026-04-29":"2026-04-29T13:16:00+03:00","2026-04-30":"2026-04-30T13:16:00+03:00","2026-05-01":"2026-05-01T13:16:00+03:00","2026-05-02":"2026-05-02T13:16:00+03:00","2026-05-03":"2026-05-03T13:16:00+03:00","2026-05-04":"2026-05-04T13:16:00+03:00","2026-05-05":"2026-05-05T13:16:00+03:00","2026-05-06":"2026-05-06T13:16:00+03:00","2026-05-07":"2026-05-07T13:16:00+03:00","2026-05-08":"2026-05-08T13:16:00+03:00","2026-05-09":"2026-05-09T13:16:00+03:00","2026-05-10":"2026-05-10T13:16:00+03:00","2026-05-11":"2026-05-11T13:16:00+03:00","2026-05-12":"2026-05-12T13:16:00+03:00","2026-05-13":"2026-05-13T13:16:00+03:00","2026-05-14":"2026-05-14T13:16:00+03:00","2026-05-15":"2026-05-15T13:16:00+03:00","2026-05-16":"2026-05-16T13:16:00+03:00","2026-05-17":"2026-05-17T13:16:00+03:00","2026-05-18":"2026-05-18T13:16:00+03:00","2026-05-19":"2026-05-19T13:16:00+03:00","2026-05-20":"2026-05-20T13:17:00+03:00","2026-05-21":"2026-05-21T13:17:00+03:00","2026-05-22":"2026-05-22T13:17:00+03:00","2026-05-23":"2026-05-23T13:17:00+03:00","2026-05-24":"2026-05-24T13:17:00+03:00","2026-05-25":"2026-05-25T13:17:00+03:00","2026-05-26":"2026-05-26T13:17:00+03:00","2026-05-27":"2026-05-27T13:17:00+03:00","2026-05-28":"2026-05-28T13:18:00+03:00","2026-05-29":"2026-05-29T13:18:00+03:00","2026-05-30":"2026-05-30T13:18:00+03:00","2026-05-31":"2026-05-31T13:18:00+03:00","2026-06-01":"2026-06-01T13:18:00+03:00","2026-06-02":"2026-06-02T13:19:00+03:00","2026-06-03":"2026-06-03T13:19:00+03:00","2026-06-04":"2026-06-04T13:19:00+03:00","2026-06-05":"2026-06-05T13:19:00+03:00","2026-06-06":"2026-06-06T13:19:00+03:00","2026-06-07":"2026-06-07T13:19:00+03:00","2026-06-08":"2026-06-08T13:20:00+03:00","2026-06-09":"2026-06-09T13:20:00+03:00","2026-06-10":"2026-06-10T13:20:00+03:00","2026-06-11":"2026-06-11T13:20:00+03:00","2026-06-12":"2026-06-12T13:21:00+03:00","2026-06-13":"2026-06-13T13:21:00+03:00","2026-06-14":"2026-06-14T13:21:00+03:00","2026-06-15":"2026-06-15T13:21:00+03:00","2026-06-16":"2026-06-16T13:21:00+03:00","2026-06-17":"2026-06-17T13:22:00+03:00","2026-06-18":"2026-06-18T13:22:00+03:00","2026-06-19":"2026-06-19T13:22:00+03:00","2026-06-20":"2026-06-20T13:22:00+03:00","2026-06-21":"2026-06-21T13:22:00+03:00","2026-06-22":"2026-06-22T13:23:00+03:00","2026-06-23":"2026-06-23T13:23:00+03:00","2026-06-24":"2026-06-24T13:23:00+03:00","2026-06-25":"2026-06-25T13:23:00+03:00","2026-06-26":"2026-06-26T13:24:00+03:00","2026-06-27":"2026-06-27T13:24:00+03:00","2026-06-28":"2026-06-28T13:24:00+03:00","2026-06-29":"2026-06-29T13:24:00+03:00","2026-06-30":"2026-06-30T13:24:00+03:00","2026-07-01":"2026-07-01T13:24:00+03:00","2026-07-02":"2026-07-02T13:25:00+03:00","2026-07-03":"2026-07-03T13:25:00+03:00","2026-07-04":"2026-07-04T13:25:00+03:00","2026-07-05":"2026-07-05T13:25:00+03:00","2026-07-06":"2026-07-06T13:25:00+03:00","2026-07-07":"2026-07-07T13:25:00+03:00","2026-07-08":"2026-07-08T13:25:00+03:00","2026-07-09":"2026-07-09T13:26:00+03:00","2026-07-10":"2026-07-10T13:26:00+03:00"},"minchaKetana":{"2026-04-23":"2026-04-23T16:29:00+03:00","2026-04-24":"2026-04-24T16:29:00+03:00","2026-04-25":"2026-04-25T16:29:00+03:00","2026-04-26":"2026-04-26T16:30:00+03:00","2026-04-27":"2026-04-27T16:30:00+03:00","2026-04-28":"2026-04-28T16:30:00+03:00","2026-04-29":"2026-04-29T16:31:00+03:00","2026-04-30":"2026-04-30T16:31:00+03:00","2026-05-01":"2026-05-01T16:31:00+03:00","2026-05-02":"2026-05-02T16:32:00+03:00","2026-05-03":"2026-05-03T16:32:00+03:00","2026-05-04":"2026-05-04T16:32:00+03:00","2026-05-05":"2026-05-05T16:33:00+03:00","2026-05-06":"2026-05-06T16:33:00+03:00","2026-05-07":"2026-05-07T16:34:00+03:00","2026-05-08":"2026-05-08T16:34:00+03:00","2026-05-09":"2026-05-09T16:34:00+03:00","2026-05-10":"2026-05-10T16:35:00+03:00","2026-05-11":"2026-05-11T16:35:00+03:00","2026-05-12":"2026-05-12T16:36:00+03:00","2026-05-13":"2026-05-13T16:36:00+03:00","2026-05-14":"2026-05-14T16:36:00+03:00","2026-05-15":"2026-05-15T16:37:00+03:00","2026-05-16":"2026-05-16T16:37:00+03:00","2026-05-17":"2026-05-17T16:38:00+03:00","2026-05-18":"2026-05-18T16:38:00+03:00","2026-05-19":"2026-05-19T16:38:00+03:00","2026-05-20":"2026-05-20T16:39:00+03:00","2026-05-21":"2026-05-21T16:39:00+03:00","2026-05-22":"2026-05-22T16:40:00+03:00","2026-05-23":"2026-05-23T16:40:00+03:00","2026-05-24":"2026-05-24T16:40:00+03:00","2026-05-25":"2026-05-25T16:41:00+03:00","2026-05-26":"2026-05-26T16:41:00+03:00","2026-05-27":"2026-05-27T16:42:00+03:00","2026-05-28":"2026-05-28T16:42:00+03:00","2026-05-29":"2026-05-29T16:42:00+03:00","2026-05-30":"2026-05-30T16:43:00+03:00","2026-05-31":"2026-05-31T16:43:00+03:00","2026-06-01":"2026-06-01T16:44:00+03:00","2026-06-02":"2026-06-02T16:44:00+03:00","2026-06-03":"2026-06-03T16:44:00+03:00","2026-06-04":"2026-06-04T16:45:00+03:00","2026-06-05":"2026-06-05T16:45:00+03:00","2026-06-06":"2026-06-06T16:45:00+03:00","2026-06-07":"2026-06-07T16:46:00+03:00","2026-06-08":"2026-06-08T16:46:00+03:00","2026-06-09":"2026-06-09T16:47:00+03:00","2026-06-10":"2026-06-10T16:47:00+03:00","2026-06-11":"2026-06-11T16:47:00+03:00","2026-06-12":"2026-06-12T16:48:00+03:00","2026-06-13":"2026-06-13T16:48:00+03:00","2026-06-14":"2026-06-14T16:48:00+03:00","2026-06-15":"2026-06-15T16:48:00+03:00","2026-06-16":"2026-06-16T16:49:00+03:00","2026-06-17":"2026-06-17T16:49:00+03:00","2026-06-18":"2026-06-18T16:49:00+03:00","2026-06-19":"2026-06-19T16:49:00+03:00","2026-06-20":"2026-06-20T16:50:00+03:00","2026-06-21":"2026-06-21T16:50:00+03:00","2026-06-22":"2026-06-22T16:50:00+03:00","2026-06-23":"2026-06-23T16:50:00+03:00","2026-06-24":"2026-06-24T16:50:00+03:00","2026-06-25":"2026-06-25T16:51:00+03:00","2026-06-26":"2026-06-26T16:51:00+03:00","2026-06-27":"2026-06-27T16:51:00+03:00","2026-06-28":"2026-06-28T16:51:00+03:00","2026-06-29":"2026-06-29T16:51:00+03:00","2026-06-30":"2026-06-30T16:51:00+03:00","2026-07-01":"2026-07-01T16:51:00+03:00","2026-07-02":"2026-07-02T16:51:00+03:00","2026-07-03":"2026-07-03T16:51:00+03:00","2026-07-04":"2026-07-04T16:51:00+03:00","2026-07-05":"2026-07-05T16:51:00+03:00","2026-07-06":"2026-07-06T16:51:00+03:00","2026-07-07":"2026-07-07T16:51:00+03:00","2026-07-08":"2026-07-08T16:51:00+03:00","2026-07-09":"2026-07-09T16:51:00+03:00","2026-07-10":"2026-07-10T16:51:00+03:00"},"minchaKetanaBaalHatanya":{"2026-04-23":"2026-04-23T16:31:00+03:00","2026-04-24":"2026-04-24T16:31:00+03:00","2026-04-25":"2026-04-25T16:31:00+03:00","2026-04-26":"2026-04-26T16:32:00+03:00","2026-04-27":"2026-04-27T16:32:00+03:00","2026-04-28":"2026-04-28T16:32:00+03:00","2026-04-29":"2026-04-29T16:33:00+03:00","2026-04-30":"2026-04-30T16:33:00+03:00","2026-05-01":"2026-05-01T16:34:00+03:00","2026-05-02":"2026-05-02T16:34:00+03:00","2026-05-03":"2026-05-03T16:34:00+03:00","2026-05-04":"2026-05-04T16:35:00+03:00","2026-05-05":"2026-05-05T16:35:00+03:00","2026-05-06":"2026-05-06T16:35:00+03:00","2026-05-07":"2026-05-07T16:36:00+03:00","2026-05-08":"2026-05-08T16:36:00+03:00","2026-05-09":"2026-05-09T16:37:00+03:00","2026-05-10":"2026-05-10T16:37:00+03:00","2026-05-11":"2026-05-11T16:37:00+03:00","2026-05-12":"2026-05-12T16:38:00+03:00","2026-05-13":"2026-05-13T16:38:00+03:00","2026-05-14":"2026-05-14T16:39:00+03:00","2026-05-15":"2026-05-15T16:39:00+03:00","2026-05-16":"2026-05-16T16:39:00+03:00","2026-05-17":"2026-05-17T16:40:00+03:00","2026-05-18":"2026-05-18T16:40:00+03:00","2026-05-19":"2026-05-19T16:41:00+03:00","2026-05-20":"2026-05-20T16:41:00+03:00","2026-05-21":"2026-05-21T16:41:00+03:00","2026-05-22":"2026-05-22T16:42:00+03:00","2026-05-23":"2026-05-23T16:42:00+03:00","2026-05-24":"2026-05-24T16:43:00+03:00","2026-05-25":"2026-05-25T16:43:00+03:00","2026-05-26":"2026-05-26T16:44:00+03:00","2026-05-27":"2026-05-27T16:44:00+03:00","2026-05-28":"2026-05-28T16:44:00+03:00","2026-05-29":"2026-05-29T16:45:00+03:00","2026-05-30":"2026-05-30T16:45:00+03:00","2026-05-31":"2026-05-31T16:46:00+03:00","2026-06-01":"2026-06-01T16:46:00+03:00","2026-06-02":"2026-06-02T16:46:00+03:00","2026-06-03":"2026-06-03T16:47:00+03:00","2026-06-04":"2026-06-04T16:47:00+03:00","2026-06-05":"2026-06-05T16:47:00+03:00","2026-06-06":"2026-06-06T16:48:00+03:00","2026-06-07":"2026-06-07T16:48:00+03:00","2026-06-08":"2026-06-08T16:49:00+03:00","2026-06-09":"2026-06-09T16:49:00+03:00","2026-06-10":"2026-06-10T16:49:00+03:00","2026-06-11":"2026-06-11T16:50:00+03:00","2026-06-12":"2026-06-12T16:50:00+03:00","2026-06-13":"2026-06-13T16:50:00+03:00","2026-06-14":"2026-06-14T16:50:00+03:00","2026-06-15":"2026-06-15T16:51:00+03:00","2026-06-16":"2026-06-16T16:51:00+03:00","2026-06-17":"2026-06-17T16:51:00+03:00","2026-06-18":"2026-06-18T16:52:00+03:00","2026-06-19":"2026-06-19T16:52:00+03:00","2026-06-20":"2026-06-20T16:52:00+03:00","2026-06-21":"2026-06-21T16:52:00+03:00","2026-06-22":"2026-06-22T16:52:00+03:00","2026-06-23":"2026-06-23T16:53:00+03:00","2026-06-24":"2026-06-24T16:53:00+03:00","2026-06-25":"2026-06-25T16:53:00+03:00","2026-06-26":"2026-06-26T16:53:00+03:00","2026-06-27":"2026-06-27T16:53:00+03:00","2026-06-28":"2026-06-28T16:53:00+03:00","2026-06-29":"2026-06-29T16:53:00+03:00","2026-06-30":"2026-06-30T16:54:00+03:00","2026-07-01":"2026-07-01T16:54:00+03:00","2026-07-02":"2026-07-02T16:54:00+03:00","2026-07-03":"2026-07-03T16:54:00+03:00","2026-07-04":"2026-07-04T16:54:00+03:00","2026-07-05":"2026-07-05T16:54:00+03:00","2026-07-06":"2026-07-06T16:54:00+03:00","2026-07-07":"2026-07-07T16:54:00+03:00","2026-07-08":"2026-07-08T16:54:00+03:00","2026-07-09":"2026-07-09T16:54:00+03:00","2026-07-10":"2026-07-10T16:53:00+03:00"},"minchaKetanaMGA":{"2026-04-23":"2026-04-23T17:11:00+03:00","2026-04-24":"2026-04-24T17:11:00+03:00","2026-04-25":"2026-04-25T17:11:00+03:00","2026-04-26":"2026-04-26T17:12:00+03:00","2026-04-27":"2026-04-27T17:12:00+03:00","2026-04-28":"2026-04-28T17:12:00+03:00","2026-04-29":"2026-04-29T17:13:00+03:00","2026-04-30":"2026-04-30T17:13:00+03:00","2026-05-01":"2026-05-01T17:13:00+03:00","2026-05-02":"2026-05-02T17:14:00+03:00","2026-05-03":"2026-05-03T17:14:00+03:00","2026-05-04":"2026-05-04T17:14:00+03:00","2026-05-05":"2026-05-05T17:15:00+03:00","2026-05-06":"2026-05-06T17:15:00+03:00","2026-05-07":"2026-05-07T17:16:00+03:00","2026-05-08":"2026-05-08T17:16:00+03:00","2026-05-09":"2026-05-09T17:16:00+03:00","2026-05-10":"2026-05-10T17:17:00+03:00","2026-05-11":"2026-05-11T17:17:00+03:00","2026-05-12":"2026-05-12T17:18:00+03:00","2026-05-13":"2026-05-13T17:18:00+03:00","2026-05-14":"2026-05-14T17:18:00+03:00","2026-05-15":"2026-05-15T17:19:00+03:00","2026-05-16":"2026-05-16T17:19:00+03:00","2026-05-17":"2026-05-17T17:20:00+03:00","2026-05-18":"2026-05-18T17:20:00+03:00","2026-05-19":"2026-05-19T17:20:00+03:00","2026-05-20":"2026-05-20T17:21:00+03:00","2026-05-21":"2026-05-21T17:21:00+03:00","2026-05-22":"2026-05-22T17:22:00+03:00","2026-05-23":"2026-05-23T17:22:00+03:00","2026-05-24":"2026-05-24T17:22:00+03:00","2026-05-25":"2026-05-25T17:23:00+03:00","2026-05-26":"2026-05-26T17:23:00+03:00","2026-05-27":"2026-05-27T17:24:00+03:00","2026-05-28":"2026-05-28T17:24:00+03:00","2026-05-29":"2026-05-29T17:24:00+03:00","2026-05-30":"2026-05-30T17:25:00+03:00","2026-05-31":"2026-05-31T17:25:00+03:00","2026-06-01":"2026-06-01T17:26:00+03:00","2026-06-02":"2026-06-02T17:26:00+03:00","2026-06-03":"2026-06-03T17:26:00+03:00","2026-06-04":"2026-06-04T17:27:00+03:00","2026-06-05":"2026-06-05T17:27:00+03:00","2026-06-06":"2026-06-06T17:27:00+03:00","2026-06-07":"2026-06-07T17:28:00+03:00","2026-06-08":"2026-06-08T17:28:00+03:00","2026-06-09":"2026-06-09T17:29:00+03:00","2026-06-10":"2026-06-10T17:29:00+03:00","2026-06-11":"2026-06-11T17:29:00+03:00","2026-06-12":"2026-06-12T17:30:00+03:00","2026-06-13":"2026-06-13T17:30:00+03:00","2026-06-14":"2026-06-14T17:30:00+03:00","2026-06-15":"2026-06-15T17:30:00+03:00","2026-06-16":"2026-06-16T17:31:00+03:00","2026-06-17":"2026-06-17T17:31:00+03:00","2026-06-18":"2026-06-18T17:31:00+03:00","2026-06-19":"2026-06-19T17:31:00+03:00","2026-06-20":"2026-06-20T17:32:00+03:00","2026-06-21":"2026-06-21T17:32:00+03:00","2026-06-22":"2026-06-22T17:32:00+03:00","2026-06-23":"2026-06-23T17:32:00+03:00","2026-06-24":"2026-06-24T17:32:00+03:00","2026-06-25":"2026-06-25T17:33:00+03:00","2026-06-26":"2026-06-26T17:33:00+03:00","2026-06-27":"2026-06-27T17:33:00+03:00","2026-06-28":"2026-06-28T17:33:00+03:00","2026-06-29":"2026-06-29T17:33:00+03:00","2026-06-30":"2026-06-30T17:33:00+03:00","2026-07-01":"2026-07-01T17:33:00+03:00","2026-07-02":"2026-07-02T17:33:00+03:00","2026-07-03":"2026-07-03T17:33:00+03:00","2026-07-04":"2026-07-04T17:33:00+03:00","2026-07-05":"2026-07-05T17:33:00+03:00","2026-07-06":"2026-07-06T17:33:00+03:00","2026-07-07":"2026-07-07T17:33:00+03:00","2026-07-08":"2026-07-08T17:33:00+03:00","2026-07-09":"2026-07-09T17:33:00+03:00","2026-07-10":"2026-07-10T17:33:00+03:00"},"plagHaMincha":{"2026-04-23":"2026-04-23T17:51:00+03:00","2026-04-24":"2026-04-24T17:52:00+03:00","2026-04-25":"2026-04-25T17:52:00+03:00","2026-04-26":"2026-04-26T17:53:00+03:00","2026-04-27":"2026-04-27T17:53:00+03:00","2026-04-28":"2026-04-28T17:54:00+03:00","2026-04-29":"2026-04-29T17:54:00+03:00","2026-04-30":"2026-04-30T17:55:00+03:00","2026-05-01":"2026-05-01T17:55:00+03:00","2026-05-02":"2026-05-02T17:56:00+03:00","2026-05-03":"2026-05-03T17:56:00+03:00","2026-05-04":"2026-05-04T17:57:00+03:00","2026-05-05":"2026-05-05T17:57:00+03:00","2026-05-06":"2026-05-06T17:58:00+03:00","2026-05-07":"2026-05-07T17:58:00+03:00","2026-05-08":"2026-05-08T17:59:00+03:00","2026-05-09":"2026-05-09T18:00:00+03:00","2026-05-10":"2026-05-10T18:00:00+03:00","2026-05-11":"2026-05-11T18:01:00+03:00","2026-05-12":"2026-05-12T18:01:00+03:00","2026-05-13":"2026-05-13T18:02:00+03:00","2026-05-14":"2026-05-14T18:02:00+03:00","2026-05-15":"2026-05-15T18:03:00+03:00","2026-05-16":"2026-05-16T18:03:00+03:00","2026-05-17":"2026-05-17T18:04:00+03:00","2026-05-18":"2026-05-18T18:04:00+03:00","2026-05-19":"2026-05-19T18:05:00+03:00","2026-05-20":"2026-05-20T18:06:00+03:00","2026-05-21":"2026-05-21T18:06:00+03:00","2026-05-22":"2026-05-22T18:07:00+03:00","2026-05-23":"2026-05-23T18:07:00+03:00","2026-05-24":"2026-05-24T18:08:00+03:00","2026-05-25":"2026-05-25T18:08:00+03:00","2026-05-26":"2026-05-26T18:09:00+03:00","2026-05-27":"2026-05-27T18:09:00+03:00","2026-05-28":"2026-05-28T18:10:00+03:00","2026-05-29":"2026-05-29T18:10:00+03:00","2026-05-30":"2026-05-30T18:11:00+03:00","2026-05-31":"2026-05-31T18:11:00+03:00","2026-06-01":"2026-06-01T18:12:00+03:00","2026-06-02":"2026-06-02T18:12:00+03:00","2026-06-03":"2026-06-03T18:13:00+03:00","2026-06-04":"2026-06-04T18:13:00+03:00","2026-06-05":"2026-06-05T18:13:00+03:00","2026-06-06":"2026-06-06T18:14:00+03:00","2026-06-07":"2026-06-07T18:14:00+03:00","2026-06-08":"2026-06-08T18:15:00+03:00","2026-06-09":"2026-06-09T18:15:00+03:00","2026-06-10":"2026-06-10T18:16:00+03:00","2026-06-11":"2026-06-11T18:16:00+03:00","2026-06-12":"2026-06-12T18:16:00+03:00","2026-06-13":"2026-06-13T18:17:00+03:00","2026-06-14":"2026-06-14T18:17:00+03:00","2026-06-15":"2026-06-15T18:17:00+03:00","2026-06-16":"2026-06-16T18:18:00+03:00","2026-06-17":"2026-06-17T18:18:00+03:00","2026-06-18":"2026-06-18T18:18:00+03:00","2026-06-19":"2026-06-19T18:18:00+03:00","2026-06-20":"2026-06-20T18:19:00+03:00","2026-06-21":"2026-06-21T18:19:00+03:00","2026-06-22":"2026-06-22T18:19:00+03:00","2026-06-23":"2026-06-23T18:19:00+03:00","2026-06-24":"2026-06-24T18:19:00+03:00","2026-06-25":"2026-06-25T18:19:00+03:00","2026-06-26":"2026-06-26T18:20:00+03:00","2026-06-27":"2026-06-27T18:20:00+03:00","2026-06-28":"2026-06-28T18:20:00+03:00","2026-06-29":"2026-06-29T18:20:00+03:00","2026-06-30":"2026-06-30T18:20:00+03:00","2026-07-01":"2026-07-01T18:20:00+03:00","2026-07-02":"2026-07-02T18:20:00+03:00","2026-07-03":"2026-07-03T18:20:00+03:00","2026-07-04":"2026-07-04T18:20:00+03:00","2026-07-05":"2026-07-05T18:20:00+03:00","2026-07-06":"2026-07-06T18:20:00+03:00","2026-07-07":"2026-07-07T18:20:00+03:00","2026-07-08":"2026-07-08T18:20:00+03:00","2026-07-09":"2026-07-09T18:19:00+03:00","2026-07-10":"2026-07-10T18:19:00+03:00"},"plagHaminchaBaalHatanya":{"2026-04-23":"2026-04-23T17:54:00+03:00","2026-04-24":"2026-04-24T17:55:00+03:00","2026-04-25":"2026-04-25T17:55:00+03:00","2026-04-26":"2026-04-26T17:56:00+03:00","2026-04-27":"2026-04-27T17:56:00+03:00","2026-04-28":"2026-04-28T17:57:00+03:00","2026-04-29":"2026-04-29T17:57:00+03:00","2026-04-30":"2026-04-30T17:58:00+03:00","2026-05-01":"2026-05-01T17:58:00+03:00","2026-05-02":"2026-05-02T17:59:00+03:00","2026-05-03":"2026-05-03T17:59:00+03:00","2026-05-04":"2026-05-04T18:00:00+03:00","2026-05-05":"2026-05-05T18:00:00+03:00","2026-05-06":"2026-05-06T18:01:00+03:00","2026-05-07":"2026-05-07T18:01:00+03:00","2026-05-08":"2026-05-08T18:02:00+03:00","2026-05-09":"2026-05-09T18:03:00+03:00","2026-05-10":"2026-05-10T18:03:00+03:00","2026-05-11":"2026-05-11T18:04:00+03:00","2026-05-12":"2026-05-12T18:04:00+03:00","2026-05-13":"2026-05-13T18:05:00+03:00","2026-05-14":"2026-05-14T18:05:00+03:00","2026-05-15":"2026-05-15T18:06:00+03:00","2026-05-16":"2026-05-16T18:06:00+03:00","2026-05-17":"2026-05-17T18:07:00+03:00","2026-05-18":"2026-05-18T18:08:00+03:00","2026-05-19":"2026-05-19T18:08:00+03:00","2026-05-20":"2026-05-20T18:09:00+03:00","2026-05-21":"2026-05-21T18:09:00+03:00","2026-05-22":"2026-05-22T18:10:00+03:00","2026-05-23":"2026-05-23T18:10:00+03:00","2026-05-24":"2026-05-24T18:11:00+03:00","2026-05-25":"2026-05-25T18:11:00+03:00","2026-05-26":"2026-05-26T18:12:00+03:00","2026-05-27":"2026-05-27T18:12:00+03:00","2026-05-28":"2026-05-28T18:13:00+03:00","2026-05-29":"2026-05-29T18:13:00+03:00","2026-05-30":"2026-05-30T18:14:00+03:00","2026-05-31":"2026-05-31T18:14:00+03:00","2026-06-01":"2026-06-01T18:15:00+03:00","2026-06-02":"2026-06-02T18:15:00+03:00","2026-06-03":"2026-06-03T18:16:00+03:00","2026-06-04":"2026-06-04T18:16:00+03:00","2026-06-05":"2026-06-05T18:17:00+03:00","2026-06-06":"2026-06-06T18:17:00+03:00","2026-06-07":"2026-06-07T18:18:00+03:00","2026-06-08":"2026-06-08T18:18:00+03:00","2026-06-09":"2026-06-09T18:18:00+03:00","2026-06-10":"2026-06-10T18:19:00+03:00","2026-06-11":"2026-06-11T18:19:00+03:00","2026-06-12":"2026-06-12T18:19:00+03:00","2026-06-13":"2026-06-13T18:20:00+03:00","2026-06-14":"2026-06-14T18:20:00+03:00","2026-06-15":"2026-06-15T18:20:00+03:00","2026-06-16":"2026-06-16T18:21:00+03:00","2026-06-17":"2026-06-17T18:21:00+03:00","2026-06-18":"2026-06-18T18:21:00+03:00","2026-06-19":"2026-06-19T18:22:00+03:00","2026-06-20":"2026-06-20T18:22:00+03:00","2026-06-21":"2026-06-21T18:22:00+03:00","2026-06-22":"2026-06-22T18:22:00+03:00","2026-06-23":"2026-06-23T18:22:00+03:00","2026-06-24":"2026-06-24T18:23:00+03:00","2026-06-25":"2026-06-25T18:23:00+03:00","2026-06-26":"2026-06-26T18:23:00+03:00","2026-06-27":"2026-06-27T18:23:00+03:00","2026-06-28":"2026-06-28T18:23:00+03:00","2026-06-29":"2026-06-29T18:23:00+03:00","2026-06-30":"2026-06-30T18:23:00+03:00","2026-07-01":"2026-07-01T18:23:00+03:00","2026-07-02":"2026-07-02T18:23:00+03:00","2026-07-03":"2026-07-03T18:23:00+03:00","2026-07-04":"2026-07-04T18:23:00+03:00","2026-07-05":"2026-07-05T18:23:00+03:00","2026-07-06":"2026-07-06T18:23:00+03:00","2026-07-07":"2026-07-07T18:23:00+03:00","2026-07-08":"2026-07-08T18:23:00+03:00","2026-07-09":"2026-07-09T18:23:00+03:00","2026-07-10":"2026-07-10T18:22:00+03:00"},"seaLevelSunset":{},"sunset":{"2026-04-23":"2026-04-23T19:14:00+03:00","2026-04-24":"2026-04-24T19:14:00+03:00","2026-04-25":"2026-04-25T19:15:00+03:00","2026-04-26":"2026-04-26T19:16:00+03:00","2026-04-27":"2026-04-27T19:16:00+03:00","2026-04-28":"2026-04-28T19:17:00+03:00","2026-04-29":"2026-04-29T19:18:00+03:00","2026-04-30":"2026-04-30T19:18:00+03:00","2026-05-01":"2026-05-01T19:19:00+03:00","2026-05-02":"2026-05-02T19:20:00+03:00","2026-05-03":"2026-05-03T19:21:00+03:00","2026-05-04":"2026-05-04T19:21:00+03:00","2026-05-05":"2026-05-05T19:22:00+03:00","2026-05-06":"2026-05-06T19:23:00+03:00","2026-05-07":"2026-05-07T19:23:00+03:00","2026-05-08":"2026-05-08T19:24:00+03:00","2026-05-09":"2026-05-09T19:25:00+03:00","2026-05-10":"2026-05-10T19:25:00+03:00","2026-05-11":"2026-05-11T19:26:00+03:00","2026-05-12":"2026-05-12T19:27:00+03:00","2026-05-13":"2026-05-13T19:28:00+03:00","2026-05-14":"2026-05-14T19:28:00+03:00","2026-05-15":"2026-05-15T19:29:00+03:00","2026-05-16":"2026-05-16T19:30:00+03:00","2026-05-17":"2026-05-17T19:30:00+03:00","2026-05-18":"2026-05-18T19:31:00+03:00","2026-05-19":"2026-05-19T19:32:00+03:00","2026-05-20":"2026-05-20T19:32:00+03:00","2026-05-21":"2026-05-21T19:33:00+03:00","2026-05-22":"2026-05-22T19:34:00+03:00","2026-05-23":"2026-05-23T19:34:00+03:00","2026-05-24":"2026-05-24T19:35:00+03:00","2026-05-25":"2026-05-25T19:36:00+03:00","2026-05-26":"2026-05-26T19:36:00+03:00","2026-05-27":"2026-05-27T19:37:00+03:00","2026-05-28":"2026-05-28T19:37:00+03:00","2026-05-29":"2026-05-29T19:38:00+03:00","2026-05-30":"2026-05-30T19:39:00+03:00","2026-05-31":"2026-05-31T19:39:00+03:00","2026-06-01":"2026-06-01T19:40:00+03:00","2026-06-02":"2026-06-02T19:40:00+03:00","2026-06-03":"2026-06-03T19:41:00+03:00","2026-06-04":"2026-06-04T19:41:00+03:00","2026-06-05":"2026-06-05T19:42:00+03:00","2026-06-06":"2026-06-06T19:42:00+03:00","2026-06-07":"2026-06-07T19:43:00+03:00","2026-06-08":"2026-06-08T19:43:00+03:00","2026-06-09":"2026-06-09T19:44:00+03:00","2026-06-10":"2026-06-10T19:44:00+03:00","2026-06-11":"2026-06-11T19:45:00+03:00","2026-06-12":"2026-06-12T19:45:00+03:00","2026-06-13":"2026-06-13T19:45:00+03:00","2026-06-14":"2026-06-14T19:46:00+03:00","2026-06-15":"2026-06-15T19:46:00+03:00","2026-06-16":"2026-06-16T19:46:00+03:00","2026-06-17":"2026-06-17T19:47:00+03:00","2026-06-18":"2026-06-18T19:47:00+03:00","2026-06-19":"2026-06-19T19:47:00+03:00","2026-06-20":"2026-06-20T19:47:00+03:00","2026-06-21":"2026-06-21T19:48:00+03:00","2026-06-22":"2026-06-22T19:48:00+03:00","2026-06-23":"2026-06-23T19:48:00+03:00","2026-06-24":"2026-06-24T19:48:00+03:00","2026-06-25":"2026-06-25T19:48:00+03:00","2026-06-26":"2026-06-26T19:48:00+03:00","2026-06-27":"2026-06-27T19:49:00+03:00","2026-06-28":"2026-06-28T19:49:00+03:00","2026-06-29":"2026-06-29T19:49:00+03:00","2026-06-30":"2026-06-30T19:49:00+03:00","2026-07-01":"2026-07-01T19:49:00+03:00","2026-07-02":"2026-07-02T19:49:00+03:00","2026-07-03":"2026-07-03T19:49:00+03:00","2026-07-04":"2026-07-04T19:48:00+03:00","2026-07-05":"2026-07-05T19:48:00+03:00","2026-07-06":"2026-07-06T19:48:00+03:00","2026-07-07":"2026-07-07T19:48:00+03:00","2026-07-08":"2026-07-08T19:48:00+03:00","2026-07-09":"2026-07-09T19:48:00+03:00","2026-07-10":"2026-07-10T19:47:00+03:00"},"beinHaShmashos":{"2026-04-23":"2026-04-23T19:31:00+03:00","2026-04-24":"2026-04-24T19:32:00+03:00","2026-04-25":"2026-04-25T19:32:00+03:00","2026-04-26":"2026-04-26T19:33:00+03:00","2026-04-27":"2026-04-27T19:34:00+03:00","2026-04-28":"2026-04-28T19:35:00+03:00","2026-04-29":"2026-04-29T19:36:00+03:00","2026-04-30":"2026-04-30T19:36:00+03:00","2026-05-01":"2026-05-01T19:37:00+03:00","2026-05-02":"2026-05-02T19:38:00+03:00","2026-05-03":"2026-05-03T19:39:00+03:00","2026-05-04":"2026-05-04T19:39:00+03:00","2026-05-05":"2026-05-05T19:40:00+03:00","2026-05-06":"2026-05-06T19:41:00+03:00","2026-05-07":"2026-05-07T19:42:00+03:00","2026-05-08":"2026-05-08T19:43:00+03:00","2026-05-09":"2026-05-09T19:43:00+03:00","2026-05-10":"2026-05-10T19:44:00+03:00","2026-05-11":"2026-05-11T19:45:00+03:00","2026-05-12":"2026-05-12T19:46:00+03:00","2026-05-13":"2026-05-13T19:46:00+03:00","2026-05-14":"2026-05-14T19:47:00+03:00","2026-05-15":"2026-05-15T19:48:00+03:00","2026-05-16":"2026-05-16T19:49:00+03:00","2026-05-17":"2026-05-17T19:50:00+03:00","2026-05-18":"2026-05-18T19:50:00+03:00","2026-05-19":"2026-05-19T19:51:00+03:00","2026-05-20":"2026-05-20T19:52:00+03:00","2026-05-21":"2026-05-21T19:52:00+03:00","2026-05-22":"2026-05-22T19:53:00+03:00","2026-05-23":"2026-05-23T19:54:00+03:00","2026-05-24":"2026-05-24T19:55:00+03:00","2026-05-25":"2026-05-25T19:55:00+03:00","2026-05-26":"2026-05-26T19:56:00+03:00","2026-05-27":"2026-05-27T19:57:00+03:00","2026-05-28":"2026-05-28T19:57:00+03:00","2026-05-29":"2026-05-29T19:58:00+03:00","2026-05-30":"2026-05-30T19:59:00+03:00","2026-05-31":"2026-05-31T19:59:00+03:00","2026-06-01":"2026-06-01T20:00:00+03:00","2026-06-02":"2026-06-02T20:01:00+03:00","2026-06-03":"2026-06-03T20:01:00+03:00","2026-06-04":"2026-06-04T20:02:00+03:00","2026-06-05":"2026-06-05T20:02:00+03:00","2026-06-06":"2026-06-06T20:03:00+03:00","2026-06-07":"2026-06-07T20:03:00+03:00","2026-06-08":"2026-06-08T20:04:00+03:00","2026-06-09":"2026-06-09T20:04:00+03:00","2026-06-10":"2026-06-10T20:05:00+03:00","2026-06-11":"2026-06-11T20:05:00+03:00","2026-06-12":"2026-06-12T20:06:00+03:00","2026-06-13":"2026-06-13T20:06:00+03:00","2026-06-14":"2026-06-14T20:06:00+03:00","2026-06-15":"2026-06-15T20:07:00+03:00","2026-06-16":"2026-06-16T20:07:00+03:00","2026-06-17":"2026-06-17T20:07:00+03:00","2026-06-18":"2026-06-18T20:08:00+03:00","2026-06-19":"2026-06-19T20:08:00+03:00","2026-06-20":"2026-06-20T20:08:00+03:00","2026-06-21":"2026-06-21T20:09:00+03:00","2026-06-22":"2026-06-22T20:09:00+03:00","2026-06-23":"2026-06-23T20:09:00+03:00","2026-06-24":"2026-06-24T20:09:00+03:00","2026-06-25":"2026-06-25T20:09:00+03:00","2026-06-26":"2026-06-26T20:09:00+03:00","2026-06-27":"2026-06-27T20:09:00+03:00","2026-06-28":"2026-06-28T20:09:00+03:00","2026-06-29":"2026-06-29T20:09:00+03:00","2026-06-30":"2026-06-30T20:09:00+03:00","2026-07-01":"2026-07-01T20:09:00+03:00","2026-07-02":"2026-07-02T20:09:00+03:00","2026-07-03":"2026-07-03T20:09:00+03:00","2026-07-04":"2026-07-04T20:09:00+03:00","2026-07-05":"2026-07-05T20:09:00+03:00","2026-07-06":"2026-07-06T20:09:00+03:00","2026-07-07":"2026-07-07T20:08:00+03:00","2026-07-08":"2026-07-08T20:08:00+03:00","2026-07-09":"2026-07-09T20:08:00+03:00","2026-07-10":"2026-07-10T20:08:00+03:00"},"dusk":{"2026-04-23":"2026-04-23T19:39:00+03:00","2026-04-24":"2026-04-24T19:40:00+03:00","2026-04-25":"2026-04-25T19:41:00+03:00","2026-04-26":"2026-04-26T19:41:00+03:00","2026-04-27":"2026-04-27T19:42:00+03:00","2026-04-28":"2026-04-28T19:43:00+03:00","2026-04-29":"2026-04-29T19:44:00+03:00","2026-04-30":"2026-04-30T19:44:00+03:00","2026-05-01":"2026-05-01T19:45:00+03:00","2026-05-02":"2026-05-02T19:46:00+03:00","2026-05-03":"2026-05-03T19:47:00+03:00","2026-05-04":"2026-05-04T19:47:00+03:00","2026-05-05":"2026-05-05T19:48:00+03:00","2026-05-06":"2026-05-06T19:49:00+03:00","2026-05-07":"2026-05-07T19:50:00+03:00","2026-05-08":"2026-05-08T19:50:00+03:00","2026-05-09":"2026-05-09T19:51:00+03:00","2026-05-10":"2026-05-10T19:52:00+03:00","2026-05-11":"2026-05-11T19:53:00+03:00","2026-05-12":"2026-05-12T19:54:00+03:00","2026-05-13":"2026-05-13T19:54:00+03:00","2026-05-14":"2026-05-14T19:55:00+03:00","2026-05-15":"2026-05-15T19:56:00+03:00","2026-05-16":"2026-05-16T19:57:00+03:00","2026-05-17":"2026-05-17T19:57:00+03:00","2026-05-18":"2026-05-18T19:58:00+03:00","2026-05-19":"2026-05-19T19:59:00+03:00","2026-05-20":"2026-05-20T19:59:00+03:00","2026-05-21":"2026-05-21T20:00:00+03:00","2026-05-22":"2026-05-22T20:01:00+03:00","2026-05-23":"2026-05-23T20:02:00+03:00","2026-05-24":"2026-05-24T20:02:00+03:00","2026-05-25":"2026-05-25T20:03:00+03:00","2026-05-26":"2026-05-26T20:04:00+03:00","2026-05-27":"2026-05-27T20:04:00+03:00","2026-05-28":"2026-05-28T20:05:00+03:00","2026-05-29":"2026-05-29T20:06:00+03:00","2026-05-30":"2026-05-30T20:06:00+03:00","2026-05-31":"2026-05-31T20:07:00+03:00","2026-06-01":"2026-06-01T20:07:00+03:00","2026-06-02":"2026-06-02T20:08:00+03:00","2026-06-03":"2026-06-03T20:09:00+03:00","2026-06-04":"2026-06-04T20:09:00+03:00","2026-06-05":"2026-06-05T20:10:00+03:00","2026-06-06":"2026-06-06T20:10:00+03:00","2026-06-07":"2026-06-07T20:11:00+03:00","2026-06-08":"2026-06-08T20:11:00+03:00","2026-06-09":"2026-06-09T20:12:00+03:00","2026-06-10":"2026-06-10T20:12:00+03:00","2026-06-11":"2026-06-11T20:13:00+03:00","2026-06-12":"2026-06-12T20:13:00+03:00","2026-06-13":"2026-06-13T20:14:00+03:00","2026-06-14":"2026-06-14T20:14:00+03:00","2026-06-15":"2026-06-15T20:14:00+03:00","2026-06-16":"2026-06-16T20:15:00+03:00","2026-06-17":"2026-06-17T20:15:00+03:00","2026-06-18":"2026-06-18T20:15:00+03:00","2026-06-19":"2026-06-19T20:15:00+03:00","2026-06-20":"2026-06-20T20:16:00+03:00","2026-06-21":"2026-06-21T20:16:00+03:00","2026-06-22":"2026-06-22T20:16:00+03:00","2026-06-23":"2026-06-23T20:16:00+03:00","2026-06-24":"2026-06-24T20:16:00+03:00","2026-06-25":"2026-06-25T20:17:00+03:00","2026-06-26":"2026-06-26T20:17:00+03:00","2026-06-27":"2026-06-27T20:17:00+03:00","2026-06-28":"2026-06-28T20:17:00+03:00","2026-06-29":"2026-06-29T20:17:00+03:00","2026-06-30":"2026-06-30T20:17:00+03:00","2026-07-01":"2026-07-01T20:17:00+03:00","2026-07-02":"2026-07-02T20:17:00+03:00","2026-07-03":"2026-07-03T20:17:00+03:00","2026-07-04":"2026-07-04T20:16:00+03:00","2026-07-05":"2026-07-05T20:16:00+03:00","2026-07-06":"2026-07-06T20:16:00+03:00","2026-07-07":"2026-07-07T20:16:00+03:00","2026-07-08":"2026-07-08T20:16:00+03:00","2026-07-09":"2026-07-09T20:15:00+03:00","2026-07-10":"2026-07-10T20:15:00+03:00"},"tzaisBaalHatanya":{"2026-04-23":"2026-04-23T19:39:00+03:00","2026-04-24":"2026-04-24T19:40:00+03:00","2026-04-25":"2026-04-25T19:41:00+03:00","2026-04-26":"2026-04-26T19:41:00+03:00","2026-04-27":"2026-04-27T19:42:00+03:00","2026-04-28":"2026-04-28T19:43:00+03:00","2026-04-29":"2026-04-29T19:44:00+03:00","2026-04-30":"2026-04-30T19:44:00+03:00","2026-05-01":"2026-05-01T19:45:00+03:00","2026-05-02":"2026-05-02T19:46:00+03:00","2026-05-03":"2026-05-03T19:47:00+03:00","2026-05-04":"2026-05-04T19:47:00+03:00","2026-05-05":"2026-05-05T19:48:00+03:00","2026-05-06":"2026-05-06T19:49:00+03:00","2026-05-07":"2026-05-07T19:50:00+03:00","2026-05-08":"2026-05-08T19:50:00+03:00","2026-05-09":"2026-05-09T19:51:00+03:00","2026-05-10":"2026-05-10T19:52:00+03:00","2026-05-11":"2026-05-11T19:53:00+03:00","2026-05-12":"2026-05-12T19:54:00+03:00","2026-05-13":"2026-05-13T19:54:00+03:00","2026-05-14":"2026-05-14T19:55:00+03:00","2026-05-15":"2026-05-15T19:56:00+03:00","2026-05-16":"2026-05-16T19:57:00+03:00","2026-05-17":"2026-05-17T19:57:00+03:00","2026-05-18":"2026-05-18T19:58:00+03:00","2026-05-19":"2026-05-19T19:59:00+03:00","2026-05-20":"2026-05-20T19:59:00+03:00","2026-05-21":"2026-05-21T20:00:00+03:00","2026-05-22":"2026-05-22T20:01:00+03:00","2026-05-23":"2026-05-23T20:02:00+03:00","2026-05-24":"2026-05-24T20:02:00+03:00","2026-05-25":"2026-05-25T20:03:00+03:00","2026-05-26":"2026-05-26T20:04:00+03:00","2026-05-27":"2026-05-27T20:04:00+03:00","2026-05-28":"2026-05-28T20:05:00+03:00","2026-05-29":"2026-05-29T20:06:00+03:00","2026-05-30":"2026-05-30T20:06:00+03:00","2026-05-31":"2026-05-31T20:07:00+03:00","2026-06-01":"2026-06-01T20:07:00+03:00","2026-06-02":"2026-06-02T20:08:00+03:00","2026-06-03":"2026-06-03T20:09:00+03:00","2026-06-04":"2026-06-04T20:09:00+03:00","2026-06-05":"2026-06-05T20:10:00+03:00","2026-06-06":"2026-06-06T20:10:00+03:00","2026-06-07":"2026-06-07T20:11:00+03:00","2026-06-08":"2026-06-08T20:11:00+03:00","2026-06-09":"2026-06-09T20:12:00+03:00","2026-06-10":"2026-06-10T20:12:00+03:00","2026-06-11":"2026-06-11T20:13:00+03:00","2026-06-12":"2026-06-12T20:13:00+03:00","2026-06-13":"2026-06-13T20:14:00+03:00","2026-06-14":"2026-06-14T20:14:00+03:00","2026-06-15":"2026-06-15T20:14:00+03:00","2026-06-16":"2026-06-16T20:15:00+03:00","2026-06-17":"2026-06-17T20:15:00+03:00","2026-06-18":"2026-06-18T20:15:00+03:00","2026-06-19":"2026-06-19T20:15:00+03:00","2026-06-20":"2026-06-20T20:16:00+03:00","2026-06-21":"2026-06-21T20:16:00+03:00","2026-06-22":"2026-06-22T20:16:00+03:00","2026-06-23":"2026-06-23T20:16:00+03:00","2026-06-24":"2026-06-24T20:16:00+03:00","2026-06-25":"2026-06-25T20:17:00+03:00","2026-06-26":"2026-06-26T20:17:00+03:00","2026-06-27":"2026-06-27T20:17:00+03:00","2026-06-28":"2026-06-28T20:17:00+03:00","2026-06-29":"2026-06-29T20:17:00+03:00","2026-06-30":"2026-06-30T20:17:00+03:00","2026-07-01":"2026-07-01T20:17:00+03:00","2026-07-02":"2026-07-02T20:17:00+03:00","2026-07-03":"2026-07-03T20:17:00+03:00","2026-07-04":"2026-07-04T20:16:00+03:00","2026-07-05":"2026-07-05T20:16:00+03:00","2026-07-06":"2026-07-06T20:16:00+03:00","2026-07-07":"2026-07-07T20:16:00+03:00","2026-07-08":"2026-07-08T20:16:00+03:00","2026-07-09":"2026-07-09T20:15:00+03:00","2026-07-10":"2026-07-10T20:15:00+03:00"},"tzeit7083deg":{"2026-04-23":"2026-04-23T19:44:00+03:00","2026-04-24":"2026-04-24T19:45:00+03:00","2026-04-25":"2026-04-25T19:46:00+03:00","2026-04-26":"2026-04-26T19:47:00+03:00","2026-04-27":"2026-04-27T19:47:00+03:00","2026-04-28":"2026-04-28T19:48:00+03:00","2026-04-29":"2026-04-29T19:49:00+03:00","2026-04-30":"2026-04-30T19:50:00+03:00","2026-05-01":"2026-05-01T19:51:00+03:00","2026-05-02":"2026-05-02T19:51:00+03:00","2026-05-03":"2026-05-03T19:52:00+03:00","2026-05-04":"2026-05-04T19:53:00+03:00","2026-05-05":"2026-05-05T19:54:00+03:00","2026-05-06":"2026-05-06T19:55:00+03:00","2026-05-07":"2026-05-07T19:55:00+03:00","2026-05-08":"2026-05-08T19:56:00+03:00","2026-05-09":"2026-05-09T19:57:00+03:00","2026-05-10":"2026-05-10T19:58:00+03:00","2026-05-11":"2026-05-11T19:58:00+03:00","2026-05-12":"2026-05-12T19:59:00+03:00","2026-05-13":"2026-05-13T20:00:00+03:00","2026-05-14":"2026-05-14T20:01:00+03:00","2026-05-15":"2026-05-15T20:01:00+03:00","2026-05-16":"2026-05-16T20:02:00+03:00","2026-05-17":"2026-05-17T20:03:00+03:00","2026-05-18":"2026-05-18T20:04:00+03:00","2026-05-19":"2026-05-19T20:05:00+03:00","2026-05-20":"2026-05-20T20:05:00+03:00","2026-05-21":"2026-05-21T20:06:00+03:00","2026-05-22":"2026-05-22T20:07:00+03:00","2026-05-23":"2026-05-23T20:07:00+03:00","2026-05-24":"2026-05-24T20:08:00+03:00","2026-05-25":"2026-05-25T20:09:00+03:00","2026-05-26":"2026-05-26T20:10:00+03:00","2026-05-27":"2026-05-27T20:10:00+03:00","2026-05-28":"2026-05-28T20:11:00+03:00","2026-05-29":"2026-05-29T20:12:00+03:00","2026-05-30":"2026-05-30T20:12:00+03:00","2026-05-31":"2026-05-31T20:13:00+03:00","2026-06-01":"2026-06-01T20:13:00+03:00","2026-06-02":"2026-06-02T20:14:00+03:00","2026-06-03":"2026-06-03T20:15:00+03:00","2026-06-04":"2026-06-04T20:15:00+03:00","2026-06-05":"2026-06-05T20:16:00+03:00","2026-06-06":"2026-06-06T20:16:00+03:00","2026-06-07":"2026-06-07T20:17:00+03:00","2026-06-08":"2026-06-08T20:17:00+03:00","2026-06-09":"2026-06-09T20:18:00+03:00","2026-06-10":"2026-06-10T20:18:00+03:00","2026-06-11":"2026-06-11T20:19:00+03:00","2026-06-12":"2026-06-12T20:19:00+03:00","2026-06-13":"2026-06-13T20:20:00+03:00","2026-06-14":"2026-06-14T20:20:00+03:00","2026-06-15":"2026-06-15T20:20:00+03:00","2026-06-16":"2026-06-16T20:21:00+03:00","2026-06-17":"2026-06-17T20:21:00+03:00","2026-06-18":"2026-06-18T20:21:00+03:00","2026-06-19":"2026-06-19T20:22:00+03:00","2026-06-20":"2026-06-20T20:22:00+03:00","2026-06-21":"2026-06-21T20:22:00+03:00","2026-06-22":"2026-06-22T20:22:00+03:00","2026-06-23":"2026-06-23T20:22:00+03:00","2026-06-24":"2026-06-24T20:23:00+03:00","2026-06-25":"2026-06-25T20:23:00+03:00","2026-06-26":"2026-06-26T20:23:00+03:00","2026-06-27":"2026-06-27T20:23:00+03:00","2026-06-28":"2026-06-28T20:23:00+03:00","2026-06-29":"2026-06-29T20:23:00+03:00","2026-06-30":"2026-06-30T20:23:00+03:00","2026-07-01":"2026-07-01T20:23:00+03:00","2026-07-02":"2026-07-02T20:23:00+03:00","2026-07-03":"2026-07-03T20:23:00+03:00","2026-07-04":"2026-07-04T20:22:00+03:00","2026-07-05":"2026-07-05T20:22:00+03:00","2026-07-06":"2026-07-06T20:22:00+03:00","2026-07-07":"2026-07-07T20:22:00+03:00","2026-07-08":"2026-07-08T20:22:00+03:00","2026-07-09":"2026-07-09T20:21:00+03:00","2026-07-10":"2026-07-10T20:21:00+03:00"},"tzeit85deg":{"2026-04-23":"2026-04-23T19:52:00+03:00","2026-04-24":"2026-04-24T19:52:00+03:00","2026-04-25":"2026-04-25T19:53:00+03:00","2026-04-26":"2026-04-26T19:54:00+03:00","2026-04-27":"2026-04-27T19:55:00+03:00","2026-04-28":"2026-04-28T19:55:00+03:00","2026-04-29":"2026-04-29T19:56:00+03:00","2026-04-30":"2026-04-30T19:57:00+03:00","2026-05-01":"2026-05-01T19:58:00+03:00","2026-05-02":"2026-05-02T19:59:00+03:00","2026-05-03":"2026-05-03T19:59:00+03:00","2026-05-04":"2026-05-04T20:00:00+03:00","2026-05-05":"2026-05-05T20:01:00+03:00","2026-05-06":"2026-05-06T20:02:00+03:00","2026-05-07":"2026-05-07T20:03:00+03:00","2026-05-08":"2026-05-08T20:03:00+03:00","2026-05-09":"2026-05-09T20:04:00+03:00","2026-05-10":"2026-05-10T20:05:00+03:00","2026-05-11":"2026-05-11T20:06:00+03:00","2026-05-12":"2026-05-12T20:07:00+03:00","2026-05-13":"2026-05-13T20:07:00+03:00","2026-05-14":"2026-05-14T20:08:00+03:00","2026-05-15":"2026-05-15T20:09:00+03:00","2026-05-16":"2026-05-16T20:10:00+03:00","2026-05-17":"2026-05-17T20:11:00+03:00","2026-05-18":"2026-05-18T20:11:00+03:00","2026-05-19":"2026-05-19T20:12:00+03:00","2026-05-20":"2026-05-20T20:13:00+03:00","2026-05-21":"2026-05-21T20:14:00+03:00","2026-05-22":"2026-05-22T20:14:00+03:00","2026-05-23":"2026-05-23T20:15:00+03:00","2026-05-24":"2026-05-24T20:16:00+03:00","2026-05-25":"2026-05-25T20:17:00+03:00","2026-05-26":"2026-05-26T20:17:00+03:00","2026-05-27":"2026-05-27T20:18:00+03:00","2026-05-28":"2026-05-28T20:19:00+03:00","2026-05-29":"2026-05-29T20:19:00+03:00","2026-05-30":"2026-05-30T20:20:00+03:00","2026-05-31":"2026-05-31T20:21:00+03:00","2026-06-01":"2026-06-01T20:21:00+03:00","2026-06-02":"2026-06-02T20:22:00+03:00","2026-06-03":"2026-06-03T20:23:00+03:00","2026-06-04":"2026-06-04T20:23:00+03:00","2026-06-05":"2026-06-05T20:24:00+03:00","2026-06-06":"2026-06-06T20:24:00+03:00","2026-06-07":"2026-06-07T20:25:00+03:00","2026-06-08":"2026-06-08T20:25:00+03:00","2026-06-09":"2026-06-09T20:26:00+03:00","2026-06-10":"2026-06-10T20:26:00+03:00","2026-06-11":"2026-06-11T20:27:00+03:00","2026-06-12":"2026-06-12T20:27:00+03:00","2026-06-13":"2026-06-13T20:28:00+03:00","2026-06-14":"2026-06-14T20:28:00+03:00","2026-06-15":"2026-06-15T20:28:00+03:00","2026-06-16":"2026-06-16T20:29:00+03:00","2026-06-17":"2026-06-17T20:29:00+03:00","2026-06-18":"2026-06-18T20:29:00+03:00","2026-06-19":"2026-06-19T20:30:00+03:00","2026-06-20":"2026-06-20T20:30:00+03:00","2026-06-21":"2026-06-21T20:30:00+03:00","2026-06-22":"2026-06-22T20:30:00+03:00","2026-06-23":"2026-06-23T20:30:00+03:00","2026-06-24":"2026-06-24T20:31:00+03:00","2026-06-25":"2026-06-25T20:31:00+03:00","2026-06-26":"2026-06-26T20:31:00+03:00","2026-06-27":"2026-06-27T20:31:00+03:00","2026-06-28":"2026-06-28T20:31:00+03:00","2026-06-29":"2026-06-29T20:31:00+03:00","2026-06-30":"2026-06-30T20:31:00+03:00","2026-07-01":"2026-07-01T20:31:00+03:00","2026-07-02":"2026-07-02T20:31:00+03:00","2026-07-03":"2026-07-03T20:31:00+03:00","2026-07-04":"2026-07-04T20:30:00+03:00","2026-07-05":"2026-07-05T20:30:00+03:00","2026-07-06":"2026-07-06T20:30:00+03:00","2026-07-07":"2026-07-07T20:30:00+03:00","2026-07-08":"2026-07-08T20:30:00+03:00","2026-07-09":"2026-07-09T20:29:00+03:00","2026-07-10":"2026-07-10T20:29:00+03:00"},"tzeit42min":{"2026-04-23":"2026-04-23T19:56:00+03:00","2026-04-24":"2026-04-24T19:56:00+03:00","2026-04-25":"2026-04-25T19:57:00+03:00","2026-04-26":"2026-04-26T19:58:00+03:00","2026-04-27":"2026-04-27T19:58:00+03:00","2026-04-28":"2026-04-28T19:59:00+03:00","2026-04-29":"2026-04-29T20:00:00+03:00","2026-04-30":"2026-04-30T20:00:00+03:00","2026-05-01":"2026-05-01T20:01:00+03:00","2026-05-02":"2026-05-02T20:02:00+03:00","2026-05-03":"2026-05-03T20:03:00+03:00","2026-05-04":"2026-05-04T20:03:00+03:00","2026-05-05":"2026-05-05T20:04:00+03:00","2026-05-06":"2026-05-06T20:05:00+03:00","2026-05-07":"2026-05-07T20:05:00+03:00","2026-05-08":"2026-05-08T20:06:00+03:00","2026-05-09":"2026-05-09T20:07:00+03:00","2026-05-10":"2026-05-10T20:07:00+03:00","2026-05-11":"2026-05-11T20:08:00+03:00","2026-05-12":"2026-05-12T20:09:00+03:00","2026-05-13":"2026-05-13T20:10:00+03:00","2026-05-14":"2026-05-14T20:10:00+03:00","2026-05-15":"2026-05-15T20:11:00+03:00","2026-05-16":"2026-05-16T20:12:00+03:00","2026-05-17":"2026-05-17T20:12:00+03:00","2026-05-18":"2026-05-18T20:13:00+03:00","2026-05-19":"2026-05-19T20:14:00+03:00","2026-05-20":"2026-05-20T20:14:00+03:00","2026-05-21":"2026-05-21T20:15:00+03:00","2026-05-22":"2026-05-22T20:16:00+03:00","2026-05-23":"2026-05-23T20:16:00+03:00","2026-05-24":"2026-05-24T20:17:00+03:00","2026-05-25":"2026-05-25T20:18:00+03:00","2026-05-26":"2026-05-26T20:18:00+03:00","2026-05-27":"2026-05-27T20:19:00+03:00","2026-05-28":"2026-05-28T20:19:00+03:00","2026-05-29":"2026-05-29T20:20:00+03:00","2026-05-30":"2026-05-30T20:21:00+03:00","2026-05-31":"2026-05-31T20:21:00+03:00","2026-06-01":"2026-06-01T20:22:00+03:00","2026-06-02":"2026-06-02T20:22:00+03:00","2026-06-03":"2026-06-03T20:23:00+03:00","2026-06-04":"2026-06-04T20:23:00+03:00","2026-06-05":"2026-06-05T20:24:00+03:00","2026-06-06":"2026-06-06T20:24:00+03:00","2026-06-07":"2026-06-07T20:25:00+03:00","2026-06-08":"2026-06-08T20:25:00+03:00","2026-06-09":"2026-06-09T20:26:00+03:00","2026-06-10":"2026-06-10T20:26:00+03:00","2026-06-11":"2026-06-11T20:27:00+03:00","2026-06-12":"2026-06-12T20:27:00+03:00","2026-06-13":"2026-06-13T20:27:00+03:00","2026-06-14":"2026-06-14T20:28:00+03:00","2026-06-15":"2026-06-15T20:28:00+03:00","2026-06-16":"2026-06-16T20:28:00+03:00","2026-06-17":"2026-06-17T20:29:00+03:00","2026-06-18":"2026-06-18T20:29:00+03:00","2026-06-19":"2026-06-19T20:29:00+03:00","2026-06-20":"2026-06-20T20:29:00+03:00","2026-06-21":"2026-06-21T20:30:00+03:00","2026-06-22":"2026-06-22T20:30:00+03:00","2026-06-23":"2026-06-23T20:30:00+03:00","2026-06-24":"2026-06-24T20:30:00+03:00","2026-06-25":"2026-06-25T20:30:00+03:00","2026-06-26":"2026-06-26T20:30:00+03:00","2026-06-27":"2026-06-27T20:31:00+03:00","2026-06-28":"2026-06-28T20:31:00+03:00","2026-06-29":"2026-06-29T20:31:00+03:00","2026-06-30":"2026-06-30T20:31:00+03:00","2026-07-01":"2026-07-01T20:31:00+03:00","2026-07-02":"2026-07-02T20:31:00+03:00","2026-07-03":"2026-07-03T20:31:00+03:00","2026-07-04":"2026-07-04T20:30:00+03:00","2026-07-05":"2026-07-05T20:30:00+03:00","2026-07-06":"2026-07-06T20:30:00+03:00","2026-07-07":"2026-07-07T20:30:00+03:00","2026-07-08":"2026-07-08T20:30:00+03:00","2026-07-09":"2026-07-09T20:30:00+03:00","2026-07-10":"2026-07-10T20:29:00+03:00"},"tzeit50min":{"2026-04-23":"2026-04-23T20:04:00+03:00","2026-04-24":"2026-04-24T20:04:00+03:00","2026-04-25":"2026-04-25T20:05:00+03:00","2026-04-26":"2026-04-26T20:06:00+03:00","2026-04-27":"2026-04-27T20:06:00+03:00","2026-04-28":"2026-04-28T20:07:00+03:00","2026-04-29":"2026-04-29T20:08:00+03:00","2026-04-30":"2026-04-30T20:08:00+03:00","2026-05-01":"2026-05-01T20:09:00+03:00","2026-05-02":"2026-05-02T20:10:00+03:00","2026-05-03":"2026-05-03T20:11:00+03:00","2026-05-04":"2026-05-04T20:11:00+03:00","2026-05-05":"2026-05-05T20:12:00+03:00","2026-05-06":"2026-05-06T20:13:00+03:00","2026-05-07":"2026-05-07T20:13:00+03:00","2026-05-08":"2026-05-08T20:14:00+03:00","2026-05-09":"2026-05-09T20:15:00+03:00","2026-05-10":"2026-05-10T20:15:00+03:00","2026-05-11":"2026-05-11T20:16:00+03:00","2026-05-12":"2026-05-12T20:17:00+03:00","2026-05-13":"2026-05-13T20:18:00+03:00","2026-05-14":"2026-05-14T20:18:00+03:00","2026-05-15":"2026-05-15T20:19:00+03:00","2026-05-16":"2026-05-16T20:20:00+03:00","2026-05-17":"2026-05-17T20:20:00+03:00","2026-05-18":"2026-05-18T20:21:00+03:00","2026-05-19":"2026-05-19T20:22:00+03:00","2026-05-20":"2026-05-20T20:22:00+03:00","2026-05-21":"2026-05-21T20:23:00+03:00","2026-05-22":"2026-05-22T20:24:00+03:00","2026-05-23":"2026-05-23T20:24:00+03:00","2026-05-24":"2026-05-24T20:25:00+03:00","2026-05-25":"2026-05-25T20:26:00+03:00","2026-05-26":"2026-05-26T20:26:00+03:00","2026-05-27":"2026-05-27T20:27:00+03:00","2026-05-28":"2026-05-28T20:27:00+03:00","2026-05-29":"2026-05-29T20:28:00+03:00","2026-05-30":"2026-05-30T20:29:00+03:00","2026-05-31":"2026-05-31T20:29:00+03:00","2026-06-01":"2026-06-01T20:30:00+03:00","2026-06-02":"2026-06-02T20:30:00+03:00","2026-06-03":"2026-06-03T20:31:00+03:00","2026-06-04":"2026-06-04T20:31:00+03:00","2026-06-05":"2026-06-05T20:32:00+03:00","2026-06-06":"2026-06-06T20:32:00+03:00","2026-06-07":"2026-06-07T20:33:00+03:00","2026-06-08":"2026-06-08T20:33:00+03:00","2026-06-09":"2026-06-09T20:34:00+03:00","2026-06-10":"2026-06-10T20:34:00+03:00","2026-06-11":"2026-06-11T20:35:00+03:00","2026-06-12":"2026-06-12T20:35:00+03:00","2026-06-13":"2026-06-13T20:35:00+03:00","2026-06-14":"2026-06-14T20:36:00+03:00","2026-06-15":"2026-06-15T20:36:00+03:00","2026-06-16":"2026-06-16T20:36:00+03:00","2026-06-17":"2026-06-17T20:37:00+03:00","2026-06-18":"2026-06-18T20:37:00+03:00","2026-06-19":"2026-06-19T20:37:00+03:00","2026-06-20":"2026-06-20T20:37:00+03:00","2026-06-21":"2026-06-21T20:38:00+03:00","2026-06-22":"2026-06-22T20:38:00+03:00","2026-06-23":"2026-06-23T20:38:00+03:00","2026-06-24":"2026-06-24T20:38:00+03:00","2026-06-25":"2026-06-25T20:38:00+03:00","2026-06-26":"2026-06-26T20:38:00+03:00","2026-06-27":"2026-06-27T20:39:00+03:00","2026-06-28":"2026-06-28T20:39:00+03:00","2026-06-29":"2026-06-29T20:39:00+03:00","2026-06-30":"2026-06-30T20:39:00+03:00","2026-07-01":"2026-07-01T20:39:00+03:00","2026-07-02":"2026-07-02T20:39:00+03:00","2026-07-03":"2026-07-03T20:39:00+03:00","2026-07-04":"2026-07-04T20:38:00+03:00","2026-07-05":"2026-07-05T20:38:00+03:00","2026-07-06":"2026-07-06T20:38:00+03:00","2026-07-07":"2026-07-07T20:38:00+03:00","2026-07-08":"2026-07-08T20:38:00+03:00","2026-07-09":"2026-07-09T20:38:00+03:00","2026-07-10":"2026-07-10T20:37:00+03:00"},"tzeit72min":{"2026-04-23":"2026-04-23T20:26:00+03:00","2026-04-24":"2026-04-24T20:26:00+03:00","2026-04-25":"2026-04-25T20:27:00+03:00","2026-04-26":"2026-04-26T20:28:00+03:00","2026-04-27":"2026-04-27T20:28:00+03:00","2026-04-28":"2026-04-28T20:29:00+03:00","2026-04-29":"2026-04-29T20:30:00+03:00","2026-04-30":"2026-04-30T20:30:00+03:00","2026-05-01":"2026-05-01T20:31:00+03:00","2026-05-02":"2026-05-02T20:32:00+03:00","2026-05-03":"2026-05-03T20:33:00+03:00","2026-05-04":"2026-05-04T20:33:00+03:00","2026-05-05":"2026-05-05T20:34:00+03:00","2026-05-06":"2026-05-06T20:35:00+03:00","2026-05-07":"2026-05-07T20:35:00+03:00","2026-05-08":"2026-05-08T20:36:00+03:00","2026-05-09":"2026-05-09T20:37:00+03:00","2026-05-10":"2026-05-10T20:37:00+03:00","2026-05-11":"2026-05-11T20:38:00+03:00","2026-05-12":"2026-05-12T20:39:00+03:00","2026-05-13":"2026-05-13T20:40:00+03:00","2026-05-14":"2026-05-14T20:40:00+03:00","2026-05-15":"2026-05-15T20:41:00+03:00","2026-05-16":"2026-05-16T20:42:00+03:00","2026-05-17":"2026-05-17T20:42:00+03:00","2026-05-18":"2026-05-18T20:43:00+03:00","2026-05-19":"2026-05-19T20:44:00+03:00","2026-05-20":"2026-05-20T20:44:00+03:00","2026-05-21":"2026-05-21T20:45:00+03:00","2026-05-22":"2026-05-22T20:46:00+03:00","2026-05-23":"2026-05-23T20:46:00+03:00","2026-05-24":"2026-05-24T20:47:00+03:00","2026-05-25":"2026-05-25T20:48:00+03:00","2026-05-26":"2026-05-26T20:48:00+03:00","2026-05-27":"2026-05-27T20:49:00+03:00","2026-05-28":"2026-05-28T20:49:00+03:00","2026-05-29":"2026-05-29T20:50:00+03:00","2026-05-30":"2026-05-30T20:51:00+03:00","2026-05-31":"2026-05-31T20:51:00+03:00","2026-06-01":"2026-06-01T20:52:00+03:00","2026-06-02":"2026-06-02T20:52:00+03:00","2026-06-03":"2026-06-03T20:53:00+03:00","2026-06-04":"2026-06-04T20:53:00+03:00","2026-06-05":"2026-06-05T20:54:00+03:00","2026-06-06":"2026-06-06T20:54:00+03:00","2026-06-07":"2026-06-07T20:55:00+03:00","2026-06-08":"2026-06-08T20:55:00+03:00","2026-06-09":"2026-06-09T20:56:00+03:00","2026-06-10":"2026-06-10T20:56:00+03:00","2026-06-11":"2026-06-11T20:57:00+03:00","2026-06-12":"2026-06-12T20:57:00+03:00","2026-06-13":"2026-06-13T20:57:00+03:00","2026-06-14":"2026-06-14T20:58:00+03:00","2026-06-15":"2026-06-15T20:58:00+03:00","2026-06-16":"2026-06-16T20:58:00+03:00","2026-06-17":"2026-06-17T20:59:00+03:00","2026-06-18":"2026-06-18T20:59:00+03:00","2026-06-19":"2026-06-19T20:59:00+03:00","2026-06-20":"2026-06-20T20:59:00+03:00","2026-06-21":"2026-06-21T21:00:00+03:00","2026-06-22":"2026-06-22T21:00:00+03:00","2026-06-23":"2026-06-23T21:00:00+03:00","2026-06-24":"2026-06-24T21:00:00+03:00","2026-06-25":"2026-06-25T21:00:00+03:00","2026-06-26":"2026-06-26T21:00:00+03:00","2026-06-27":"2026-06-27T21:01:00+03:00","2026-06-28":"2026-06-28T21:01:00+03:00","2026-06-29":"2026-06-29T21:01:00+03:00","2026-06-30":"2026-06-30T21:01:00+03:00","2026-07-01":"2026-07-01T21:01:00+03:00","2026-07-02":"2026-07-02T21:01:00+03:00","2026-07-03":"2026-07-03T21:01:00+03:00","2026-07-04":"2026-07-04T21:00:00+03:00","2026-07-05":"2026-07-05T21:00:00+03:00","2026-07-06":"2026-07-06T21:00:00+03:00","2026-07-07":"2026-07-07T21:00:00+03:00","2026-07-08":"2026-07-08T21:00:00+03:00","2026-07-09":"2026-07-09T21:00:00+03:00","2026-07-10":"2026-07-10T20:59:00+03:00"},"molad":{"2026-05-16":"2026-05-16T18:42:00+03:00","2026-06-15":"2026-06-15T07:26:00+03:00"},"tchilasZmanKidushLevana3Days":{"2026-05-19":"2026-05-19T18:42:00+03:00","2026-06-18":"2026-06-18T07:26:00+03:00"},"tchilasZmanKidushLevana7Days":{"2026-04-24":"2026-04-24T05:58:00+03:00","2026-05-23":"2026-05-23T18:42:00+03:00","2026-06-22":"2026-06-22T07:26:00+03:00"},"sofZmanKidushLevanaBetweenMoldos":{"2026-05-02":"2026-05-02T00:19:00+03:00","2026-05-31":"2026-05-31T13:03:00+03:00","2026-06-30":"2026-06-30T01:47:00+03:00"},"sofZmanKidushLevana15Days":{"2026-05-02":"2026-05-02T05:57:00+03:00","2026-05-31":"2026-05-31T18:41:00+03:00","2026-06-30":"2026-06-30T07:25:00+03:00"}}} \ No newline at end of file diff --git a/tests/Fixtures/hebcal-jerusalem-provenance.json b/tests/Fixtures/hebcal-jerusalem-provenance.json new file mode 100644 index 0000000..e086143 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-provenance.json @@ -0,0 +1,35 @@ +{ + "retrieved_date": "2026-10-14", + "attribution": "Hebcal.com", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "service_docs": "https://www.hebcal.com/home/1663/zmanim-halachic-times-api", + "midnight_semantics": "https://hebcal.github.io/api/core/classes/Zmanim.html#nighthour", + "notes": "Public response excerpts. Only unused fields were removed and JSON whitespace was normalized. No time, date, event or offset value was invented or recalculated. Unit tests replay these immutable files without network access.", + "fixtures": [ + { + "sha256": "7d4b71adf0c32552dfb44b008208dbd58938ee89b6914ac8fa20bffe90f24254", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.778&longitude=35.235&tzid=Asia%2FJerusalem&start=2026-09-23&end=2026-12-10", + "file": "hebcal-jerusalem-solar-2026-10.json", + "projection": "date, location, unchanged chatzotNight/alotHaShachar/misheyakir/sunrise/sunset/tzeit7083deg/tzeit85deg time series" + }, + { + "sha256": "d6f5a942d5caa9998d6334a75e1e686c4e06367e7fd6ee3e70430d0ed2561942", + "url": "https://www.hebcal.com/hebcal?v=1&cfg=json&start=2026-09-23&end=2026-12-10&i=on&maj=on&min=on&mf=on&s=on&mvch=on&o=on&F=on&d=on&hdp=1&lg=he", + "file": "hebcal-jerusalem-calendar-2026-10.json", + "projection": "range and every item with unchanged date/category/title/title_orig/hebrew/hdate/yomtov fields when present" + }, + { + "sha256": "ef7c23b6cb56bce16b9d21fffa23f23ee351e98f713ead10c7b7c15e82bf4ba8", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.778&longitude=35.235&tzid=Asia%2FJerusalem&start=2026-07-01&end=2026-07-02", + "file": "hebcal-jerusalem-solar-2026-07.json", + "projection": "date, location, unchanged chatzotNight/sunrise/sunset time series" + }, + { + "sha256": "7a0689589543e8137d6d3dc9c736acdb89c2d0b30b639d9a2d8be262457777b2", + "url": "https://www.hebcal.com/zmanim?cfg=json&latitude=31.778&longitude=35.235&tzid=Asia%2FJerusalem&start=2026-03-26&end=2026-03-28", + "file": "hebcal-jerusalem-solar-2026-03.json", + "projection": "date, location, unchanged chatzotNight/sunrise/sunset time series" + } + ] +} diff --git a/tests/Fixtures/hebcal-jerusalem-solar-2026-03.json b/tests/Fixtures/hebcal-jerusalem-solar-2026-03.json new file mode 100644 index 0000000..2f04da5 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-solar-2026-03.json @@ -0,0 +1,31 @@ +{ + "date": { + "start": "2026-03-26", + "end": "2026-03-28" + }, + "location": { + "title": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "city": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "tzid": "Asia/Jerusalem", + "latitude": 31.778, + "longitude": 35.235, + "geo": "pos" + }, + "times": { + "chatzotNight": { + "2026-03-26": "2026-03-25T23:45:00+02:00", + "2026-03-27": "2026-03-26T23:44:00+02:00", + "2026-03-28": "2026-03-28T00:44:00+03:00" + }, + "sunrise": { + "2026-03-26": "2026-03-26T05:35:00+02:00", + "2026-03-27": "2026-03-27T06:34:00+03:00", + "2026-03-28": "2026-03-28T06:33:00+03:00" + }, + "sunset": { + "2026-03-26": "2026-03-26T17:54:00+02:00", + "2026-03-27": "2026-03-27T18:55:00+03:00", + "2026-03-28": "2026-03-28T18:56:00+03:00" + } + } +} diff --git a/tests/Fixtures/hebcal-jerusalem-solar-2026-07.json b/tests/Fixtures/hebcal-jerusalem-solar-2026-07.json new file mode 100644 index 0000000..d3e66aa --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-solar-2026-07.json @@ -0,0 +1,28 @@ +{ + "date": { + "start": "2026-07-01", + "end": "2026-07-02" + }, + "location": { + "title": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "city": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "tzid": "Asia/Jerusalem", + "latitude": 31.778, + "longitude": 35.235, + "geo": "pos" + }, + "times": { + "chatzotNight": { + "2026-07-01": "2026-07-01T00:43:00+03:00", + "2026-07-02": "2026-07-02T00:43:00+03:00" + }, + "sunrise": { + "2026-07-01": "2026-07-01T05:37:00+03:00", + "2026-07-02": "2026-07-02T05:38:00+03:00" + }, + "sunset": { + "2026-07-01": "2026-07-01T19:49:00+03:00", + "2026-07-02": "2026-07-02T19:49:00+03:00" + } + } +} diff --git a/tests/Fixtures/hebcal-jerusalem-solar-2026-10.json b/tests/Fixtures/hebcal-jerusalem-solar-2026-10.json new file mode 100644 index 0000000..2045bf6 --- /dev/null +++ b/tests/Fixtures/hebcal-jerusalem-solar-2026-10.json @@ -0,0 +1,583 @@ +{ + "date": { + "start": "2026-09-23", + "end": "2026-12-10" + }, + "location": { + "title": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "city": "31ยฐ46โ€ฒN 35ยฐ14โ€ฒE Asia/Jerusalem", + "tzid": "Asia/Jerusalem", + "latitude": 31.778, + "longitude": 35.235, + "geo": "pos" + }, + "times": { + "chatzotNight": { + "2026-09-23": "2026-09-23T00:32:00+03:00", + "2026-09-24": "2026-09-24T00:32:00+03:00", + "2026-09-25": "2026-09-25T00:31:00+03:00", + "2026-09-26": "2026-09-26T00:31:00+03:00", + "2026-09-27": "2026-09-27T00:31:00+03:00", + "2026-09-28": "2026-09-28T00:30:00+03:00", + "2026-09-29": "2026-09-29T00:30:00+03:00", + "2026-09-30": "2026-09-30T00:30:00+03:00", + "2026-10-01": "2026-10-01T00:29:00+03:00", + "2026-10-02": "2026-10-02T00:29:00+03:00", + "2026-10-03": "2026-10-03T00:29:00+03:00", + "2026-10-04": "2026-10-04T00:28:00+03:00", + "2026-10-05": "2026-10-05T00:28:00+03:00", + "2026-10-06": "2026-10-06T00:28:00+03:00", + "2026-10-07": "2026-10-07T00:27:00+03:00", + "2026-10-08": "2026-10-08T00:27:00+03:00", + "2026-10-09": "2026-10-09T00:27:00+03:00", + "2026-10-10": "2026-10-10T00:26:00+03:00", + "2026-10-11": "2026-10-11T00:26:00+03:00", + "2026-10-12": "2026-10-12T00:26:00+03:00", + "2026-10-13": "2026-10-13T00:26:00+03:00", + "2026-10-14": "2026-10-14T00:25:00+03:00", + "2026-10-15": "2026-10-15T00:25:00+03:00", + "2026-10-16": "2026-10-16T00:25:00+03:00", + "2026-10-17": "2026-10-17T00:25:00+03:00", + "2026-10-18": "2026-10-18T00:25:00+03:00", + "2026-10-19": "2026-10-19T00:24:00+03:00", + "2026-10-20": "2026-10-20T00:24:00+03:00", + "2026-10-21": "2026-10-21T00:24:00+03:00", + "2026-10-22": "2026-10-22T00:24:00+03:00", + "2026-10-23": "2026-10-23T00:24:00+03:00", + "2026-10-24": "2026-10-24T00:24:00+03:00", + "2026-10-25": "2026-10-25T00:23:00+03:00", + "2026-10-26": "2026-10-25T23:23:00+02:00", + "2026-10-27": "2026-10-26T23:23:00+02:00", + "2026-10-28": "2026-10-27T23:23:00+02:00", + "2026-10-29": "2026-10-28T23:23:00+02:00", + "2026-10-30": "2026-10-29T23:23:00+02:00", + "2026-10-31": "2026-10-30T23:23:00+02:00", + "2026-11-01": "2026-10-31T23:23:00+02:00", + "2026-11-02": "2026-11-01T23:23:00+02:00", + "2026-11-03": "2026-11-02T23:23:00+02:00", + "2026-11-04": "2026-11-03T23:23:00+02:00", + "2026-11-05": "2026-11-04T23:23:00+02:00", + "2026-11-06": "2026-11-05T23:23:00+02:00", + "2026-11-07": "2026-11-06T23:23:00+02:00", + "2026-11-08": "2026-11-07T23:23:00+02:00", + "2026-11-09": "2026-11-08T23:23:00+02:00", + "2026-11-10": "2026-11-09T23:23:00+02:00", + "2026-11-11": "2026-11-10T23:23:00+02:00", + "2026-11-12": "2026-11-11T23:23:00+02:00", + "2026-11-13": "2026-11-12T23:23:00+02:00", + "2026-11-14": "2026-11-13T23:24:00+02:00", + "2026-11-15": "2026-11-14T23:24:00+02:00", + "2026-11-16": "2026-11-15T23:24:00+02:00", + "2026-11-17": "2026-11-16T23:24:00+02:00", + "2026-11-18": "2026-11-17T23:24:00+02:00", + "2026-11-19": "2026-11-18T23:24:00+02:00", + "2026-11-20": "2026-11-19T23:25:00+02:00", + "2026-11-21": "2026-11-20T23:25:00+02:00", + "2026-11-22": "2026-11-21T23:25:00+02:00", + "2026-11-23": "2026-11-22T23:25:00+02:00", + "2026-11-24": "2026-11-23T23:26:00+02:00", + "2026-11-25": "2026-11-24T23:26:00+02:00", + "2026-11-26": "2026-11-25T23:26:00+02:00", + "2026-11-27": "2026-11-26T23:27:00+02:00", + "2026-11-28": "2026-11-27T23:27:00+02:00", + "2026-11-29": "2026-11-28T23:27:00+02:00", + "2026-11-30": "2026-11-29T23:28:00+02:00", + "2026-12-01": "2026-11-30T23:28:00+02:00", + "2026-12-02": "2026-12-01T23:28:00+02:00", + "2026-12-03": "2026-12-02T23:29:00+02:00", + "2026-12-04": "2026-12-03T23:29:00+02:00", + "2026-12-05": "2026-12-04T23:30:00+02:00", + "2026-12-06": "2026-12-05T23:30:00+02:00", + "2026-12-07": "2026-12-06T23:30:00+02:00", + "2026-12-08": "2026-12-07T23:31:00+02:00", + "2026-12-09": "2026-12-08T23:31:00+02:00", + "2026-12-10": "2026-12-09T23:32:00+02:00" + }, + "alotHaShachar": { + "2026-09-23": "2026-09-23T05:16:00+03:00", + "2026-09-24": "2026-09-24T05:16:00+03:00", + "2026-09-25": "2026-09-25T05:17:00+03:00", + "2026-09-26": "2026-09-26T05:18:00+03:00", + "2026-09-27": "2026-09-27T05:18:00+03:00", + "2026-09-28": "2026-09-28T05:19:00+03:00", + "2026-09-29": "2026-09-29T05:19:00+03:00", + "2026-09-30": "2026-09-30T05:20:00+03:00", + "2026-10-01": "2026-10-01T05:21:00+03:00", + "2026-10-02": "2026-10-02T05:21:00+03:00", + "2026-10-03": "2026-10-03T05:22:00+03:00", + "2026-10-04": "2026-10-04T05:23:00+03:00", + "2026-10-05": "2026-10-05T05:23:00+03:00", + "2026-10-06": "2026-10-06T05:24:00+03:00", + "2026-10-07": "2026-10-07T05:25:00+03:00", + "2026-10-08": "2026-10-08T05:25:00+03:00", + "2026-10-09": "2026-10-09T05:26:00+03:00", + "2026-10-10": "2026-10-10T05:27:00+03:00", + "2026-10-11": "2026-10-11T05:27:00+03:00", + "2026-10-12": "2026-10-12T05:28:00+03:00", + "2026-10-13": "2026-10-13T05:29:00+03:00", + "2026-10-14": "2026-10-14T05:29:00+03:00", + "2026-10-15": "2026-10-15T05:30:00+03:00", + "2026-10-16": "2026-10-16T05:31:00+03:00", + "2026-10-17": "2026-10-17T05:31:00+03:00", + "2026-10-18": "2026-10-18T05:32:00+03:00", + "2026-10-19": "2026-10-19T05:33:00+03:00", + "2026-10-20": "2026-10-20T05:33:00+03:00", + "2026-10-21": "2026-10-21T05:34:00+03:00", + "2026-10-22": "2026-10-22T05:35:00+03:00", + "2026-10-23": "2026-10-23T05:35:00+03:00", + "2026-10-24": "2026-10-24T05:36:00+03:00", + "2026-10-25": "2026-10-25T04:37:00+02:00", + "2026-10-26": "2026-10-26T04:37:00+02:00", + "2026-10-27": "2026-10-27T04:38:00+02:00", + "2026-10-28": "2026-10-28T04:39:00+02:00", + "2026-10-29": "2026-10-29T04:40:00+02:00", + "2026-10-30": "2026-10-30T04:40:00+02:00", + "2026-10-31": "2026-10-31T04:41:00+02:00", + "2026-11-01": "2026-11-01T04:42:00+02:00", + "2026-11-02": "2026-11-02T04:42:00+02:00", + "2026-11-03": "2026-11-03T04:43:00+02:00", + "2026-11-04": "2026-11-04T04:44:00+02:00", + "2026-11-05": "2026-11-05T04:45:00+02:00", + "2026-11-06": "2026-11-06T04:45:00+02:00", + "2026-11-07": "2026-11-07T04:46:00+02:00", + "2026-11-08": "2026-11-08T04:47:00+02:00", + "2026-11-09": "2026-11-09T04:48:00+02:00", + "2026-11-10": "2026-11-10T04:48:00+02:00", + "2026-11-11": "2026-11-11T04:49:00+02:00", + "2026-11-12": "2026-11-12T04:50:00+02:00", + "2026-11-13": "2026-11-13T04:51:00+02:00", + "2026-11-14": "2026-11-14T04:51:00+02:00", + "2026-11-15": "2026-11-15T04:52:00+02:00", + "2026-11-16": "2026-11-16T04:53:00+02:00", + "2026-11-17": "2026-11-17T04:54:00+02:00", + "2026-11-18": "2026-11-18T04:54:00+02:00", + "2026-11-19": "2026-11-19T04:55:00+02:00", + "2026-11-20": "2026-11-20T04:56:00+02:00", + "2026-11-21": "2026-11-21T04:57:00+02:00", + "2026-11-22": "2026-11-22T04:57:00+02:00", + "2026-11-23": "2026-11-23T04:58:00+02:00", + "2026-11-24": "2026-11-24T04:59:00+02:00", + "2026-11-25": "2026-11-25T05:00:00+02:00", + "2026-11-26": "2026-11-26T05:00:00+02:00", + "2026-11-27": "2026-11-27T05:01:00+02:00", + "2026-11-28": "2026-11-28T05:02:00+02:00", + "2026-11-29": "2026-11-29T05:03:00+02:00", + "2026-11-30": "2026-11-30T05:03:00+02:00", + "2026-12-01": "2026-12-01T05:04:00+02:00", + "2026-12-02": "2026-12-02T05:05:00+02:00", + "2026-12-03": "2026-12-03T05:06:00+02:00", + "2026-12-04": "2026-12-04T05:06:00+02:00", + "2026-12-05": "2026-12-05T05:07:00+02:00", + "2026-12-06": "2026-12-06T05:08:00+02:00", + "2026-12-07": "2026-12-07T05:08:00+02:00", + "2026-12-08": "2026-12-08T05:09:00+02:00", + "2026-12-09": "2026-12-09T05:10:00+02:00", + "2026-12-10": "2026-12-10T05:10:00+02:00" + }, + "misheyakir": { + "2026-09-23": "2026-09-23T05:37:00+03:00", + "2026-09-24": "2026-09-24T05:38:00+03:00", + "2026-09-25": "2026-09-25T05:39:00+03:00", + "2026-09-26": "2026-09-26T05:39:00+03:00", + "2026-09-27": "2026-09-27T05:40:00+03:00", + "2026-09-28": "2026-09-28T05:41:00+03:00", + "2026-09-29": "2026-09-29T05:41:00+03:00", + "2026-09-30": "2026-09-30T05:42:00+03:00", + "2026-10-01": "2026-10-01T05:43:00+03:00", + "2026-10-02": "2026-10-02T05:43:00+03:00", + "2026-10-03": "2026-10-03T05:44:00+03:00", + "2026-10-04": "2026-10-04T05:44:00+03:00", + "2026-10-05": "2026-10-05T05:45:00+03:00", + "2026-10-06": "2026-10-06T05:46:00+03:00", + "2026-10-07": "2026-10-07T05:46:00+03:00", + "2026-10-08": "2026-10-08T05:47:00+03:00", + "2026-10-09": "2026-10-09T05:48:00+03:00", + "2026-10-10": "2026-10-10T05:48:00+03:00", + "2026-10-11": "2026-10-11T05:49:00+03:00", + "2026-10-12": "2026-10-12T05:50:00+03:00", + "2026-10-13": "2026-10-13T05:50:00+03:00", + "2026-10-14": "2026-10-14T05:51:00+03:00", + "2026-10-15": "2026-10-15T05:52:00+03:00", + "2026-10-16": "2026-10-16T05:52:00+03:00", + "2026-10-17": "2026-10-17T05:53:00+03:00", + "2026-10-18": "2026-10-18T05:54:00+03:00", + "2026-10-19": "2026-10-19T05:54:00+03:00", + "2026-10-20": "2026-10-20T05:55:00+03:00", + "2026-10-21": "2026-10-21T05:56:00+03:00", + "2026-10-22": "2026-10-22T05:56:00+03:00", + "2026-10-23": "2026-10-23T05:57:00+03:00", + "2026-10-24": "2026-10-24T05:58:00+03:00", + "2026-10-25": "2026-10-25T04:58:00+02:00", + "2026-10-26": "2026-10-26T04:59:00+02:00", + "2026-10-27": "2026-10-27T05:00:00+02:00", + "2026-10-28": "2026-10-28T05:01:00+02:00", + "2026-10-29": "2026-10-29T05:01:00+02:00", + "2026-10-30": "2026-10-30T05:02:00+02:00", + "2026-10-31": "2026-10-31T05:03:00+02:00", + "2026-11-01": "2026-11-01T05:04:00+02:00", + "2026-11-02": "2026-11-02T05:04:00+02:00", + "2026-11-03": "2026-11-03T05:05:00+02:00", + "2026-11-04": "2026-11-04T05:06:00+02:00", + "2026-11-05": "2026-11-05T05:07:00+02:00", + "2026-11-06": "2026-11-06T05:07:00+02:00", + "2026-11-07": "2026-11-07T05:08:00+02:00", + "2026-11-08": "2026-11-08T05:09:00+02:00", + "2026-11-09": "2026-11-09T05:10:00+02:00", + "2026-11-10": "2026-11-10T05:10:00+02:00", + "2026-11-11": "2026-11-11T05:11:00+02:00", + "2026-11-12": "2026-11-12T05:12:00+02:00", + "2026-11-13": "2026-11-13T05:13:00+02:00", + "2026-11-14": "2026-11-14T05:13:00+02:00", + "2026-11-15": "2026-11-15T05:14:00+02:00", + "2026-11-16": "2026-11-16T05:15:00+02:00", + "2026-11-17": "2026-11-17T05:16:00+02:00", + "2026-11-18": "2026-11-18T05:17:00+02:00", + "2026-11-19": "2026-11-19T05:17:00+02:00", + "2026-11-20": "2026-11-20T05:18:00+02:00", + "2026-11-21": "2026-11-21T05:19:00+02:00", + "2026-11-22": "2026-11-22T05:20:00+02:00", + "2026-11-23": "2026-11-23T05:21:00+02:00", + "2026-11-24": "2026-11-24T05:21:00+02:00", + "2026-11-25": "2026-11-25T05:22:00+02:00", + "2026-11-26": "2026-11-26T05:23:00+02:00", + "2026-11-27": "2026-11-27T05:24:00+02:00", + "2026-11-28": "2026-11-28T05:25:00+02:00", + "2026-11-29": "2026-11-29T05:25:00+02:00", + "2026-11-30": "2026-11-30T05:26:00+02:00", + "2026-12-01": "2026-12-01T05:27:00+02:00", + "2026-12-02": "2026-12-02T05:28:00+02:00", + "2026-12-03": "2026-12-03T05:28:00+02:00", + "2026-12-04": "2026-12-04T05:29:00+02:00", + "2026-12-05": "2026-12-05T05:30:00+02:00", + "2026-12-06": "2026-12-06T05:31:00+02:00", + "2026-12-07": "2026-12-07T05:31:00+02:00", + "2026-12-08": "2026-12-08T05:32:00+02:00", + "2026-12-09": "2026-12-09T05:33:00+02:00", + "2026-12-10": "2026-12-10T05:33:00+02:00" + }, + "sunrise": { + "2026-09-23": "2026-09-23T06:28:00+03:00", + "2026-09-24": "2026-09-24T06:28:00+03:00", + "2026-09-25": "2026-09-25T06:29:00+03:00", + "2026-09-26": "2026-09-26T06:30:00+03:00", + "2026-09-27": "2026-09-27T06:30:00+03:00", + "2026-09-28": "2026-09-28T06:31:00+03:00", + "2026-09-29": "2026-09-29T06:32:00+03:00", + "2026-09-30": "2026-09-30T06:32:00+03:00", + "2026-10-01": "2026-10-01T06:33:00+03:00", + "2026-10-02": "2026-10-02T06:33:00+03:00", + "2026-10-03": "2026-10-03T06:34:00+03:00", + "2026-10-04": "2026-10-04T06:35:00+03:00", + "2026-10-05": "2026-10-05T06:35:00+03:00", + "2026-10-06": "2026-10-06T06:36:00+03:00", + "2026-10-07": "2026-10-07T06:37:00+03:00", + "2026-10-08": "2026-10-08T06:37:00+03:00", + "2026-10-09": "2026-10-09T06:38:00+03:00", + "2026-10-10": "2026-10-10T06:39:00+03:00", + "2026-10-11": "2026-10-11T06:39:00+03:00", + "2026-10-12": "2026-10-12T06:40:00+03:00", + "2026-10-13": "2026-10-13T06:41:00+03:00", + "2026-10-14": "2026-10-14T06:41:00+03:00", + "2026-10-15": "2026-10-15T06:42:00+03:00", + "2026-10-16": "2026-10-16T06:43:00+03:00", + "2026-10-17": "2026-10-17T06:44:00+03:00", + "2026-10-18": "2026-10-18T06:44:00+03:00", + "2026-10-19": "2026-10-19T06:45:00+03:00", + "2026-10-20": "2026-10-20T06:46:00+03:00", + "2026-10-21": "2026-10-21T06:47:00+03:00", + "2026-10-22": "2026-10-22T06:47:00+03:00", + "2026-10-23": "2026-10-23T06:48:00+03:00", + "2026-10-24": "2026-10-24T06:49:00+03:00", + "2026-10-25": "2026-10-25T05:50:00+02:00", + "2026-10-26": "2026-10-26T05:50:00+02:00", + "2026-10-27": "2026-10-27T05:51:00+02:00", + "2026-10-28": "2026-10-28T05:52:00+02:00", + "2026-10-29": "2026-10-29T05:53:00+02:00", + "2026-10-30": "2026-10-30T05:54:00+02:00", + "2026-10-31": "2026-10-31T05:54:00+02:00", + "2026-11-01": "2026-11-01T05:55:00+02:00", + "2026-11-02": "2026-11-02T05:56:00+02:00", + "2026-11-03": "2026-11-03T05:57:00+02:00", + "2026-11-04": "2026-11-04T05:58:00+02:00", + "2026-11-05": "2026-11-05T05:58:00+02:00", + "2026-11-06": "2026-11-06T05:59:00+02:00", + "2026-11-07": "2026-11-07T06:00:00+02:00", + "2026-11-08": "2026-11-08T06:01:00+02:00", + "2026-11-09": "2026-11-09T06:02:00+02:00", + "2026-11-10": "2026-11-10T06:03:00+02:00", + "2026-11-11": "2026-11-11T06:04:00+02:00", + "2026-11-12": "2026-11-12T06:04:00+02:00", + "2026-11-13": "2026-11-13T06:05:00+02:00", + "2026-11-14": "2026-11-14T06:06:00+02:00", + "2026-11-15": "2026-11-15T06:07:00+02:00", + "2026-11-16": "2026-11-16T06:08:00+02:00", + "2026-11-17": "2026-11-17T06:09:00+02:00", + "2026-11-18": "2026-11-18T06:10:00+02:00", + "2026-11-19": "2026-11-19T06:11:00+02:00", + "2026-11-20": "2026-11-20T06:11:00+02:00", + "2026-11-21": "2026-11-21T06:12:00+02:00", + "2026-11-22": "2026-11-22T06:13:00+02:00", + "2026-11-23": "2026-11-23T06:14:00+02:00", + "2026-11-24": "2026-11-24T06:15:00+02:00", + "2026-11-25": "2026-11-25T06:16:00+02:00", + "2026-11-26": "2026-11-26T06:17:00+02:00", + "2026-11-27": "2026-11-27T06:18:00+02:00", + "2026-11-28": "2026-11-28T06:18:00+02:00", + "2026-11-29": "2026-11-29T06:19:00+02:00", + "2026-11-30": "2026-11-30T06:20:00+02:00", + "2026-12-01": "2026-12-01T06:21:00+02:00", + "2026-12-02": "2026-12-02T06:22:00+02:00", + "2026-12-03": "2026-12-03T06:23:00+02:00", + "2026-12-04": "2026-12-04T06:23:00+02:00", + "2026-12-05": "2026-12-05T06:24:00+02:00", + "2026-12-06": "2026-12-06T06:25:00+02:00", + "2026-12-07": "2026-12-07T06:26:00+02:00", + "2026-12-08": "2026-12-08T06:27:00+02:00", + "2026-12-09": "2026-12-09T06:27:00+02:00", + "2026-12-10": "2026-12-10T06:28:00+02:00" + }, + "sunset": { + "2026-09-23": "2026-09-23T18:35:00+03:00", + "2026-09-24": "2026-09-24T18:33:00+03:00", + "2026-09-25": "2026-09-25T18:32:00+03:00", + "2026-09-26": "2026-09-26T18:31:00+03:00", + "2026-09-27": "2026-09-27T18:29:00+03:00", + "2026-09-28": "2026-09-28T18:28:00+03:00", + "2026-09-29": "2026-09-29T18:27:00+03:00", + "2026-09-30": "2026-09-30T18:26:00+03:00", + "2026-10-01": "2026-10-01T18:24:00+03:00", + "2026-10-02": "2026-10-02T18:23:00+03:00", + "2026-10-03": "2026-10-03T18:22:00+03:00", + "2026-10-04": "2026-10-04T18:20:00+03:00", + "2026-10-05": "2026-10-05T18:19:00+03:00", + "2026-10-06": "2026-10-06T18:18:00+03:00", + "2026-10-07": "2026-10-07T18:17:00+03:00", + "2026-10-08": "2026-10-08T18:15:00+03:00", + "2026-10-09": "2026-10-09T18:14:00+03:00", + "2026-10-10": "2026-10-10T18:13:00+03:00", + "2026-10-11": "2026-10-11T18:12:00+03:00", + "2026-10-12": "2026-10-12T18:11:00+03:00", + "2026-10-13": "2026-10-13T18:09:00+03:00", + "2026-10-14": "2026-10-14T18:08:00+03:00", + "2026-10-15": "2026-10-15T18:07:00+03:00", + "2026-10-16": "2026-10-16T18:06:00+03:00", + "2026-10-17": "2026-10-17T18:05:00+03:00", + "2026-10-18": "2026-10-18T18:04:00+03:00", + "2026-10-19": "2026-10-19T18:03:00+03:00", + "2026-10-20": "2026-10-20T18:01:00+03:00", + "2026-10-21": "2026-10-21T18:00:00+03:00", + "2026-10-22": "2026-10-22T17:59:00+03:00", + "2026-10-23": "2026-10-23T17:58:00+03:00", + "2026-10-24": "2026-10-24T17:57:00+03:00", + "2026-10-25": "2026-10-25T16:56:00+02:00", + "2026-10-26": "2026-10-26T16:55:00+02:00", + "2026-10-27": "2026-10-27T16:54:00+02:00", + "2026-10-28": "2026-10-28T16:53:00+02:00", + "2026-10-29": "2026-10-29T16:52:00+02:00", + "2026-10-30": "2026-10-30T16:51:00+02:00", + "2026-10-31": "2026-10-31T16:51:00+02:00", + "2026-11-01": "2026-11-01T16:50:00+02:00", + "2026-11-02": "2026-11-02T16:49:00+02:00", + "2026-11-03": "2026-11-03T16:48:00+02:00", + "2026-11-04": "2026-11-04T16:47:00+02:00", + "2026-11-05": "2026-11-05T16:46:00+02:00", + "2026-11-06": "2026-11-06T16:46:00+02:00", + "2026-11-07": "2026-11-07T16:45:00+02:00", + "2026-11-08": "2026-11-08T16:44:00+02:00", + "2026-11-09": "2026-11-09T16:43:00+02:00", + "2026-11-10": "2026-11-10T16:43:00+02:00", + "2026-11-11": "2026-11-11T16:42:00+02:00", + "2026-11-12": "2026-11-12T16:41:00+02:00", + "2026-11-13": "2026-11-13T16:41:00+02:00", + "2026-11-14": "2026-11-14T16:40:00+02:00", + "2026-11-15": "2026-11-15T16:40:00+02:00", + "2026-11-16": "2026-11-16T16:39:00+02:00", + "2026-11-17": "2026-11-17T16:39:00+02:00", + "2026-11-18": "2026-11-18T16:38:00+02:00", + "2026-11-19": "2026-11-19T16:38:00+02:00", + "2026-11-20": "2026-11-20T16:37:00+02:00", + "2026-11-21": "2026-11-21T16:37:00+02:00", + "2026-11-22": "2026-11-22T16:37:00+02:00", + "2026-11-23": "2026-11-23T16:36:00+02:00", + "2026-11-24": "2026-11-24T16:36:00+02:00", + "2026-11-25": "2026-11-25T16:36:00+02:00", + "2026-11-26": "2026-11-26T16:36:00+02:00", + "2026-11-27": "2026-11-27T16:35:00+02:00", + "2026-11-28": "2026-11-28T16:35:00+02:00", + "2026-11-29": "2026-11-29T16:35:00+02:00", + "2026-11-30": "2026-11-30T16:35:00+02:00", + "2026-12-01": "2026-12-01T16:35:00+02:00", + "2026-12-02": "2026-12-02T16:35:00+02:00", + "2026-12-03": "2026-12-03T16:35:00+02:00", + "2026-12-04": "2026-12-04T16:35:00+02:00", + "2026-12-05": "2026-12-05T16:35:00+02:00", + "2026-12-06": "2026-12-06T16:35:00+02:00", + "2026-12-07": "2026-12-07T16:35:00+02:00", + "2026-12-08": "2026-12-08T16:35:00+02:00", + "2026-12-09": "2026-12-09T16:35:00+02:00", + "2026-12-10": "2026-12-10T16:36:00+02:00" + }, + "tzeit7083deg": { + "2026-09-23": "2026-09-23T19:04:00+03:00", + "2026-09-24": "2026-09-24T19:03:00+03:00", + "2026-09-25": "2026-09-25T19:01:00+03:00", + "2026-09-26": "2026-09-26T19:00:00+03:00", + "2026-09-27": "2026-09-27T18:59:00+03:00", + "2026-09-28": "2026-09-28T18:58:00+03:00", + "2026-09-29": "2026-09-29T18:56:00+03:00", + "2026-09-30": "2026-09-30T18:55:00+03:00", + "2026-10-01": "2026-10-01T18:54:00+03:00", + "2026-10-02": "2026-10-02T18:52:00+03:00", + "2026-10-03": "2026-10-03T18:51:00+03:00", + "2026-10-04": "2026-10-04T18:50:00+03:00", + "2026-10-05": "2026-10-05T18:49:00+03:00", + "2026-10-06": "2026-10-06T18:47:00+03:00", + "2026-10-07": "2026-10-07T18:46:00+03:00", + "2026-10-08": "2026-10-08T18:45:00+03:00", + "2026-10-09": "2026-10-09T18:44:00+03:00", + "2026-10-10": "2026-10-10T18:43:00+03:00", + "2026-10-11": "2026-10-11T18:41:00+03:00", + "2026-10-12": "2026-10-12T18:40:00+03:00", + "2026-10-13": "2026-10-13T18:39:00+03:00", + "2026-10-14": "2026-10-14T18:38:00+03:00", + "2026-10-15": "2026-10-15T18:37:00+03:00", + "2026-10-16": "2026-10-16T18:36:00+03:00", + "2026-10-17": "2026-10-17T18:35:00+03:00", + "2026-10-18": "2026-10-18T18:33:00+03:00", + "2026-10-19": "2026-10-19T18:32:00+03:00", + "2026-10-20": "2026-10-20T18:31:00+03:00", + "2026-10-21": "2026-10-21T18:30:00+03:00", + "2026-10-22": "2026-10-22T18:29:00+03:00", + "2026-10-23": "2026-10-23T18:28:00+03:00", + "2026-10-24": "2026-10-24T18:27:00+03:00", + "2026-10-25": "2026-10-25T17:26:00+02:00", + "2026-10-26": "2026-10-26T17:25:00+02:00", + "2026-10-27": "2026-10-27T17:24:00+02:00", + "2026-10-28": "2026-10-28T17:23:00+02:00", + "2026-10-29": "2026-10-29T17:23:00+02:00", + "2026-10-30": "2026-10-30T17:22:00+02:00", + "2026-10-31": "2026-10-31T17:21:00+02:00", + "2026-11-01": "2026-11-01T17:20:00+02:00", + "2026-11-02": "2026-11-02T17:19:00+02:00", + "2026-11-03": "2026-11-03T17:18:00+02:00", + "2026-11-04": "2026-11-04T17:18:00+02:00", + "2026-11-05": "2026-11-05T17:17:00+02:00", + "2026-11-06": "2026-11-06T17:16:00+02:00", + "2026-11-07": "2026-11-07T17:16:00+02:00", + "2026-11-08": "2026-11-08T17:15:00+02:00", + "2026-11-09": "2026-11-09T17:14:00+02:00", + "2026-11-10": "2026-11-10T17:14:00+02:00", + "2026-11-11": "2026-11-11T17:13:00+02:00", + "2026-11-12": "2026-11-12T17:12:00+02:00", + "2026-11-13": "2026-11-13T17:12:00+02:00", + "2026-11-14": "2026-11-14T17:11:00+02:00", + "2026-11-15": "2026-11-15T17:11:00+02:00", + "2026-11-16": "2026-11-16T17:10:00+02:00", + "2026-11-17": "2026-11-17T17:10:00+02:00", + "2026-11-18": "2026-11-18T17:10:00+02:00", + "2026-11-19": "2026-11-19T17:09:00+02:00", + "2026-11-20": "2026-11-20T17:09:00+02:00", + "2026-11-21": "2026-11-21T17:09:00+02:00", + "2026-11-22": "2026-11-22T17:08:00+02:00", + "2026-11-23": "2026-11-23T17:08:00+02:00", + "2026-11-24": "2026-11-24T17:08:00+02:00", + "2026-11-25": "2026-11-25T17:08:00+02:00", + "2026-11-26": "2026-11-26T17:07:00+02:00", + "2026-11-27": "2026-11-27T17:07:00+02:00", + "2026-11-28": "2026-11-28T17:07:00+02:00", + "2026-11-29": "2026-11-29T17:07:00+02:00", + "2026-11-30": "2026-11-30T17:07:00+02:00", + "2026-12-01": "2026-12-01T17:07:00+02:00", + "2026-12-02": "2026-12-02T17:07:00+02:00", + "2026-12-03": "2026-12-03T17:07:00+02:00", + "2026-12-04": "2026-12-04T17:07:00+02:00", + "2026-12-05": "2026-12-05T17:07:00+02:00", + "2026-12-06": "2026-12-06T17:07:00+02:00", + "2026-12-07": "2026-12-07T17:07:00+02:00", + "2026-12-08": "2026-12-08T17:07:00+02:00", + "2026-12-09": "2026-12-09T17:08:00+02:00", + "2026-12-10": "2026-12-10T17:08:00+02:00" + }, + "tzeit85deg": { + "2026-09-23": "2026-09-23T19:11:00+03:00", + "2026-09-24": "2026-09-24T19:09:00+03:00", + "2026-09-25": "2026-09-25T19:08:00+03:00", + "2026-09-26": "2026-09-26T19:07:00+03:00", + "2026-09-27": "2026-09-27T19:06:00+03:00", + "2026-09-28": "2026-09-28T19:04:00+03:00", + "2026-09-29": "2026-09-29T19:03:00+03:00", + "2026-09-30": "2026-09-30T19:02:00+03:00", + "2026-10-01": "2026-10-01T19:00:00+03:00", + "2026-10-02": "2026-10-02T18:59:00+03:00", + "2026-10-03": "2026-10-03T18:58:00+03:00", + "2026-10-04": "2026-10-04T18:57:00+03:00", + "2026-10-05": "2026-10-05T18:55:00+03:00", + "2026-10-06": "2026-10-06T18:54:00+03:00", + "2026-10-07": "2026-10-07T18:53:00+03:00", + "2026-10-08": "2026-10-08T18:52:00+03:00", + "2026-10-09": "2026-10-09T18:50:00+03:00", + "2026-10-10": "2026-10-10T18:49:00+03:00", + "2026-10-11": "2026-10-11T18:48:00+03:00", + "2026-10-12": "2026-10-12T18:47:00+03:00", + "2026-10-13": "2026-10-13T18:46:00+03:00", + "2026-10-14": "2026-10-14T18:45:00+03:00", + "2026-10-15": "2026-10-15T18:43:00+03:00", + "2026-10-16": "2026-10-16T18:42:00+03:00", + "2026-10-17": "2026-10-17T18:41:00+03:00", + "2026-10-18": "2026-10-18T18:40:00+03:00", + "2026-10-19": "2026-10-19T18:39:00+03:00", + "2026-10-20": "2026-10-20T18:38:00+03:00", + "2026-10-21": "2026-10-21T18:37:00+03:00", + "2026-10-22": "2026-10-22T18:36:00+03:00", + "2026-10-23": "2026-10-23T18:35:00+03:00", + "2026-10-24": "2026-10-24T18:34:00+03:00", + "2026-10-25": "2026-10-25T17:33:00+02:00", + "2026-10-26": "2026-10-26T17:32:00+02:00", + "2026-10-27": "2026-10-27T17:31:00+02:00", + "2026-10-28": "2026-10-28T17:30:00+02:00", + "2026-10-29": "2026-10-29T17:29:00+02:00", + "2026-10-30": "2026-10-30T17:29:00+02:00", + "2026-10-31": "2026-10-31T17:28:00+02:00", + "2026-11-01": "2026-11-01T17:27:00+02:00", + "2026-11-02": "2026-11-02T17:26:00+02:00", + "2026-11-03": "2026-11-03T17:25:00+02:00", + "2026-11-04": "2026-11-04T17:25:00+02:00", + "2026-11-05": "2026-11-05T17:24:00+02:00", + "2026-11-06": "2026-11-06T17:23:00+02:00", + "2026-11-07": "2026-11-07T17:22:00+02:00", + "2026-11-08": "2026-11-08T17:22:00+02:00", + "2026-11-09": "2026-11-09T17:21:00+02:00", + "2026-11-10": "2026-11-10T17:21:00+02:00", + "2026-11-11": "2026-11-11T17:20:00+02:00", + "2026-11-12": "2026-11-12T17:19:00+02:00", + "2026-11-13": "2026-11-13T17:19:00+02:00", + "2026-11-14": "2026-11-14T17:18:00+02:00", + "2026-11-15": "2026-11-15T17:18:00+02:00", + "2026-11-16": "2026-11-16T17:17:00+02:00", + "2026-11-17": "2026-11-17T17:17:00+02:00", + "2026-11-18": "2026-11-18T17:17:00+02:00", + "2026-11-19": "2026-11-19T17:16:00+02:00", + "2026-11-20": "2026-11-20T17:16:00+02:00", + "2026-11-21": "2026-11-21T17:16:00+02:00", + "2026-11-22": "2026-11-22T17:15:00+02:00", + "2026-11-23": "2026-11-23T17:15:00+02:00", + "2026-11-24": "2026-11-24T17:15:00+02:00", + "2026-11-25": "2026-11-25T17:15:00+02:00", + "2026-11-26": "2026-11-26T17:15:00+02:00", + "2026-11-27": "2026-11-27T17:14:00+02:00", + "2026-11-28": "2026-11-28T17:14:00+02:00", + "2026-11-29": "2026-11-29T17:14:00+02:00", + "2026-11-30": "2026-11-30T17:14:00+02:00", + "2026-12-01": "2026-12-01T17:14:00+02:00", + "2026-12-02": "2026-12-02T17:14:00+02:00", + "2026-12-03": "2026-12-03T17:14:00+02:00", + "2026-12-04": "2026-12-04T17:14:00+02:00", + "2026-12-05": "2026-12-05T17:14:00+02:00", + "2026-12-06": "2026-12-06T17:14:00+02:00", + "2026-12-07": "2026-12-07T17:14:00+02:00", + "2026-12-08": "2026-12-08T17:15:00+02:00", + "2026-12-09": "2026-12-09T17:15:00+02:00", + "2026-12-10": "2026-12-10T17:15:00+02:00" + } + } +} diff --git a/tests/Fixtures/legacy/data/-100101/MsgCloser.txt b/tests/Fixtures/legacy/data/-100101/MsgCloser.txt new file mode 100644 index 0000000..988b1fe --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/MsgCloser.txt @@ -0,0 +1 @@ +ืกื™ืžื•ืœืฆื™ื”: ื”ืงื‘ื•ืฆื” ื ืกื’ืจืช diff --git a/tests/Fixtures/legacy/data/-100101/MsgCloser2.txt b/tests/Fixtures/legacy/data/-100101/MsgCloser2.txt new file mode 100644 index 0000000..101b257 --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/MsgCloser2.txt @@ -0,0 +1 @@ +[{"_":"bold","offset":0,"length":8}] diff --git a/tests/Fixtures/legacy/data/-100101/MsgCloserButtons.txt b/tests/Fixtures/legacy/data/-100101/MsgCloserButtons.txt new file mode 100644 index 0000000..906992b --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/MsgCloserButtons.txt @@ -0,0 +1 @@ +ืžืงื•ืจ - https://github.com/WizardLoop/shabbat - style:bg_success - icon:123456789&& ื”ืขืชืง - copy:fixture diff --git a/tests/Fixtures/legacy/data/-100101/MsgCloserMedia.txt b/tests/Fixtures/legacy/data/-100101/MsgCloserMedia.txt new file mode 100644 index 0000000..d5a169f --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/MsgCloserMedia.txt @@ -0,0 +1 @@ +fixture-media-id diff --git a/tests/Fixtures/legacy/data/-100101/alertshabat.txt b/tests/Fixtures/legacy/data/-100101/alertshabat.txt new file mode 100644 index 0000000..0217ac9 --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/alertshabat.txt @@ -0,0 +1 @@ +on diff --git a/tests/Fixtures/legacy/data/-100101/alertshabat2.txt b/tests/Fixtures/legacy/data/-100101/alertshabat2.txt new file mode 100644 index 0000000..0217ac9 --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/alertshabat2.txt @@ -0,0 +1 @@ +on diff --git a/tests/Fixtures/legacy/data/-100101/chatb1.json b/tests/Fixtures/legacy/data/-100101/chatb1.json new file mode 100644 index 0000000..e39615f --- /dev/null +++ b/tests/Fixtures/legacy/data/-100101/chatb1.json @@ -0,0 +1 @@ +{"_":"chatBannedRights","send_polls":true,"send_messages":false,"until_date":0} diff --git a/tests/Fixtures/legacy/data/17/ent.txt b/tests/Fixtures/legacy/data/17/ent.txt new file mode 100644 index 0000000..60b0742 --- /dev/null +++ b/tests/Fixtures/legacy/data/17/ent.txt @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/legacy/data/17/groupid.txt b/tests/Fixtures/legacy/data/17/groupid.txt new file mode 100644 index 0000000..47e8b4e --- /dev/null +++ b/tests/Fixtures/legacy/data/17/groupid.txt @@ -0,0 +1 @@ +-100101 diff --git a/tests/Fixtures/legacy/data/17/txt.txt b/tests/Fixtures/legacy/data/17/txt.txt new file mode 100644 index 0000000..68b02b4 --- /dev/null +++ b/tests/Fixtures/legacy/data/17/txt.txt @@ -0,0 +1 @@ +Private fixture draft diff --git a/tests/Fixtures/legacy/data/DBgroups.txt b/tests/Fixtures/legacy/data/DBgroups.txt new file mode 100644 index 0000000..d65fb79 --- /dev/null +++ b/tests/Fixtures/legacy/data/DBgroups.txt @@ -0,0 +1,3 @@ +-100101 +-100102 +-100101 diff --git a/tests/Fixtures/legacy/state_open.txt b/tests/Fixtures/legacy/state_open.txt new file mode 100644 index 0000000..b01ba00 --- /dev/null +++ b/tests/Fixtures/legacy/state_open.txt @@ -0,0 +1 @@ +{"close_datetime":"01/05/2026 18:00","open_datetime":"02/05/2026 20:00"} diff --git a/tests/Fixtures/telegram-default-permissions.json b/tests/Fixtures/telegram-default-permissions.json new file mode 100644 index 0000000..8e64359 --- /dev/null +++ b/tests/Fixtures/telegram-default-permissions.json @@ -0,0 +1,60 @@ +{ + "provenance": { + "kind": "synthetic compatibility fixture; not a Telegram capture", + "sdk": "MadelineProto 8.7.0, Telegram layer 225", + "source": "https://github.com/tdlib/td/blob/ad2178896533ff92831accdf56ea7edd8393f2ca/td/telegram/DialogParticipant.cpp", + "notes": "Models permanent default-rights readback and granular rights emitted/read by official TDLib. No live group was contacted." + }, + "original": { + "_": "chatBannedRights", + "view_messages": false, + "send_messages": false, + "send_media": false, + "send_stickers": false, + "send_gifs": false, + "send_games": false, + "send_inline": false, + "embed_links": false, + "send_polls": true, + "change_info": false, + "invite_users": true, + "pin_messages": false, + "manage_topics": false, + "send_photos": false, + "send_videos": false, + "send_roundvideos": false, + "send_audios": false, + "send_voices": false, + "send_docs": false, + "send_plain": false, + "edit_rank": false, + "send_reactions": false, + "until_date": 2147483647 + }, + "locked_readback": { + "_": "chatBannedRights", + "view_messages": false, + "send_messages": false, + "send_media": false, + "send_stickers": true, + "send_gifs": true, + "send_games": true, + "send_inline": true, + "embed_links": true, + "send_polls": true, + "change_info": true, + "invite_users": true, + "pin_messages": true, + "manage_topics": true, + "send_photos": true, + "send_videos": true, + "send_roundvideos": true, + "send_audios": true, + "send_voices": true, + "send_docs": true, + "send_plain": true, + "edit_rank": true, + "send_reactions": true, + "until_date": 2147483647 + } +} \ No newline at end of file diff --git a/tests/FlowParentRaceTest.php b/tests/FlowParentRaceTest.php new file mode 100644 index 0000000..ca86cae --- /dev/null +++ b/tests/FlowParentRaceTest.php @@ -0,0 +1,209 @@ +migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-14T12:00:00Z')); + $this->settings = new SettingsRepository($db, $this->clock); + $this->flows = new FlowTokenRepository($db, $this->clock); + $this->settings->ensureGroup('-1001', 'First group'); + $this->settings->ensureGroup('-1002', 'Second group'); + } + + private function parent(string $user = '1', string $scope = 'group', ?string $group = '-1001'): string + { + return $this->flows->create($user, $scope, $group, 1, 'location_input', 600); + } + + private function save(mixed $parent): string + { + return $this->flows->create('1', 'group', '-1001', 1, 'save', 3600, ['source_input_token' => $parent]); + } + + public static function promptChanges(): array + { + return [['replacement'], ['cancellation'], ['expiry']]; + } + + #[DataProvider('promptChanges')] + public function testParentIsRevalidatedAfterAuthorizationAwaitBeforeAnyWrite(string $change): void + { + $parent = $this->parent(); + $save = $this->save($parent); + $error = null; + $saved = null; + $mutations = 0; + $authorizationCalls = 0; + $fiber = new Fiber(function () use ( + $save, + &$error, + &$saved, + &$mutations, + &$authorizationCalls + ): void { + try { + $saved = $this->flows->confirmAndApply( + $save, + '1', + 'save', + static function (string $group, string $user) use (&$authorizationCalls): bool { + self::assertSame('-1001', $group); + self::assertSame('1', $user); + $authorizationCalls++; + Fiber::suspend('authorization-await'); + return true; + }, + ['timezone_id' => 'Europe/London'], + static function (PDO $pdo) use (&$mutations): void { + $mutations++; + } + ); + } catch (StaleSettings $failure) { + $error = $failure; + } + }); + self::assertSame('authorization-await', $fiber->start()); + self::assertTrue($fiber->isSuspended()); + if ($change === 'replacement') { + $this->flows->revokeAction('1', 'location_input'); + $replacement = $this->parent(group: '-1002'); + self::assertNotNull($this->flows->peek($replacement, '1', 'location_input')); + } elseif ($change === 'cancellation') { + $this->flows->revoke($parent, '1', 'location_input'); + } else { + $this->clock->set(new DateTimeImmutable('2026-10-14T12:10:01Z')); + } + $fiber->resume(); + self::assertTrue($fiber->isTerminated()); + self::assertInstanceOf(StaleSettings::class, $error); + self::assertNull($saved); + self::assertSame(1, $authorizationCalls); + self::assertSame(0, $mutations); + self::assertSame(1, $this->settings->getGroup('-1001')['revision']); + self::assertSame('Asia/Jerusalem', $this->settings->getGroup('-1001')['timezone_id']); + self::assertSame(1, $this->settings->getGroup('-1002')['revision']); + self::assertNotNull($this->flows->peek($save, '1', 'save')); + self::assertNull($this->flows->peek($parent, '1', 'location_input')); + } + + public function testMatchingActiveParentStillAllowsExactlyOneConfirmation(): void + { + $save = $this->save($this->parent()); + $saved = $this->flows->confirm( + $save, + '1', + 'save', + static fn(string $group, string $user): bool => $group === '-1001' && $user === '1', + ['timezone_id' => 'Europe/London'] + ); + self::assertSame(2, $saved['revision']); + self::assertSame('Europe/London', $saved['timezone_id']); + self::assertNull($this->flows->peek($save, '1', 'save')); + $this->expectException(StaleSettings::class); + $this->flows->confirm($save, '1', 'save', static fn(): bool => true, ['candle_minutes' => 30]); + } + + public static function mismatchedParents(): array + { + return [['user'], ['scope'], ['group'], ['revision'], ['action'], ['null'], ['invalid']]; + } + + #[DataProvider('mismatchedParents')] + public function testParentMustMatchUserActionScopeGroupAndRevision(string $kind): void + { + $parent = match ($kind) { + 'user' => $this->parent(user: '2'), + 'scope' => $this->parent(scope: 'user', group: null), + 'group' => $this->parent(group: '-1002'), + 'revision' => $this->flows->create('1', 'group', '-1001', 2, 'location_input'), + 'action' => $this->flows->create('1', 'group', '-1001', 1, 'panel'), + 'null' => null, + 'invalid' => ['not-a-token'], + }; + $save = $this->save($parent); + try { + $this->flows->confirm($save, '1', 'save', static fn(): bool => true, ['candle_minutes' => 30]); + self::fail('A mismatched original prompt was allowed to save.'); + } catch (StaleSettings) { + self::assertSame(1, $this->settings->getGroup('-1001')['revision']); + self::assertNull($this->settings->getGroup('-1001')['candle_minutes']); + self::assertNotNull($this->flows->peek($save, '1', 'save')); + } + } + public static function exitActions(): array + { + return [['/start'], ['/groups'], ['/preferences'], ['sb:start'], ['sb:groups'], ['sb:personal']]; + } + + #[DataProvider('exitActions')] + public function testExplicitExitRevokesNaturalPromptAndPreservesIndependentLegacyFlow(string $exit): void + { + $gateway = new UiNavigationFakeGateway(); + $http = new class implements \Shabbat\Http\JsonClient { + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + throw new \RuntimeException('No network in prompt exit tests.'); + } + }; + $ui = new \Shabbat\UI\BotController( + $this->settings, + $this->flows, + new \Shabbat\Calendar\CalendarService(new \Shabbat\Calendar\HebcalProvider($http), $this->clock), + $gateway, + new \Shabbat\Location\GeoNames($http, $this->clock, ''), + $this->clock + ); + $panel = $this->flows->create('1', 'group', '-1001', 1, 'panel'); + $ui->handleCallback([ + 'user_id' => '1', 'private' => true, 'chat_id' => '1', 'message_id' => 707, + 'query_id' => 'fixture', 'data' => 'sb:lookup:' . $panel . ':coordinates', + ]); + $natural = $this->flows->latest('1', 'location_input'); + self::assertNotNull($natural); + $legacy = $this->flows->create('1', 'group', '-1002', 1, 'location'); + $save = $this->save($natural['payload']['token']); + if (str_starts_with($exit, '/')) { + self::assertTrue($ui->handleMessage(['user_id' => '1', 'private' => true, 'text' => $exit])); + } else { + self::assertTrue($ui->handleCallback([ + 'user_id' => '1', 'private' => true, 'chat_id' => '1', 'message_id' => 707, + 'query_id' => 'fixture', 'data' => $exit, + ])); + } + self::assertNull($this->flows->latest('1', 'location_input')); + self::assertNotNull($this->flows->peek($legacy, '1', 'location')); + $edits = count($gateway->edited); + $sent = count($gateway->sent); + self::assertFalse($ui->handleMessage([ + 'user_id' => '1', 'private' => true, 'text' => '51.5, -0.1 Europe/London', + ])); + self::assertCount($edits, $gateway->edited); + self::assertCount($sent, $gateway->sent); + $this->expectException(StaleSettings::class); + $this->flows->confirm($save, '1', 'save', static fn(): bool => true, ['timezone_id' => 'Europe/London']); + } +} diff --git a/tests/FormattedDateEntityTest.php b/tests/FormattedDateEntityTest.php new file mode 100644 index 0000000..a650384 --- /dev/null +++ b/tests/FormattedDateEntityTest.php @@ -0,0 +1,236 @@ +init(new TLSchema()); + } + + protected function setUp(): void + { + $property = new \ReflectionProperty(Runner::class, 'stopping'); + $this->previousStopping = $property->getValue(); + $property->setValue(null, false); + } + + protected function tearDown(): void + { + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, $this->previousStopping); + } + + public static function validFormats(): array + { + $cases = []; + for ($mask = 0; $mask < 64; $mask++) { + if (($mask & 1) !== 0 && $mask !== 1 || ($mask & 6) === 6 || ($mask & 24) === 24) { + continue; + } + $format = $mask === 1 ? 'r' : (($mask & 32) !== 0 ? 'w' : '') + . (($mask & 8) !== 0 ? 'd' : (($mask & 16) !== 0 ? 'D' : '')) + . (($mask & 2) !== 0 ? 't' : (($mask & 4) !== 0 ? 'T' : '')); + $cases['mask-' . $mask] = [$mask, $format]; + } + return $cases; + } + + private static function raw(int $mask, int $offset = 2, int $length = 8, int $date = 1792000000): array + { + return ['_' => 'messageEntityFormattedDate', 'offset' => $offset, 'length' => $length, 'date' => $date, + 'relative' => ($mask & 1) !== 0, 'short_time' => ($mask & 2) !== 0, + 'long_time' => ($mask & 4) !== 0, 'short_date' => ($mask & 8) !== 0, + 'long_date' => ($mask & 16) !== 0, 'day_of_week' => ($mask & 32) !== 0]; + } + + #[DataProvider('validFormats')] + public function testAllNineteenNativeFlagsBotApiAndLockedDecoderRoundTrip(int $mask, string $format): void + { + self::assertCount(19, self::validFormats()); + $raw = self::raw($mask); + $entity = MessageEntity::fromRawEntity($raw); + self::assertInstanceOf(FormattedDate::class, $entity); + self::assertSame($raw, $entity->toMTProto()); + self::assertSame(['type' => 'date_time', 'offset' => 2, 'length' => 8, + 'unix_time' => 1792000000, 'date_time_format' => $format], $entity->toBotAPI()); + self::assertSame($raw, MessageEntity::fromRawEntity($entity->toBotAPI())->toMTProto()); + // Genuine locked decoder; schema-assembled wire uses native primitive int encoding. + // TL::serializeParams needs a real API adapter and is deliberately not claimed here. + $descriptor = self::$tl->getConstructors()->findByPredicate('messageEntityFormattedDate'); + $wire = $descriptor['id']; + foreach ([$mask, $raw['offset'], $raw['length'], $raw['date']] as $integer) { + $wire .= self::$tl->serializeObject(['type' => 'int'], $integer, 'date_entity_fixture'); + } + $decoded = self::$tl->deserialize($wire, ['type' => 'MessageEntity', 'encrypted' => false, 'connection' => null]); + foreach ($raw as $field => $value) { + self::assertSame($value, $decoded[$field]); + } + self::assertSame($raw, MessageEntity::fromRawEntity($decoded)->toMTProto()); + } + + public static function invalidMasks(): array + { + $valid = self::validFormats(); + $cases = []; + for ($mask = 0; $mask < 64; $mask++) { + if (!isset($valid['mask-' . $mask])) { + $cases[] = [$mask]; + } + } + return $cases; + } + + #[DataProvider('invalidMasks')] + public function testEveryContradictoryNativeFlagCombinationIsRejected(int $mask): void + { + $this->expectException(\InvalidArgumentException::class); + MessageEntity::fromRawEntity(self::raw($mask)); + } + + public static function invalidNativeFields(): array + { + return [['offset', '2'], ['offset', -1], ['length', 1.0], ['length', null], ['date', '1792000000'], + ['date', 1792000000.0], ['date', -1], ['date', 2147483648], ['date', true], + ['relative', 1], ['short_time', 'true'], ['long_date', null]]; + } + + #[DataProvider('invalidNativeFields')] + public function testNativeFieldsAreNotSilentlyCoerced(string $field, mixed $value): void + { + $raw = self::raw(0); + $raw[$field] = $value; + $this->expectException(\InvalidArgumentException::class); + MessageEntity::fromRawEntity($raw); + } + + public static function invalidBotApiFields(): array + { + return [['unix_time', '1792000000'], ['unix_time', 1792000000.5], ['offset', '2'], ['length', false], + ['date_time_format', 'rw'], ['date_time_format', 'td'], ['date_time_format', 'dD'], + ['date_time_format', 'tT'], ['date_time_format', 'W'], ['date_time_format', null], ['date_time_format', []]]; + } + + #[DataProvider('invalidBotApiFields')] + public function testBotApiFieldsAndFormatGrammarAreStrict(string $field, mixed $value): void + { + $raw = ['type' => 'date_time', 'offset' => 2, 'length' => 8, 'unix_time' => 1792000000, 'date_time_format' => '']; + $raw[$field] = $value; + $this->expectException(\InvalidArgumentException::class); + MessageEntity::fromRawEntity($raw); + } + + public function testOmittedFlagsEmptyFormatEpochAndReadonlyUtf16Fields(): void + { + $raw = ['_' => 'messageEntityFormattedDate', 'offset' => 2, 'length' => 4, 'date' => 0]; + $entity = MessageEntity::fromRawEntity($raw); + self::assertSame(self::raw(0, 2, 4, 0), $entity->toMTProto()); + self::assertSame('', $entity->toBotAPI()['date_time_format']); + $bot = ['type' => 'date_time', 'offset' => 2, 'length' => 4, 'unix_time' => 0]; + self::assertSame(self::raw(0, 2, 4, 0), MessageEntity::fromRawEntity($bot)->toMTProto()); + $reflection = new \ReflectionObject($entity); + foreach (['offset', 'length', 'date', 'relative', 'shortTime', 'longTime', 'shortDate', 'longDate', 'dayOfWeek'] as $field) { + self::assertTrue($reflection->getProperty($field)->isReadOnly()); + } + self::assertSame(2, $entity->offset); + } + + public function testActualStrToolsPreservesTextWithoutInventingDateHtmlTags(): void + { + foreach ([false, true] as $allowTags) { + self::assertSame('๐Ÿ˜€date<>', StrTools::entitiesToHtml('๐Ÿ˜€date<>', [self::raw(50, 2, 4)], $allowTags)); + self::assertSame('๐Ÿ˜€date<>', StrTools::entitiesToHtml('๐Ÿ˜€date<>', [ + self::raw(50, 2, 4), ['_' => 'messageEntityBold', 'offset' => 2, 'length' => 4], + ], $allowTags)); + } + } + + public function testUnknownNativeEntityStillFailsAtTheOfficialFactory(): void + { + $this->expectException(\AssertionError::class); + $this->expectExceptionMessage('Unknown entity type: messageEntityDeliberatelyUnknown'); + MessageEntity::fromRawEntity(['_' => 'messageEntityDeliberatelyUnknown', 'offset' => 0, 'length' => 1]); + } + + public function testActualTemplateCaptureStorageUtf16ExpansionAndNativeSendRetainDateFields(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipantAdmin']]; + $gateway = new MadelineGateway($transport); + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-14T12:00:00Z')); + $http = new RuntimeFakeJson(); + $app = new Application($gateway, $clock, $db, new CalendarService(new HebcalProvider($http), $clock)); + $app->settings->ensureGroup('-1001', 'Date Entity Fixture'); + $editor = new TemplateEditor($app); + self::assertTrue($editor->callback(['user_id' => '1', 'private' => true, 'query_id' => '42', 'message_id' => 900, 'data' => 'v3:templates:-1001'])); + $messages = array_values(array_filter($transport->calls, static fn (array $call): bool => $call[0] === 'messages.editMessage' + && isset($call[1]['reply_markup']['rows'][0]['buttons'][0]['data']))); + self::assertNotEmpty($messages); + self::assertSame('1', $messages[array_key_last($messages)][1]['peer']); + self::assertSame(900, $messages[array_key_last($messages)][1]['id']); + $token = substr($messages[array_key_last($messages)][1]['reply_markup']['rows'][0]['buttons'][0]['data'], 9); + $prefix = '/template ' . $token . "\n"; + $prefixLength = intdiv(strlen(mb_convert_encoding($prefix, 'UTF-16LE', 'UTF-8')), 2); + $captured = MessageEntity::fromRawEntity(self::raw(50, $prefixLength + 2)); + self::assertTrue($editor->message(['user_id' => '1', 'private' => true, + 'text' => $prefix . '๐Ÿ˜€{{date}}', 'entities' => [$captured->toMTProto()]])); + $stored = $app->templates->get('-1001', 'close'); + self::assertNotNull($stored); + self::assertSame([self::raw(50)], $stored['entities']); + $rendered = (new TemplateRenderer())->renderPayload($stored, ['date' => '2026-10-14']); + self::assertSame('๐Ÿ˜€2026-10-14', $rendered['text']); + self::assertSame([self::raw(50, 2, 10)], $rendered['entities']); + $before = count($transport->calls); + $gateway->send('-1001', $rendered, '78901234'); + self::assertCount($before + 1, $transport->calls); + $sent = $transport->calls[array_key_last($transport->calls)]; + self::assertSame('messages.sendMessage', $sent[0]); + self::assertSame([self::raw(50, 2, 10)], $sent[1]['entities']); + self::assertArrayNotHasKey('parse_mode', $sent[1]); + self::assertSame(78901234, $sent[1]['random_id']); + // Reopen the saved template and preview by editing the same private bot panel. + self::assertTrue($editor->callback(['user_id' => '1', 'private' => true, 'query_id' => '43', 'message_id' => 900, 'data' => 'v3:templates:-1001:close'])); + $panels = array_values(array_filter($transport->calls, static fn(array $call): bool => $call[0] === 'messages.editMessage' + && isset($call[1]['reply_markup']['rows'][0]['buttons'][0]['data']))); + $previewToken = substr($panels[array_key_last($panels)][1]['reply_markup']['rows'][0]['buttons'][0]['data'], 9); + $newSendsBefore = count(array_filter($transport->calls, static fn(array $call): bool => in_array($call[0], ['messages.sendMessage', 'messages.sendMedia'], true))); + self::assertTrue($editor->callback(['user_id' => '1', 'private' => true, 'query_id' => '44', 'message_id' => 900, 'data' => 'tpl:preview:' . $previewToken])); + $edits = array_values(array_filter($transport->calls, static fn(array $call): bool => $call[0] === 'messages.editMessage')); + $preview = $edits[array_key_last($edits)][1]; + self::assertSame('1', $preview['peer']); + self::assertSame(900, $preview['id']); + self::assertSame('๐Ÿ˜€2026-10-14', $preview['message']); + self::assertSame([self::raw(50, 2, 10)], $preview['entities']); + self::assertArrayNotHasKey('parse_mode', $preview); + self::assertArrayNotHasKey('random_id', $preview); + self::assertSame($newSendsBefore, count(array_filter($transport->calls, static fn(array $call): bool => in_array($call[0], ['messages.sendMessage', 'messages.sendMedia'], true)))); + } +} diff --git a/tests/FormattedDatePatchTest.php b/tests/FormattedDatePatchTest.php new file mode 100644 index 0000000..9d76e24 --- /dev/null +++ b/tests/FormattedDatePatchTest.php @@ -0,0 +1,242 @@ +fixture = sys_get_temp_dir() . '/shabbat-date-patch-' . bin2hex(random_bytes(8)); + $this->vendor = $this->fixture . '/vendor'; + $this->patches = $this->fixture . '/patches'; + $this->manifest = json_decode((string) file_get_contents($root . '/patches/madelineproto-formatted-date/manifest.json'), true, flags: JSON_THROW_ON_ERROR); + $this->factory = $this->vendor . '/danog/madelineproto/' . $this->manifest['target']; + $this->class = dirname($this->factory) . '/FormattedDate.php'; + mkdir(dirname($this->factory), 0700, true); + mkdir($this->vendor . '/composer', 0700); + mkdir($this->patches, 0700); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/patches/madelineproto-formatted-date', FilesystemIterator::SKIP_DOTS)) as $file) { + if (!$file->isFile()) { + continue; + } + $relative = substr($file->getPathname(), strlen($root . '/patches/madelineproto-formatted-date') + 1); + $target = $this->patches . '/' . $relative; + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0700, true); + } + copy($file->getPathname(), $target); + } + copy($this->patches . '/baseline/MessageEntity.php', $this->factory); + copy($this->patches . '/baseline/TL_telegram_v225.tl', $this->vendor . '/danog/madelineproto/src/TL_telegram_v225.tl'); + $this->installed(); + } + + protected function tearDown(): void + { + $resolved = realpath($this->fixture); + if ($resolved === false || !str_starts_with($resolved, realpath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . 'shabbat-date-patch-')) { + throw new RuntimeException('Date patch fixture cleanup escaped its private directory.'); + } + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($resolved, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isLink() || !$file->isDir() ? unlink($file->getPathname()) : rmdir($file->getPathname()); + } + rmdir($resolved); + } + + private function installed(string $version = '8.7.0', string $commit = '7b99dd0f2d964a492c377912ffce9f5ce61aa9ed'): void + { + file_put_contents($this->vendor . '/composer/installed.json', json_encode(['packages' => [[ + 'name' => 'danog/madelineproto', 'version' => $version, 'source' => ['reference' => $commit], + ]]], JSON_THROW_ON_ERROR)); + } + + private function manager(): PatchManager + { + return new PatchManager(dirname(__DIR__), $this->vendor, $this->patches . '/manifest.json', $this->fixture . '/writer.lock'); + } + + private function refusal(): RuntimeException + { + $error = null; + try { + $this->manager()->process(true); + } catch (RuntimeException $caught) { + $error = $caught; + } + self::assertInstanceOf(RuntimeException::class, $error); + self::assertNotSame('', $error->getMessage()); + return $error; + } + + public function testPristineInstallRepeatAndVerificationCoverTheWholeSet(): void + { + self::assertSame('patched', $this->manager()->process(true)); + self::assertSame($this->manifest['approved'][0]['patched_sha256'], hash_file('sha256', $this->factory)); + self::assertSame($this->manifest['approved'][0]['added_files'][0]['patched_sha256'], hash_file('sha256', $this->class)); + $factory = file_get_contents($this->factory); + $class = file_get_contents($this->class); + self::assertSame('already-patched', $this->manager()->process(true)); + self::assertSame('already-patched', $this->manager()->process(false)); + self::assertSame($factory, file_get_contents($this->factory)); + self::assertSame($class, file_get_contents($this->class)); + } + + public function testVerificationRejectsTheEntirePristineSetWithoutMutation(): void + { + $before = file_get_contents($this->factory); + $error = null; + try { + $this->manager()->process(false); + } catch (RuntimeException $caught) { + $error = $caught; + } + self::assertInstanceOf(RuntimeException::class, $error); + self::assertSame($before, file_get_contents($this->factory)); + self::assertFileDoesNotExist($this->class); + } + + public function testUnknownVersionAndCommitNeverWrite(): void + { + foreach ([['8.6.5', '7b99dd0f2d964a492c377912ffce9f5ce61aa9ed'], ['8.7.0', str_repeat('0', 40)]] as [$version, $commit]) { + $this->installed($version, $commit); + self::assertStringContainsString('Unapproved', $this->refusal()->getMessage()); + self::assertSame(file_get_contents($this->patches . '/baseline/MessageEntity.php'), file_get_contents($this->factory)); + self::assertFileDoesNotExist($this->class); + } + } + + public function testClassOnlyAndFactoryOnlyStatesAreNotRepaired(): void + { + copy($this->patches . '/reviewed/FormattedDate.php', $this->class); + self::assertStringContainsString('Partial', $this->refusal()->getMessage()); + self::assertSame(file_get_contents($this->patches . '/baseline/MessageEntity.php'), file_get_contents($this->factory)); + unlink($this->class); + copy($this->patches . '/reviewed/MessageEntity.php', $this->factory); + self::assertStringContainsString('Partial', $this->refusal()->getMessage()); + self::assertFileDoesNotExist($this->class); + } + + public function testUnknownClassCannotBecomeUpstreamSupported(): void + { + copy($this->patches . '/reviewed/MessageEntity.php', $this->factory); + file_put_contents($this->class, "refusal()->getMessage()); + } + + public function testUnknownFactoryAndSchemaPreventAddedFileCreation(): void + { + file_put_contents($this->factory, "\n", FILE_APPEND); + self::assertStringContainsString('Unknown', $this->refusal()->getMessage()); + self::assertFileDoesNotExist($this->class); + copy($this->patches . '/baseline/MessageEntity.php', $this->factory); + file_put_contents($this->vendor . '/danog/madelineproto/src/TL_telegram_v225.tl', "\n", FILE_APPEND); + self::assertStringContainsString('prerequisite', $this->refusal()->getMessage()); + self::assertFileDoesNotExist($this->class); + } + + public static function artifacts(): array + { + return [['baseline/MessageEntity.php'], ['baseline/TL_telegram_v225.tl'], ['reviewed/MessageEntity.php'], ['reviewed/FormattedDate.php'], ['formatted-date-entity.patch'], ['upstream-LICENSE']]; + } + + #[DataProvider('artifacts')] + public function testEveryCorrespondingArtifactIsVerifiedEvenWhenAlreadyPatched(string $artifact): void + { + $this->manager()->process(true); + file_put_contents($this->patches . '/' . $artifact, "\n", FILE_APPEND); + self::assertStringContainsString('hash mismatch', $this->refusal()->getMessage()); + } + + public function testActiveWriterPreventsBothFileMutations(): void + { + $writer = SingleInstance::acquire($this->fixture . '/writer.lock'); + try { + self::assertStringContainsString('Another bot or maintenance', $this->refusal()->getMessage()); + self::assertFileDoesNotExist($this->class); + self::assertSame(file_get_contents($this->patches . '/baseline/MessageEntity.php'), file_get_contents($this->factory)); + } finally { + $writer->release(); + } + } + + public function testCompleteReadOnlyVerificationDoesNotRequireMutatingWriter(): void + { + $this->manager()->process(true); + $writer = SingleInstance::acquire($this->fixture . '/writer.lock'); + try { + self::assertSame('already-patched', $this->manager()->process(false)); + self::assertSame('already-patched', $this->manager()->process(true)); + } finally { + $writer->release(); + } + } + + public function testAddedDirectoryIsRefused(): void + { + mkdir($this->class); + self::assertStringContainsString('regular source file', $this->refusal()->getMessage()); + } + + private function createSymlink(string $target, string $link): bool + { + // A fresh PHP child has no SDK error handler; unsupported host privileges + // return an explicit skip status instead of changing global error handlers. + $process = proc_open( + [PHP_BINARY, '-r', 'exit(@symlink($argv[1], $argv[2]) ? 0 : 77);', '--', $target, $link], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (!is_resource($process)) { + throw new RuntimeException('Unable to start the isolated symlink capability fixture.'); + } + fclose($pipes[0]); + stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + if (!in_array($status, [0, 77], true)) { + throw new RuntimeException('Symlink capability fixture failed: ' . $errors); + } + return $status === 0; + } + public function testExistingAndAddedSymlinksCannotBeVerifiedAsInstalledSources(): void + { + $outside = $this->fixture . '/outside.php'; + copy($this->patches . '/baseline/MessageEntity.php', $outside); + unlink($this->factory); + if (!$this->createSymlink($outside, $this->factory)) { + copy($outside, $this->factory); + self::markTestSkipped('This host does not permit creation of file symlink fixtures.'); + } + self::assertStringContainsString('regular package directory', $this->refusal()->getMessage()); + unlink($this->factory); + copy($this->patches . '/reviewed/MessageEntity.php', $this->factory); + copy($this->patches . '/reviewed/FormattedDate.php', $outside); + self::assertTrue($this->createSymlink($outside, $this->class)); + self::assertStringContainsString('regular package directory', $this->refusal()->getMessage()); + } +} diff --git a/tests/FreshPeerFixture.php b/tests/FreshPeerFixture.php new file mode 100644 index 0000000..833443f --- /dev/null +++ b/tests/FreshPeerFixture.php @@ -0,0 +1,38 @@ + $basic ? 'chat' : 'channel', 'id' => (int)$peer, 'title' => 'Offline fixture', + 'photo' => ['_' => 'chatPhotoEmpty'], 'date' => 1]; + if ($basic) { + $chat += ['participants_count' => 3, 'version' => 1]; + } else { + $chat += ['access_hash' => 42, 'megagroup' => $type === 'supergroup']; + } + if ($rights !== null) { + $chat['default_banned_rights'] = $rights; + } + $full = ['_' => $basic ? 'chatFull' : 'channelFull', 'id' => (int)$peer, + 'about' => 'Offline fixture', 'notify_settings' => ['_' => 'peerNotifySettings']]; + if ($basic) { + $full['participants'] = ['_' => 'chatParticipants', 'chat_id' => (int)$peer, 'participants' => [], 'version' => 1]; + } else { + $full += ['read_inbox_max_id' => 0, 'read_outbox_max_id' => 0, 'unread_count' => 0, 'pts' => 1, + 'chat_photo' => ['_' => 'photoEmpty', 'id' => 0], 'bot_info' => []]; + } + return ['Chat' => $chat, 'full' => $full, 'type' => $type, 'bot_api_id' => (int)$peer, + FreshPeerReader::EVIDENCE_KEY => ['_' => 'messages.chatFull', 'full_chat' => $full, 'chats' => [$chat], 'users' => []]]; + } +} diff --git a/tests/FreshPeerPatchTest.php b/tests/FreshPeerPatchTest.php new file mode 100644 index 0000000..fe6e09b --- /dev/null +++ b/tests/FreshPeerPatchTest.php @@ -0,0 +1,147 @@ +root = sys_get_temp_dir() . '/shabbat-fresh-peer-patch-' . bin2hex(random_bytes(8)); + $this->vendor = $this->root . '/vendor'; + $this->target = $this->vendor . '/danog/madelineproto/src/MTProtoTools/PeerHandler.php'; + $this->manifest = $this->root . '/patches/madelineproto-fresh-peer/manifest.json'; + $this->writer = $this->root . '/application.writer.lock'; + mkdir(dirname($this->target), 0700, true); + mkdir($this->vendor . '/composer', 0700, true); + mkdir(dirname($this->manifest) . '/baseline', 0700, true); + $source = dirname(__DIR__) . '/patches/madelineproto-fresh-peer/'; + foreach (['manifest.json', 'fresh-full-peer-response.patch', 'baseline/PeerHandler.php'] as $file) { + copy($source . $file, dirname($this->manifest) . '/' . $file); + } + copy($source . 'baseline/PeerHandler.php', $this->target); + $this->installed('8.7.0', self::COMMIT); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->root); + } + + private function removeDirectory(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $child = $path . '/' . $entry; + if (is_dir($child) && !is_link($child)) { + $this->removeDirectory($child); + } else { + unlink($child); + } + } + rmdir($path); + } + + private function installed(string $version, string $commit): void + { + file_put_contents($this->vendor . '/composer/installed.json', json_encode(['packages' => [ + ['name' => 'danog/madelineproto', 'version' => $version, 'source' => ['reference' => $commit]], + ]], JSON_THROW_ON_ERROR)); + } + + private function installer(): PatchManager + { + return new PatchManager($this->root, $this->vendor, $this->manifest, $this->writer); + } + + private function rejected(callable $operation): void + { + $error = null; + try { + $operation(); + } catch (RuntimeException $caught) { + $error = $caught; + } + self::assertInstanceOf(RuntimeException::class, $error); + } + + public function testCleanRepeatedAndVerifyOnlyInstallationMatchReviewedSource(): void + { + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame('patched', $this->installer()->process(true)); + $approved = json_decode(file_get_contents($this->manifest), true, 64, JSON_THROW_ON_ERROR)['approved'][0]; + self::assertSame($approved['patched_sha256'], hash_file('sha256', $this->target)); + self::assertSame(file_get_contents(dirname(__DIR__) . '/patches/madelineproto-fresh-peer/reviewed/PeerHandler.php'), file_get_contents($this->target)); + $before = file_get_contents($this->target); + self::assertSame('already-patched', $this->installer()->process(true)); + self::assertSame('already-patched', $this->installer()->process(false)); + self::assertSame($before, file_get_contents($this->target)); + } + + public function testUnapprovedVersionOrCommitDoNotMutateSource(): void + { + $before = file_get_contents($this->target); + foreach ([['8.6.5', self::COMMIT], ['8.7.0', str_repeat('0', 40)]] as [$version, $commit]) { + $this->installed($version, $commit); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } + } + + public function testUnknownAndPartiallyCorrectedSourceFailClosed(): void + { + $baseline = file_get_contents($this->target); + $partial = str_replace('public function getFullInfo(mixed $id): array', 'public function getFullInfo(mixed $id): array /* partial */', $baseline); + foreach ([$baseline . "\n// unreviewed bytes\n", $partial] as $changed) { + file_put_contents($this->target, $changed); + $this->rejected(fn (): string => $this->installer()->process(true)); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame($changed, file_get_contents($this->target)); + } + } + + public function testBaselineAndDiffHashesAreRequiredEvenForAlreadyPatchedTarget(): void + { + self::assertSame('patched', $this->installer()->process(true)); + $before = file_get_contents($this->target); + foreach (['baseline/PeerHandler.php', 'fresh-full-peer-response.patch'] as $artifact) { + $path = dirname($this->manifest) . '/' . $artifact; + $original = file_get_contents($path); + file_put_contents($path, $original . "\n"); + $this->rejected(fn (): string => $this->installer()->process(false)); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + file_put_contents($path, $original); + } + } + + public function testActiveWriterPreventsSourceMutation(): void + { + $before = file_get_contents($this->target); + $writer = SingleInstance::acquire($this->writer); + try { + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } finally { + $writer->release(); + } + self::assertSame('patched', $this->installer()->process(true)); + } +} diff --git a/tests/FreshPeerReaderTest.php b/tests/FreshPeerReaderTest.php new file mode 100644 index 0000000..e564b35 --- /dev/null +++ b/tests/FreshPeerReaderTest.php @@ -0,0 +1,272 @@ +cached['default_banned_rights'] = ['_' => 'chatBannedRights', 'until_date' => 2147483647, 'edit_rank' => true]; + $harness->peerDatabase->full['full']['about'] = 'stale full cache'; + $harness->keepStaleCache = true; + $fresh = FreshPeerReader::read($harness->peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertTrue($fresh['Chat']['default_banned_rights']['send_photos']); + self::assertTrue($fresh['Chat']['default_banned_rights']['invite_users']); + self::assertSame('Offline fixture', $fresh['full']['about']); + self::assertSame(['channels.getFullChannel'], $harness->calls); + self::assertArrayNotHasKey('send_photos', $harness->cached['default_banned_rights']); + } + + public function testActualPinnedGetInfoCanReturnMinAfterItAlreadyRefreshedItsCache(): void + { + $harness = new NativePeerReadHarness(); + $harness->cached['min'] = true; + unset($harness->cached['default_banned_rights']); + $oldResult = $harness->getInfo($harness->peer); + self::assertTrue($oldResult['Chat']['min']); + self::assertArrayNotHasKey('default_banned_rights', $oldResult['Chat']); + self::assertTrue($harness->cached['default_banned_rights']['invite_users']); + self::assertSame(['channels.getChannels'], $harness->calls); + // Restart at the same minimal state, now exercise the production reader. + $harness->cached = $oldResult['Chat']; + $fresh = FreshPeerReader::read($harness->peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertFalse($fresh['Chat']['min'] ?? false); + self::assertTrue($fresh['Chat']['default_banned_rights']['invite_users']); + self::assertSame(['channels.getChannels', 'channels.getChannels', 'channels.getFullChannel'], $harness->calls); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('peerTypes')] + public function testFreshReadsBypassSameSecondAndFutureCacheTimes(string $peer, string $type, string $rpc): void + { + $harness = new NativePeerReadHarness($peer, $type); + foreach ([time(), time() + 86400] as $inserted) { + $harness->peerDatabase->full['inserted'] = $inserted; + $fresh = FreshPeerReader::read($peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertSame($peer, (string)$fresh['Chat']['id']); + self::assertSame($harness->server['Chat']['default_banned_rights'], $fresh['Chat']['default_banned_rights']); + } + self::assertSame([$rpc, $rpc], $harness->calls); + } + + public static function peerTypes(): array + { + return [['-123', 'chat', 'messages.getFullChat'], ['-1000000000123', 'supergroup', 'channels.getFullChannel']]; + } + + public function testNormalPublicHelperCacheBehaviorAndSignatureArePreserved(): void + { + $harness = new NativePeerReadHarness(); + $harness->cacheLifetime = 3600; + $cached = $harness->getFullInfo($harness->peer); + self::assertSame([], $harness->calls); + self::assertArrayNotHasKey(FreshPeerReader::EVIDENCE_KEY, $cached); + self::assertCount(1, (new \ReflectionMethod(\danog\MadelineProto\MTProto::class, 'getFullInfo'))->getParameters()); + self::assertCount(1, (new \ReflectionMethod(\danog\MadelineProto\SimpleEventHandler::class, 'getFullInfo'))->getParameters()); + } + + public function testRemovedDefaultsInCompleteReplyDoNotResurrectCachedRestrictions(): void + { + $harness = new NativePeerReadHarness(); + $harness->reply = FreshPeerFixture::info($harness->peer, null)[FreshPeerReader::EVIDENCE_KEY]; + $harness->keepStaleCache = true; + $fresh = FreshPeerReader::read($harness->peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertArrayNotHasKey('default_banned_rights', $fresh['Chat']); + self::assertTrue($harness->cached['default_banned_rights']['invite_users']); + } + + public static function invalidEvidence(): iterable + { + $base = FreshPeerFixture::info('-1000000000123', ['_' => 'chatBannedRights', 'until_date' => 0]); + $key = FreshPeerReader::EVIDENCE_KEY; + $withoutMarker = $base; + unset($withoutMarker[$key]); + yield 'no per-call marker' => [$withoutMarker]; + foreach (['min' => true, 'deactivated' => true, 'left' => true, 'monoforum' => true, 'migrated_to' => ['_' => 'inputChannel']] as $name => $value) { + $candidate = $base; + $candidate[$key]['chats'][0][$name] = $value; + yield $name => [$candidate]; + } + foreach (['channelForbidden', 'chat', 'chatForbidden'] as $constructor) { + $candidate = $base; + $candidate[$key]['chats'][0]['_'] = $constructor; + yield $constructor => [$candidate]; + } + foreach (['title', 'date', 'photo'] as $name) { + $candidate = $base; + unset($candidate[$key]['chats'][0][$name]); + unset($candidate[$key]['chats'][0]['default_banned_rights']); + yield 'missing Chat ' . $name => [$candidate]; + } + foreach (['users', 'full_chat', 'chats'] as $name) { + $candidate = $base; + unset($candidate[$key][$name]); + yield 'missing reply ' . $name => [$candidate]; + } + $candidate = $base; + $candidate[$key]['chats'][] = $candidate[$key]['chats'][0]; + yield 'duplicate target' => [$candidate]; + $candidate = $base; + $candidate[$key]['chats'][0]['id'] = -1000000000124; + yield 'wrong target' => [$candidate]; + $candidate = $base; + $candidate[$key]['full_chat']['id'] = -1000000000124; + yield 'wrong full target' => [$candidate]; + $candidate = $base; + $candidate[$key]['full_chat']['_'] = 'chatFull'; + yield 'wrong full type' => [$candidate]; + foreach ([null, [], ['_' => 'chatBannedRights'], ['_' => 'chatBannedRights', 'until_date' => '0'], ['_' => 'chatBannedRights', 'until_date' => 0, 'send_photos' => 'true']] as $rights) { + $candidate = $base; + $candidate[$key]['chats'][0]['default_banned_rights'] = $rights; + yield 'malformed rights ' . json_encode($rights) => [$candidate]; + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidEvidence')] + public function testUnverifiableReplyFailsWithoutUsingOtherwiseCompleteCaches(array $reply): void + { + $this->expectException(\RuntimeException::class); + FreshPeerReader::read('-1000000000123', Runner::settings(), static fn (): array => $reply); + } + + public function testIntentionalCancellationAndTimeoutAreNotConvertedIntoEmptyDefaults(): void + { + foreach ([new \Amp\CancelledException(), new \Amp\TimeoutException()] as $failure) { + $harness = new NativePeerReadHarness(); + $harness->failure = $failure; + try { + FreshPeerReader::read($harness->peer, Runner::settings(), $harness->getFullInfo(...)); + self::fail('RPC failure became a permissions snapshot'); + } catch (\Throwable $actual) { + self::assertSame($failure, $actual); + } + } + } + + public function testForumStateDoesNotRewriteTheNativeManageTopicsRestriction(): void + { + foreach ([false, true] as $forum) { + $harness = new NativePeerReadHarness(); + $harness->reply = $harness->server[FreshPeerReader::EVIDENCE_KEY]; + $harness->reply['chats'][0]['forum'] = $forum; + $harness->reply['chats'][0]['default_banned_rights']['manage_topics'] = true; + $fresh = FreshPeerReader::read($harness->peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertSame($forum, $fresh['Chat']['forum']); + self::assertTrue($fresh['Chat']['default_banned_rights']['manage_topics']); + } + } + public function testUnknownCachePolicyFailsBeforeAnyRead(): void + { + $calls = 0; + $reader = static function () use (&$calls): array { + $calls++; + return []; + }; + try { + FreshPeerReader::read('-100123', new Settings(), $reader); + self::fail('Cached settings accepted'); + } catch (\RuntimeException $error) { + self::assertStringContainsString('zero full-info cache', $error->getMessage()); + self::assertSame(0, $calls); + } + } + + public function testStartupSettingsUseVerifiedPublicRefreshPolicy(): void + { + $settings = Runner::settings(); + self::assertSame(0, $settings->getPeer()->getFullInfoCacheTime()); + self::assertSame(5, $settings->getRPC()->getFloodTimeout()); + $method = new \ReflectionMethod(\danog\MadelineProto\MTProto::class, 'getFullInfo'); + $lines = file($method->getFileName()); + $source = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); + self::assertStringContainsString('getFullInfoCacheTime()', $source); + self::assertStringContainsString("methodCallAsyncRead('channels.getFullChannel'", $source); + self::assertStringContainsString("methodCallAsyncRead('messages.getFullChat'", $source); + foreach (['getSettings', 'getFullInfo', 'getInfo'] as $name) { + self::assertTrue((new \ReflectionMethod(\danog\MadelineProto\SimpleEventHandler::class, $name))->isPublic()); + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('peerTypes')] + public function testPinnedNativeTlDecodesMatchingSignedPeerIdsAndDefaultRights(string $peer, string $type, string $rpc): void + { + $previousLogger = \danog\MadelineProto\Logger::$default; + $adapter = (new \ReflectionClass(\danog\MadelineProto\MTProto::class))->newInstanceWithoutConstructor(); + $adapter->settings = new \danog\MadelineProto\Settings(); + $adapter->logger = new \danog\MadelineProto\Logger((new \danog\MadelineProto\Settings\Logger())->setType(\danog\MadelineProto\Logger::CALLABLE_LOGGER)->setExtra(static function (): void { + })); + try { + $tl = new \danog\MadelineProto\TL\TL($adapter); + $tl->init(new \danog\MadelineProto\Settings\TLSchema()); + foreach ([null, ['_' => 'chatBannedRights', 'until_date' => 2147483647, 'invite_users' => true, 'send_photos' => true, 'edit_rank' => true]] as $rights) { + $reply = FreshPeerFixture::info($peer, $rights, $type)[FreshPeerReader::EVIDENCE_KEY]; + // Server wire IDs are positive; the SDK converts both native constructors. + $reply['full_chat']['id'] = 123; + $reply['chats'][0]['id'] = 123; + if ($type === 'chat') { + $reply['full_chat']['participants']['chat_id'] = 123; + } + $wire = $tl->serializeObject(['type' => 'messages.ChatFull'], $reply, 'fresh-peer-fixture'); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $wire); + rewind($stream); + $parsed = $tl->deserialize($stream, ['type' => 'messages.ChatFull', 'connection' => null, 'encrypted' => false]); + fclose($stream); + $fresh = FreshPeerReader::verified($peer, [FreshPeerReader::EVIDENCE_KEY => $parsed]); + self::assertSame($peer, (string)$fresh['Chat']['id']); + self::assertSame($peer, (string)$fresh['full']['id']); + if ($rights === null) { + self::assertArrayNotHasKey('default_banned_rights', $fresh['Chat']); + } else { + self::assertTrue($fresh['Chat']['default_banned_rights']['invite_users']); + self::assertTrue($fresh['Chat']['default_banned_rights']['send_photos']); + self::assertTrue($fresh['Chat']['default_banned_rights']['edit_rank']); + self::assertFalse($fresh['Chat']['default_banned_rights']['manage_topics']); + self::assertSame(2147483647, $fresh['Chat']['default_banned_rights']['until_date']); + } + } + } finally { + $tl = null; + $adapter = null; + \danog\MadelineProto\Logger::$default = $previousLogger; + } + } + public function testEventLoopBindingRemainsEffectiveAfterHandlerAttachment(): void + { + $previous = \Revolt\EventLoop::getErrorHandler(); + $handler = (new \ReflectionClass(ReplyScopeFixture::class))->newInstanceWithoutConstructor(); + try { + \Revolt\EventLoop::setErrorHandler(static function (): void { + }); + Runner::attachHandler($handler); + $callback = \Revolt\EventLoop::getErrorHandler(); + self::assertNotNull($callback); + $error = new \RuntimeException('fixture unknown error'); + try { + $callback($error); + self::fail('Unknown event-loop error swallowed'); + } catch (\RuntimeException $actual) { + self::assertSame($error, $actual); + } + } finally { + \Revolt\EventLoop::setErrorHandler($previous); + $property = new \ReflectionProperty(Runner::class, 'handler'); + $property->setValue(null, null); + } + } +} diff --git a/tests/FullCalendarGateway.php b/tests/FullCalendarGateway.php new file mode 100644 index 0000000..27471d6 --- /dev/null +++ b/tests/FullCalendarGateway.php @@ -0,0 +1,53 @@ +private[] = ['user' => $userId,'text' => $text,'markup' => $markup]; + } + public function editPrivate(string $userId, int $messageId, string $text, array $markup = []): void + { + $this->sendPrivate($userId, $text, $markup); + } + public function answerCallback(string $queryId, string $text): void + { + } + public function answerInline(string $queryId, array $results, array $options = []): void + { + } + public function botUsername(): string + { + return 'OfflineCalendarBot'; + } + public function getPermissions(string $groupId): array + { + return $this->rights[$groupId] ??= ['_' => 'chatBannedRights','until_date' => 0,'send_messages' => false,'send_media' => false]; + } + public function setPermissions(string $groupId, array $rights): void + { + $this->rights[$groupId] = $rights; + $this->permissionChanges++; + } + public function send(string $peer, array $payload, string $randomId): array + { + $this->sent[] = [$peer,$payload,$randomId]; + return ['_' => 'updates','updates' => []]; + } +} diff --git a/tests/FullHebcalFixtureClient.php b/tests/FullHebcalFixtureClient.php new file mode 100644 index 0000000..3b5a34a --- /dev/null +++ b/tests/FullHebcalFixtureClient.php @@ -0,0 +1,53 @@ +throwIfRequested(); + $this->calls[] = [$url,$query]; + $month = match ([$query['start'] ?? '',$query['end'] ?? '']) { + ['2026-09-23','2026-12-10'] => '10', + ['2026-03-24','2026-06-09'] => '04', + ['2026-04-23','2026-07-10'] => '05', + default => throw new \LogicException('Unexpected fixture request range.'), + }; + if (str_ends_with($url, '/zmanim')) { + if ((float)$query['latitude'] !== 31.76904 || (float)$query['longitude'] !== 35.21633 || $query['tzid'] !== 'Asia/Jerusalem') { + throw new \LogicException('Fixture coordinates/timezone must match the unmodified public response.'); + } + return self::read($month === '10' ? 'hebcal-jerusalem-default-full-solar-2026-09-23_2026-12-10.json' : 'hebcal-jerusalem-full-solar-2026-' . $month . '.json'); + } + if (!str_ends_with($url, '/hebcal')) { + throw new \LogicException('No network or extra provider is permitted in this regression.'); + } + $language = match ($query['lg']) { + 'he' => 'he','s' => 'en','ru' => 'ru','fr' => 'fr','es' => 'es','pt' => 'pt-br', + default => throw new \LogicException('Unexpected provider language.'), + }; + if ($query['i'] === 'off') { + if ($month !== '05' || $language !== 'he') { + throw new \LogicException('Missing exact diaspora fixture.'); + } + return self::read('hebcal-jerusalem-full-calendar-2026-05-diaspora.json'); + } + if ($month === '10' && $language === 'he') { + return self::read('hebcal-jerusalem-default-full-calendar-2026-09-23_2026-12-10.json'); + } + $suffix = $language === 'he' ? '' : '-' . $language; + return self::read('hebcal-jerusalem-full-calendar-2026-' . $month . $suffix . '.json'); + } +} diff --git a/tests/GatewayFixtureTransport.php b/tests/GatewayFixtureTransport.php new file mode 100644 index 0000000..602e0b6 --- /dev/null +++ b/tests/GatewayFixtureTransport.php @@ -0,0 +1,51 @@ + 777, 'username' => 'FixtureShabbatBot', 'bot' => true]; + public array $peerInfo = ['type' => 'supergroup', 'Chat' => ['id' => 123]]; + public array $mediaInfo = ['InputMedia' => ['_' => 'inputMediaPhoto', 'id' => ['_' => 'inputPhoto', 'id' => 42, 'access_hash' => 88, 'file_reference' => 'fixture']]]; + public ?Throwable $error = null; + public ?Throwable $infoError = null; + public ?\Closure $onInfo = null; + public function call(string $method, array $parameters): mixed + { + $this->calls[] = [$method, $parameters]; + if ($this->error) { + throw $this->error; + } + return $this->responses[$method] ?? ['_' => 'updates', 'updates' => []]; + } + public function info(string $peer): array + { + if ($this->infoError !== null) { + throw $this->infoError; + } + ($this->onInfo)?->__invoke(); + return $this->peerInfos[$peer] ?? $this->peerInfo; + } + public function fullInfo(string $peer): array + { + return $this->call('getFullInfo', ['id' => $peer]); + } + public function fileInfo(string $fileId): array + { + return $this->mediaInfo; + } + public function self(): array + { + return $this->selfInfo; + } +} diff --git a/tests/GatewayMaintenanceTest.php b/tests/GatewayMaintenanceTest.php new file mode 100644 index 0000000..1b1ba90 --- /dev/null +++ b/tests/GatewayMaintenanceTest.php @@ -0,0 +1,208 @@ +previousStopping = $property->getValue(); + $property->setValue(null, false); + } + + protected function tearDown(): void + { + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, $this->previousStopping); + } + + public function testSupergroupDeleteUsesNativeChannelAndIdempotentAck(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.deleteMessages'] = ['_' => 'messages.affectedMessages', 'pts' => 0, 'pts_count' => 0]; + (new MadelineGateway($transport))->deleteMessages('-100123', [11, 12, 11]); + self::assertSame([['channels.deleteMessages', ['channel' => '-100123', 'id' => [11, 12], 'floodWaitLimit' => 0]]], $transport->calls); + } + + public function testBasicChatDeleteUsesRevokeAndGlobalNativeIds(): void + { + $transport = new GatewayFixtureTransport(); + $transport->peerInfo['type'] = 'chat'; + $transport->responses['messages.deleteMessages'] = ['_' => 'messages.affectedMessages', 'pts' => 51, 'pts_count' => 2]; + (new MadelineGateway($transport))->deleteMessages('-123', [11, 12]); + self::assertSame([['messages.deleteMessages', ['revoke' => true, 'id' => [11, 12], 'floodWaitLimit' => 0]]], $transport->calls); + } + + public static function invalidIds(): array + { + return [[[0]], [[-1]], [['11']], [[11.0]], [[2147483648]], [range(1, 101)], [[2 => 11]]]; + } + + #[DataProvider('invalidIds')] + public function testInvalidIdsCannotStartDeletionRpc(array $ids): void + { + $transport = new GatewayFixtureTransport(); + try { + (new MadelineGateway($transport))->deleteMessages('-100123', $ids); + self::fail('Invalid message IDs accepted'); + } catch (\InvalidArgumentException) { + self::assertSame([], $transport->calls); + } + } + + public static function failedAcknowledgements(): array + { + return [[false], [[]], [['_' => 'updates']], [['_' => 'messages.affectedMessages', 'pts' => 1, 'pts_count' => -1]], [['_' => 'messages.affectedMessages', 'pts' => '1', 'pts_count' => 0]]]; + } + + #[DataProvider('failedAcknowledgements')] + public function testUnconfirmedDeletionFailsExplicitly(mixed $response): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.deleteMessages'] = $response; + $this->expectException(\RuntimeException::class); + (new MadelineGateway($transport))->deleteMessages('-100123', [11]); + } + + public function testUnknownTargetAndEmptyDeletionNeverMutate(): void + { + $transport = new GatewayFixtureTransport(); + $transport->peerInfo['type'] = 'user'; + $gateway = new MadelineGateway($transport); + $gateway->deleteMessages('777', []); + try { + $gateway->deleteMessages('777', [11]); + self::fail('Private peer accepted as group'); + } catch (\RuntimeException) { + self::assertSame([], $transport->calls); + } + } + + public function testShutdownDuringPeerLookupDoesNotStartDelete(): void + { + $transport = new GatewayFixtureTransport(); + $transport->onInfo = Runner::requestShutdown(...); + try { + (new MadelineGateway($transport))->deleteMessages('-100123', [11]); + self::fail('Delete started after shutdown'); + } catch (CancelledException) { + self::assertSame([], $transport->calls); + } + } + + public function testRateLimitsAndTimeoutsRetainTypedRetryDeadlines(): void + { + foreach ([new FloodPremiumWaitError('FLOOD_PREMIUM_WAIT_12', 12, 420, 'channels.deleteMessages'), new TimeoutException()] as $native) { + $transport = new GatewayFixtureTransport(); + $transport->error = $native; + try { + (new MadelineGateway($transport))->deleteMessages('-100123', [11]); + self::fail('Retry deadline lost'); + } catch (RetryableTelegramException $error) { + self::assertSame($native, $error->getPrevious()); + self::assertGreaterThanOrEqual(1, $error->retryAfterSeconds); + self::assertLessThanOrEqual(30, $error->retryAfterSeconds); + } + } + } + + public function testIntentionalCancellationPropagates(): void + { + $transport = new GatewayFixtureTransport(); + $transport->error = new CancelledException(); + $this->expectException(CancelledException::class); + (new MadelineGateway($transport))->deleteMessages('-100123', [11]); + } + + public function testSenderBotFlagUsesVerifiedUserAndUnknownNeverMeansHuman(): void + { + $transport = new GatewayFixtureTransport(); + $transport->peerInfos['888'] = ['type' => 'bot', 'User' => ['_' => 'user', 'id' => 888, 'bot' => true]]; + $transport->peerInfos['999'] = ['type' => 'user', 'User' => ['_' => 'user', 'id' => 999]]; + $gateway = new MadelineGateway($transport); + self::assertTrue($gateway->senderIsBot('-100123', '888')); + self::assertFalse($gateway->senderIsBot('-100123', '999')); + foreach ([['_' => 'user', 'id' => 888, 'min' => true], ['_' => 'userEmpty', 'id' => 888], ['_' => 'user', 'id' => 999, 'bot' => true], ['_' => 'user', 'id' => 888, 'bot' => 'true'], ['_' => 'user', 'id' => 888, 'bot' => false], ['_' => 'user', 'id' => 888, 'min' => 'false', 'bot' => true]] as $unknown) { + $transport->peerInfos['888']['User'] = $unknown; + self::assertNull($gateway->senderIsBot('-100123', '888')); + } + self::assertNull($gateway->senderIsBot('-100123', '-100456')); + self::assertNull($gateway->senderIsBot('-100123', '0')); + $transport->infoError = new \RuntimeException('Unknown peer'); + self::assertNull($gateway->senderIsBot('-100123', '888')); + } + + public function testSenderLookupQuotaAndCancellationPropagate(): void + { + $transport = new GatewayFixtureTransport(); + $transport->infoError = new FloodPremiumWaitError('FLOOD_PREMIUM_WAIT_12', 12, 420, 'contacts.resolveUsername'); + try { + (new MadelineGateway($transport))->senderIsBot('-100123', '888'); + self::fail('Sender quota guessed as human'); + } catch (RetryableTelegramException) { + self::assertSame([], $transport->calls); + } + $transport->infoError = new CancelledException(); + $this->expectException(CancelledException::class); + (new MadelineGateway($transport))->senderIsBot('-100123', '888'); + } + + public function testCurrentCapabilitiesExposeAllNativeFlagsWithoutPromoting(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['getFullInfo'] = ['type' => 'supergroup', 'Chat' => ['_' => 'channel', 'id' => 123, 'admin_rights' => ['_' => 'chatAdminRights', 'delete_messages' => true, 'ban_users' => true]]]; + $gateway = new MadelineGateway($transport); + $capabilities = $gateway->botCapabilities('-100123'); + self::assertSame(AdminRights::FLAGS, array_keys($capabilities['rights'])); + self::assertTrue($capabilities['can_delete_messages']); + self::assertTrue($capabilities['rights']['ban_users']); + self::assertFalse($capabilities['rights']['add_admins']); + $transport->responses['getFullInfo']['Chat']['admin_rights']['delete_messages'] = false; + self::assertFalse($gateway->botCanDeleteMessages('-100123')); + self::assertSame(['getFullInfo', 'getFullInfo'], array_column($transport->calls, 0)); + } + + public function testCreatorDeletionIsEffectiveWithoutInventingNativeFlags(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['getFullInfo'] = ['type' => 'supergroup', 'Chat' => ['_' => 'channel', 'id' => 123, 'creator' => true]]; + $capabilities = (new MadelineGateway($transport))->botCapabilities('-100123'); + self::assertTrue($capabilities['creator']); + self::assertTrue($capabilities['can_delete_messages']); + self::assertSame(array_fill_keys(AdminRights::FLAGS, false), $capabilities['rights']); + } + + public function testMalformedCapabilitiesFailClosed(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['getFullInfo'] = ['type' => 'supergroup', 'Chat' => ['_' => 'channel', 'id' => 999, 'admin_rights' => ['_' => 'chatAdminRights', 'delete_messages' => true]]]; + $this->expectException(\RuntimeException::class); + (new MadelineGateway($transport))->botCapabilities('-100123'); + } + + public function testSelfIdentityComesFromVerifiedSelf(): void + { + self::assertSame('777', (new MadelineGateway(new GatewayFixtureTransport()))->selfUserId()); + } +} diff --git a/tests/GeoPointPatchTest.php b/tests/GeoPointPatchTest.php new file mode 100644 index 0000000..06d6888 --- /dev/null +++ b/tests/GeoPointPatchTest.php @@ -0,0 +1,194 @@ +root = sys_get_temp_dir() . '/shabbat-geopoint-patch-' . bin2hex(random_bytes(8)); + $this->vendor = $this->root . '/vendor'; + $this->target = $this->vendor . '/danog/madelineproto/src/EventHandler/Media/GeoPoint.php'; + $this->manifest = $this->root . '/patches/madelineproto-geopoint/manifest.json'; + $this->writer = $this->root . '/application.writer.lock'; + mkdir(dirname($this->target), 0700, true); + mkdir($this->vendor . '/composer', 0700, true); + mkdir(dirname($this->manifest) . '/baseline', 0700, true); + $source = dirname(__DIR__) . '/patches/madelineproto-geopoint/'; + foreach (['manifest.json', 'geopoint-double-coordinates.patch', 'baseline/GeoPoint.php'] as $file) { + copy($source . $file, dirname($this->manifest) . '/' . $file); + } + copy($source . 'baseline/GeoPoint.php', $this->target); + $this->installed('8.7.0', self::COMMIT); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->root); + } + + private function removeDirectory(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $child = $path . '/' . $entry; + if (is_dir($child) && !is_link($child)) { + $this->removeDirectory($child); + } else { + unlink($child); + } + } + rmdir($path); + } + + private function installed(string $version, string $commit): void + { + file_put_contents($this->vendor . '/composer/installed.json', json_encode(['packages' => [ + ['name' => 'danog/madelineproto', 'version' => $version, 'source' => ['reference' => $commit]], + ]], JSON_THROW_ON_ERROR)); + } + + private function installer(): PatchManager + { + return new PatchManager($this->root, $this->vendor, $this->manifest, $this->writer); + } + + private function rejected(callable $operation): void + { + $error = null; + try { + $operation(); + } catch (RuntimeException $caught) { + $error = $caught; + } + self::assertInstanceOf(RuntimeException::class, $error); + } + + public function testCleanRepeatedAndVerifyOnlyInstallationMatchReviewedSource(): void + { + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame('patched', $this->installer()->process(true)); + $approved = json_decode(file_get_contents($this->manifest), true, 64, JSON_THROW_ON_ERROR)['approved'][0]; + self::assertSame($approved['patched_sha256'], hash_file('sha256', $this->target)); + self::assertSame(file_get_contents(dirname(__DIR__) . '/patches/madelineproto-geopoint/reviewed/GeoPoint.php'), file_get_contents($this->target)); + $before = file_get_contents($this->target); + self::assertSame('already-patched', $this->installer()->process(true)); + self::assertSame('already-patched', $this->installer()->process(false)); + self::assertSame($before, file_get_contents($this->target)); + } + + public function testUnapprovedVersionOrCommitDoNotMutateSource(): void + { + $before = file_get_contents($this->target); + foreach ([['8.6.5', self::COMMIT], ['8.7.0', str_repeat('0', 40)]] as [$version, $commit]) { + $this->installed($version, $commit); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } + } + + public function testUnknownAndPartiallyCorrectedSourceFailClosed(): void + { + $baseline = file_get_contents($this->target); + $partial = str_replace('public readonly int $long;', 'public readonly float $long;', $baseline); + foreach ([$baseline . "\n// unreviewed bytes\n", $partial] as $changed) { + file_put_contents($this->target, $changed); + $this->rejected(fn (): string => $this->installer()->process(true)); + $this->rejected(fn (): string => $this->installer()->process(false)); + self::assertSame($changed, file_get_contents($this->target)); + } + } + + public function testBaselineAndDiffHashesAreRequiredEvenForAlreadyPatchedTarget(): void + { + self::assertSame('patched', $this->installer()->process(true)); + $before = file_get_contents($this->target); + foreach (['baseline/GeoPoint.php', 'geopoint-double-coordinates.patch'] as $artifact) { + $path = dirname($this->manifest) . '/' . $artifact; + $original = file_get_contents($path); + file_put_contents($path, $original . "\n"); + $this->rejected(fn (): string => $this->installer()->process(false)); + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + file_put_contents($path, $original); + } + } + + public function testActiveWriterPreventsSourceMutation(): void + { + $before = file_get_contents($this->target); + $writer = SingleInstance::acquire($this->writer); + try { + $this->rejected(fn (): string => $this->installer()->process(true)); + self::assertSame($before, file_get_contents($this->target)); + } finally { + $writer->release(); + } + self::assertSame('patched', $this->installer()->process(true)); + } + + public function testPristineConstructorReproducesFractionalCoordinateTypeError(): void + { + $code = <<<'PHP' +require $argv[1]; +try { + new \danog\MadelineProto\EventHandler\Media\GeoPoint(['long' => 35.235, 'lat' => 31.778, 'access_hash' => 0]); +} catch (\TypeError $error) { + echo 'confirmed-type-error'; + exit(0); +} +exit(1); +PHP; + $process = proc_open([PHP_BINARY, '-r', $code, dirname($this->manifest) . '/baseline/GeoPoint.php'], [ + 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], + ], $pipes); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $error); + self::assertSame('confirmed-type-error', $output); + } + + public function testActualPatchedConstructorPreservesFiniteCoordinatesAndJsonRoundtrip(): void + { + self::assertSame('float', (string)(new ReflectionProperty(GeoPoint::class, 'long'))->getType()); + self::assertSame('float', (string)(new ReflectionProperty(GeoPoint::class, 'lat'))->getType()); + foreach ([[31.778, 35.235], [-33.8688, -151.2093], [0.0, 0.0], [90.0, -180.0], [0, 0]] as [$latitude, $longitude]) { + $geo = new GeoPoint(['long' => $longitude, 'lat' => $latitude, 'access_hash' => 123, 'accuracy_radius' => 7]); + self::assertSame((float)$latitude, $geo->lat); + self::assertSame((float)$longitude, $geo->long); + self::assertTrue(is_finite($geo->lat) && is_finite($geo->long)); + self::assertSame(123, $geo->accessHash); + self::assertSame(7, $geo->accuracyRadius); + $json = json_decode(json_encode($geo, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION), true, 32, JSON_THROW_ON_ERROR); + self::assertSame((float)$latitude, $json['lat']); + self::assertSame((float)$longitude, $json['long']); + $roundtrip = new GeoPoint(['long' => $json['long'], 'lat' => $json['lat'], 'access_hash' => $json['accessHash'], 'accuracy_radius' => $json['accuracyRadius']]); + self::assertSame($geo->jsonSerialize(), $roundtrip->jsonSerialize()); + } + self::assertNull((new GeoPoint(['long' => 0, 'lat' => 0, 'access_hash' => 0]))->accuracyRadius); + } +} diff --git a/tests/GpsBridgeFakeGateway.php b/tests/GpsBridgeFakeGateway.php new file mode 100644 index 0000000..87eee99 --- /dev/null +++ b/tests/GpsBridgeFakeGateway.php @@ -0,0 +1,49 @@ +admin && $user === '42'; + } + public function sendPrivate(string $user, string $text, array $markup = []): void + { + $this->messages[] = compact('user', 'text', 'markup'); + } + public function editPrivate(string $user, int $id, string $text, array $markup = []): void + { + $this->sendPrivate($user, $text, $markup); + } + public function answerCallback(string $id, string $text): void + { + } + public function answerInline(string $id, array $results, array $options = []): void + { + } + public function botUsername(): string + { + return 'FixtureBot'; + } + public function getPermissions(string $group): array + { + throw new \RuntimeException('Permission RPC forbidden in GPS bridge tests.'); + } + public function setPermissions(string $group, array $permissions): void + { + throw new \RuntimeException('Permission RPC forbidden in GPS bridge tests.'); + } + public function send(string $peer, array $payload, string $randomId): array + { + throw new \RuntimeException('Telegram sends forbidden in GPS bridge tests.'); + } +} diff --git a/tests/GpsBridgeTest.php b/tests/GpsBridgeTest.php new file mode 100644 index 0000000..2e56a72 --- /dev/null +++ b/tests/GpsBridgeTest.php @@ -0,0 +1,147 @@ +migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-13T12:00:00Z')); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('No provider network in native GPS tests.'); + } + }; + $this->gateway = new GpsBridgeFakeGateway(); + $this->app = new Application($this->gateway, $clock, $db, new CalendarService(new HebcalProvider($http), $clock), new GeoNames($http, $clock, '')); + $this->app->settings->ensureGroup('-1001', 'Group One'); + $this->app->settings->ensureGroup('-1002', 'Group Two'); + $this->app->settings->getUser('42'); + $this->bridge = ReplyScopeFixture::create($this->app); + } + private function arm(string $group, string $zone): string + { + $g = $this->app->settings->getGroup($group); + $parent = $this->app->flows->create('42', 'group', $group, $g['revision'], 'location', 600); + $this->app->ui->handleMessage(['user_id' => '42','private' => true,'text' => '/location ' . $parent . ' ' . $zone]); + return $this->last()['text']; + } + private function last(): array + { + return $this->gateway->messages[array_key_last($this->gateway->messages)]; + } + private function packet(int $user = 42, ?int $peer = null, float $latitude = 31.778, float $longitude = 35.235): array + { + return ['_' => 'updateNewMessage','message' => ['_' => 'message','id' => 222,'out' => false,'from_id' => $user,'peer_id' => $peer ?? self::SELF,'reply_to' => ['_' => 'messageReplyHeader','reply_to_msg_id' => 111],'media' => ['_' => 'messageMediaGeo','geo' => ['_' => 'geoPoint','lat' => $latitude,'long' => $longitude]]]]; + } + private function reply(string $text, int $user = 42): array + { + return ['_' => 'message','id' => 111,'from_id' => self::SELF,'peer_id' => $user,'out' => true,'message' => $text]; + } + private function accept(array $packet, array $reply): bool + { + return $this->bridge->rawLocation($packet, self::SELF, static fn(int $id): array=>['messages' => [$reply]]); + } + private function save(): void + { + $this->app->ui->handleCallback(['user_id' => '42','private' => true,'query_id' => 'fixture','message_id' => 123,'data' => $this->last()['markup']['rows'][0]['buttons'][0]['data']]); + } + public function testActualNativeIntegerBridgePreservesFloatCoordinatesAndRequiresPrivateConfirmation(): void + { + $prompt = $this->arm('-1001', 'Asia/Jerusalem'); + $before = $this->app->settings->getGroup('-1001'); + $personal = $this->app->settings->getUser('42'); + self::assertTrue($this->accept($this->packet(), $this->reply($prompt))); + self::assertStringContainsString('Group One', $this->last()['text']); + self::assertStringContainsString('31.778', $this->last()['text']); + self::assertStringContainsString('35.235', $this->last()['text']); + self::assertSame($before['revision'], $this->app->settings->getGroup('-1001')['revision']); + $this->save(); + $after = $this->app->settings->getGroup('-1001'); + self::assertSame(31.778, $after['latitude']); + self::assertSame(35.235, $after['longitude']); + self::assertSame('Asia/Jerusalem', $after['timezone_id']); + self::assertSame($personal['latitude'], $this->app->settings->getUser('42')['latitude']); + } + public function testTwoNativePrivatePeerShapesKeepReversedGpsFlowsBoundToTheirGroups(): void + { + $one = $this->arm('-1001', 'Asia/Jerusalem'); + $two = $this->arm('-1002', 'Europe/London'); + self::assertTrue($this->accept($this->packet(42, 42, 51.5085, -0.1257), $this->reply($two))); + self::assertStringContainsString('Group Two', $this->last()['text']); + $this->save(); + self::assertTrue($this->accept($this->packet(), $this->reply($one))); + self::assertStringContainsString('Group One', $this->last()['text']); + $this->save(); + self::assertSame(51.5085, $this->app->settings->getGroup('-1002')['latitude']); + self::assertSame('Europe/London', $this->app->settings->getGroup('-1002')['timezone_id']); + self::assertSame(31.778, $this->app->settings->getGroup('-1001')['latitude']); + } + public function testGroupsOutgoingSelfAndMismatchedRecipientsNeverLookupRepliesOrEnterGpsFlow(): void + { + $calls = 0; + $lookup = static function (int $id) use (&$calls): array { + $calls++; + return ['messages' => []]; + }; + foreach ([['peer_id' => -1001],['peer_id' => 99],['out' => true],['from_id' => self::SELF],['from_id' => 0],['from_id' => '42'],['peer_id' => ['_' => 'peerUser','user_id' => 42]],['from_id' => -1001]] as $bad) { + $packet = $this->packet(); + $packet['message'] = array_replace($packet['message'], $bad); + self::assertTrue($this->bridge->rawLocation($packet, self::SELF, $lookup)); + } + self::assertSame(0, $calls); + self::assertSame([], $this->gateway->messages); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + $ordinary = $this->packet(); + unset($ordinary['message']['media']); + self::assertFalse($this->bridge->rawLocation($ordinary, self::SELF, $lookup)); + } + public function testReplySenderRecipientIdAndForwardedPromptMustMatchThePrivateBotDialog(): void + { + $prompt = $this->arm('-1001', 'Asia/Jerusalem'); + $valid = $this->reply($prompt); + foreach ([['from_id' => 42,'fwd_from' => ['from_id' => self::SELF]],['from_id' => 999],['peer_id' => 99],['peer_id' => -1001],['peer_id' => self::SELF],['id' => 112],['message' => ['invalid']],['from_id' => ['_' => 'peerUser','user_id' => self::SELF]]] as $bad) { + self::assertTrue($this->accept($this->packet(), array_replace($valid, $bad))); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + self::assertSame([], $this->last()['markup']); + } + } + public function testValidBotReplyDoesNotBypassFlowOwnerOrFreshGroupAdminPermission(): void + { + $prompt = $this->arm('-1001', 'Asia/Jerusalem'); + self::assertTrue($this->accept($this->packet(99), $this->reply($prompt, 99))); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + self::assertSame([], $this->last()['markup']); + $this->gateway->admin = false; + self::assertTrue($this->accept($this->packet(), $this->reply($prompt))); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + self::assertSame([], $this->last()['markup']); + } + public function testNativeBotReplyAndPeerHelperRequirePositiveIntegerAuthenticatedIds(): void + { + self::assertSame(42, PrivatePeer::sender($this->packet()['message'], self::SELF)); + self::assertSame(42, PrivatePeer::sender($this->packet(42, 42)['message'], self::SELF)); + self::assertNull(PrivatePeer::sender($this->packet()['message'], 0)); + self::assertFalse(PrivatePeer::botReply($this->reply('GPS token'), self::SELF, 42, 0)); + self::assertTrue(PrivatePeer::botReply($this->reply('GPS token'), self::SELF, 42, 111)); + } +} diff --git a/tests/GroupMessageButtonsGateway.php b/tests/GroupMessageButtonsGateway.php new file mode 100644 index 0000000..762e1a5 --- /dev/null +++ b/tests/GroupMessageButtonsGateway.php @@ -0,0 +1,75 @@ + 'chatBannedRights', 'until_date' => 0, 'send_messages' => false]; + } + public function setPermissions(string $group, array $rights): void + { + throw new \RuntimeException('Integration must not mutate Telegram permissions.'); + } + public function send(string $peer, array $payload, string $randomId): array + { + $id = $this->nextId++; + $this->sent[] = compact('peer', 'payload', 'randomId', 'id'); + return $this->verified ? ['_' => 'updateShortSentMessage', 'id' => $id] : ['_' => 'updates', 'updates' => []]; + } + public function deleteMessages(string $peer, array $ids): void + { + throw new \RuntimeException('Integration must not delete Telegram messages.'); + } + public function senderIsBot(string $group, string $sender): ?bool + { + return null; + } + public function botCapabilities(string $group): array + { + return []; + } + public function botCanDeleteMessages(string $group): bool + { + return false; + } + public function selfUserId(): string + { + return '999'; + } + public function extractSentMessageIds(array $response, ?string $randomId = null, ?string $peer = null): array + { + return SentMessageIds::extract($response, $randomId, $peer); + } +} diff --git a/tests/GroupMessageButtonsIntegrationTest.php b/tests/GroupMessageButtonsIntegrationTest.php new file mode 100644 index 0000000..e84d817 --- /dev/null +++ b/tests/GroupMessageButtonsIntegrationTest.php @@ -0,0 +1,195 @@ +setValue(null, false); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->gateway = new GroupMessageButtonsGateway(); + $http = new GroupMessageFixtureClient(); + $calendar = new CalendarService(new HebcalProvider($http), $this->clock); + $this->app = new Application($this->gateway, $this->clock, $this->db, $calendar, new GeoNames($http, $this->clock, '')); + $this->app->settings->ensureGroup('-1001', 'Worker integration', ['tefillin_enabled' => true, 'zmanim_enabled' => true, 'daf_enabled' => true, 'times_alert_enabled' => true]); + $this->jobs = new JobRepository($this->db, $this->clock); + $this->worker = (new ReflectionProperty(Application::class, 'worker'))->getValue($this->app); + } + protected function tearDown(): void + { + (new ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function deliver(string $kind): array + { + $g = $this->app->settings->getGroup('-1001'); + $now = $this->clock->now()->getTimestamp(); + $id = $this->jobs->enqueue('-1001', $kind, 'button-integration-' . ++$this->sequence, $now, $now + 300, $g['revision'], []); + $result = $this->worker->runOnce('fixture-worker'); + self::assertSame('sent', $result['status'], Database::encode($result)); + self::assertSame($id, $result['job_id']); + return ['job' => $this->jobs->get($id), 'result' => $result, 'sent' => $this->gateway->sent[array_key_last($this->gateway->sent)]]; + } + private function payload(string $kind): array + { + return (new ReflectionMethod(Application::class, 'payload'))->invoke($this->app, $this->app->settings->getGroup('-1001'), $kind, $this->clock->now()); + } + private function advanceTefillinSpacing(): void + { + // Separate content fixtures must still obey the actual-delivery spacing gate. + $minutes = $this->app->settings->getGroup('-1001')['tefillin_min_spacing_minutes']; + $this->clock->set($this->clock->now()->modify('+' . $minutes . ' minutes')); + } + public function testActualScheduledShabbatDeliveryKeepsConciseOverviewAndDefaultShare(): void + { + $delivery = $this->deliver('times_alert'); + $payload = $delivery['sent']['payload']; + $group = $this->app->settings->getGroup('-1001'); + self::assertSame($this->app->ui->renderView('0', $group, 'shabbat'), $payload['text']); + self::assertSame($delivery['job']['payload']['text'], $payload['text']); + self::assertSame('shabat', $payload['buttons'][0][0]['switch_inline_query']); + foreach (['2026-10-16 17:26', '2026-10-17 18:41', 'ื—ื ื•ื›ื”', '2026-12-04'] as $visible) { + self::assertStringContainsString($visible, $payload['text']); + } + foreach (['2026-10-24', '2026-10-31', '2026-11-07', 'Hebcal', 'ืขื•ื“ื›ืŸ', '(+03:00)', 'Genesis', 'Isaiah'] as $hidden) { + self::assertStringNotContainsString($hidden, $payload['text']); + } + } + + public function testActualWorkerPreparesPersistsSendsAndRegistersDefaultTefillinRound(): void + { + $delivery = $this->deliver('tefillin'); + $job = $delivery['job']; + $message = $delivery['sent']['id']; + self::assertNotNull($job['payload_prepared_at']); + self::assertNotNull($job['module_delivery_processed_at']); + $token = $job['payload']['_tefillin_ack_token']; + self::assertSame($token, $delivery['sent']['payload']['_tefillin_ack_token']); + self::assertSame('ื”ื ื—ืชื™ (0)', $job['payload']['reply_markup']['inline_keyboard'][0][0]['text']); + self::assertSame(0, $this->app->tefillinAcknowledgements->snapshot('-1001', $message)['count']); + self::assertSame([$message], $this->app->deletions->getLastModuleMessages('-1001', 'tefillin')); + self::assertSame(1, $this->app->tefillinAcknowledgements->toggle('42', '-1001', $message, $token, 'native-query-1')['count']); + } + public function testConfirmedSendBookkeepingFailureRecoversThroughActualApplicationReplayWithoutResend(): void + { + $this->db->pdo()->exec("CREATE TRIGGER ack_registration_failure BEFORE INSERT ON tefillin_ack_messages BEGIN SELECT RAISE(ABORT,'fixture'); END"); + $delivery = $this->deliver('tefillin'); + self::assertTrue($delivery['result']['cleanup_pending']); + self::assertNull($delivery['job']['module_delivery_processed_at']); + self::assertSame('sent', $delivery['job']['status']); + self::assertNull($this->app->tefillinAcknowledgements->snapshot('-1001', $delivery['sent']['id'])); + $this->db->pdo()->exec('DROP TRIGGER ack_registration_failure'); + (new ReflectionMethod(Application::class, 'replayDeliveryBookkeeping'))->invoke($this->app); + $job = $this->jobs->get($delivery['job']['job_id']); + self::assertNotNull($job['module_delivery_processed_at']); + self::assertCount(1, $this->gateway->sent); + $message = $delivery['sent']['id']; + $token = $job['payload']['_tefillin_ack_token']; + $this->app->tefillinAcknowledgements->toggle('42', '-1001', $message, $token, 'query-a'); + (new ReflectionMethod(Application::class, 'replayDeliveryBookkeeping'))->invoke($this->app); + self::assertSame(1, $this->app->tefillinAcknowledgements->snapshot('-1001', $message)['count']); + self::assertCount(1, $this->gateway->sent); + } + public function testUnverifiedResponsePreservesPreviousPointerAndDoesNotBindUnknownMessage(): void + { + $first = $this->deliver('tefillin'); + $this->advanceTefillinSpacing(); + $this->gateway->verified = false; + $second = $this->deliver('tefillin'); + self::assertSame([$first['sent']['id']], $this->app->deletions->getLastModuleMessages('-1001', 'tefillin')); + self::assertNull($this->app->tefillinAcknowledgements->snapshot('-1001', $second['sent']['id'])); + self::assertNotNull($second['job']['module_delivery_processed_at']); + } + public function testGeneratedZmanimDafAndTimesAlertsUseOriginalInlineShareActionInAllSixLanguages(): void + { + foreach (Lang::CODES as $language) { + $g = $this->app->settings->getGroup('-1001'); + $this->app->settings->updateGroup('-1001', $g['revision'], ['language' => $language], '42'); + foreach (['zmanim' => 'zmanim', 'daf' => 'daf', 'times_alert' => 'shabat'] as $kind => $query) { + $p = $this->payload($kind); + self::assertNotSame('', trim($p['text'])); + self::assertSame('inherit', $p['buttons_mode']); + self::assertSame($query, $p['buttons'][0][0]['switch_inline_query']); + self::assertSame(GroupMessageButtons::defaults($kind, $language), $this->app->groupMessageButtons($this->app->settings->getGroup('-1001'), $kind)); + } + } + } + public function testKeyboardOnlyTemplatesPreserveCurrentGeneratedCalendarBodyAndCustomButtonStyles(): void + { + $expected = $this->payload('zmanim')['text']; + $buttons = [[['text' => 'Personal', 'url' => 'https://example.com/', 'style' => ['_' => 'keyboardButtonStyle', 'bg_success' => true, 'icon' => 123]]]]; + $template = $this->app->templates->save('-1001', 'zmanim', ['text' => '', 'buttons' => $buttons, 'buttons_mode' => 'custom'], '42'); + $p = $this->payload('zmanim'); + self::assertSame($expected, $p['text']); + self::assertSame($buttons, $p['buttons']); + self::assertSame($buttons, $this->app->groupMessageButtons($this->app->settings->getGroup('-1001'), 'zmanim')); + $template['buttons'] = []; + $template['buttons_mode'] = 'none'; + $template = $this->app->templates->save('-1001', 'zmanim', $template, '42', $template['revision']); + $p = $this->payload('zmanim'); + self::assertSame($expected, $p['text']); + self::assertArrayNotHasKey('buttons', $p); + self::assertSame([], $this->app->groupMessageButtons($this->app->settings->getGroup('-1001'), 'zmanim')); + $template['buttons_mode'] = 'inherit'; + $this->app->templates->save('-1001', 'zmanim', $template, '42', $template['revision']); + self::assertSame($expected, $this->payload('zmanim')['text']); + self::assertSame('zmanim', $this->payload('zmanim')['buttons'][0][0]['switch_inline_query']); + } + public function testCustomRichTefillinBodyInheritsDefaultAckAndExplicitCustomOrNoneOverrideIt(): void + { + foreach (['inherit', 'custom', 'none'] as $mode) { + $content = ['text' => 'My reminder ๐Ÿš€', 'entities' => [['_' => 'messageEntityBold', 'offset' => 0, 'length' => 11]], 'media' => ['bot_api_file_id' => 'offline-file'], 'buttons_mode' => $mode, + 'buttons' => $mode === 'custom' ? [[['text' => 'Custom', 'url' => 'https://example.com/']]] : []]; + $this->app->templates->save('-1001', 'tefillin', $content, '42'); + $delivery = $this->deliver('tefillin'); + $p = $delivery['sent']['payload']; + self::assertSame($content['text'], $p['text']); + self::assertSame($content['entities'], $p['entities']); + self::assertSame($content['media'], $p['media']); + if ($mode === 'inherit') { + self::assertArrayHasKey('_tefillin_ack_token', $p); + self::assertNotNull($this->app->tefillinAcknowledgements->snapshot('-1001', $delivery['sent']['id'])); + } else { + self::assertArrayNotHasKey('_tefillin_ack_token', $p); + self::assertNull($this->app->tefillinAcknowledgements->snapshot('-1001', $delivery['sent']['id'])); + if ($mode === 'custom') { + self::assertSame($content['buttons'], $p['buttons']); + } + } + $this->advanceTefillinSpacing(); + } + } + public function testAcknowledgementSettingOffStillDeliversTheReminderWithoutCounter(): void + { + $g = $this->app->settings->getGroup('-1001'); + $this->app->settings->updateGroup('-1001', $g['revision'], ['tefillin_ack_enabled' => false], '42'); + $delivery = $this->deliver('tefillin'); + self::assertArrayNotHasKey('_tefillin_ack_token', $delivery['sent']['payload']); + self::assertNull($this->app->tefillinAcknowledgements->snapshot('-1001', $delivery['sent']['id'])); + self::assertNotSame('', $delivery['sent']['payload']['text']); + } +} diff --git a/tests/GroupMessageFixtureClient.php b/tests/GroupMessageFixtureClient.php new file mode 100644 index 0000000..e47b5ba --- /dev/null +++ b/tests/GroupMessageFixtureClient.php @@ -0,0 +1,29 @@ +full = new FullHebcalFixtureClient(); + } + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + if (str_ends_with($url, '/hebcal') && ($query['start'] ?? '') === '2026-10-15' && ($query['end'] ?? '') === '2026-10-15') { + $data = $this->full->get($url, array_replace($query, ['start' => '2026-09-23', 'end' => '2026-12-10']), $cancellation); + $data['range'] = ['start' => '2026-10-15', 'end' => '2026-10-15']; + $data['items'] = array_values(array_filter($data['items'], static fn(array $item): bool => $item['date'] === '2026-10-15')); + return $data; + } + return $this->full->get($url, $query, $cancellation); + } +} diff --git a/tests/GroupModerationStorageTest.php b/tests/GroupModerationStorageTest.php new file mode 100644 index 0000000..d31e235 --- /dev/null +++ b/tests/GroupModerationStorageTest.php @@ -0,0 +1,253 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-14T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1'); + $this->gateway = new SchedulerFakeGateway(); + } + public function testRestrictionCatalogExactlyMatchesLockedNativeSchema(): void + { + $tl = file_get_contents(dirname(__DIR__) . '/vendor/danog/madelineproto/src/TL_telegram_v225.tl'); + self::assertSame(1, preg_match('/^chatBannedRights#[^\n]+/m', $tl, $line)); + preg_match_all('/([a-z_]+):flags\.\d+\?true/', $line[0], $matches); + self::assertSame($matches[1], Permissions::FLAGS); + self::assertCount(22, Permissions::FLAGS); + self::assertCount(21, Permissions::DEFAULT_BLOCK_FLAGS); + self::assertNotContains('view_messages', Permissions::DEFAULT_BLOCK_FLAGS); + self::assertSame(Permissions::DEFAULT_BLOCK_FLAGS, $this->settings->getGroup('-1')['lock_block_flags']); + } + public function testGroupOnlyConsentDefaultsValidationAndRevisionCas(): void + { + $group = $this->settings->getGroup('-1'); + foreach (['delete_holy_user_messages','delete_holy_bot_messages','tefillin_delete_previous'] as $field) { + self::assertFalse($group[$field]); + self::assertArrayNotHasKey($field, $this->settings->getUser('42')); + try { + $this->settings->updateUser('42', 1, [$field => true]); + self::fail('Personal settings accepted group cleanup consent.'); + } catch (InvalidArgumentException $error) { + self::assertStringContainsString('Unsupported setting', $error->getMessage()); + } + } + $saved = $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_reactions','send_plain'],'delete_holy_user_messages' => true,'tefillin_delete_previous' => true], '42'); + self::assertSame(['send_plain','send_reactions'], $saved['lock_block_flags']); + self::assertTrue($saved['delete_holy_user_messages']); + foreach ([['view_messages'], ['unknown'], ['send_photos','send_photos'], ['send_photos' => true]] as $invalid) { + try { + $this->settings->updateGroup('-1', 2, ['lock_block_flags' => $invalid], '42'); + self::fail('Invalid restriction mask accepted.'); + } catch (InvalidArgumentException $error) { + self::assertNotSame('', $error->getMessage()); + } + } + $this->expectException(StaleSettings::class); + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => []], '42'); + } + public function testSelectedFlagsPreserveUntoggledRestrictionsAndRestoreExpiry(): void + { + $original = ['_' => 'chatBannedRights','send_polls' => true,'send_voices' => false,'until_date' => 2000000000]; + $this->gateway->permissions['-1'] = $original; + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_photos','edit_rank']], '42'); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame('locked', $locks->reconcile('-1', 'event', true)['status']); + $locked = $this->gateway->getPermissions('-1'); + self::assertTrue($locked['send_polls']); + self::assertFalse($locked['send_voices']); + self::assertTrue($locked['send_photos']); + self::assertTrue($locked['edit_rank']); + self::assertArrayNotHasKey('send_messages', $locked); + self::assertSame(0, $locked['until_date']); + self::assertSame('restored', $locks->reconcile('-1', 'event', false)['status']); + self::assertSame($original, $this->gateway->getPermissions('-1')); + } + public function testMaskChangesDuringOwnedLockAddAndDropOnlyFromOriginalSnapshot(): void + { + $original = $this->gateway->getPermissions('-1'); + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_photos']], '42'); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $snapshot = $locks->state('-1')['snapshot_json']; + $this->settings->updateGroup('-1', 2, ['lock_block_flags' => ['send_voices','send_reactions']], '42'); + self::assertSame(['status' => 'locked','changed' => false,'permissions_updated' => true], $locks->reconcile('-1', 'event', true)); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + self::assertSame(hash('sha256', $snapshot), $locks->state('-1')['snapshot_sha256']); + $actual = $this->gateway->getPermissions('-1'); + self::assertArrayNotHasKey('send_photos', $actual); + self::assertTrue($actual['send_voices']); + self::assertTrue($actual['send_reactions']); + self::assertTrue($actual['send_polls']); + self::assertNull($locks->state('-1')['previous_applied_json']); + $locks->reconcile('-1', 'event', false); + self::assertSame($original, $this->gateway->getPermissions('-1')); + } + public function testFailedMaskMutationRetainsBothKnownMasksAndRestoresOriginal(): void + { + $original = $this->gateway->getPermissions('-1'); + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_photos']], '42'); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $old = $this->gateway->getPermissions('-1'); + $this->settings->updateGroup('-1', 2, ['lock_block_flags' => ['send_voices']], '42'); + $this->gateway->onSet = static function (): never { + throw new RuntimeException('RPC unavailable'); + }; + self::assertSame('error', $locks->reconcile('-1', 'event', true)['status']); + self::assertTrue(Permissions::equal($old, json_decode($locks->state('-1')['previous_applied_json'], true))); + $this->gateway->onSet = null; + self::assertSame('restored', $locks->reconcile('-1', 'event', false)['status']); + self::assertSame($original, $this->gateway->getPermissions('-1')); + } + public function testLostMaskResponseRecognizesNewOwnedMaskWithoutTakingNewSnapshot(): void + { + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_photos']], '42'); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $snapshot = $locks->state('-1')['snapshot_json']; + $this->settings->updateGroup('-1', 2, ['lock_block_flags' => Permissions::BLOCK_FLAGS], '42'); + $this->gateway->onSet = function (string $id, array $rights): never { + $this->gateway->permissions[$id] = $rights; + throw new RuntimeException('lost response'); + }; + self::assertSame('error', $locks->reconcile('-1', 'event', true)['status']); + $this->gateway->onSet = null; + self::assertSame(['status' => 'locked','changed' => false], $locks->reconcile('-1', 'event', true)); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + self::assertArrayNotHasKey('view_messages', $this->gateway->permissions['-1']); + self::assertSame('restored', $locks->reconcile('-1', 'event', false)['status']); + } + public function testFreshMaskChangeDefersMutationAndFailedSaveDoesNotChangeDebt(): void + { + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_photos']], '42'); + $changed = false; + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, function () use (&$changed): bool { + if (!$changed) { + $changed = true; + $this->settings->updateGroup('-1', 2, ['lock_block_flags' => ['send_voices']], '42'); + } + return true; + }); + self::assertSame('deferred', $locks->reconcile('-1', 'event', true)['status']); + self::assertCount(0, $this->gateway->changes); + self::assertSame('locked', $locks->reconcile('-1', 'event', true)['status']); + $before = $locks->state('-1')['snapshot_json']; + $this->db->pdo()->exec("CREATE TRIGGER reject_mask BEFORE UPDATE OF lock_block_flags ON group_settings BEGIN SELECT RAISE(FAIL,'disk failure'); END"); + try { + $this->settings->updateGroup('-1', 3, ['lock_block_flags' => []], '42'); + self::fail('Failed settings persistence was accepted.'); + } catch (PDOException $error) { + self::assertStringContainsString('disk failure', $error->getMessage()); + } + self::assertSame(['send_voices'], $this->settings->getGroup('-1')['lock_block_flags']); + self::assertSame($before, $locks->state('-1')['snapshot_json']); + } + public function testManualConflictsAndPreexistingLocksAreNeverOverwritten(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $this->gateway->permissions['-1']['send_photos'] = false; + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_voices']], '42'); + self::assertSame('conflict', $locks->reconcile('-1', 'event', true)['status']); + self::assertCount(1, $this->gateway->changes); + $this->settings->ensureGroup('-2'); + $this->gateway->permissions['-2'] = ['_' => 'chatBannedRights','view_messages' => true]; + self::assertSame('prelocked', $locks->reconcile('-2', 'event', true)['status']); + self::assertSame('prelocked', $locks->reconcile('-2', 'event', false)['status']); + self::assertCount(1, $this->gateway->changes); + } + public function testMigrationPreservesSevenMigrationDataAndSeparatesAnnouncementTemplates(): void + { + $directory = sys_get_temp_dir() . '/shabbat-moderation-schema-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700); + try { + foreach (glob(dirname(__DIR__) . '/migrations/*.sql') as $file) { + if ((int)basename($file) <= 7) { + copy($file, $directory . '/' . basename($file)); + } + } + $old = Database::open(':memory:'); + $old->migrate($directory); + $settings = new SettingsRepository($old, $this->clock); + $settings->ensureGroup('-9', 'Retained', ['language' => 'fr','candle_minutes' => 22]); + $settings->getUser('42'); + $templates = new TemplateRepository($old, $this->clock); + // Seed the historic schema using its own columns, before newer repository fields exist. + $old->pdo()->prepare('INSERT INTO message_templates(group_id,kind,text,entities_json,media_json,buttons_json,updated_at) VALUES(?,?,?,?,?,?,?)') + ->execute(['-9', 'zmanim', '๐Ÿ˜€ Retained', Database::encode([['_' => 'messageEntityBold', 'offset' => 3, 'length' => 8]]), Database::encode(['bot_api_file_id' => 'retained-media']), Database::encode([[['text' => 'Link', 'url' => 'https://example.com']]]), $this->clock->now()->getTimestamp()]); + $old->pdo()->exec("INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,applied_json,snapshot_sha256,prepared_at) VALUES('-9','legacy','locked','{\"send_polls\":true}','{\"send_photos\":true}','retained-checksum',1)"); + $old->pdo()->exec("INSERT INTO scheduled_jobs(group_id,module,event_key,run_at,expires_at,settings_revision,payload_json,random_id,created_at) VALUES('-9','tefillin','retained',1,2,1,'{}','19',1)"); + $old->pdo()->exec("INSERT INTO delivery_log(job_id,attempt,random_id,started_at,outcome,response_json) VALUES(1,1,'19',1,'sent','{\"message_id\":5}')"); + $snapshots = []; + foreach (['groups','group_settings','user_preferences','message_templates','group_lock_state','scheduled_jobs','delivery_log'] as $table) { + $snapshots[$table] = $old->pdo()->query('SELECT * FROM ' . $table)->fetchAll(); + } + self::assertSame(['008_group_moderation.sql', '009_admin_roles_links.sql', '010_subscriber_directory.sql', '011_custom_star_amounts.sql', '012_tefillin_acknowledgements.sql', '013_permission_recovery.sql', '014_diagnostic_panels.sql', '015_permission_snapshot_provenance.sql', '016_delivery_safety.sql'], $old->migrate()); + foreach ($snapshots as $table => $rows) { + $current = $old->pdo()->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(); + foreach ($rows as $index => $row) { + self::assertSame($row, array_intersect_key($current[$index], $row), $table); + } + } + $announcement = $templates->get('-9', 'times_alert'); + $daily = $templates->get('-9', 'zmanim'); + foreach (['text','entities','media','buttons','revision','imported_at'] as $field) { + self::assertSame($daily[$field], $announcement[$field]); + } + $templates->save('-9', 'times_alert', ['text' => 'Announcement only'], '42', $announcement['revision']); + self::assertSame('๐Ÿ˜€ Retained', $templates->get('-9', 'zmanim')['text']); + self::assertSame([], $old->integrity()['foreign_key_check']); + $old->assertCurrentSchema(); + } finally { + foreach (glob($directory . '/*') as $file) { + unlink($file); + } + rmdir($directory); + } + } + public function testPreviousUpgradeUnverifiedMaskRequiresReviewWithoutReplacingLegacySnapshot(): void + { + $original = $this->gateway->getPermissions('-1'); + $snapshot = Database::encode($original); + $oldMask = Permissions::locked($original, array_slice(Permissions::DEFAULT_BLOCK_FLAGS, 0, 19)); + $this->gateway->permissions['-1'] = $oldMask; + $this->db->pdo()->prepare("INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,applied_json,snapshot_sha256,prepared_at,locked_at) VALUES('-1','legacy','locked',?,?,?,1,1)") + ->execute([$snapshot,Database::encode($oldMask),hash('sha256', $snapshot)]); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $result = $locks->reconcile('-1', 'legacy', true); + self::assertSame('recovery_required', $result['status']); + self::assertSame('unverified_snapshot', $result['recovery_reason']); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + self::assertSame('legacy_unverified', $locks->state('-1')['snapshot_provenance']); + self::assertSame($oldMask, $this->gateway->getPermissions('-1')); + self::assertSame('recovery_required', $locks->reconcile('-1', 'legacy', false)['status']); + self::assertSame($oldMask, $this->gateway->getPermissions('-1')); + } +} diff --git a/tests/GroupReactivationTest.php b/tests/GroupReactivationTest.php new file mode 100644 index 0000000..cdacdbb --- /dev/null +++ b/tests/GroupReactivationTest.php @@ -0,0 +1,122 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1001', 'Retained group'); + $this->settings->updateGroup('-1001', 1, [ + 'candle_minutes' => 22, 'havdalah_method' => 'minutes', 'havdalah_value' => 72.0, + 'location_name' => 'Paris', 'latitude' => 48.85, 'longitude' => 2.35, 'timezone_id' => 'Europe/Paris', + 'language' => 'fr', 'israel' => false, 'mode' => 'lock_messages', + 'tefillin_enabled' => true, 'omer_enabled' => false, 'daf_enabled' => true, 'zmanim_enabled' => false, + ], '17'); + } + + public function testExplicitRemoveRejoinPreservesSettingsAndConsentButRevokesStaleFlowsAndJobs(): void + { + $flows = new FlowTokenRepository($this->db, $this->clock); + $token = $flows->create('17', 'group', '-1001', 2, 'edit'); + $jobs = new JobRepository($this->db, $this->clock); + $old = $jobs->enqueue('-1001', 'daf', 'today', 1791882000, 1791885600, 2, ['text' => 'Old pending content']); + $restore = $jobs->enqueue('-1001', 'restore', 'permission-debt', 1791882000, 1791885600, 2, []); + $this->settings->deactivateGroup('-1001', '17'); + self::assertSame(0, $this->settings->getGroup('-1001')['active']); + self::assertSame([], $this->settings->listGroups()); + $before = $this->db->pdo()->query("SELECT * FROM group_settings WHERE group_id='-1001'")->fetch(); + $this->clock->set(new DateTimeImmutable('2026-10-13T09:01:00Z')); + $saved = $this->settings->reactivateGroup('-1001', '17', 'Fresh Telegram name'); + self::assertSame(1, $saved['active']); + self::assertSame(3, $saved['revision']); + self::assertSame('Fresh Telegram name', $saved['title']); + self::assertCount(1, $this->settings->listGroups()); + $after = $this->db->pdo()->query("SELECT * FROM group_settings WHERE group_id='-1001'")->fetch(); + foreach (['revision', 'updated_at', 'updated_by'] as $field) { + unset($before[$field], $after[$field]); + } + self::assertSame($before, $after); + self::assertTrue($saved['tefillin_enabled']); + self::assertFalse($saved['omer_enabled']); + self::assertTrue($saved['daf_enabled']); + self::assertFalse($saved['zmanim_enabled']); + self::assertNull($flows->peek($token, '17', 'edit')); + self::assertSame('cancelled', $jobs->get($old)['status']); + self::assertSame('pending', $jobs->get($restore)['status']); + $jobs->enqueue('-1001', 'daf', 'today', 1791882060, 1791885600, 3, ['text' => 'Replanned current content']); + self::assertSame('pending', $jobs->get($old)['status']); + self::assertSame(3, $jobs->get($old)['settings_revision']); + self::assertSame(1, (int) $this->db->pdo()->query("SELECT COUNT(*) FROM audit_log WHERE action='group.reactivate'")->fetchColumn()); + } + + public function testAutomaticListImportCannotResurrectRemovedGroup(): void + { + $this->settings->deactivateGroup('-1001', '17'); + $before = $this->settings->getGroup('-1001'); + $root = sys_get_temp_dir() . '/shabbat-removed-import-' . bin2hex(random_bytes(8)); + mkdir($root . '/data', 0700, true); + file_put_contents($root . '/data/DBgroups.txt', "-1001\n"); + try { + $report = (new LegacyImporter($this->db, $this->clock))->importGroupListAutomatically($root); + self::assertSame(['-1001'], $report['existing']); + self::assertSame([], $report['added']); + self::assertSame($before, $this->settings->getGroup('-1001')); + self::assertSame([], $this->settings->listGroups()); + } finally { + unlink($root . '/data/DBgroups.txt'); + rmdir($root . '/data'); + rmdir($root); + } + } + + public function testAlreadyActiveRegistrationIsIdempotentAndUnknownRejoinCannotCreateGroup(): void + { + $before = $this->settings->getGroup('-1001'); + self::assertSame($before, $this->settings->reactivateGroup('-1001', '17', 'Ignored active rename')); + self::assertSame($before, $this->settings->getGroup('-1001')); + $this->expectException(InvalidArgumentException::class); + $this->settings->reactivateGroup('-999', '17'); + } + + public function testFailedActivationRollsBackRevisionTokenInvalidationAndMetadata(): void + { + $this->settings->deactivateGroup('-1001', '17'); + $flows = new FlowTokenRepository($this->db, $this->clock); + $token = $flows->create('17', 'group', '-1001', 2, 'edit'); + $before = $this->settings->getGroup('-1001'); + $this->db->pdo()->exec("CREATE TRIGGER reject_rejoin BEFORE UPDATE OF active ON groups WHEN NEW.active=1 BEGIN SELECT RAISE(FAIL,'simulated disk error'); END"); + try { + $this->settings->reactivateGroup('-1001', '17', 'Fresh title'); + self::fail('Activation database error was swallowed.'); + } catch (PDOException) { + self::assertSame($before, $this->settings->getGroup('-1001')); + self::assertNotNull($flows->peek($token, '17', 'edit')); + self::assertSame(0, (int) $this->db->pdo()->query("SELECT COUNT(*) FROM audit_log WHERE action='group.reactivate'")->fetchColumn()); + } + } +} diff --git a/tests/GroupTitleTest.php b/tests/GroupTitleTest.php new file mode 100644 index 0000000..ce47c71 --- /dev/null +++ b/tests/GroupTitleTest.php @@ -0,0 +1,86 @@ +migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $settings = new SettingsRepository($db, $clock); + $settings->ensureGroup('-1001', '-1001'); + $settings->updateGroup('-1001', 1, ['language' => 'en', 'location_name' => 'Edited city', 'mode' => 'messages', 'daf_enabled' => true], '17'); + $flows = new FlowTokenRepository($db, $clock); + $token = $flows->create('17', 'group', '-1001', 2, 'edit'); + $jobs = new JobRepository($db, $clock); + $job = $jobs->enqueue('-1001', 'daf', 'civil-date-fixture', 1791882000, 1791889200, 2, ['text' => 'Preserved delivery']); + $tables = ['group_settings', 'conversation_states', 'scheduled_jobs', 'delivery_log', 'audit_log']; + $before = []; + foreach ($tables as $table) { + $before[$table] = $db->pdo()->query('SELECT * FROM ' . $table)->fetchAll(); + } + $clock->set(new DateTimeImmutable('2026-10-13T09:01:00Z')); + $title = 'ืงื”ื™ืœืช ืฉื‘ืช ๐Ÿ˜€ Paris'; + $settings->refreshGroupTitle('-1001', $title); + foreach ($tables as $table) { + self::assertSame($before[$table], $db->pdo()->query('SELECT * FROM ' . $table)->fetchAll(), $table . ' changed on a title refresh'); + } + self::assertSame($title, $settings->getGroup('-1001')['title']); + self::assertSame(2, $settings->getGroup('-1001')['revision']); + self::assertSame(1791882060, (int) $db->pdo()->query("SELECT updated_at FROM groups WHERE group_id='-1001'")->fetchColumn()); + self::assertCount(1, $settings->listGroups(0, 20, 'ืงื”ื™ืœืช')); + self::assertNotNull($flows->peek($token, '17', 'edit')); + self::assertSame('pending', $jobs->get($job)['status']); + $saved = $flows->confirm($token, '17', 'edit', static fn (): bool => true, ['language' => 'fr']); + self::assertSame(3, $saved['revision']); + self::assertSame($title, $saved['title']); + } + + public function testRefreshNeverRegistersAnUnknownPeer(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $settings = new SettingsRepository($db, new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z'))); + try { + $settings->refreshGroupTitle('-1009876', 'Unknown channel'); + self::fail('An unknown peer was implicitly registered.'); + } catch (InvalidArgumentException) { + self::assertNull($settings->getGroup('-1009876')); + self::assertSame(0, (int) $db->pdo()->query('SELECT COUNT(*) FROM groups')->fetchColumn()); + self::assertSame(0, (int) $db->pdo()->query('SELECT COUNT(*) FROM group_settings')->fetchColumn()); + } + } + + public function testInvalidNamesAndIdentifiersCannotChangeStoredMetadata(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $settings = new SettingsRepository($db, new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z'))); + $original = $settings->ensureGroup('-1001', 'Original name'); + foreach (['', ' ', "bad\0name", "bad\nname", "bad\xFFname", str_repeat('ื', 257)] as $invalid) { + try { + $settings->refreshGroupTitle('-1001', $invalid); + self::fail('Invalid group name was accepted.'); + } catch (InvalidArgumentException) { + self::assertSame($original, $settings->getGroup('-1001')); + } + } + $this->expectException(InvalidArgumentException::class); + $settings->refreshGroupTitle('invalid-id', 'Otherwise valid name'); + } +} diff --git a/tests/GuidedSettingsTest.php b/tests/GuidedSettingsTest.php new file mode 100644 index 0000000..0530e18 --- /dev/null +++ b/tests/GuidedSettingsTest.php @@ -0,0 +1,249 @@ +migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $this->settings = new SettingsRepository($db, $this->clock); + $this->settings->ensureGroup('-1001', 'Guided group'); + $this->flows = new FlowTokenRepository($db, $this->clock); + $this->gateway = new UiNavigationFakeGateway(); + $http = new FullHebcalFixtureClient(); + $this->ui = new BotController( + $this->settings, + $this->flows, + new CalendarService(new HebcalProvider($http), $this->clock), + $this->gateway, + new GeoNames($http, $this->clock, ''), + $this->clock + ); + } + + private function click(string $data): void + { + self::assertTrue($this->ui->handleCallback(['user_id' => '1','chat_id' => '1','message_id' => 101,'private' => true,'query_id' => '123','data' => $data])); + } + + private function last(): array + { + return $this->gateway->edited[array_key_last($this->gateway->edited)]; + } + + private function dataFor(string $prefix, ?string $label = null): string + { + foreach ($this->last()['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', $prefix) && ($label === null || str_starts_with($button['text'], $label))) { + return $button['data']; + } + } + } + self::fail('Missing callback ' . $prefix . ' / ' . ($label ?? 'any')); + } + + private function openTefillin(string $language = 'he'): void + { + $user = $this->settings->getUser('1'); + $this->settings->updateUser('1', $user['revision'], ['language' => $language]); + $group = $this->settings->getGroup('-1001'); + $panel = $this->flows->create('1', 'group', '-1001', $group['revision'], 'panel'); + $this->click('sb:v:' . $panel . ':tefillin'); + } + + private function openField(string $field, string $language = 'he'): void + { + $lang = new Lang(); + if ($field === 'delete_previous') { + $this->click($this->dataFor('sb:wp:')); + } + $this->click($this->dataFor('sb:wf:', $lang->text($language, 'field_' . $field))); + } + + public function testEveryLanguageShowsHumanSettingsAndPresetSavesWithoutEnablingAlerts(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $this->openTefillin($language); + $text = $this->last()['text']; + foreach (['days =','fixed_times','interval_minutes','quiet_start','true/false','/configure','city'] as $raw) { + self::assertStringNotContainsString($raw, $text); + } + self::assertStringContainsString($lang->text($language, 'field_interval_minutes'), $text); + $this->openField('interval_minutes', $language); + $this->click($this->dataFor('sb:wo:', '60')); + $this->click($this->dataFor('sb:save:')); + self::assertSame(60, $this->settings->getGroup('-1001')['tefillin_interval_minutes']); + self::assertFalse($this->settings->getGroup('-1001')['tefillin_enabled']); + } + self::assertSame([], $this->gateway->sent); + foreach ($this->gateway->edited as $edit) { + self::assertSame(101, $edit['messageId']); + } + } + + public function testCustomTimesWeekdaysAndLastPageToggleUseOnePanelAndExplicitConfirm(): void + { + $this->openTefillin(); + $this->openField('fixed_times'); + $this->click($this->dataFor('sb:wi:')); + self::assertStringNotContainsString('/configure', $this->last()['text']); + self::assertDoesNotMatchRegularExpression('/[A-Za-z0-9_-]{24}/', $this->last()['text']); + self::assertTrue($this->ui->handleMessage(['user_id' => '1','private' => true,'text' => '09:15,14:45'])); + self::assertSame(['09:00'], $this->settings->getGroup('-1001')['tefillin_fixed_times']); + $this->click($this->dataFor('sb:save:')); + self::assertSame(['09:15','14:45'], $this->settings->getGroup('-1001')['tefillin_fixed_times']); + + $this->openTefillin(); + $this->openField('days'); + $this->click($this->dataFor('sb:wd:', 'โœ… ื™ื•ื ืฉื™ืฉื™')); + $this->click($this->dataFor('sb:wc:')); + self::assertStringContainsString('ืจืืฉื•ืŸ', $this->last()['text']); + $this->click($this->dataFor('sb:save:')); + self::assertSame([0,1,2,3,4], $this->settings->getGroup('-1001')['tefillin_days']); + + $this->openTefillin(); + $this->openField('delete_previous'); + $this->click($this->dataFor('sb:wo:', (new Lang())->text('he', 'on'))); + $this->click($this->dataFor('sb:save:')); + self::assertTrue($this->settings->getGroup('-1001')['tefillin_delete_previous']); + self::assertFalse($this->settings->getGroup('-1001')['tefillin_enabled']); + self::assertSame([], $this->gateway->sent); + } + + private function openSection(string $screen): void + { + $group = $this->settings->getGroup('-1001'); + $token = $this->flows->create('1', 'group', '-1001', $group['revision'], 'panel'); + $this->click('sb:v:' . $token . ':' . $screen); + } + + public function testModeSelectorHasFourChoicesAndSaveReturnsToShabbatInEveryLanguage(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $user = $this->settings->getUser('1'); + $this->settings->updateUser('1', $user['revision'], ['language' => $language]); + $this->openSection('shabbat'); + self::assertStringContainsString('Asia/Jerusalem', $this->last()['text']); + $buttons = array_merge(...array_column($this->last()['markup']['rows'], 'buttons')); + self::assertCount(0, array_filter($buttons, static fn(array $button): bool => str_contains($button['data'] ?? '', ':m:'))); + $this->click($this->dataFor('sb:v:', $lang->text($language, 'settings_mode_button', ['mode' => $lang->text($language, 'mode_' . $this->settings->getGroup('-1001')['mode'])]))); + $buttons = array_merge(...array_column($this->last()['markup']['rows'], 'buttons')); + $modes = array_values(array_filter($buttons, static fn(array $button): bool => str_contains($button['data'] ?? '', ':m:'))); + self::assertCount(4, $modes); + $this->click($modes[1]['data']); + $this->click($this->dataFor('sb:save:')); + self::assertSame('messages', $this->settings->getGroup('-1001')['mode']); + self::assertStringContainsString($lang->text($language, 'settings_mode_button', ['mode' => $lang->text($language, 'mode_messages')]), json_encode($this->last(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + self::assertStringContainsString(':shabbat_timing', json_encode($this->last(), JSON_THROW_ON_ERROR)); + } + self::assertSame([], $this->gateway->sent); + } + + public function testTimingAndAnnouncementSaveAndCancelStayInTheirCategory(): void + { + $this->openSection('shabbat_timing'); + $this->click($this->dataFor('sb:wf:', (new Lang())->text('he', 'field_candle_minutes'))); + $this->click($this->dataFor('sb:wo:', '40')); + $this->click($this->dataFor('sb:save:')); + self::assertSame(40, $this->settings->getGroup('-1001')['candle_minutes']); + self::assertStringContainsString((new Lang())->text('he', 'settings_shabbat_timing_intro'), $this->last()['text']); + $this->click($this->dataFor('sb:wf:', (new Lang())->text('he', 'field_candle_minutes'))); + $this->click($this->dataFor('sb:wo:', '60')); + $this->click($this->dataFor('sb:cancel:')); + self::assertSame(40, $this->settings->getGroup('-1001')['candle_minutes']); + self::assertStringContainsString((new Lang())->text('he', 'settings_shabbat_timing_intro'), $this->last()['text']); + + $this->openSection('announcement'); + $this->click($this->dataFor('sb:at:', '12:30')); + $this->click($this->dataFor('sb:save:')); + self::assertSame('12:30', $this->settings->getGroup('-1001')['times_alert_time']); + self::assertStringContainsString((new Lang())->text('he', 'announcement_schedule_intro', ['timezone' => 'Asia/Jerusalem']), $this->last()['text']); + self::assertStringContainsString('sb:at:', json_encode($this->last(), JSON_THROW_ON_ERROR)); + } + + public function testAdvancedPageAndCustomQuestionReturnToTheSameFieldPage(): void + { + $this->openTefillin(); + $this->click($this->dataFor('sb:wp:')); + $this->click($this->dataFor('sb:wf:', (new Lang())->text('he', 'field_sunset_safety_minutes'))); + $this->click($this->dataFor('sb:wi:')); + self::assertTrue($this->ui->handleMessage(['user_id' => '1', 'private' => true, 'text' => '45'])); + $this->click($this->dataFor('sb:save:')); + self::assertSame(45, $this->settings->getGroup('-1001')['tefillin_sunset_safety_minutes']); + self::assertStringContainsString((new Lang())->text('he', 'field_delete_previous'), json_encode($this->last(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString((new Lang())->text('he', 'field_fixed_times') . ':', json_encode($this->last()['markup'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + $this->click($this->dataFor('sb:wf:', (new Lang())->text('he', 'field_sunset_safety_minutes'))); + $this->click($this->dataFor('sb:wi:')); + $this->click($this->dataFor('sb:wp:')); + self::assertNull($this->flows->latest('1', 'settings_wizard')); + self::assertStringContainsString((new Lang())->text('he', 'field_delete_previous'), json_encode($this->last(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + public function testCancelReplacedPromptWrongUserAndLostRoleCannotSave(): void + { + $this->openTefillin(); + $this->openField('fixed_times'); + $this->click($this->dataFor('sb:wi:')); + self::assertFalse($this->ui->handleMessage(['user_id' => '2','private' => true,'text' => '10:00'])); + $this->ui->handleMessage(['user_id' => '1','private' => true,'text' => '10:00']); + $stale = $this->dataFor('sb:save:'); + $this->click('sb:start'); + $edits = count($this->gateway->edited); + $this->click($stale); + self::assertCount($edits, $this->gateway->edited); + self::assertSame(['09:00'], $this->settings->getGroup('-1001')['tefillin_fixed_times']); + + $this->openTefillin(); + $this->openField('fixed_times'); + $this->click($this->dataFor('sb:wi:')); + $this->gateway->admin = false; + $this->ui->handleMessage(['user_id' => '1','private' => true,'text' => '11:00']); + self::assertSame(['09:00'], $this->settings->getGroup('-1001')['tefillin_fixed_times']); + self::assertSame([], $this->gateway->sent); + } + + public function testPromptRevokedDuringAsyncAuthorizationCannotConfirmInTransaction(): void + { + $context = $this->settings->getGroup('-1001'); + $parent = $this->flows->create('1', 'group', '-1001', $context['revision'], 'settings_wizard'); + $save = $this->flows->create('1', 'group', '-1001', $context['revision'], 'save', 600, ['source_wizard_token' => $parent]); + try { + $this->flows->confirm($save, '1', 'save', function (): bool { + $this->flows->revokeAction('1', 'settings_wizard'); + return true; + }, ['tefillin_fixed_times' => ['10:00']]); + self::fail('A canceled parent prompt must not save after authorization.'); + } catch (StaleSettings) { + self::assertSame(['09:00'], $this->settings->getGroup('-1001')['tefillin_fixed_times']); + self::assertNotNull($this->flows->peek($save, '1', 'save')); + } + } +} diff --git a/tests/GuidedTemplateTest.php b/tests/GuidedTemplateTest.php new file mode 100644 index 0000000..993afd8 --- /dev/null +++ b/tests/GuidedTemplateTest.php @@ -0,0 +1,396 @@ +migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z')); + $this->gateway = new GuidedUiGateway(); + $this->http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('No calendar HTTP in guided template tests.'); + } + }; + $this->app = $this->application($db); + $this->app->settings->ensureGroup('-1001', 'Fixture group'); + $this->editor = new TemplateEditor($this->app); + } + + private function application(Database $db): Application + { + return new Application($this->gateway, $this->clock, $db, new CalendarService(new HebcalProvider($this->http), $this->clock), new GeoNames($this->http, $this->clock, '')); + } + + private function click(string $data, string $user = '1', int $panel = 200, bool $private = true): bool + { + return $this->editor->callback(['user_id' => $user,'private' => $private,'chat_id' => $user,'message_id' => $panel,'data' => $data,'query_id' => 'fixture-template']); + } + + private function begin(string $kind = 'close', string $mode = 'edit', int $panel = 200): void + { + $this->click('v3:templates:-1001:' . $kind, panel:$panel); + $menu = end($this->gateway->edited); + foreach ($menu['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', 'tpl:' . $mode . ':')) { + $this->click($button['data'], panel: $panel); + return; + } + } + } + self::fail('Missing template action ' . $mode); + } + + private function message(string $text, array $entities = [], array $extra = [], bool $confirm = true): bool + { + $handled = $this->editor->message(array_replace(['user_id' => '1','private' => true,'text' => $text,'entities' => $entities], $extra)); + if ($confirm && $handled && $this->app->flows->latest('1', 'template_review') !== null) { + $review = end($this->gateway->payloads); + if ($review !== false) { + foreach ($review['payload']['reply_markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', 'tpl:save:')) { + $this->click($button['data'], panel:$review['messageId']); + break 2; + } + } + } + } + } + return $handled; + } + + private function reviewAction(string $action): string + { + $review = end($this->gateway->payloads); + foreach ($review['payload']['reply_markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', 'tpl:' . $action . ':')) { + return $button['data']; + } + } + } + self::fail('No pending review action ' . $action); + } + + public function testNaturalTemplatePromptIsSimpleLocalizedAndEditsTheSamePanelInSixLanguages(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $user = $this->app->settings->getUser('1'); + $this->app->settings->updateUser('1', $user['revision'], ['language' => $language]); + $this->begin(); + $last = end($this->gateway->edited); + self::assertSame($lang->text($language, 'template_edit_guided_prompt'), $last['text']); + self::assertStringNotContainsString('/template', $last['text']); + self::assertStringNotContainsString('TOKEN', $last['text']); + self::assertSame(200, $last['id']); + self::assertNotNull($this->app->flows->latest('1', 'template_input')); + self::assertTrue($this->message('Simple message ' . $language)); + self::assertSame('Simple message ' . $language, $this->app->templates->get('-1001', 'close')['text']); + } + self::assertSame([], $this->gateway->sent); + } + + public function testRichUtf16EntitiesAndMediaSurviveSaveRestartAndActualPayload(): void + { + $this->begin(); + $text = '๐Ÿš€ bold SECRET'; + $entities = [ + ['_' => 'messageEntityCustomEmoji','offset' => 0,'length' => 2,'document_id' => 5944760643524500583], + ['_' => 'messageEntityBold','offset' => 3,'length' => 4], + ['_' => 'messageEntitySpoiler','offset' => 8,'length' => 6], + ]; + $media = ['type' => 'photo','bot_api_file_id' => 'fixture-reusable-file']; + self::assertTrue($this->message($text, $entities, ['media' => $media])); + $saved = $this->app->templates->get('-1001', 'close'); + self::assertSame($text, $saved['text']); + self::assertSame($entities, $saved['entities']); + self::assertSame($media, $saved['media']); + $this->app = $this->application($this->app->db); + $this->editor = new TemplateEditor($this->app); + $payload = $this->app->payload($this->app->settings->getGroup('-1001'), 'close', $this->clock->now()); + self::assertSame($text, $payload['text']); + self::assertSame($entities, $payload['entities']); + self::assertSame($media, $payload['media']); + self::assertSame([], $this->gateway->sent); + } + + public function testPromptSurvivesRestartAndButtonsKeepNativeColorsIconsAndActions(): void + { + $this->begin('close', 'buttons'); + $this->app = $this->application($this->app->db); + $this->editor = new TemplateEditor($this->app); + $buttons = [[ + ['text' => 'Settings','url' => 'https://example.org','style' => ['_' => 'keyboardButtonStyle','bg_primary' => true,'icon' => 5944760643524500583]], + ['text' => 'Copy','copy_text' => 'Sample','style' => ['_' => 'keyboardButtonStyle','bg_success' => true]], + ]]; + self::assertTrue($this->message(json_encode($buttons, JSON_THROW_ON_ERROR))); + $saved = $this->app->templates->get('-1001', 'close'); + self::assertSame($buttons, $saved['buttons']); + self::assertFalse($this->message('Unprompted message')); + self::assertSame(200, end($this->gateway->edited)['id']); + self::assertSame([], $this->gateway->sent); + } + + public function testPairedMessageActionsAndSaveReturnToTheSameEditorWithFreshButtons(): void + { + $this->click('v3:templates:-1001:close'); + $menu = end($this->gateway->edited); + self::assertCount(2, $menu['markup']['rows'][0]['buttons']); + self::assertStringContainsString('tpl:edit:', $menu['markup']['rows'][0]['buttons'][0]['data']); + self::assertStringContainsString('tpl:buttons:', $menu['markup']['rows'][0]['buttons'][1]['data']); + $this->begin(); + self::assertTrue($this->message('Custom editor content')); + $saved = end($this->gateway->edited); + self::assertStringContainsString((new Lang())->text('he', 'template_text_custom'), $saved['text']); + $actions = array_merge(...array_column($saved['markup']['rows'], 'buttons')); + self::assertCount(1, array_filter($actions, static fn(array $button): bool => str_starts_with($button['data'] ?? '', 'tpl:edit:'))); + self::assertCount(1, array_filter($actions, static fn(array $button): bool => str_ends_with($button['data'] ?? '', ':shabbat_messages'))); + $edit = array_values(array_filter($actions, static fn(array $button): bool => str_starts_with($button['data'] ?? '', 'tpl:edit:')))[0]; + $this->click($edit['data']); + self::assertNotNull($this->app->flows->latest('1', 'template_input')); + self::assertSame(200, end($this->gateway->edited)['id']); + self::assertSame([], $this->gateway->sent); + } + + public function testAdvancedButtonFormatsAreAnExplicitOptionalStep(): void + { + $this->begin('close', 'buttons'); + $prompt = end($this->gateway->edited); + self::assertSame((new Lang())->text('he', 'template_buttons_simple_prompt'), $prompt['text']); + self::assertStringNotContainsString('JSON', $prompt['text']); + $this->click($prompt['markup']['rows'][1]['buttons'][0]['data']); + self::assertSame((new Lang())->text('he', 'template_buttons_guided_prompt'), end($this->gateway->edited)['text']); + self::assertSame('buttons', $this->app->flows->latest('1', 'template_input')['payload']['mode']); + } + + public function testPendingReviewRequiresExplicitSaveAndRejectsReplacedOrCancelledCandidates(): void + { + $this->begin(); + self::assertTrue($this->message('First candidate', confirm:false)); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + $oldSave = $this->reviewAction('save'); + self::assertTrue($this->message('Second candidate', confirm:false)); + $newSave = $this->reviewAction('save'); + self::assertNotSame($oldSave, $newSave); + $this->click($oldSave); + self::assertNull($this->app->templates->get('-1001', 'close')); + $this->click($newSave); + self::assertSame('Second candidate', $this->app->templates->get('-1001', 'close')['text']); + $revision = $this->app->templates->get('-1001', 'close')['revision']; + $this->click($newSave); + self::assertSame($revision, $this->app->templates->get('-1001', 'close')['revision']); + $this->begin(); + $this->message('Discard me', confirm:false); + $this->click($this->reviewAction('discard')); + self::assertSame('Second candidate', $this->app->templates->get('-1001', 'close')['text']); + self::assertNull($this->app->flows->latest('1', 'template_input')); + } + + public function testNativeEmptyEntitiesRemainLiteralThroughPreviewConfirmRestartAndExpansion(): void + { + $this->http = new FullHebcalFixtureClient(); + $this->app = $this->application($this->app->db); + $this->editor = new TemplateEditor($this->app); + $this->begin(); + $text = ' & {{location}}'; + $this->message($text, [], confirm:false); + self::assertNull($this->app->templates->get('-1001', 'close')); + $group = $this->app->settings->getGroup('-1001'); + $expected = ' & ' . $group['location_name']; + $preview = end($this->gateway->payloads)['payload']; + self::assertSame($expected, $preview['text']); + self::assertSame([], $preview['entities']); + $this->click($this->reviewAction('save')); + self::assertSame($text, $this->app->templates->get('-1001', 'close')['text']); + self::assertSame([], $this->app->templates->get('-1001', 'close')['entities']); + $this->app = $this->application($this->app->db); + $payload = $this->app->payload($this->app->settings->getGroup('-1001'), 'close', $this->clock->now()); + self::assertSame($expected, $payload['text']); + self::assertSame([], $payload['entities']); + } + + public function testSimpleLinkWizardAsksLabelThenUrlAndReviewsBeforeSaving(): void + { + $this->begin('close', 'buttons'); + $prompt = end($this->gateway->edited); + self::assertStringContainsString('https://', $prompt['text']); + $this->click($prompt['markup']['rows'][0]['buttons'][0]['data']); + self::assertTrue($this->message('More information', confirm:false)); + self::assertStringContainsString('https://', end($this->gateway->edited)['text']); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertTrue($this->message('https://example.org/help', confirm:false)); + self::assertNull($this->app->templates->get('-1001', 'close')); + $this->click($this->reviewAction('save')); + $saved = $this->app->templates->get('-1001', 'close'); + self::assertSame('custom', $saved['buttons_mode']); + self::assertSame('More information', $saved['buttons'][0][0]['text']); + self::assertSame('https://example.org/help', $saved['buttons'][0][0]['url']); + self::assertSame([], $this->gateway->sent); + } + + public function testPendingSaveRechecksParentAfterAsyncAuthorization(): void + { + $this->begin(); + $this->message('Pending content', confirm:false); + $save = $this->reviewAction('save'); + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $this->gateway->onAdmin = static function () use ($entered, $release): void { + $entered->complete(); + $release->getFuture()->await(); + }; + $pending = \Amp\async(fn(): bool=>$this->click($save)); + $entered->getFuture()->await(); + $this->app->flows->revokeAction('1', 'template_input'); + $release->complete(); + self::assertTrue($pending->await()); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + } + public function testWrongUserLostAdminAndForeignCallbackNeverPersistTemplates(): void + { + $this->begin(); + self::assertFalse($this->message('Wrong user', extra:['user_id' => '2'])); + self::assertFalse($this->message('In group', extra:['private' => false])); + $this->gateway->admin = false; + self::assertTrue($this->message('Lost administrator permission')); + self::assertNull($this->app->templates->get('-1001', 'close')); + $this->click('v3:templates:-1001:close', private:false); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertSame([], $this->gateway->sent); + } + + public function testCommandsAndBackNavigationCancelTheHiddenInput(): void + { + $this->begin(); + self::assertFalse($this->message('/start')); + self::assertNull($this->app->flows->latest('1', 'template_input')); + self::assertFalse($this->message('Old prompt reply')); + $this->begin(); + $last = end($this->gateway->edited); + $this->click($last['markup']['rows'][0]['buttons'][0]['data']); + self::assertNull($this->app->flows->latest('1', 'template_input')); + self::assertFalse($this->message('Cancelled input')); + self::assertNull($this->app->templates->get('-1001', 'close')); + } + + public function testCancelledParentDuringAdministratorLookupCannotApplyChild(): void + { + $this->begin(); + $flow = $this->app->flows->latest('1', 'template_input'); + $child = $flow['payload']['template_token']; + $this->gateway->onAdmin = function (): void { + $this->app->flows->revokeAction('1', 'template_input'); + }; + self::assertTrue($this->message('Stale after authorization wait')); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertNotNull($this->app->flows->peek($child, '1', 'template')); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + } + + public function testReplacedPromptDuringAdministratorLookupStaysActiveAndCannotBeOverwritten(): void + { + $this->begin(); + $newText = null; + $this->gateway->onAdmin = function () use (&$newText): void { + $this->begin('close', 'buttons', 201); + $newText = end($this->gateway->edited)['text']; + }; + self::assertTrue($this->message('Old content')); + self::assertNull($this->app->templates->get('-1001', 'close')); + $latest = $this->app->flows->latest('1', 'template_input'); + self::assertSame('buttons', $latest['payload']['mode']); + self::assertSame(201, $latest['payload']['panel_id']); + // A stale answer must not repaint a new prompt or send another message. + self::assertSame($newText, end($this->gateway->edited)['text']); + self::assertSame(201, end($this->gateway->edited)['id']); + self::assertSame([], $this->gateway->sent); + } + + public function testMalformedEntityAndUnknownPlaceholderCannotPoisonFutureDelivery(): void + { + foreach ( + [ + ['text' => '๐Ÿš€','entities' => [['_' => 'messageEntityBold','offset' => 1,'length' => 1]]], + ['text' => 'Invalid {{unrecognized}}','entities' => []], + ] as $input + ) { + $this->begin(); + self::assertTrue($this->message($input['text'], $input['entities'])); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + } + } + + public function testFailureRollsBackChildClaimAndRetryKeepsNaturalInput(): void + { + $this->begin(); + $child = $this->app->flows->latest('1', 'template_input')['payload']['template_token']; + $this->app->db->pdo()->exec("CREATE TRIGGER reject_guided_template BEFORE INSERT ON message_templates BEGIN SELECT RAISE(ABORT,'fixture write failure'); END"); + self::assertTrue($this->message('Retry me')); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertNotNull($this->app->flows->peek($child, '1', 'template')); + self::assertNotNull($this->app->flows->latest('1', 'template_input')); + $this->app->db->pdo()->exec('DROP TRIGGER reject_guided_template'); + self::assertTrue($this->message('Retry me')); + self::assertSame('Retry me', $this->app->templates->get('-1001', 'close')['text']); + self::assertNull($this->app->flows->peek($child, '1', 'template')); + } + + public function testActualAsyncAdministratorLookupCannotApplyAfterNavigationCancellation(): void + { + $this->begin(); + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $this->gateway->onAdmin = static function () use ($entered, $release): void { + $entered->complete(); + $release->getFuture()->await(); + }; + $pending = \Amp\async(fn(): bool => $this->message('Content from the old screen')); + $entered->getFuture()->await(); + $this->app->flows->revokeAction('1', 'template_input'); + $release->complete(); + self::assertTrue($pending->await()); + self::assertNull($this->app->templates->get('-1001', 'close')); + self::assertSame(1, $this->app->settings->getGroup('-1001')['revision']); + self::assertSame([], $this->gateway->sent); + } + + public function testExpiredInputAndInvalidStyledButtonsDoNotPersist(): void + { + $this->begin('close', 'buttons'); + self::assertTrue($this->message(json_encode([[['text' => 'Bad','url' => 'https://example.org','style' => ['_' => 'keyboardButtonStyle','bg_primary' => true,'bg_danger' => true]]]], JSON_THROW_ON_ERROR))); + self::assertNull($this->app->templates->get('-1001', 'close')); + $this->clock->set($this->clock->now()->modify('+11 minutes')); + self::assertFalse($this->message('Expired reply')); + self::assertNull($this->app->templates->get('-1001', 'close')); + } +} diff --git a/tests/GuidedUiGateway.php b/tests/GuidedUiGateway.php new file mode 100644 index 0000000..a7b9f11 --- /dev/null +++ b/tests/GuidedUiGateway.php @@ -0,0 +1,81 @@ +adminChecks++; + if ($this->onAdmin !== null) { + $hook = $this->onAdmin; + $this->onAdmin = null; + $hook(); + } + return $this->admin && $user === '1' && $group === '-1001'; + } + + public function sendPrivate(string $user, string $text, array $markup = []): void + { + $this->sent[] = compact('user', 'text', 'markup'); + } + + public function editPrivate(string $user, int $id, string $text, array $markup = []): void + { + if ($this->editFails) { + throw new \RuntimeException('Fixture panel was deleted.'); + } + $this->edited[] = compact('user', 'id', 'text', 'markup'); + } + + public function editPrivatePayload(string $userId, int $messageId, array $payload): void + { + $this->payloads[] = compact('userId', 'messageId', 'payload'); + } + + public function answerCallback(string $id, string $text): void + { + $this->answers[] = compact('id', 'text'); + } + + public function answerInline(string $id, array $results, array $options = []): void + { + throw new \RuntimeException('No inline requests in guided editor tests.'); + } + + public function botUsername(): string + { + return 'FixtureBot'; + } + + public function getPermissions(string $group): array + { + throw new \RuntimeException('No Telegram group operations in guided tests.'); + } + + public function setPermissions(string $group, array $permissions): void + { + throw new \RuntimeException('No Telegram group operations in guided tests.'); + } + + public function send(string $peer, array $payload, string $randomId): array + { + throw new \RuntimeException('No Telegram deliveries in guided tests.'); + } +} diff --git a/tests/HebcalFullSolarResponseTest.php b/tests/HebcalFullSolarResponseTest.php new file mode 100644 index 0000000..2b3bd2b --- /dev/null +++ b/tests/HebcalFullSolarResponseTest.php @@ -0,0 +1,192 @@ +solar(self::settings(), self::START, self::END); + self::assertCount(79, $days); + foreach ($days as $day => $times) { + self::assertCount(35, $times); + self::assertSame((new \DateTimeImmutable($raw['times']['sunset'][$day]))->getTimestamp(), $times['sunset']); + self::assertSame((new \DateTimeImmutable($raw['times']['tzeit85deg'][$day]))->getTimestamp(), $times['tzeit85deg']); + self::assertArrayNotHasKey('seaLevelSunrise', $times); + self::assertArrayNotHasKey('seaLevelSunset', $times); + self::assertArrayNotHasKey('molad', $times); + self::assertArrayNotHasKey('tchilasZmanKidushLevana3Days', $times); + self::assertArrayNotHasKey('tchilasZmanKidushLevana7Days', $times); + self::assertArrayNotHasKey('sofZmanKidushLevanaBetweenMoldos', $times); + self::assertArrayNotHasKey('sofZmanKidushLevana15Days', $times); + } + self::assertSame( + (new \DateTimeImmutable('2026-10-25T23:23:00+02:00'))->getTimestamp(), + $days['2026-10-26']['chatzotNight'] + ); + } + + public function testPublicFixtureHashesAndRetentionAreRecorded(): void + { + $provenance = json_decode( + file_get_contents(__DIR__ . '/Fixtures/hebcal-jerusalem-default-full-provenance.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + self::assertSame('CC-BY-4.0', $provenance['license']); + self::assertSame('2026-10-15', $provenance['retrieved_date']); + self::assertCount(2, $provenance['fixtures']); + foreach ($provenance['fixtures'] as $fixture) { + self::assertSame('complete response; no fields projected or values changed', $fixture['retention']); + self::assertSame($fixture['sha256'], hash_file('sha256', __DIR__ . '/Fixtures/' . $fixture['file'])); + self::assertStringStartsWith('https://www.hebcal.com/', $fixture['url']); + } + } + + public function testUnknownProviderExtensionsCannotBecomeOperationalValues(): void + { + $raw = self::fixture(); + $expected = self::provider($raw)->solar(self::settings(), self::START, self::END); + $raw['times']['futureEmptyExtension'] = []; + $raw['times']['futureScalarExtension'] = 'not a daily series'; + $raw['times']['futureSparseExtension'] = ['2026-10-15' => 'untrusted extension text']; + $raw['times'][0] = null; + self::assertSame($expected, self::provider($raw)->solar(self::settings(), self::START, self::END)); + } + + public function testSunsetSeriesIsAlwaysRequired(): void + { + $raw = self::fixture(); + unset($raw['times']['sunset']); + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Missing sunset time series.'); + self::provider($raw)->solar(self::settings(), self::START, self::END); + } + + #[DataProvider('incompleteDailySeries')] + public function testEveryPresentSupportedDailySeriesRequiresFullCoverage(string $name): void + { + $raw = self::fixture(); + unset($raw['times'][$name]['2026-10-15']); + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Incomplete solar date coverage.'); + self::provider($raw)->solar(self::settings(), self::START, self::END); + } + + public static function incompleteDailySeries(): array + { + return [ + 'permission sunset' => ['sunset'], + 'configured nightfall' => ['tzeit85deg'], + 'alternate configured nightfall' => ['tzeit7083deg'], + 'omer dawn' => ['alotHaShachar'], + 'tefillin earliest time' => ['misheyakir'], + 'displayed sunrise' => ['sunrise'], + 'displayed latest prayer' => ['sofZmanTfillaMGA'], + 'preceding solar midnight' => ['chatzotNight'], + ]; + } + + #[DataProvider('invalidDailyValues')] + public function testSupportedDailyValuesRetainStrictTimestampAndDateValidation(string $name, string $value): void + { + $raw = self::fixture(); + $raw['times'][$name]['2026-10-15'] = $value; + $this->expectException(ProviderException::class); + self::provider($raw)->solar(self::settings(), self::START, self::END); + } + + public static function invalidDailyValues(): array + { + return [ + 'sunset wrong IANA offset' => ['sunset', '2026-10-15T18:00:00+02:00'], + 'sunrise wrong civil day' => ['sunrise', '2026-10-16T06:00:00+03:00'], + 'dawn invalid civil date' => ['alotHaShachar', '2026-10-32T05:00:00+03:00'], + 'nightfall missing offset' => ['tzeit85deg', '2026-10-15T19:00:00'], + 'midnight following night' => ['chatzotNight', '2026-10-16T00:23:00+03:00'], + 'midnight after sunrise' => ['chatzotNight', '2026-10-15T10:23:00+03:00'], + ]; + } + + public function testRecognizedDailySeriesCannotBeScalar(): void + { + $raw = self::fixture(); + $raw['times']['misheyakir'] = 'malformed'; + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Malformed solar time series.'); + self::provider($raw)->solar(self::settings(), self::START, self::END); + } + + public function testEmptyRecognizedDailySeriesRemainsAnError(): void + { + $raw = self::fixture(); + $raw['times']['sunset'] = []; + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Incomplete solar date coverage.'); + self::provider($raw)->solar(self::settings(), self::START, self::END); + } + + public function testExplicitNullPolarTimesArePreservedWithoutInventingTimes(): void + { + $raw = self::fixture(); + foreach (['sunset', 'tzeit85deg', 'tzeit7083deg', 'alotHaShachar', 'sunrise', 'chatzotNight'] as $name) { + $raw['times'][$name] = array_fill_keys(array_keys($raw['times'][$name]), null); + } + $days = self::provider($raw)->solar(self::settings(), self::START, self::END); + foreach ($days as $times) { + self::assertArrayHasKey('sunset', $times); + self::assertNull($times['sunset']); + self::assertNull($times['tzeit85deg']); + self::assertNull($times['alotHaShachar']); + } + } + + private static function fixture(): array + { + return json_decode(file_get_contents(__DIR__ . '/Fixtures/' . self::FIXTURE), true, 512, JSON_THROW_ON_ERROR); + } + + private static function settings(): array + { + return ['latitude' => 31.76904, 'longitude' => 35.21633, 'timezone_id' => 'Asia/Jerusalem']; + } + + private static function provider(array $raw): HebcalProvider + { + $client = new class ($raw) implements JsonClient { + public function __construct(private array $response) + { + } + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + $cancellation?->throwIfRequested(); + return $this->response; + } + }; + return new HebcalProvider($client); + } +} diff --git a/tests/HebcalMidnightRegressionTest.php b/tests/HebcalMidnightRegressionTest.php new file mode 100644 index 0000000..4c38380 --- /dev/null +++ b/tests/HebcalMidnightRegressionTest.php @@ -0,0 +1,183 @@ +migrate(); + return (new SettingsRepository($db, $clock))->ensureGroup('-1'); + } + + private function fixture(string $name): array + { + return json_decode((string) file_get_contents(__DIR__ . '/Fixtures/' . $name), true, 64, JSON_THROW_ON_ERROR); + } + + private function client(array $solar, ?array $calendar = null): JsonClient + { + return new class ($solar, $calendar) implements JsonClient { + public function __construct(private array $solar, private ?array $calendar) + { + } + + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + $cancellation?->throwIfRequested(); + if (str_ends_with($url, '/hebcal')) { + return $this->calendar ?? throw new \LogicException('Unexpected calendar request.'); + } + return $this->solar; + } + }; + } + + private function solarRange(string $start, string $end): array + { + $data = $this->fixture('hebcal-jerusalem-solar-2026-10.json'); + $data['date'] = ['start' => $start, 'end' => $end]; + foreach ($data['times'] as &$values) { + $values = array_filter($values, static fn(string $key): bool => $key >= $start && $key <= $end, ARRAY_FILTER_USE_KEY); + } + unset($values); + return $data; + } + + public function testPublicOctoberRangeKeepsWinterMidnightOnItsOriginalInstant(): void + { + $raw = $this->fixture('hebcal-jerusalem-solar-2026-10.json'); + self::assertSame('2026-10-25T23:23:00+02:00', $raw['times']['chatzotNight']['2026-10-26']); + $solar = (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-09-23', '2026-12-10'); + self::assertCount(79, $solar); + self::assertSame((new \DateTimeImmutable('2026-10-25T23:23:00+02:00'))->getTimestamp(), $solar['2026-10-26']['chatzotNight']); + self::assertSame((new \DateTimeImmutable($raw['times']['sunset']['2026-10-26']))->getTimestamp(), $solar['2026-10-26']['sunset']); + } + + public function testSummerMidnightRetainsTheNominalCivilDate(): void + { + $raw = $this->fixture('hebcal-jerusalem-solar-2026-07.json'); + $solar = (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-07-01', '2026-07-02'); + foreach ($solar as $day => $values) { + $instant = (new \DateTimeImmutable('@' . $values['chatzotNight']))->setTimezone(new \DateTimeZone('Asia/Jerusalem')); + self::assertSame($day, $instant->format('Y-m-d')); + self::assertSame((new \DateTimeImmutable($raw['times']['chatzotNight'][$day]))->getTimestamp(), $values['chatzotNight']); + self::assertLessThan($values['sunrise'], $values['chatzotNight']); + } + } + + public function testSpringTransitionPreservesMidnightBeforeTheClockChange(): void + { + $raw = $this->fixture('hebcal-jerusalem-solar-2026-03.json'); + $solar = (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-03-26', '2026-03-28'); + self::assertSame((new \DateTimeImmutable('2026-03-26T23:44:00+02:00'))->getTimestamp(), $solar['2026-03-27']['chatzotNight']); + self::assertSame((new \DateTimeImmutable('2026-03-27T06:34:00+03:00'))->getTimestamp(), $solar['2026-03-27']['sunrise']); + self::assertSame((new \DateTimeImmutable('2026-03-28T00:44:00+03:00'))->getTimestamp(), $solar['2026-03-28']['chatzotNight']); + } + + public function testWinterSingleDayHasNoDependencyOnPreviousRequestedDay(): void + { + $raw = $this->solarRange('2026-11-04', '2026-11-04'); + $solar = (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-11-04', '2026-11-04'); + self::assertSame((new \DateTimeImmutable('2026-11-03T23:23:00+02:00'))->getTimestamp(), $solar['2026-11-04']['chatzotNight']); + } + + public function testAutumnTransitionUsesEachActualInstantOffset(): void + { + $raw = $this->solarRange('2026-10-25', '2026-10-26'); + $solar = (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-10-25', '2026-10-26'); + self::assertSame((new \DateTimeImmutable('2026-10-25T00:23:00+03:00'))->getTimestamp(), $solar['2026-10-25']['chatzotNight']); + self::assertSame((new \DateTimeImmutable('2026-10-25T23:23:00+02:00'))->getTimestamp(), $solar['2026-10-26']['chatzotNight']); + self::assertSame((new \DateTimeImmutable($raw['times']['sunset']['2026-10-25']))->getTimestamp(), $solar['2026-10-25']['sunset']); + } + + public function testSnapshotAndZmanimUseLiveCapturedRangeWithoutChangingOperationalTimes(): void + { + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-14T12:00:00Z')); + $raw = $this->fixture('hebcal-jerusalem-solar-2026-10.json'); + $calendar = $this->fixture('hebcal-jerusalem-calendar-2026-10.json'); + $settings = $this->settings(); + $settings['close_before_candles_minutes'] = 10; + $settings['open_after_havdalah_minutes'] = 15; + $service = new CalendarService(new HebcalProvider($this->client($raw, $calendar)), $clock); + $state = $service->snapshot($settings); + self::assertFalse($state['should_group_be_locked']); + self::assertSame(['2026-10-17'], $state['next_event']['days']); + $sunset = (new \DateTimeImmutable($raw['times']['sunset']['2026-10-16']))->getTimestamp(); + self::assertSame($sunset - 40 * 60, $state['next_event']['candles_at']); + self::assertSame($sunset - 50 * 60, $state['next_event']['close_at']); + self::assertSame((new \DateTimeImmutable($raw['times']['tzeit85deg']['2026-10-17']))->getTimestamp() + 15 * 60, $state['next_event']['open_at']); + $zmanim = $service->zmanim($settings, '2026-10-26'); + self::assertSame((new \DateTimeImmutable($raw['times']['chatzotNight']['2026-10-26']))->getTimestamp(), $zmanim['times']['chatzotNight']); + $duringShabbat = $service->snapshot($settings, new \DateTimeImmutable('2026-10-17T12:00:00+03:00')); + self::assertTrue($duringShabbat['should_group_be_locked']); + $afterShabbat = $service->snapshot($settings, new \DateTimeImmutable('@' . $state['next_event']['open_at'])); + self::assertFalse($afterShabbat['should_group_be_locked']); + } + + public function testOnlyMidnightCanUseThePrecedingCivilDate(): void + { + foreach (['sunrise', 'sunset', 'alotHaShachar', 'misheyakir', 'tzeit7083deg', 'tzeit85deg'] as $name) { + $raw = $this->solarRange('2026-11-04', '2026-11-04'); + $raw['times'][$name]['2026-11-04'] = '2026-11-03T23:23:00+02:00'; + try { + (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-11-04', '2026-11-04'); + self::fail('Accepted previous-day ordinary field: ' . $name); + } catch (ProviderException $error) { + self::assertSame('Solar date/timezone mismatch.', $error->getMessage()); + } + } + } + + public function testMidnightRejectsOtherNightsNoonAndInvalidTimezoneOffsets(): void + { + foreach ( + [ + '2026-11-02T23:23:00+02:00', + '2026-11-04T23:23:00+02:00', + '2026-11-05T00:23:00+02:00', + '2026-11-03T11:59:00+02:00', + '2026-11-04T12:00:00+02:00', + '2026-11-03T23:23:00+03:00', + ] as $invalid + ) { + $raw = $this->solarRange('2026-11-04', '2026-11-04'); + $raw['times']['chatzotNight']['2026-11-04'] = $invalid; + try { + (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-11-04', '2026-11-04'); + self::fail('Accepted invalid midnight: ' . $invalid); + } catch (ProviderException) { + self::assertTrue(true); + } + } + } + + public function testMidnightMustFollowPreviousSunsetAndPrecedeCurrentSunriseWhenAvailable(): void + { + foreach (['2026-11-03T15:00:00+02:00', '2026-11-04T08:00:00+02:00'] as $invalid) { + $raw = $this->solarRange('2026-11-03', '2026-11-04'); + $raw['times']['chatzotNight']['2026-11-04'] = $invalid; + try { + (new HebcalProvider($this->client($raw)))->solar($this->settings(), '2026-11-03', '2026-11-04'); + self::fail('Accepted midnight outside its solar night: ' . $invalid); + } catch (ProviderException) { + self::assertTrue(true); + } + } + } +} diff --git a/tests/InlineFallbackTest.php b/tests/InlineFallbackTest.php new file mode 100644 index 0000000..e5a5c2a --- /dev/null +++ b/tests/InlineFallbackTest.php @@ -0,0 +1,124 @@ +newInstanceWithoutConstructor(); + (new ReflectionProperty(RPCErrorException::class, 'rpc'))->setValue($error, 'REPLY_MARKUP_INVALID'); + (new ReflectionProperty(\Exception::class, 'code'))->setValue($error, 400); + $fixture = new GatewayFixtureTransport(); + $fixture->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipantAdmin']]; + $transport = new class ($fixture, $error, $outcome) implements MadelineTransport { + public array $inline = []; + public function __construct( + private GatewayFixtureTransport $fixture, + private RPCErrorException $error, + private string $outcome + ) { + } + public function call(string $method, array $parameters): mixed + { + if ($method === 'messages.setInlineBotResults') { + $this->inline[] = $parameters; + if (count($this->inline) === 1 || $this->outcome === 'failure') { + throw $this->error; + } + return true; + } + return $this->fixture->call($method, $parameters); + } + public function info(string $peer): array + { + return $this->fixture->info($peer); + } + public function fullInfo(string $peer): array + { + return $this->fixture->fullInfo($peer); + } + public function fileInfo(string $fileId): array + { + return $this->fixture->fileInfo($fileId); + } + public function self(): array + { + return $this->fixture->self(); + } + }; + $clock = new FrozenClock(new DateTimeImmutable('2026-10-14T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $settings = new SettingsRepository($db, $clock); + $flows = new FlowTokenRepository($db, $clock); + $settings->ensureGroup('-1001'); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + return ['totalResultsCount' => 1, 'geonames' => [[ + 'geonameId' => 281184, 'name' => 'Jerusalem', 'lat' => '31.76904', 'lng' => '35.21633', + ]]]; + } + }; + $ui = new BotController( + $settings, + $flows, + new CalendarService(new HebcalProvider($http), $clock), + new MadelineGateway($transport), + new GeoNames($http, $clock, 'offline-fixture'), + $clock + ); + $token = $flows->create('1', 'group', '-1001', 1, 'location'); + $failure = null; + try { + self::assertTrue($ui->handleInline([ + 'user_id' => '1', 'query_id' => '123', 'query' => 'loc ' . $token . ' Jerusalem', + ])); + } catch (RPCErrorException $caught) { + $failure = $caught; + } + self::assertCount(2, $transport->inline); + self::assertNotEmpty($transport->inline[0]['results'][0]['send_message']['reply_markup']['rows']); + $fallback = $transport->inline[1]['results']; + self::assertCount(1, $fallback); + self::assertSame('location-unavailable', $fallback[0]['id']); + self::assertArrayNotHasKey('reply_markup', $fallback[0]['send_message']); + self::assertSame(1, $settings->getGroup('-1001')['revision']); + if ($outcome === 'success') { + self::assertNull($failure); + } else { + self::assertSame($error, $failure); + self::assertSame(400, $failure->getCode()); + } + } +} diff --git a/tests/InlineSerializationTest.php b/tests/InlineSerializationTest.php new file mode 100644 index 0000000..0ec26e0 --- /dev/null +++ b/tests/InlineSerializationTest.php @@ -0,0 +1,139 @@ +newInstanceWithoutConstructor(); + $adapter->settings = new danog\MadelineProto\Settings(); + $adapter->logger = new Logger((new danog\MadelineProto\Settings\Logger())->setType(Logger::CALLABLE_LOGGER)->setExtra(static function (): void { + })); + $tl = new TL($adapter); + $tl->init(new TLSchema()); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-14T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $settings = new SettingsRepository($db, $clock); + $flows = new FlowTokenRepository($db, $clock); + $group = $settings->ensureGroup('-1001', 'Native serialization group'); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + $cancellation?->throwIfRequested(); + if (!str_ends_with($url, '/searchJSON')) { + throw new RuntimeException('No other remote request is allowed by this offline fixture.'); + } + return ['totalResultsCount' => 1, 'geonames' => [['geonameId' => 281184, 'name' => 'Jerusalem', 'countryName' => 'Israel', 'lat' => '31.76904', 'lng' => '35.21633', 'timezone' => ['timeZoneId' => 'Asia/Jerusalem']]]]; + } + }; + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipantAdmin']]; + $gateway = new MadelineGateway($transport); + $ui = new BotController($settings, $flows, new CalendarService(new HebcalProvider($http), $clock), $gateway, new GeoNames($http, $clock, 'offline-fixture'), $clock); + $ensure = static function (bool $condition, string $message): void { + self::assertTrue($condition, $message); + }; + $lastInline = static function () use ($transport, $ensure): array { + $calls = array_values(array_filter($transport->calls, static fn (array $call): bool => $call[0] === 'messages.setInlineBotResults')); + $ensure($calls !== [], 'No native inline RPC was emitted.'); + return $calls[array_key_last($calls)][1]; + }; + $roundTrip = static function (array $params) use ($tl, $ensure): array { + $wire = $tl->serializeMethod('messages.setInlineBotResults', $params); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $wire); + rewind($stream); + $decoded = $tl->deserialize($stream, ['type' => '', 'connection' => null, 'encrypted' => false]); + $ensure(ftell($stream) === strlen($wire), 'Native decoder did not consume the full RPC payload.'); + fclose($stream); + $ensure($decoded['_'] === 'method_messages.setInlineBotResults', 'Native RPC identity mismatch.'); + $ensure($decoded['query_id'] === $params['query_id'], 'Native query identity changed.'); + return [$decoded, strlen($wire), hash('sha256', $wire)]; + }; + $cases = []; + foreach (['public-city' => 'Jerusalem', 'public-timezone' => 'tz Jerusalem'] as $case => $query) { + $ui->handleInline(['user_id' => '1', 'query_id' => '123', 'query' => $query]); + $params = $lastInline(); + [$decoded, $bytes, $sha] = $roundTrip($params); + $ensure($decoded['results'] !== [], 'Buttonless inline results unexpectedly empty.'); + foreach ($decoded['results'] as $result) { + $ensure($result['_'] === 'inputBotInlineResult', 'Wrong input result constructor.'); + $ensure($result['send_message']['_'] === 'inputBotInlineMessageText', 'Wrong input message constructor.'); + $ensure(!array_key_exists('reply_markup', $result['send_message']), 'Buttonless native message contains a reply markup flag.'); + } + $cases[$case] = ['results' => count($decoded['results']), 'wire_bytes' => $bytes, 'wire_sha256' => $sha, 'reply_markup_present' => false]; + } + $buttonless = $lastInline(); + $buttonless['results'][0]['send_message']['reply_markup'] = ['_' => 'replyInlineMarkup', 'rows' => []]; + [$oldDecoded, $oldBytes] = $roundTrip($buttonless); + $ensure(array_key_exists('reply_markup', $oldDecoded['results'][0]['send_message']), 'Old empty markup did not set the native optional-field flag.'); + $ensure($oldDecoded['results'][0]['send_message']['reply_markup']['rows'] === [], 'Old empty keyboard was not preserved on wire.'); + $cases['dev1-empty-markup-reproduction'] = ['wire_bytes' => $oldBytes, 'reply_markup_present' => true, 'rows' => 0, 'server_rejection' => 'REPLY_MARKUP_INVALID corroborated by supplied real test-bot trace; no live RPC made here']; + foreach (['bound-city' => ['city', 'location Jerusalem'], 'bound-timezone' => ['timezone', 'timezone Jerusalem']] as $case => [$kind, $query]) { + $token = $flows->create('1', 'group', '-1001', $group['revision'], 'panel'); + $ui->handleCallback(['user_id' => '1', 'chat_id' => '1', 'private' => true, 'message_id' => 707, 'query_id' => '124', 'data' => 'sb:lookup:' . $token . ':' . $kind]); + $ui->handleInline(['user_id' => '1', 'query_id' => '125', 'query' => $query]); + [$decoded, $bytes, $sha] = $roundTrip($lastInline()); + $ensure($decoded['results'] !== [], 'Bound inline results unexpectedly empty.'); + foreach ($decoded['results'] as $result) { + $markup = $result['send_message']['reply_markup'] ?? []; + $ensure(($markup['_'] ?? '') === 'replyInlineMarkup' && ($markup['rows'] ?? []) !== [], 'Bound message has no nonempty inline markup.'); + $buttons = array_merge(...array_column($markup['rows'], 'buttons')); + $ensure(count($buttons) === 2, 'Expected exactly a selection callback and private deep link.'); + $ensure($buttons[0]['_'] === 'keyboardButtonCallback', 'Wrong native selection button.'); + $ensure(str_starts_with((string)$buttons[0]['data'], 'sb:lp:'), 'Selection flow identity changed.'); + $ensure($buttons[1]['_'] === 'keyboardButtonUrl' && str_starts_with($buttons[1]['url'], 'https://t.me/FixtureShabbatBot?start=loc_'), 'Wrong native confirmation link.'); + } + $cases[$case] = ['results' => count($decoded['results']), 'wire_bytes' => $bytes, 'wire_sha256' => $sha, 'reply_markup_present' => true, 'buttons_per_result' => 2]; + } + $unconfigured = new BotController($settings, $flows, new CalendarService(new HebcalProvider($http), $clock), $gateway, new GeoNames($http, $clock, ''), $clock); + $unconfigured->handleInline(['user_id' => '1', 'query_id' => '126', 'query' => 'UnknownCity']); + [$decoded, $bytes] = $roundTrip($lastInline()); + $ensure($decoded['results'][0]['id'] === 'location-unavailable', 'Provider error article missing.'); + $ensure(!array_key_exists('reply_markup', $decoded['results'][0]['send_message']), 'Provider error fallback contains invalid empty native markup.'); + $cases['provider-error'] = ['wire_bytes' => $bytes, 'reply_markup_present' => false]; + $ensure($settings->getGroup('-1001')['revision'] === $group['revision'] && $settings->getUser('1')['revision'] === 1, 'Inline search mutated settings.'); + self::assertSame(['public-city', 'public-timezone', 'dev1-empty-markup-reproduction', 'bound-city', 'bound-timezone', 'provider-error'], array_keys($cases)); + } finally { + $tl = null; + $adapter = null; + Logger::$default = $previousLogger; + } + } +} diff --git a/tests/LanguageMigrationTest.php b/tests/LanguageMigrationTest.php new file mode 100644 index 0000000..4176746 --- /dev/null +++ b/tests/LanguageMigrationTest.php @@ -0,0 +1,119 @@ +migrate($dir); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $settings = new SettingsRepository($db, $clock); + $settings->ensureGroup('-1', 'Preserved'); + $settings->getUser('17'); + $settings->updateGroup('-1', 1, ['language' => 'en','location_name' => 'Edited city','latitude' => 42.1,'longitude' => -71.2,'timezone_id' => 'America/New_York','israel' => false,'omer_enabled' => true,'omer_nusach' => 'sephard','tefillin_fixed_times' => ['09:01','12:07'],'manual_override_mode' => 'lock','manual_override_until' => 2000000000], '17'); + $settings->updateUser('17', 1, ['language' => 'en','location_name' => 'Personal city']); + $db->pdo()->exec("INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,snapshot_sha256,prepared_at) VALUES('-1','festival','recovery_required','{\"send_polls\":true}','original-signature',1791882000)"); + // Historical 001-003 fixture: do not call today's repository, which requires later schema fields. + $db->pdo()->prepare("INSERT INTO scheduled_jobs(job_id,group_id,module,event_key,run_at,expires_at,settings_revision,payload_json,random_id,status,attempts,created_at,completed_at) VALUES(91,'-1','omer','omer-2026',1791882000,1791885600,2,?,'9191','sent',1,1791882000,1791882001)") + ->execute([Database::encode(['message' => 'Stored text'])]); + $db->pdo()->prepare("INSERT INTO delivery_log(job_id,attempt,random_id,started_at,finished_at,outcome,response_json) VALUES(91,1,'9191',1791882000,1791882001,'sent',?)") + ->execute([Database::encode(['message_id' => 51])]); + $db->pdo()->exec("INSERT INTO admin_state(user_id,state_key,value_kind,value_text,updated_at) VALUES('17','txt.txt','text','Edited draft',1791882000)"); + $donations = new Donations($db, $clock, static fn(string $method, array $args): mixed=>$method === 'payments.exportInvoice' ? ['url' => 'https://t.me/invoice-fixture'] : true); + $offer = $donations->createOffers('17', 'en')['offers'][0]; + $donations->answerPrecheckout(['query_id' => '91','user_id' => 17,'currency' => 'XTR','total_amount' => 5,'payload' => $offer['payload']]); + $donations->recordPayment(['user_id' => 17,'currency' => 'XTR','total_amount' => 5,'payload' => $offer['payload'],'charge' => ['id' => 'fixture-preserved-charge']]); + $tables = ['group_settings','user_preferences','payment_intents','donations','groups','users','group_lock_state','scheduled_jobs','delivery_log','audit_log','admin_state']; + $before = []; + foreach ($tables as $table) { + $before[$table] = $db->pdo()->query('SELECT * FROM ' . $table . ' ORDER BY rowid')->fetchAll(); + } + copy(dirname(__DIR__) . '/migrations/004_expand_languages.sql', $dir . '/004_expand_languages.sql'); + self::assertSame(['004_expand_languages.sql'], $db->migrate($dir)); + foreach ($tables as $table) { + self::assertSame($before[$table], $db->pdo()->query('SELECT * FROM ' . $table . ' ORDER BY rowid')->fetchAll(), $table . ' changed during language-only migration'); + } + self::assertSame([], $db->integrity()['foreign_key_check']); + self::assertSame(['ok'], $db->integrity()['integrity_check']); + self::assertSame(['donations' => 1,'stars' => 5], $donations->stats()); + self::assertSame('payment_intents', $db->pdo()->query('PRAGMA foreign_key_list(donations)')->fetchAll()[1]['table'] ?? $db->pdo()->query('PRAGMA foreign_key_list(donations)')->fetchAll()[0]['table']); + self::assertSame([], $db->migrate($dir)); + } finally { + foreach (glob($dir . '/*') ?: [] as $file) { + unlink($file); + } rmdir($dir); + } + } + public function testAllSixGroupUserAndInvoiceLanguagesRoundTripWithoutCrossScopeChanges(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $settings = new SettingsRepository($db, $clock); + $lang = new Lang(); + $group = $settings->ensureGroup('-1'); + $user = $settings->getUser('17'); + foreach (Lang::CODES as $code) { + $group = $settings->updateGroup('-1', $group['revision'], ['language' => $code], '17'); + self::assertSame($user['language'], $settings->getUser('17')['language']); + $user = $settings->updateUser('17', $user['revision'], ['language' => $code]); + self::assertSame($code, $settings->getGroup('-1')['language']); + self::assertSame($code, $settings->getUser('17')['language']); + $calls = []; + $service = new Donations($db, $clock, static function (string $method, array $args) use (&$calls): array { + $calls[] = $args; + return ['url' => 'https://t.me/invoice-fixture']; + }); + $offers = $service->createOffers('17', $code); + self::assertSame($lang->text($code, 'donate_prompt'), $offers['text']); + self::assertCount(6, $offers['offers']); + self::assertSame($lang->text($code, 'donate_title'), $calls[0]['invoice_media']['title']); + self::assertSame($code, $db->pdo()->query('SELECT language FROM payment_intents ORDER BY intent_id DESC LIMIT 1')->fetchColumn()); + } + try { + $db->pdo()->exec("UPDATE group_settings SET language='unsupported'"); + self::fail('SQL constraint accepted an unknown language.'); + } catch (PDOException) { + } + } + public function testSixProviderMappingsAndBundledOmerCountsAreCompleteAndDeterministic(): void + { + self::assertSame(['he','s','ru','fr','es','pt'], array_map(HebcalProvider::providerLanguage(...), Lang::CODES)); + $localized = json_decode(file_get_contents(dirname(__DIR__) . '/resources/templates/omer-counts-localized.json'), true, 64, JSON_THROW_ON_ERROR)['counts']; + foreach (Lang::CODES as $code) { + for ($day = 1; $day <= 49; $day++) { + $text = OmerText::count($day, 'ashkenaz', $code); + self::assertSame($text, OmerText::count($day, 'ashkenaz', $code)); + self::assertStringContainsString('ื‘ึธึผืขึฝื•ึนืžึถืจ', $text); + self::assertNotSame('', $text); + if (!in_array($code, ['he','en'], true)) { + self::assertStringContainsString($localized[(string)$day][$code], $text); + } + } + self::assertStringNotContainsString('ื‘ืจื•ืš ืืชื”', OmerText::render(7, 'sephard', true, true, $code)); + self::assertStringContainsString('ื‘ืจื•ืš ืืชื”', OmerText::render(49, 'sephard', true, false, $code)); + } + } +} diff --git a/tests/LegacyAdminImportTest.php b/tests/LegacyAdminImportTest.php new file mode 100644 index 0000000..e0bf555 --- /dev/null +++ b/tests/LegacyAdminImportTest.php @@ -0,0 +1,142 @@ +root = sys_get_temp_dir() . '/shabbat-legacy-admin-' . bin2hex(random_bytes(8)); + mkdir($this->root . '/data/17', 0700, true); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + file_put_contents($this->root . '/data/DBgroups.txt', "-1001\n"); + file_put_contents($this->root . '/ids.txt', "17\n18\n17\n-1001\n-1009876\n0\n9223372036854775808\nbad\n"); + file_put_contents($this->root . '/idsnew.txt', "18\n-1001\n"); + file_put_contents($this->root . '/data/17/grs1.txt', "chatpost\r\n"); + file_put_contents($this->root . '/data/17/messagetodelete.txt', '913'); + file_put_contents($this->root . '/data/17/txt.txt', "ืฉื‘ืช ืฉืœื•ื ๐Ÿ˜€\r\nLiteral text"); + file_put_contents($this->root . '/data/17/ent.txt', '[{"_":"messageEntityBold","offset":0,"length":4}]'); + file_put_contents($this->root . '/data/17/media.txt', 'preserved-bot-file-id'); + file_put_contents($this->root . '/data/BUTTONS.txt', '[[{"text":"Help","url":"https://example.test","style":{"_":"keyboardButtonStyle","bg_primary":true}}]]'); + file_put_contents($this->root . '/data/17/BUTTONS.txt', '[[{"text":"Private","url":"https://private.example.test","style":{"_":"keyboardButtonStyle","bg_success":true}}]]'); + file_put_contents($this->root . '/data/17/pinmessage.txt', '0'); + file_put_contents($this->root . '/data/17/broadcastsend.txt', '2'); + file_put_contents($this->root . '/data/17/page_var.txt', '4'); + file_put_contents($this->root . '/data/pinmessage.txt', '1'); + file_put_contents($this->root . '/data/broadcastsend.txt', '3'); + file_put_contents($this->root . '/api.txt', 'never-import-this-secret'); + AdminStateFiles::configure($this->db, $this->clock, $this->root); + } + protected function tearDown(): void + { + $this->removeDirectory($this->root); + } + private function removeDirectory(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } $child = $path . '/' . $entry; + if (is_dir($child)) { + $this->removeDirectory($child); + } else { + unlink($child); + } + } rmdir($path); + } + public function testDryRunReportsKnownUsersAndWhitelistedDraftsWithoutWrites(): void + { + $result = (new LegacyImporter($this->db, $this->clock))->run($this->root); + self::assertSame(2, $result['counts']['users']); + self::assertSame(14, $result['counts']['admin_state']); + foreach (['users','groups','admin_state','message_templates','legacy_imports','known_broadcast_peers'] as $table) { + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn()); + } + self::assertStringContainsString('never-import', $this->root !== '' ? file_get_contents($this->root . '/api.txt') : ''); + } + public function testCommittedImportPreservesExactDraftBytesAndAddsUsersWithoutAuthority(): void + { + $importer = new LegacyImporter($this->db, $this->clock); + $result = $importer->run($this->root, false); + self::assertSame(['17','18'], $this->db->pdo()->query('SELECT user_id FROM users ORDER BY user_id')->fetchAll(\PDO::FETCH_COLUMN)); + self::assertSame(4, $result['counts']['known_peers']); + self::assertSame(['-1001','-1009876','17','18'], $this->db->pdo()->query('SELECT peer_id FROM known_broadcast_peers ORDER BY peer_id')->fetchAll(\PDO::FETCH_COLUMN)); + self::assertSame(['-1001'], $this->db->pdo()->query('SELECT group_id FROM groups ORDER BY group_id')->fetchAll(\PDO::FETCH_COLUMN)); + self::assertSame('unknown', $this->db->pdo()->query("SELECT peer_type FROM known_broadcast_peers WHERE peer_id='-1009876'")->fetchColumn()); + foreach (['grs1.txt','messagetodelete.txt','txt.txt','ent.txt','media.txt','BUTTONS.txt','pinmessage.txt','broadcastsend.txt','page_var.txt'] as $key) { + self::assertSame(file_get_contents($this->root . '/data/17/' . $key), AdminStateFiles::read('data/17/' . $key)); + } + foreach (['BUTTONS.txt','pinmessage.txt','broadcastsend.txt'] as $key) { + self::assertSame(file_get_contents($this->root . '/data/' . $key), AdminStateFiles::read('data/' . $key)); + } + self::assertSame(file_get_contents($this->root . '/ids.txt'), AdminStateFiles::read('ids.txt')); + self::assertSame(0, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM legacy_imports WHERE source_path LIKE '%api.txt'")->fetchColumn()); + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM conversation_states')->fetchColumn()); + self::assertTrue(AdminStateFiles::isDirectory('data/17')); + $draft = json_decode($this->db->pdo()->query("SELECT buttons_json FROM message_templates WHERE user_id='17' AND kind='broadcast'")->fetchColumn(), true, 64, JSON_THROW_ON_ERROR); + self::assertSame('custom', $this->db->pdo()->query("SELECT buttons_mode FROM message_templates WHERE user_id='17' AND kind='broadcast'")->fetchColumn()); + self::assertSame('Private', $draft[0][0]['text']); + self::assertTrue($draft[0][0]['style']['bg_success']); + self::assertSame('0', AdminStateFiles::read('data/17/pinmessage.txt')); + self::assertSame('1', AdminStateFiles::read('data/pinmessage.txt')); + $settings = new SettingsRepository($this->db, $this->clock); + $settings->updateUser('17', 1, ['language' => 'fr','location_name' => 'Edited location']); + AdminStateFiles::write('data/17/txt.txt', 'Edited live draft'); + AdminStateFiles::write('ids.txt', "42\n"); + $this->db->pdo()->exec("UPDATE known_broadcast_peers SET peer_type='channel',title='Reviewed channel' WHERE peer_id='-1009876'"); + $importer->run($this->root, false); + self::assertSame('channel', $this->db->pdo()->query("SELECT peer_type FROM known_broadcast_peers WHERE peer_id='-1009876'")->fetchColumn()); + self::assertSame('Reviewed channel', $this->db->pdo()->query("SELECT title FROM known_broadcast_peers WHERE peer_id='-1009876'")->fetchColumn()); + self::assertSame('Edited live draft', AdminStateFiles::read('data/17/txt.txt')); + self::assertSame("42\n", AdminStateFiles::read('ids.txt')); + self::assertSame('fr', $settings->getUser('17')['language']); + self::assertSame('Edited location', $settings->getUser('17')['location_name']); + self::assertSame(1, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM message_templates WHERE user_id='17' AND kind='broadcast'")->fetchColumn()); + self::assertSame(14, $result['counts']['admin_state']); + } + public function testAutomaticGroupBridgeDoesNotImportPrivateUsersOrDrafts(): void + { + (new LegacyImporter($this->db, $this->clock))->importGroupListAutomatically($this->root); + self::assertSame(1, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM groups')->fetchColumn()); + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn()); + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM admin_state')->fetchColumn()); + self::assertSame(0, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM known_broadcast_peers')->fetchColumn()); + } + + public function testNativeGroupAndBotApiPrivateWrappersPreserveButtonsStylesAndCustomMode(): void + { + $nativeButton = ['_' => 'keyboardButtonCallback', 'text' => 'Existing callback', 'data' => 'legacy:action', 'style' => ['_' => 'keyboardButtonStyle', 'bg_primary' => true, 'icon' => 5944760643524500583]]; + $privateButton = ['text' => 'Existing link', 'url' => 'https://example.com/', 'style' => ['_' => 'keyboardButtonStyle', 'bg_success' => true, 'icon' => 5945186253308699040]]; + $native = Database::encode(['_' => 'replyInlineMarkup', 'rows' => [['_' => 'keyboardButtonRow', 'buttons' => [$nativeButton]]]]); + $botApi = Database::encode(['inline_keyboard' => [[$privateButton]]]); + mkdir($this->root . '/data/-1001', 0700); + file_put_contents($this->root . '/data/-1001/MsgCloserButtons.txt', $native); + file_put_contents($this->root . '/data/17/BUTTONS.txt', $botApi); + (new LegacyImporter($this->db, $this->clock))->run($this->root, false); + $templates = new \Shabbat\Storage\TemplateRepository($this->db, $this->clock); + $group = $templates->get('-1001', 'close'); + self::assertSame('custom', $group['buttons_mode']); + self::assertSame([[$nativeButton]], $group['buttons']); + $private = $this->db->pdo()->query("SELECT buttons_mode,buttons_json FROM message_templates WHERE user_id='17' AND kind='broadcast'")->fetch(); + self::assertSame('custom', $private['buttons_mode']); + self::assertSame([[$privateButton]], json_decode($private['buttons_json'], true, 64, JSON_THROW_ON_ERROR)); + self::assertSame($native, file_get_contents($this->root . '/data/-1001/MsgCloserButtons.txt')); + self::assertSame($botApi, file_get_contents($this->root . '/data/17/BUTTONS.txt')); + } +} diff --git a/tests/LocationTest.php b/tests/LocationTest.php new file mode 100644 index 0000000..88dbb0b --- /dev/null +++ b/tests/LocationTest.php @@ -0,0 +1,191 @@ +calls[] = [$url, $query]; + $cancellation?->throwIfRequested(); + return array_shift($this->responses) ?? throw new \RuntimeException('unexpected HTTP call'); + } + }; + } + private function row(): array + { + return ['geonameId' => 281184, 'name' => 'ื™ืจื•ืฉืœื™ื', 'countryName' => 'Israel', 'adminName1' => 'Jerusalem', 'lat' => '31.76904', 'lng' => '35.21633']; + } + public function testHebrewNormalizationCacheAndPaging(): void + { + $http = $this->http([['geonames' => [$this->row()], 'totalResultsCount' => 15]]); + $geo = new GeoNames($http, $this->clock(), 'fixture'); + $a = $geo->search(' ื™ืจื•ืฉืœื™ื ', 0, '1'); + $b = $geo->search('ื™ืจื•ืฉืœื™ื', 0, '2'); + self::assertSame($a, $b); + self::assertCount(1, $http->calls); + self::assertSame(1, $a['next_offset']); + self::assertSame('ื™ืจื•ืฉืœื™ื', $http->calls[0][1]['q']); + self::assertStringStartsWith('https://', $http->calls[0][0]); + } + public function testHttp200QuotaErrorIsNotAnEmptyResult(): void + { + $geo = new GeoNames($this->http([['status' => ['value' => 18, 'message' => 'quota exceeded']]]), $this->clock(), 'fixture'); + $this->expectException(LocationException::class); + $this->expectExceptionMessage('18'); + $geo->search('Jerusalem'); + } + public function testMalformedResponseFails(): void + { + $geo = new GeoNames($this->http([['geonames' => 'invalid']]), $this->clock(), 'fixture'); + $this->expectException(LocationException::class); + $geo->search('Jerusalem'); + } + public function testMissingAccountFailsBeforeCallingProvider(): void + { + $http = $this->http([]); + $geo = new GeoNames($http, $this->clock(), ''); + self::assertFalse($geo->configured()); + try { + $geo->search('London'); + self::fail('missing username accepted'); + } catch (LocationException $e) { + self::assertStringContainsString('GEONAMES_USERNAME', $e->getMessage()); + } + self::assertCount(0, $http->calls); + } + public function testOnlySelectionRequiresTimezoneResolution(): void + { + $http = $this->http([['geonames' => [$this->row()]], $this->row(), ['timezoneId' => 'Asia/Jerusalem', 'rawOffset' => 2]]); + $geo = new GeoNames($http, $this->clock(), 'fixture'); + $geo->search('Jerusalem'); + self::assertCount(1, $http->calls); + $place = $geo->get(281184); + self::assertSame('Asia/Jerusalem', $place['timezone_id']); + self::assertCount(3, $http->calls); + self::assertStringEndsWith('/timezoneJSON', $http->calls[2][0]); + } + public function testReliableTimezoneAvoidsExtraCall(): void + { + $row = $this->row() + ['timezone' => ['timeZoneId' => 'Asia/Jerusalem']]; + $http = $this->http([$row]); + self::assertSame('Asia/Jerusalem', (new GeoNames($http, $this->clock(), 'fixture'))->get(281184)['timezone_id']); + self::assertCount(1, $http->calls); + } + public function testOffsetsCannotReplaceIanaTimezone(): void + { + $geo = new GeoNames($this->http([$this->row(), ['rawOffset' => 2, 'gmtOffset' => 3]]), $this->clock(), 'fixture'); + $this->expectException(LocationException::class); + $geo->get(281184); + } + public function testExplicitCancellationPropagates(): void + { + $cancel = new \Amp\DeferredCancellation(); + $cancel->cancel(); + $geo = new GeoNames($this->http([]), $this->clock(), 'fixture'); + $this->expectException(\Amp\CancelledException::class); + $geo->search('London', 0, '1', $cancel->getCancellation()); + } + public function testRateLimitAndTimeoutAreNotEmptyResults(): void + { + $http = $this->http([['geonames' => []]]); + $geo = new GeoNames($http, $this->clock(), 'fixture'); + $geo->search('London', 0, '1'); + $this->expectException(LocationException::class); + $geo->search('Paris', 0, '1'); + } + public function testCoordinatesAndTimezoneAreValidated(): void + { + foreach (Place::common() as $place) { + self::assertSame($place, Place::validate($place)); + } + $this->expectException(LocationException::class); + Place::validate(['location_name' => 'invalid', 'latitude' => 91, 'longitude' => 0, 'timezone_id' => 'Asia/Jerusalem']); + } + public function testCoordinateTimezoneRequiresReliableIanaAndChecksStatus(): void + { + $geo = new GeoNames($this->http([['timezoneId' => 'Europe/London', 'countryName' => 'United Kingdom', 'rawOffset' => 0, 'sunset' => 'untrusted']]), $this->clock(), 'fixture'); + self::assertSame(['timezone_id' => 'Europe/London', 'country' => 'United Kingdom'], $geo->timezone(51.5, -0.1)); + foreach ([['status' => ['value' => 19, 'message' => 'hourly quota']], ['rawOffset' => 3, 'gmtOffset' => 2]] as $response) { + try { + (new GeoNames($this->http([$response]), $this->clock(), 'fixture'))->timezone(31.7, 35.2); + self::fail('unverified timezone accepted'); + } catch (LocationException) { + self::assertTrue(true); + } + } + } + public function testAutomaticTimezoneDisabledWithoutAccount(): void + { + $http = $this->http([]); + $geo = new GeoNames($http, $this->clock(), ''); + try { + $geo->timezone(31.7, 35.2); + self::fail('missing account accepted'); + } catch (LocationException $e) { + self::assertStringContainsString('IANA', $e->getMessage()); + } + self::assertSame([], $http->calls); + } + public function testTimeoutIsAnExplicitProviderFailure(): void + { + $http = new class implements JsonClient + { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \Shabbat\Http\ProviderException('request timed out'); + } + }; + $this->expectException(\Shabbat\Http\ProviderException::class); + $this->expectExceptionMessage('timed out'); + (new GeoNames($http, $this->clock(), 'fixture'))->search('London'); + } + public function testDemoAccountIsNotEnabled(): void + { + $http = $this->http([]); + $geo = new GeoNames($http, $this->clock(), 'demo'); + self::assertFalse($geo->configured()); + try { + $geo->search('London'); + self::fail('demo account accepted'); + } catch (LocationException) { + self::assertSame([], $http->calls); + } + } + public function testMalformedCoordinatesInSearchAreRejected(): void + { + $row = $this->row(); + $row['lat'] = 'not a number'; + $geo = new GeoNames($this->http([['geonames' => [$row]]]), $this->clock(), 'fixture'); + $this->expectException(LocationException::class); + $geo->search('London'); + } + public function testInsecureEndpointRejected(): void + { + $this->expectException(LocationException::class); + new GeoNames($this->http([]), $this->clock(), 'fixture', 'http://api.geonames.org'); + } +} diff --git a/tests/ManualGroupsFakeGateway.php b/tests/ManualGroupsFakeGateway.php new file mode 100644 index 0000000..97d2749 --- /dev/null +++ b/tests/ManualGroupsFakeGateway.php @@ -0,0 +1,33 @@ +rights[$groupId] ?? ['_' => 'chatBannedRights','send_polls' => true]; + } + public function setPermissions(string $groupId, array $permissions): void + { + if ($this->onSet !== null) { + ($this->onSet)($groupId, $permissions); + } + $this->rights[$groupId] = $permissions; + $this->changes[] = [$groupId,$permissions]; + } + public function send(string $peer, array $payload, string $randomId): array + { + throw new RuntimeException('Manual service must not send an unverified announcement.'); + } +} diff --git a/tests/ManualGroupsTest.php b/tests/ManualGroupsTest.php new file mode 100644 index 0000000..e590dcb --- /dev/null +++ b/tests/ManualGroupsTest.php @@ -0,0 +1,127 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1'); + $this->settings->ensureGroup('-2'); + $this->gateway = new ManualGroupsFakeGateway(); + $this->locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + } + private function service(): ManualGroups + { + return new ManualGroups($this->db, $this->settings, $this->locks, $this->clock, static fn(string $id): bool=>$id === '17'); + } + public function testUnauthorizedOperatorCannotWriteOverridesOrTouchTelegram(): void + { + try { + $this->service()->apply(true, '18'); + self::fail('Unauthorized operation succeeded.'); + } catch (RuntimeException) { + } + self::assertSame('none', $this->settings->getGroup('-1')['manual_override_mode']); + self::assertSame(1, $this->settings->getGroup('-1')['revision']); + self::assertCount(0, $this->gateway->changes); + } + public function testManualCloseUsesDurableSnapshotsAndOpenRestoresOriginalPermissions(): void + { + $original = $this->gateway->getPermissions('-1'); + $closed = $this->service()->apply(true, '17', 600); + self::assertSame(2, $closed['changed']); + self::assertSame(0, $closed['failed']); + self::assertSame('lock', $this->settings->getGroup('-1')['manual_override_mode']); + self::assertNotNull($this->locks->state('-1')['snapshot_sha256']); + self::assertTrue(ManualGroups::desired($this->settings->getGroup('-1'), false, $this->clock->now()->getTimestamp())); + $opened = $this->service()->apply(false, '17', 600); + self::assertSame(2, $opened['changed']); + self::assertSame($original, $this->gateway->getPermissions('-1')); + self::assertFalse(ManualGroups::desired($this->settings->getGroup('-1'), true, $this->clock->now()->getTimestamp())); + self::assertStringContainsString('active festival', $opened['warning']); + self::assertSame(281184, $this->settings->getGroup('-1')['geoname_id']); + } + public function testExpiredOverrideResumesCalendarDesiredStateAtExactCutoff(): void + { + $this->service()->apply(false, '17', 10); + $g = $this->settings->getGroup('-1'); + $now = $this->clock->now()->getTimestamp(); + self::assertFalse(ManualGroups::desired($g, true, $now + 9)); + self::assertTrue(ManualGroups::desired($g, true, $now + 10)); + self::assertFalse(ManualGroups::desired($g, false, $now + 10)); + } + public function testPartialFailureDoesNotClaimSuccessOrBlockOtherGroup(): void + { + $this->gateway->onSet = static function (string $id): void { + if ($id === '-1') { + throw new RuntimeException('simulated permissions RPC failure'); + } + }; + $result = $this->service()->apply(true, '17'); + self::assertSame(1, $result['changed']); + self::assertSame(1, $result['failed']); + self::assertSame('error', $this->locks->state('-1')['status']); + self::assertSame('locked', $this->locks->state('-2')['status']); + self::assertNotNull($this->locks->state('-1')['snapshot_json']); + } + public function testAdministratorPrelockedGroupIsNotOpenedByManualService(): void + { + $this->gateway->rights['-1'] = ['_' => 'chatBannedRights','send_messages' => true]; + $closed = $this->service()->apply(true, '17'); + self::assertSame(1, $closed['changed']); + $opened = $this->service()->apply(false, '17'); + self::assertSame(1, $opened['changed']); + self::assertTrue($this->gateway->rights['-1']['send_messages']); + self::assertSame('prelocked', $this->locks->state('-1')['status']); + } + public function testFailedOverrideDatabaseWriteCannotStartPermissionsRpc(): void + { + $this->db->pdo()->exec("CREATE TRIGGER reject_override BEFORE UPDATE OF manual_override_mode ON group_settings WHEN NEW.group_id='-1' BEGIN SELECT RAISE(FAIL,'simulated disk error'); END"); + $result = $this->service()->apply(true, '17'); + self::assertSame(1, $result['failed']); + self::assertSame('none', $this->settings->getGroup('-1')['manual_override_mode']); + self::assertCount(1, $this->gateway->changes); + self::assertSame('-2', $this->gateway->changes[0][0]); + } + public function testCancellationStopsBatchAndKeepsPersistedRecoverySnapshot(): void + { + $this->gateway->onSet = static function (): void { + throw new Amp\CancelledException(); + }; + try { + $this->service()->apply(true, '17'); + self::fail('Cancellation was swallowed.'); + } catch (Amp\CancelledException) { + } + self::assertSame('prepared', $this->locks->state('-1')['status']); + self::assertNotNull($this->locks->state('-1')['snapshot_sha256']); + self::assertSame('none', $this->settings->getGroup('-2')['manual_override_mode']); + } +} diff --git a/tests/MessageCleanupTest.php b/tests/MessageCleanupTest.php new file mode 100644 index 0000000..9f40080 --- /dev/null +++ b/tests/MessageCleanupTest.php @@ -0,0 +1,343 @@ +setValue(null, false); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T17:45:00+03:00')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->deletions = new DeletionRepository($this->db, $this->clock); + $this->gateway = new CleanupFakeGateway(); + $this->calendar = new CalendarService(new HebcalProvider(new RuntimeFakeJson()), $this->clock); + $this->worker = new MessageCleanup($this->deletions, $this->settings, $this->gateway, $this->calendar, $this->clock, fn(): bool => $this->stopping); + $this->settings->ensureGroup('-1001', 'Example'); + $this->gateway->rights['-1001'] = ['_' => 'chatBannedRights', 'until_date' => 0, 'send_messages' => true]; + } + private function configure(array $changes): array + { + $group = $this->settings->getGroup('-1001'); + return $this->settings->updateGroup('-1001', $group['revision'], $changes, '42'); + } + private function receive(int $id, string $sender = '42', ?int $date = null, bool $outgoing = false): bool + { + return $this->worker->receive('-1001', $id, $date ?? $this->clock->now()->getTimestamp(), $sender, $outgoing); + } + public function testBothDefaultsAreOptInAndNoIdentityLookupOccurs(): void + { + self::assertFalse($this->receive(1)); + self::assertFalse($this->receive(2, '43')); + self::assertSame(0, $this->gateway->classifications); + self::assertNull($this->worker->runOnce('worker')); + } + public function testHumanAndBotPreferencesAreIndependentAndDuplicatesDeleteOnce(): void + { + $this->configure(['delete_holy_user_messages' => true]); + self::assertTrue($this->receive(1)); + self::assertTrue($this->receive(1)); + self::assertFalse($this->receive(2, '43')); + self::assertSame('deleted', $this->worker->runOnce('worker')['status']); + self::assertNull($this->worker->runOnce('worker')); + self::assertSame([['-1001', [1]]], $this->gateway->deleted); + $this->configure(['delete_holy_user_messages' => false, 'delete_holy_bot_messages' => true]); + self::assertFalse($this->receive(3)); + self::assertTrue($this->receive(4, '43')); + self::assertSame('deleted', $this->worker->runOnce('worker')['status']); + self::assertSame([['-1001', [1]], ['-1001', [4]]], $this->gateway->deleted); + } + public function testEarlyClosurePreservesBeforeEntryMessagesAndDelayedOldMessages(): void + { + $g = $this->configure(['delete_holy_user_messages' => true]); + $event = $this->calendar->snapshot($g)['current_event']; + $this->clock->set(new \DateTimeImmutable('@' . ($event['start_at'] - 60))); + self::assertFalse($this->receive(1)); + $this->clock->set(new \DateTimeImmutable('@' . ($event['start_at'] + 60))); + self::assertFalse($this->receive(2, '42', $event['start_at'] - 1)); + self::assertTrue($this->receive(3, '42', $event['start_at'])); + self::assertSame('deleted', $this->worker->runOnce('worker')['status']); + self::assertSame([['-1001', [3]]], $this->gateway->deleted); + } + public function testOwnOutgoingUnknownAnonymousFutureAndInvalidIdsAreNeverDeleted(): void + { + $this->configure(['delete_holy_user_messages' => true, 'delete_holy_bot_messages' => true]); + foreach ([['9000', false], ['99', false], ['-1001', false], ['42', true]] as [$sender, $out]) { + self::assertFalse($this->receive(1, $sender, null, $out)); + } + self::assertFalse($this->receive(0)); + self::assertFalse($this->receive(2, '42', $this->clock->now()->getTimestamp() + 1)); + self::assertNull($this->worker->runOnce('worker')); + self::assertSame([], $this->gateway->deleted); + } + public function testActuallyOpenGroupAndChangedPreferencesCancelQueuedDeletion(): void + { + $this->configure(['delete_holy_user_messages' => true]); + self::assertTrue($this->receive(1)); + $this->gateway->rights['-1001']['send_messages'] = false; + self::assertSame('cancelled', $this->worker->runOnce('worker')['status']); + $this->gateway->rights['-1001']['send_messages'] = true; + self::assertTrue($this->receive(2)); + $this->configure(['delete_holy_user_messages' => false]); + self::assertNull($this->worker->runOnce('worker')); + self::assertSame('cancelled', $this->deletions->get(2)['status']); + self::assertSame([], $this->gateway->deleted); + } + public function testManualOpeningOffModeAndDisabledReligiousSwitchSuppressModeration(): void + { + $this->configure(['delete_holy_user_messages' => true, 'manual_override_mode' => 'open', 'manual_override_until' => $this->clock->now()->getTimestamp() + 3600]); + self::assertFalse($this->receive(1)); + $this->configure(['manual_override_mode' => 'none', 'mode' => 'off']); + self::assertFalse($this->receive(2)); + $this->configure(['mode' => 'lock', 'shabbat_enabled' => false]); + self::assertFalse($this->receive(3)); + } + public function testCrossingExitDuringPermissionReadAndLeaseExpiryNeverDeletes(): void + { + $g = $this->configure(['delete_holy_user_messages' => true]); + $event = $this->calendar->snapshot($g)['current_event']; + self::assertTrue($this->receive(1)); + $this->gateway->beforePermissions = function () use ($event): void { + $this->clock->set(new \DateTimeImmutable('@' . $event['end_at'])); + }; + self::assertSame('lease_lost', $this->worker->runOnce('worker')['status']); + self::assertSame([], $this->gateway->deleted); + $this->clock->set(new \DateTimeImmutable('2026-10-16T17:45:00+03:00')); + $this->gateway->beforePermissions = null; + self::assertTrue($this->receive(2)); + $this->gateway->beforeCapabilities = function (): void { + $this->clock->set($this->clock->now()->modify('+121 seconds')); + }; + self::assertSame('lease_lost', $this->worker->runOnce('worker')['status']); + self::assertSame([], $this->gateway->deleted); + } + public function testNoCapabilityRetriesIndependentlyAndFloodWaitDoesNotSpin(): void + { + $this->configure(['delete_holy_user_messages' => true]); + self::assertTrue($this->receive(1)); + $this->gateway->canDelete = false; + self::assertSame('retry', $this->worker->runOnce('worker')['status']); + self::assertNull($this->worker->runOnce('worker')); + $this->clock->set($this->clock->now()->modify('+30 seconds')); + $this->gateway->canDelete = true; + $this->gateway->deleteError = new RetryableTelegramException('flood', 90); + self::assertSame('retry', $this->worker->runOnce('worker')['status']); + self::assertNull($this->worker->runOnce('worker')); + $this->clock->set($this->clock->now()->modify('+90 seconds')); + $this->gateway->deleteError = null; + self::assertSame('deleted', $this->worker->runOnce('worker')['status']); + self::assertCount(1, $this->gateway->deleted); + } + public function testShutdownAfterNativeReadStopsAndIntentionalCancellationPropagates(): void + { + $this->configure(['delete_holy_user_messages' => true]); + self::assertTrue($this->receive(1)); + $this->gateway->beforePermissions = function (): void { + $this->stopping = true; + }; + self::assertSame('cancelled', $this->worker->runOnce('worker')['status']); + self::assertFalse($this->receive(2)); + self::assertSame([], $this->gateway->deleted); + $this->stopping = false; + $this->gateway->beforePermissions = null; + self::assertTrue($this->receive(3)); + $this->gateway->deleteError = new CancelledException(new \RuntimeException('intentional')); + $this->expectException(CancelledException::class); + $this->worker->runOnce('worker'); + } + public function testTefillinReplacementDeletesOnlyConfirmedPreviousDeliveryAndKeepsNewest(): void + { + $this->clock->set(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->configure(['tefillin_enabled' => true, 'tefillin_delete_previous' => true, 'tefillin_fixed_times' => ['09:00','10:00','11:00']]); + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $heartbeat = sys_get_temp_dir() . '/cleanup-heartbeat-' . bin2hex(random_bytes(6)) . '.json'; + putenv('HEARTBEAT_PATH=' . $heartbeat); + try { + $app->tick(); + self::assertCount(1, $this->gateway->sent); + self::assertSame([], $this->gateway->deleted); + $this->clock->set($this->clock->now()->modify('+1 hour')); + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $app->tick(); + self::assertCount(2, $this->gateway->sent); + self::assertSame([['-1001', [100]]], $this->gateway->deleted); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1001', 'tefillin')); + $this->clock->set($this->clock->now()->modify('+1 hour')); + $this->gateway->sendError = new \RuntimeException('Transport failed before third send.'); + $app->tick(); + self::assertCount(2, $this->gateway->sent); + self::assertCount(1, $this->gateway->deleted); + self::assertSame([101], $this->deletions->getLastModuleMessages('-1001', 'tefillin')); + } finally { + putenv('HEARTBEAT_PATH'); + if (is_file($heartbeat)) { + unlink($heartbeat); + } + } + } + public function testBookkeepingFailureCannotResendAcknowledgedReminder(): void + { + $this->clock->set(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $g = $this->configure(['tefillin_enabled' => true, 'tefillin_delete_previous' => true]); + $jobs = new JobRepository($this->db, $this->clock); + $id = $jobs->enqueue('-1001', 'tefillin', 'fixture', $this->clock->now()->getTimestamp(), $this->clock->now()->getTimestamp() + 3600, $g['revision'], ['text' => 'reminder']); + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true, null, static function (): void { + throw new \RuntimeException('Bookkeeping unavailable.'); + }); + $sent = $worker->runOnce('outbox'); + self::assertSame('sent', $sent['status']); + self::assertTrue($sent['cleanup_pending']); + self::assertSame('sent', $jobs->get($id)['status']); + self::assertNull($worker->runOnce('outbox')); + $pending = $jobs->pendingDeliveryBookkeeping(20); + self::assertCount(1, $pending); + $this->deletions->recordSentJobDelivery($id, $this->gateway->extractSentMessageIds($pending[0]['response']), true); + self::assertSame([], $jobs->pendingDeliveryBookkeeping(20)); + self::assertSame([100], $this->deletions->getLastModuleMessages('-1001', 'tefillin')); + self::assertCount(1, $this->gateway->sent); + } + public static function holySequences(): array + { + return [ + 'same-day Shabbat festival' => [['2026-10-17'], '2026-10-16T17:45:00+03:00', '2026-10-17T18:35:00+03:00'], + 'festival then Shabbat' => [['2026-10-16'], '2026-10-15T17:45:00+03:00', '2026-10-17T18:35:00+03:00'], + 'Shabbat then festival' => [['2026-10-18'], '2026-10-16T17:45:00+03:00', '2026-10-18T18:35:00+03:00'], + 'two festival days then Shabbat' => [['2026-10-15','2026-10-16'], '2026-10-14T17:45:00+03:00', '2026-10-17T18:35:00+03:00'], + ]; + } + #[\PHPUnit\Framework\Attributes\DataProvider('holySequences')] + public function testDeletionCoversTheEntireContinuousHolySequence(array $holidayDays, string $first, string $last): void + { + $http = new class ($holidayDays) implements \Shabbat\Http\JsonClient { + public function __construct(private array $holidayDays) + { + } + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + if (str_ends_with($url, '/hebcal')) { + return ['range' => ['start' => $query['start'],'end' => $query['end']], 'items' => array_map(static fn(string $date): array=>['date' => $date,'category' => 'holiday','yomtov' => true,'title' => 'Fixture festival','hebrew' => 'ื—ื’ ื‘ื“ื™ืงื”'], $this->holidayDays)]; + } + return (new RuntimeFakeJson())->get($url, $query, $cancellation); + } + }; + $calendar = new CalendarService(new HebcalProvider($http), $this->clock); + $worker = new MessageCleanup($this->deletions, $this->settings, $this->gateway, $calendar, $this->clock); + $this->configure(['delete_holy_user_messages' => true]); + $this->clock->set(new \DateTimeImmutable($first)); + self::assertTrue($worker->receive('-1001', 1, $this->clock->now()->getTimestamp(), '42')); + self::assertSame('deleted', $worker->runOnce('worker')['status']); + $this->clock->set(new \DateTimeImmutable($last)); + self::assertTrue($worker->receive('-1001', 2, $this->clock->now()->getTimestamp(), '42')); + self::assertSame('deleted', $worker->runOnce('worker')['status']); + $this->clock->set($this->clock->now()->modify('+10 minutes')); + self::assertFalse($worker->receive('-1001', 3, $this->clock->now()->getTimestamp(), '42')); + self::assertSame([['-1001',[1]],['-1001',[2]]], $this->gateway->deleted); + } + public function testActualNativeGroupBridgeForBothGroupTypesUsesOriginalMessageDate(): void + { + $this->configure(['delete_holy_user_messages' => true]); + $this->settings->ensureGroup('-12', 'Basic group'); + $basic = $this->settings->getGroup('-12'); + $this->settings->updateGroup('-12', $basic['revision'], ['delete_holy_user_messages' => true], '42'); + $this->gateway->rights['-12'] = ['_' => 'chatBannedRights','send_messages' => true,'until_date' => 0]; + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $bridge = ReplyScopeFixture::create($app); + foreach ([-1001,-12] as $group) { + $message = (new \ReflectionClass(\danog\MadelineProto\EventHandler\Message\GroupMessage::class))->newInstanceWithoutConstructor(); + foreach (['chatId' => $group,'senderId' => 42,'id' => 7,'date' => $this->clock->now()->getTimestamp(),'out' => false] as $field => $value) { + (new \ReflectionProperty($message, $field))->getDeclaringClass()->getProperty($field)->setValue($message, $value); + } + $bridge->groupMessageCleanup($message); + } + self::assertSame([['-1001',[7]],['-12',[7]]], $this->gateway->deleted); + } + public function testDeletionFailureNeverRetriesTheSuccessfulReplacementSend(): void + { + $this->clock->set(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->configure(['tefillin_enabled' => true,'tefillin_delete_previous' => true]); + $this->deletions->recordModuleDelivery('-1001', 'tefillin', [10], 'first', true); + $this->deletions->recordModuleDelivery('-1001', 'tefillin', [11], 'second', true); + $this->gateway->deleteError = new RetryableTelegramException('rate limit', 60); + self::assertSame('retry', $this->worker->runOnce('worker')['status']); + self::assertSame([], $this->gateway->sent); + self::assertSame([11], $this->deletions->getLastModuleMessages('-1001', 'tefillin')); + $this->configure(['tefillin_delete_previous' => false]); + $this->clock->set($this->clock->now()->modify('+60 seconds')); + self::assertNull($this->worker->runOnce('worker')); + self::assertSame('cancelled', $this->deletions->get(1)['status']); + self::assertSame([], $this->gateway->deleted); + } + public static function announcementPresets(): array + { + return array_map(static fn(string $time): array => [$time], ['10:00','11:00','12:00','12:30','13:00','13:30','14:00']); + } + #[\PHPUnit\Framework\Attributes\DataProvider('announcementPresets')] + public function testAnnouncementPresetIsScheduledInTheGroupsOwnTimezone(string $time): void + { + $g = $this->configure([ + 'times_alert_enabled' => true, 'times_alert_time' => $time, 'timezone_id' => 'America/New_York', + 'location_name' => 'New York', 'latitude' => 40.7128, 'longitude' => -74.006, 'geoname_id' => 5128581, 'israel' => false, 'language' => 'en', + ]); + $this->clock->set(new \DateTimeImmutable('2026-10-16T' . $time . ':00-04:00')); + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $heartbeat = sys_get_temp_dir() . '/announcement-heartbeat-' . bin2hex(random_bytes(6)) . '.json'; + putenv('HEARTBEAT_PATH=' . $heartbeat); + try { + $app->tick(); + self::assertCount(1, $this->gateway->sent); + $payload = $this->gateway->sent[0][1]; + self::assertStringContainsString('New York', $payload['text']); + self::assertStringContainsString('America/New_York', $payload['text']); + self::assertStringContainsString('2026-10-16 17:42', $payload['text']); + self::assertStringContainsString('2026-10-17 18:40', $payload['text']); + self::assertDoesNotMatchRegularExpression('/\([+-]\d{2}:\d{2}\)/', $payload['text']); + self::assertStringNotContainsString('Jerusalem', $payload['text']); + $job = $this->db->pdo()->query("SELECT * FROM scheduled_jobs WHERE module='times_alert'")->fetch(); + self::assertSame($this->clock->now()->getTimestamp(), $job['run_at']); + self::assertSame('sent', $job['status']); + } finally { + putenv('HEARTBEAT_PATH'); + if (is_file($heartbeat)) { + unlink($heartbeat); + } + } + } + public function testAnnouncementDesignAndTefillinRichButtonsAreIndependentFromDailyTimes(): void + { + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + $g = $this->settings->getGroup('-1001'); + $instant = new \DateTimeImmutable('2026-10-16T13:30:00+03:00'); + $app->templates->save('-1001', 'zmanim', ['text' => 'daily custom'], '42'); + $app->templates->save('-1001', 'times_alert', ['text' => 'announcement {{location}}','buttons' => [[['text' => 'Open','url' => 'https://example.com','style' => ['_' => 'keyboardButtonStyle','bg_success' => true]]]]], '42'); + $app->templates->save('-1001', 'tefillin', ['text' => 'tefillin {{sunset}}','buttons' => [[['text' => 'Ready','callback_data' => 'notice','style' => ['_' => 'keyboardButtonStyle','bg_primary' => true]]]]], '42'); + $announcement = $app->payload($g, 'times_alert', $instant); + $tefillin = $app->payload($g, 'tefillin', $instant); + self::assertStringContainsString($g['location_name'], $announcement['text']); + self::assertTrue($announcement['buttons'][0][0]['style']['bg_success']); + self::assertSame('tefillin 18:00', $tefillin['text']); + self::assertTrue($tefillin['buttons'][0][0]['style']['bg_primary']); + self::assertSame('daily custom', $app->payload($g, 'zmanim', $instant)['text']); + } +} diff --git a/tests/NativePeerReadHarness.php b/tests/NativePeerReadHarness.php new file mode 100644 index 0000000..2a43cf4 --- /dev/null +++ b/tests/NativePeerReadHarness.php @@ -0,0 +1,97 @@ + 'chatBannedRights', 'until_date' => 0, 'send_photos' => true, 'invite_users' => true]; + $this->server = FreshPeerFixture::info($peer, $rights, $type); + $this->cached = $this->server['Chat']; + $this->peerDatabase = new class ($this) { + public array $full; + public function __construct(public NativePeerReadHarness $owner) + { + $this->full = ['inserted' => time(), 'full' => $owner->server['full']]; + } + public function isset(int $id): bool + { + return true; + } + public function get(int $id): array + { + return $this->owner->cached; + } + public function getFull(int $id): array + { + return $this->full; + } + }; + $this->minDatabase = new class { + public function hasPeer(int $id): bool + { + return true; + } + }; + $this->logger = new class { + public function logger(mixed ...$arguments): void + { + } + }; + $this->TL = new class { + public function getConstructors(): object + { + return new class { + public function findByPredicate(string $constructor): array + { + return ['type' => 'Chat']; + } + }; + } + }; + } + public function getIdInternal(mixed $id): int + { + return (int)$id; + } + public function getSettings(): \danog\MadelineProto\Settings + { + $settings = Runner::settings(); + $settings->getPeer()->setFullInfoCacheTime($this->cacheLifetime); + return $settings; + } + public function methodCallAsyncRead(string $method, array $parameters): array + { + $this->calls[] = $method; + if ($this->failure !== null) { + throw $this->failure; + } + if (!$this->keepStaleCache) { + $this->cached = $this->server['Chat']; + } + return $this->reply ?? $this->server[FreshPeerReader::EVIDENCE_KEY]; + } +} diff --git a/tests/NativeRpcContractTest.php b/tests/NativeRpcContractTest.php new file mode 100644 index 0000000..08d1ae1 --- /dev/null +++ b/tests/NativeRpcContractTest.php @@ -0,0 +1,107 @@ +isPublic()); + $declared = array_map(static fn (\ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters()); + $schema = self::schemaEntry($rpc); + foreach ($arguments as $argument) { + // SDK array-call mode also supports TL fields such as random_id, + // intentionally omitted from the generated named-argument interface. + self::assertTrue(in_array($argument, $declared, true) || preg_match('/\b' . preg_quote($argument, '/') . ':/', $schema) === 1, $rpc . ':' . $argument); + } + self::assertContains('floodWaitLimit', $declared); + } + + public function testEmittedGatewayParametersMatchTheActualSdk(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipantAdmin']]; + $transport->responses['messages.getForumTopicsByID'] = ['topics' => [['id' => 7, 'closed' => false]]]; + $gateway = new MadelineGateway($transport); + $gateway->isGroupAdmin('7', '-100123'); + $gateway->setPermissions('-100123', ['_' => 'chatBannedRights', 'until_date' => 0]); + $gateway->send('-100123', ['text' => 'fixture', 'topic_id' => 7], '42'); + $gateway->send('-100123', ['text' => 'fixture', 'media' => 'fixture-file-id'], '43'); + $gateway->sendPrivate('7', 'fixture'); + $gateway->editPrivate('7', 1, 'fixture'); + $gateway->answerCallback('123', 'fixture'); + $gateway->answerInline('124', [], ['next_offset' => '']); + foreach ($transport->calls as [$rpc, $arguments]) { + self::assertArrayNotHasKey($rpc, Blacklist::BLACKLIST); + [$namespace, $method] = explode('.', $rpc, 2); + $reflection = new \ReflectionMethod('danog\\MadelineProto\\Namespace\\' . ucfirst($namespace), $method); + $declared = array_map(static fn (\ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters()); + foreach (array_keys($arguments) as $argument) { + self::assertTrue(in_array($argument, $declared, true) || preg_match('/\b' . preg_quote($argument, '/') . ':/', self::schemaEntry($rpc)) === 1, $rpc . ':' . $argument); + } + } + self::assertCount(9, $transport->calls); + } + + public function testInvoiceAndMenuConstructorsExistInTheNativeSchema(): void + { + $expected = [ + 'inputMediaInvoice' => ['title', 'description', 'invoice', 'payload', 'provider_data'], + 'invoice' => ['currency', 'prices'], + 'labeledPrice' => ['label', 'amount'], + 'dataJSON' => ['data'], + 'botCommandScopeUsers' => [], + 'botCommandScopeChats' => [], + 'botCommandScopeChatAdmins' => [], + 'botCommandScopePeer' => ['peer'], + 'botCommand' => ['command', 'description'], + ]; + foreach ($expected as $constructor => $fields) { + $schema = self::schemaEntry($constructor); + foreach ($fields as $field) { + self::assertMatchesRegularExpression('/\b' . preg_quote($field, '/') . ':/', $schema); + } + } + } + + private static function schemaEntry(string $name): string + { + $schema = file_get_contents(dirname(__DIR__) . '/vendor/danog/madelineproto/src/TL_telegram_v225.tl'); + self::assertIsString($schema); + self::assertSame(1, preg_match('/^' . preg_quote($name, '/') . '#[^\r\n]+/m', $schema, $match), 'Missing native TL entry: ' . $name); + return $match[0]; + } +} diff --git a/tests/OmerPayloadTest.php b/tests/OmerPayloadTest.php new file mode 100644 index 0000000..bdd98b8 --- /dev/null +++ b/tests/OmerPayloadTest.php @@ -0,0 +1,68 @@ +migrate(); + $clock = new FrozenClock(new DateTimeImmutable('2026-04-02T20:00:00+03:00')); + $gateway = new RuntimeFakeGateway(); + $calendar = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $clock); + $app = new Application($gateway, $clock, $db, $calendar); + $settings = new SettingsRepository($db, $clock); + $name = str_repeat('๐Ÿ˜€', 256); + self::assertSame(256, mb_strlen($name, 'UTF-8')); + foreach (Lang::CODES as $index => $language) { + foreach (['ashkenaz' => '3017054', 'sephard' => '2929975'] as $nusach => $revision) { + $group = $settings->ensureGroup('-100' . $index . ($nusach === 'ashkenaz' ? '1' : '2'), $name, [ + 'language' => $language, 'location_name' => $name, 'omer_nusach' => $nusach, 'omer_format' => 'full', + ]); + for ($day = 1; $day <= 49; $day++) { + $instant = (new DateTimeImmutable('2026-04-02T20:00:00+03:00'))->modify('+' . ($day - 1) . ' days'); + $clock->set($instant); + $payload = $app->payload($group, 'omer', $instant); + self::assertLessThanOrEqual(4096, strlen(mb_convert_encoding($payload['text'], 'UTF-16LE', 'UTF-8')) / 2, $language . ':' . $nusach . ':' . $day); + self::assertStringContainsString(OmerText::count($day, $nusach, $language), html_entity_decode($payload['text'], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + self::assertStringContainsString('Hebrew Wikisource contributors', $payload['text']); + self::assertStringContainsString('CC-BY-SA-4.0', $payload['text']); + self::assertStringContainsString('https://he.wikisource.org/w/index.php?oldid=' . $revision, $payload['text']); + self::assertStringContainsString('https://creativecommons.org/licenses/by-sa/4.0/', $payload['text']); + self::assertStringContainsString($name, $payload['text']); + self::assertStringContainsString('ื”ืจื—ืžืŸ', $payload['text']); + self::assertStringContainsString('ืื ื ื‘ื›ื•ื—', $payload['text']); + } + } + } + self::assertSame([], $gateway->sent); + self::assertSame(0, $gateway->mutations); + } + + public function testShortAndDayCompletionDoNotAppendUnincludedLiturgyGrant(): void + { + foreach (Lang::CODES as $language) { + self::assertStringNotContainsString('CC-BY-SA', OmerText::render(49, 'ashkenaz', false, false, $language)); + self::assertStringNotContainsString('CC-BY-SA', OmerText::render(49, 'sephard', true, true, $language)); + self::assertStringNotContainsString('ื‘ืจื•ืš ืืชื”', OmerText::render(49, 'sephard', true, true, $language)); + } + } +} diff --git a/tests/OmerTextTest.php b/tests/OmerTextTest.php new file mode 100644 index 0000000..953a30c --- /dev/null +++ b/tests/OmerTextTest.php @@ -0,0 +1,61 @@ + [$branch, $scenario]; + } + } + } + + #[DataProvider('cases')] + public function testRealUpdateLoopBehavior(string $branch, string $scenario): void + { + $pipes = []; + $process = proc_open([PHP_BINARY, __DIR__ . '/PatchLoopHarness.php', $scenario, $branch], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $error . $out); + $data = json_decode($out, true, flags: JSON_THROW_ON_ERROR); + if ($scenario === 'intentional') { + self::assertSame(Amp\CancelledException::class, $data['exception']); + self::assertSame(1, $data['calls']); + self::assertSame(0, $data['reports']); + } elseif (str_starts_with($scenario, 'shutdown-')) { + self::assertSame(danog\Loop\Loop::STOP, (float) $data['first']); + self::assertSame(1, $data['calls']); + self::assertSame(0, $data['reports']); + } else { + self::assertArrayHasKey('first', $data, 'The timeout harness failed: ' . json_encode($data, JSON_THROW_ON_ERROR)); + self::assertSame(10, $data['first']); + self::assertSame(10, $data['second']); + self::assertSame(1, $data['calls_after_first'], 'Must return to parent loop rather than retry immediately.'); + self::assertSame(2, $data['calls'], 'Next cycle must recover.'); + self::assertSame(1, $data['reports']); + } + } +} diff --git a/tests/PatchGitBytesTest.php b/tests/PatchGitBytesTest.php new file mode 100644 index 0000000..e6cb0c7 --- /dev/null +++ b/tests/PatchGitBytesTest.php @@ -0,0 +1,146 @@ +fixture = sys_get_temp_dir() . '/shabbat-git-bytes-' . bin2hex(random_bytes(8)); + mkdir($this->fixture, 0700); + $this->git = PHP_OS_FAMILY === 'Windows' && is_file('C:/Program Files/Git/cmd/git.exe') ? 'C:/Program Files/Git/cmd/git.exe' : 'git'; + $this->command(['init', '--bare', $this->fixture . '/git']); + } + + protected function tearDown(): void + { + $resolved = realpath($this->fixture); + if ($resolved === false || !str_starts_with($resolved, realpath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . 'shabbat-git-bytes-')) { + throw new RuntimeException('Git-byte fixture cleanup escaped its isolated directory.'); + } + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($resolved, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($resolved); + } + + private function command(array $arguments, string $input = ''): string + { + $process = proc_open([$this->git, ...$arguments], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__)); + if (!is_resource($process)) { + throw new RuntimeException('Unable to launch Git byte verification.'); + } + $offset = 0; + while ($offset < strlen($input)) { + $written = fwrite($pipes[0], substr($input, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to stream binary patch source to Git.'); + } + $offset += $written; + } + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), (string) $errors); + return trim((string) $output); + } + + public function testAutocrlfCannotAlterReviewedPatchOrPublisherLicenseBytes(): void + { + $root = dirname(__DIR__); + $files = ['LICENSES/CC-BY-4.0.txt', 'LICENSES/CC-BY-SA-4.0.txt']; + foreach (['madelineproto', 'madelineproto-geopoint', 'madelineproto-formatted-date', 'madelineproto-fresh-peer', 'broadcastmanager'] as $library) { + $manifest = json_decode((string) file_get_contents($root . '/patches/' . $library . '/manifest.json'), true, flags: JSON_THROW_ON_ERROR); + $entry = $manifest['approved'][0]; + foreach (['baseline' => 'original_sha256', 'patch' => 'patch_sha256'] as $kind => $hashField) { + $relative = 'patches/' . $library . '/' . $entry[$kind]; + self::assertSame($entry[$hashField], hash_file('sha256', $root . '/' . $relative)); + $files[] = $relative; + } + } + $date = json_decode((string) file_get_contents($root . '/patches/madelineproto-formatted-date/manifest.json'), true, flags: JSON_THROW_ON_ERROR)['approved'][0]; + $additional = [[$date['reviewed_output'], $date['patched_sha256']]]; + foreach ($date['added_files'] as $file) { + $additional[] = [$file['reviewed_output'], $file['patched_sha256']]; + } + foreach ($date['prerequisites'] as $file) { + $additional[] = [$file['baseline'], $file['sha256']]; + } + foreach ($date['license_files'] as $file) { + $additional[] = [$file['file'], $file['sha256']]; + } + foreach ($additional as [$file, $sha256]) { + $relative = 'patches/madelineproto-formatted-date/' . $file; + self::assertSame($sha256, hash_file('sha256', $root . '/' . $relative)); + $files[] = $relative; + } + + $broadcast = json_decode((string) file_get_contents($root . '/patches/broadcastmanager/manifest.json'), true, flags: JSON_THROW_ON_ERROR)['approved'][0]; + $broadcastSources = [[$broadcast['reviewed_output'], $broadcast['patched_sha256']]]; + foreach ($broadcast['intermediates'] ?? [] as $intermediate) { + $broadcastSources[] = [$intermediate['source'], $intermediate['sha256']]; + $broadcastSources[] = [$intermediate['patch'], $intermediate['patch_sha256']]; + } + foreach ($broadcastSources as [$file, $sha256]) { + $relative = 'patches/broadcastmanager/' . $file; + self::assertSame($sha256, hash_file('sha256', $root . '/' . $relative)); + $files[] = $relative; + } + foreach ($files as $relative) { + $bytes = (string) file_get_contents($root . '/' . $relative); + $expected = hash('sha1', 'blob ' . strlen($bytes) . "\0" . $bytes); + $filtered = $this->command([ + '--git-dir=' . $this->fixture . '/git', '--work-tree=' . $root, + '-c', 'core.bare=false', '-c', 'core.autocrlf=true', + 'hash-object', '--path=' . $relative, '--stdin', + ], $bytes); + self::assertSame($expected, $filtered, 'Git normalization changed reviewed bytes: ' . $relative); + } + self::assertFileDoesNotExist($this->fixture . '/git/index'); + self::assertSame([], glob($this->fixture . '/git/objects/[0-9a-f][0-9a-f]')); + } + + public function testAutocrlfCannotAlterMigrationOrBundledResourceBytes(): void + { + $root = dirname(__DIR__); + $files = [ + 'resources/templates/omer-counts.json', + 'resources/templates/omer-counts-localized.json', + 'resources/templates/omer-liturgy.json', + 'tests/Fixtures/CalendarFixtureClient.php', + 'tests/Fixtures/hebcal-full-runtime-provenance.json', + 'tests/Fixtures/legacy/data/-100101/chatb1.json', + ]; + foreach (glob($root . '/migrations/*.sql') ?: [] as $migration) { + $files[] = 'migrations/' . basename($migration); + } + self::assertGreaterThan(6, count($files)); + foreach ($files as $relative) { + $bytes = (string) file_get_contents($root . '/' . $relative); + $expected = hash('sha1', 'blob ' . strlen($bytes) . "\0" . $bytes); + foreach (['true', 'false'] as $autocrlf) { + $filtered = $this->command([ + '--git-dir=' . $this->fixture . '/git', '--work-tree=' . $root, + '-c', 'core.bare=false', '-c', 'core.autocrlf=' . $autocrlf, + 'hash-object', '--path=' . $relative, '--stdin', + ], $bytes); + self::assertSame($expected, $filtered, 'Git normalization changed immutable bytes: ' . $relative); + } + } + } +} diff --git a/tests/PatchInstallTest.php b/tests/PatchInstallTest.php new file mode 100644 index 0000000..da4af60 --- /dev/null +++ b/tests/PatchInstallTest.php @@ -0,0 +1,182 @@ +root = dirname(__DIR__); + $this->fixture = sys_get_temp_dir() . '/shabbat-patch-' . bin2hex(random_bytes(8)); + $this->target = $this->fixture . '/danog/madelineproto/src/Loop/Update/UpdateLoop.php'; + mkdir(dirname($this->target), 0700, true); + mkdir($this->fixture . '/composer'); + copy($this->root . '/patches/madelineproto/baseline/UpdateLoop.php', $this->target); + $this->installed('8.7.0'); + } + + private function installed(string $version): void + { + file_put_contents($this->fixture . '/composer/installed.json', json_encode(['packages' => [['name' => 'danog/madelineproto', 'version' => $version, 'source' => ['reference' => '7b99dd0f2d964a492c377912ffce9f5ce61aa9ed']]]], JSON_THROW_ON_ERROR)); + } + + protected function tearDown(): void + { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->fixture, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->fixture); + } + + public function testManifestTargetCannotEscapeVendor(): void + { + $manifest = $this->fixture . '/test-manifest.json'; + file_put_contents($manifest, json_encode(['package' => 'danog/madelineproto', 'target' => '../outside.php', 'approved' => []], JSON_THROW_ON_ERROR)); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('contained relative paths'); + $this->manager($manifest); + } + + public function testUnknownManifestModeIsRejected(): void + { + $data = json_decode((string) file_get_contents($this->root . '/patches/madelineproto/manifest.json'), true, flags: JSON_THROW_ON_ERROR); + $data['approved'][0]['mode'] = 'unreviewed-mode'; + $manifest = $this->fixture . '/test-manifest.json'; + file_put_contents($manifest, json_encode($data, JSON_THROW_ON_ERROR)); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown approved patch mode'); + $this->manager($manifest)->process(true); + } + + public function testTamperedCorrespondingSourceBaselinePreventsVendorMutation(): void + { + $data = json_decode((string) file_get_contents($this->root . '/patches/madelineproto/manifest.json'), true, flags: JSON_THROW_ON_ERROR); + mkdir($this->fixture . '/baseline'); + file_put_contents($this->fixture . '/baseline/UpdateLoop.php', (string) file_get_contents($this->target) . "\n"); + $manifest = $this->fixture . '/test-manifest.json'; + file_put_contents($manifest, json_encode($data, JSON_THROW_ON_ERROR)); + $before = file_get_contents($this->target); + try { + $this->manager($manifest)->process(true); + self::fail('Tampered source baseline was accepted.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('baseline hash mismatch', $error->getMessage()); + self::assertSame($before, file_get_contents($this->target)); + } + } + + public function testUnifiedDiffSupportsImplicitSingleLineCounts(): void + { + $diff = "--- a/example.php\n+++ b/example.php\n@@ -1 +1 @@\n-old\n+new\n"; + self::assertSame("new\n", \Shabbat\Runtime\PatchManager::applyDiff("old\n", $diff)); + $diff = str_replace('@@ -1 +1 @@', '@@ -1,1 +1 @@', $diff); + self::assertSame("new\n", \Shabbat\Runtime\PatchManager::applyDiff("old\n", $diff)); + } + + public function testCleanInstallAndRepeatedApplyAreIdentical(): void + { + $manager = $this->manager(); + self::assertSame('patched', $manager->process(true)); + $hash = hash_file('sha256', $this->target); + self::assertSame('already-patched', $manager->process(true)); + self::assertSame('already-patched', $manager->process(false)); + self::assertSame($hash, hash_file('sha256', $this->target)); + } + + public function testVerificationRefusesUnpatchedInstall(): void + { + $this->expectException(RuntimeException::class); + ($this->manager())->process(false); + } + + public function testUnknownVersionFailsWithoutWriting(): void + { + $this->installed('9.0.0'); + $before = file_get_contents($this->target); + try { + ($this->manager())->process(true); + self::fail('Unknown version accepted'); + } catch (RuntimeException $error) { + self::assertStringContainsString('Unapproved', $error->getMessage()); + self::assertSame($before, file_get_contents($this->target)); + } + } + + public function testPartialPatchIsRejected(): void + { + $manager = $this->manager(); + $manager->process(true); + file_put_contents($this->target, str_replace('use Amp\\CancelledException;' . "\n", '', (string) file_get_contents($this->target))); + $this->expectException(RuntimeException::class); + $manager->process(true); + } + + public function testModifiedKnownSourceIsRejected(): void + { + file_put_contents($this->target, "\n", FILE_APPEND); + $this->expectException(RuntimeException::class); + ($this->manager())->process(true); + } + + private function manager(?string $manifest = null): \Shabbat\Runtime\PatchManager + { + return new \Shabbat\Runtime\PatchManager($this->root, $this->fixture, $manifest, $this->fixture . '/installer.writer.lock'); + } + + public function testActiveWriterPreventsPatchMutation(): void + { + $writer = \Shabbat\Runtime\SingleInstance::acquire($this->fixture . '/installer.writer.lock'); + $before = file_get_contents($this->target); + try { + $this->manager()->process(true); + self::fail('A live writer must prevent patch application.'); + } catch (RuntimeException $error) { + self::assertStringContainsString('Another bot or maintenance', $error->getMessage()); + self::assertSame($before, file_get_contents($this->target)); + } finally { + $writer->release(); + } + } + + public function testReadOnlyVerificationCanRunWithActiveWriter(): void + { + $this->manager()->process(true); + $writer = \Shabbat\Runtime\SingleInstance::acquire($this->fixture . '/installer.writer.lock'); + try { + self::assertSame('already-patched', $this->manager()->process(false)); + self::assertSame('already-patched', $this->manager()->process(true)); + } finally { + $writer->release(); + } + } + + public function testReviewedOfficialModeIsNoOp(): void + { + $manifest = $this->fixture . '/test-manifest.json'; + file_put_contents($manifest, json_encode(['approved' => [['version' => '8.7.0', 'commit' => '7b99dd0f2d964a492c377912ffce9f5ce61aa9ed', 'mode' => 'official', 'sha256' => hash_file('sha256', $this->target)]]], JSON_THROW_ON_ERROR)); + $before = file_get_contents($this->target); + self::assertSame('official-verified', ($this->manager($manifest))->process(true)); + self::assertSame($before, file_get_contents($this->target)); + // Fixture exercises the future manifest path. Current release is not official-fixed. + } +} diff --git a/tests/PatchIntegrationAnalysis.php b/tests/PatchIntegrationAnalysis.php new file mode 100644 index 0000000..2c09e7e --- /dev/null +++ b/tests/PatchIntegrationAnalysis.php @@ -0,0 +1,18 @@ +setValue($loop, true); +$authorized = new \ReflectionProperty($loop, 'authorizedDc'); +$authorized->setValue($loop, 1); +if (str_starts_with($scenario, 'shutdown-')) { + $api->onCall = static fn() => $loop->stop(); +} +try { + $first = $loop->loop(); + \Revolt\EventLoop::run(); + $callsAfterFirst = $api->calls; + $second = null; + if (!str_starts_with($scenario, 'shutdown-')) { + $second = $loop->loop(); + } + echo json_encode(['first' => $first, 'second' => $second, 'calls_after_first' => $callsAfterFirst, 'calls' => $api->calls, 'reports' => $api->reports], JSON_THROW_ON_ERROR); +} catch (\Throwable $error) { + echo json_encode(['exception' => get_class($error), 'calls' => $api->calls, 'reports' => $api->reports], JSON_THROW_ON_ERROR); +} diff --git a/tests/PatchRunnerTest.php b/tests/PatchRunnerTest.php new file mode 100644 index 0000000..eaa4187 --- /dev/null +++ b/tests/PatchRunnerTest.php @@ -0,0 +1,63 @@ +expectException(Amp\CancelledException::class); + Runner::handleEventError(new Amp\CancelledException(new RuntimeException('deliberate'))); + } + + public function testTimeoutCancellationDuringShutdownIsNotSilentlySwallowed(): void + { + $this->expectException(Amp\CancelledException::class); + Runner::handleEventError(new Amp\CancelledException(new Amp\TimeoutException('timeout')), true); + } + + public function testCancellationTextCannotHideUnrelatedError(): void + { + $this->expectException(RuntimeException::class); + Runner::handleEventError(new RuntimeException('The operation was cancelled')); + } + + public function testSingleInstanceCannotAcquireSharedWriterLockTwice(): void + { + $path = sys_get_temp_dir() . '/shabbat-writer-' . bin2hex(random_bytes(6)); + $first = SingleInstance::acquire($path); + try { + try { + SingleInstance::acquire($path); + self::fail('Duplicate instance was permitted'); + } catch (RuntimeException) { + self::assertTrue(true); + } + } finally { + $first->release(); + unlink($path); + } + } +} diff --git a/tests/PermissionCanonicalizationTest.php b/tests/PermissionCanonicalizationTest.php new file mode 100644 index 0000000..62e1698 --- /dev/null +++ b/tests/PermissionCanonicalizationTest.php @@ -0,0 +1,337 @@ +setValue(null, false); + foreach (['database','clock','root'] as $name) { + $this->adminState[$name] = (new \ReflectionProperty(\Shabbat\Storage\AdminStateFiles::class, $name))->getValue(); + } + $path = tempnam(sys_get_temp_dir(), 'shabbat-default-rights-'); + if ($path === false) { + throw new \RuntimeException('Cannot create isolated permission database.'); + } + $this->path = $path; + $this->heartbeat = $path . '.heartbeat'; + putenv('HEARTBEAT_PATH=' . $this->heartbeat); + $this->db = Database::open($path); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->transport = new CanonicalPermissionsTransport(); + $this->gateway = new MadelineGateway($this->transport, false); + (new SettingsRepository($this->db, $this->clock))->ensureGroup('-1001', 'Offline fixture'); + } + protected function tearDown(): void + { + foreach ($this->adminState as $name => $value) { + (new \ReflectionProperty(\Shabbat\Storage\AdminStateFiles::class, $name))->setValue(null, $value); + } + $this->adminState = []; + unset($this->db); + gc_collect_cycles(); + foreach ([$this->path, $this->path . '-wal', $this->path . '-shm', $this->heartbeat] as $path) { + if (is_file($path)) { + unlink($path); + } + } + putenv('HEARTBEAT_PATH'); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function app(?Database $db = null): Application + { + return new Application( + $this->gateway, + $this->clock, + $db ?? $this->db, + new CalendarService(new HebcalProvider(new RuntimeFakeJson()), $this->clock), + authorizeOperator: static fn(string $actor): bool => $actor === '42' + ); + } + public function testForeverSentinelsAreEquivalentButFiniteExpiryAndUnknownRestrictionsAreNot(): void + { + $request = ['_' => 'chatBannedRights', 'send_polls' => true, 'until_date' => 0]; + self::assertTrue(Permissions::equal($request, array_replace($request, ['until_date' => 2147483647]))); + self::assertTrue(Permissions::equal($request, ['send_polls' => true, 'send_photos' => false])); + foreach ([1, 1792069200, 2000000000, 2147483646] as $expiry) { + self::assertFalse(Permissions::equal($request, array_replace($request, ['until_date' => $expiry]))); + } + self::assertFalse(Permissions::equal($request, $request + ['future_permission' => true])); + self::assertSame(['unknown'], Permissions::differentFields($request, $request + ['secret-untrusted-key' => true])); + } + public function testAggregateEquivalenceRequiresEveryGranularRestrictionOnBothSides(): void + { + $all = Permissions::locked($this->transport->rights); + $observed = CanonicalPermissionsTransport::readback($all); + self::assertTrue(Permissions::equal($all, $observed)); + $media = ['send_photos','send_videos','send_roundvideos','send_audios','send_voices','send_docs']; + $sends = [...$media,'send_plain','send_stickers','send_gifs','send_games','send_inline','embed_links','send_polls']; + foreach ($sends as $field) { + $changed = array_replace($observed, [$field => false]); + self::assertFalse(Permissions::equal($all, $changed), $field); + self::assertContains($field, Permissions::differentFields($all, $changed)); + } + self::assertFalse(Permissions::equal(['send_media' => true], [])); + self::assertFalse(Permissions::equal(['send_messages' => true], [])); + self::assertFalse(Permissions::equal(['send_messages' => true], ['send_plain' => true])); + $mediaRequest = Permissions::locked(['until_date' => 0], ['send_media']); + foreach ($media as $field) { + self::assertTrue($mediaRequest[$field], $field); + } + self::assertArrayNotHasKey('send_plain', $mediaRequest); + self::assertTrue(Permissions::equal($mediaRequest, CanonicalPermissionsTransport::readback($mediaRequest))); + $messageRequest = Permissions::locked(['until_date' => 0], ['send_messages']); + foreach ($sends as $field) { + self::assertTrue($messageRequest[$field], $field); + } + self::assertArrayNotHasKey('send_reactions', $messageRequest); + self::assertArrayNotHasKey('edit_rank', $messageRequest); + } + public static function groupTypes(): array + { + return ['basic group' => ['chat'], 'supergroup' => ['supergroup']]; + } + #[DataProvider('groupTypes')] + public function testManualCloseRepeatAndOpenUseFreshNativeGatewayReadback(string $type): void + { + $this->transport->type = $type; + $app = $this->app(); + $original = $this->transport->rights; + $closed = $app->manualGroups->apply(true, '42'); + self::assertSame(1, $closed['changed']); + self::assertSame(0, $closed['failed']); + self::assertTrue($this->transport->rights['send_plain']); + self::assertTrue($this->transport->rights['send_reactions']); + self::assertTrue($this->transport->rights['edit_rank']); + self::assertFalse($this->transport->rights['send_messages']); + self::assertSame(2147483647, $this->transport->rights['until_date']); + $snapshot = $app->locks->state('-1001')['snapshot_json']; + self::assertSame(Database::encode($original), $snapshot); + self::assertSame(0, $app->manualGroups->apply(true, '42')['failed']); + self::assertCount(1, $this->transport->writes); + $opened = $app->manualGroups->apply(false, '42'); + self::assertSame(1, $opened['changed']); + self::assertSame(0, $opened['failed']); + self::assertSame($original, $this->transport->writes[1]); + self::assertTrue(Permissions::equal($original, $this->transport->rights)); + self::assertSame($snapshot, $app->locks->state('-1001')['snapshot_json']); + self::assertSame(hash('sha256', $snapshot), $app->locks->state('-1001')['snapshot_sha256']); + } + public function testAutomaticCloseAndOpenAfterRestartPreserveExactOriginalSnapshot(): void + { + $this->clock->set(new \DateTimeImmutable('2026-10-16T17:45:00+03:00')); + $app = $this->app(); + $original = $this->transport->rights; + $app->tick(); + self::assertSame('locked', $app->locks->state('-1001')['status']); + $snapshot = $app->locks->state('-1001')['snapshot_json']; + $app->tick(); + self::assertCount(1, $this->transport->writes); + $this->clock->set(new \DateTimeImmutable('2026-10-18T10:00:00+03:00')); + $reopened = Database::open($this->path); + $restarted = $this->app($reopened); + $restarted->tick(); + self::assertSame('restored', $restarted->locks->state('-1001')['status']); + self::assertSame($original, $this->transport->writes[1]); + self::assertSame($snapshot, $restarted->locks->state('-1001')['snapshot_json']); + } + public static function oldDebtCases(): array + { + $cases = []; + foreach (['prepared','error','conflict','restoring'] as $status) { + foreach ([true,false] as $close) { + foreach (['manual','automatic'] as $entry) { + $cases[$status . '-' . ($close ? 'close' : 'open') . '-' . $entry] = [$status, $close, $entry]; + } + } + } + return $cases; + } + #[DataProvider('oldDebtCases')] + public function testUnverifiedLegacyDebtRequiresReviewEvenWhenCurrentRightsMatchKnownMask(string $status, bool $close, string $entry): void + { + $original = $this->transport->rights; + $snapshot = Database::encode($original); + $applied = Permissions::locked($original); + $this->transport->rights = CanonicalPermissionsTransport::readback($applied); + $this->db->pdo()->prepare('INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,applied_json,snapshot_sha256,prepared_at,last_error) VALUES(?,?,?,?,?,?,?,?)') + ->execute(['-1001','dev5-owned-lock',$status,$snapshot,Database::encode($applied),hash('sha256', $snapshot),1,'Old unnormalized readback mismatch']); + if ($entry === 'automatic') { + $this->clock->set(new \DateTimeImmutable($close ? '2026-10-16T17:45:00+03:00' : '2026-10-18T10:00:00+03:00')); + } + $reopened = Database::open($this->path); + $app = $this->app($reopened); + if ($entry === 'manual') { + self::assertSame(1, $app->manualGroups->apply($close, '42')['failed']); + } else { + $app->tick(); + } + $state = $app->locks->state('-1001'); + self::assertSame('recovery_required', $state['status']); + self::assertSame($snapshot, $state['snapshot_json']); + self::assertSame(hash('sha256', $snapshot), $state['snapshot_sha256']); + self::assertNotNull($state['last_error']); + self::assertSame('legacy_unverified', $state['snapshot_provenance']); + self::assertCount(0, $this->transport->writes); + } + public static function meaningfulDifferences(): array + { + $cases = []; + foreach (['send_photos','send_plain','change_info','invite_users','pin_messages','manage_topics','edit_rank','send_reactions'] as $field) { + $cases[$field] = [$field,false]; + } + $cases['finite expiry'] = ['until_date',2000000000]; + $cases['unknown restriction'] = ['future_unrecognized_permission',true]; + return $cases; + } + #[DataProvider('meaningfulDifferences')] + public function testRealChangesBlockBothManualDirectionsAndRetainSnapshot(string $field, bool|int $value): void + { + $app = $this->app(); + $app->manualGroups->apply(true, '42'); + $snapshot = $app->locks->state('-1001')['snapshot_json']; + $this->transport->rights[$field] = $value; + foreach ([false,true] as $close) { + $result = $app->manualGroups->apply($close, '42'); + self::assertSame(1, $result['failed']); + self::assertSame('conflict', $result['groups'][0]['status']); + self::assertContains(in_array($field, Permissions::FLAGS, true) || $field === 'until_date' ? $field : 'unknown', $result['groups'][0]['different_fields']); + self::assertCount(1, $this->transport->writes); + self::assertSame($snapshot, $app->locks->state('-1001')['snapshot_json']); + self::assertSame(hash('sha256', $snapshot), $app->locks->state('-1001')['snapshot_sha256']); + } + } + public function testPostWriteMismatchIsImmediatelyAConflictAndNeverGuessedAway(): void + { + $this->transport->afterWrite = static function (CanonicalPermissionsTransport $transport): void { + $transport->rights['send_reactions'] = false; + }; + $result = $this->app()->manualGroups->apply(true, '42'); + self::assertSame('conflict', $result['groups'][0]['status']); + self::assertContains('send_reactions', $result['groups'][0]['different_fields']); + self::assertSame(1, $result['failed']); + self::assertCount(1, $this->transport->writes); + } + public function testLostLockAndRestoreAcknowledgementsRecoverWithoutDuplicateMutation(): void + { + $app = $this->app(); + $original = $this->transport->rights; + $this->transport->afterWrite = static fn() => throw new \RuntimeException('Lost RPC response'); + self::assertSame(1, $app->manualGroups->apply(true, '42')['failed']); + $snapshot = $app->locks->state('-1001')['snapshot_json']; + $this->transport->afterWrite = null; + $app = $this->app(Database::open($this->path)); + self::assertSame(0, $app->manualGroups->apply(true, '42')['failed']); + self::assertCount(1, $this->transport->writes); + $this->transport->afterWrite = static fn() => throw new \RuntimeException('Lost restore response'); + self::assertSame(1, $app->manualGroups->apply(false, '42')['failed']); + $this->transport->afterWrite = null; + $app = $this->app(Database::open($this->path)); + self::assertSame(0, $app->manualGroups->apply(false, '42')['failed']); + self::assertCount(2, $this->transport->writes); + self::assertSame($original, $this->transport->writes[1]); + self::assertSame($snapshot, $app->locks->state('-1001')['snapshot_json']); + self::assertSame('restored', $app->locks->state('-1001')['status']); + } + public function testCorruptOrMissingOriginalSnapshotCannotBeRecoveredByNormalization(): void + { + $app = $this->app(); + $app->manualGroups->apply(true, '42'); + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_json='{}' WHERE group_id='-1001'"); + self::assertSame(1, $app->manualGroups->apply(false, '42')['failed']); + self::assertCount(1, $this->transport->writes); + $this->db->pdo()->exec("UPDATE group_lock_state SET status='conflict',snapshot_json=NULL WHERE group_id='-1001'"); + foreach ([true,false] as $close) { + $result = $app->manualGroups->apply($close, '42'); + self::assertSame('recovery_required', $result['groups'][0]['status']); + self::assertCount(1, $this->transport->writes); + } + } + public function testLocalizedMismatchDetailsAllowlistFieldsAndNeverPrintArbitraryPayloads(): void + { + $app = $this->app(); + $app->manualGroups->apply(true, '42'); + $this->transport->rights['send_reactions'] = false; + $result = $app->manualGroups->apply(false, '42'); + $result['groups'][0]['different_fields'][] = 'never-print'; + $result['groups'][0]['error'] = 'credential-like arbitrary failure text'; + $lang = new \Shabbat\UI\Lang(); + foreach ($lang->languages() as $language) { + $text = ManualGroupReport::format($result, $language, 'Asia/Jerusalem'); + self::assertStringContainsString($lang->text($language, 'permission_send_reactions'), $text); + self::assertStringNotContainsString('never-print', $text); + self::assertStringNotContainsString('credential-like', $text); + } + } + #[DataProvider('groupTypes')] + public function testPinnedSdkRefreshBypassesSameSecondFullCacheAndRereadsPostResponsePeer(string $type): void + { + $harness = new PinnedPeerRefreshHarness($type, $this->transport->rights); + $harness->server['Chat']['default_banned_rights'] = CanonicalPermissionsTransport::readback(Permissions::locked($this->transport->rights)); + $peer = (string)$harness->cached['bot_api_id']; + // The actual SDK method refreshes its DB but returns its pre-request partial Chat. + $stale = $harness->getFullInfo($peer); + self::assertFalse($stale['Chat']['default_banned_rights']['send_plain']); + $fresh = \Shabbat\Runtime\FreshPeerReader::read($peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertTrue($fresh['Chat']['default_banned_rights']['send_plain']); + self::assertTrue($fresh['Chat']['default_banned_rights']['send_reactions']); + $harness->server['Chat']['default_banned_rights'] = $this->transport->rights; + $restored = \Shabbat\Runtime\FreshPeerReader::read($peer, Runner::settings(), $harness->getFullInfo(...)); + self::assertSame($this->transport->rights, $restored['Chat']['default_banned_rights']); + self::assertSame(array_fill(0, 3, $type === 'chat' ? 'messages.getFullChat' : 'channels.getFullChannel'), $harness->calls); + } + + public function testModeledReadbackRoundTripsThroughPinnedNativeTlAndKeepsNewRights(): void + { + $fixture = json_decode((string)file_get_contents(__DIR__ . '/Fixtures/telegram-default-permissions.json'), true, 32, JSON_THROW_ON_ERROR); + $previousLogger = \danog\MadelineProto\Logger::$default; + $adapter = (new \ReflectionClass(\danog\MadelineProto\MTProto::class))->newInstanceWithoutConstructor(); + $adapter->settings = new \danog\MadelineProto\Settings(); + $adapter->logger = new \danog\MadelineProto\Logger((new \danog\MadelineProto\Settings\Logger())->setType(\danog\MadelineProto\Logger::CALLABLE_LOGGER)->setExtra(static function (): void { + })); + try { + $tl = new \danog\MadelineProto\TL\TL($adapter); + $tl->init(new \danog\MadelineProto\Settings\TLSchema()); + $wire = $tl->serializeObject(['type' => 'ChatBannedRights'], $fixture['locked_readback'], 'default-rights-fixture'); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $wire); + rewind($stream); + $actual = $tl->deserialize($stream, ['type' => 'ChatBannedRights', 'connection' => null, 'encrypted' => false]); + fclose($stream); + self::assertTrue(Permissions::equal(Permissions::locked($fixture['original']), $actual)); + self::assertSame(2147483647, $actual['until_date']); + self::assertTrue($actual['send_reactions']); + self::assertTrue($actual['edit_rank']); + self::assertFalse($actual['send_messages']); + self::assertFalse($actual['send_media']); + } finally { + $tl = null; + $adapter = null; + \danog\MadelineProto\Logger::$default = $previousLogger; + } + } +} diff --git a/tests/PermissionConcurrencyTest.php b/tests/PermissionConcurrencyTest.php new file mode 100644 index 0000000..a4cc366 --- /dev/null +++ b/tests/PermissionConcurrencyTest.php @@ -0,0 +1,200 @@ +resetRunner(); + $this->previousHeartbeat = getenv('HEARTBEAT_PATH'); + $verification = dirname(__DIR__) . '/dist/verification'; + if (!is_dir($verification) && !mkdir($verification, 0700, true) && !is_dir($verification)) { + throw new \RuntimeException('Cannot create isolated permission test directory.'); + } + $this->heartbeatPath = dirname(__DIR__) . '/dist/verification/dev9-permission-heartbeat-' . bin2hex(random_bytes(8)) . '.json'; + putenv('HEARTBEAT_PATH=' . $this->heartbeatPath); + } + + protected function tearDown(): void + { + if (is_file($this->heartbeatPath)) { + unlink($this->heartbeatPath); + } + putenv($this->previousHeartbeat === false ? 'HEARTBEAT_PATH' : 'HEARTBEAT_PATH=' . $this->previousHeartbeat); + $this->resetRunner(); + } + + private function resetRunner(): void + { + foreach (['stopping' => false, 'restartPending' => false, 'quiesce' => null, 'handler' => null] as $name => $value) { + (new \ReflectionProperty(Runner::class, $name))->setValue(null, $value); + } + } + + private function fixture(int $groups): array + { + $clock = new FrozenClock(new DateTimeImmutable('2026-10-16T09:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $gateway = new RuntimeFakeGateway(); + $http = new class implements \Shabbat\Http\JsonClient { + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + throw new \LogicException('A current explicit override must not fetch a provider.'); + } + }; + $app = new Application($gateway, $clock, $db, new CalendarService(new HebcalProvider($http), $clock)); + for ($i = 1; $i <= $groups; $i++) { + $app->settings->ensureGroup('-' . $i); + } + $db->pdo()->prepare("UPDATE group_settings SET mode='lock',manual_override_mode='lock',manual_override_until=?") + ->execute([$clock->now()->getTimestamp() + 3600]); + return [$app, $gateway, $db, $clock]; + } + + private function heartbeat(Application $app): ?int + { + return (new \ReflectionProperty(Application::class, 'permissionHeartbeat'))->getValue($app); + } + + public function testOneHeldPeerCannotBlockOtherSlotsAcrossPagination(): void + { + [$app, $gateway, $db, $clock] = $this->fixture(150); + $release = new DeferredFuture(); + $entered = new DeferredFuture(); + $othersDone = new DeferredFuture(); + $active = 0; + $peak = 0; + $seen = []; + $held = false; + $writes = 0; + $gateway->beforeGetPermissions = static function (string $group) use ($release, $entered, &$active, &$peak, &$seen, &$held): void { + $active++; + $peak = max($peak, $active); + $seen[$group] = true; + try { + if ($group === '-1' && !$held) { + $held = true; + $entered->complete(); + $release->getFuture()->await(); + } else { + \Amp\delay(0.001); + } + } finally { + $active--; + } + }; + $gateway->beforeSetPermissions = static function (string $group) use (&$writes, $othersDone): void { + if ($group !== '-1' && ++$writes === 149) { + $othersDone->complete(); + } + }; + $tick = \Amp\async($app->permissionTick(...)); + try { + $entered->getFuture()->await(new TimeoutCancellation(30)); + $othersDone->getFuture()->await(new TimeoutCancellation(30)); + self::assertFalse($tick->isComplete(), 'Full pass must still join the held peer.'); + self::assertNull($this->heartbeat($app), 'A partial pass must not refresh health.'); + self::assertSame(149, $gateway->mutations); + self::assertCount(150, $seen, 'Free workers must pass the 100-peer pagination boundary.'); + self::assertSame(4, $peak); + $app->permissionTick(); // A concurrent trigger cannot start a second pool. + self::assertSame(149, $gateway->mutations); + } finally { + $release->complete(); + $tick->await(new TimeoutCancellation(30)); + } + self::assertSame(0, $active); + self::assertSame(150, $gateway->mutations); + self::assertSame(150, (int)$db->pdo()->query("SELECT COUNT(*) FROM group_lock_state WHERE status='locked'")->fetchColumn()); + self::assertSame($clock->now()->getTimestamp(), $this->heartbeat($app)); + self::assertSame(150, (new \ReflectionProperty(Application::class, 'cycleMetrics'))->getValue($app)['permissions']['groups']); + } + + public function testCancellationStopsNewClaimsAndJoinsTheAlreadyRunningPeer(): void + { + [$app, $gateway] = $this->fixture(150); + $release = new DeferredFuture(); + $cancelled = new DeferredFuture(); + $error = new CancelledException(); + $seen = []; + $held = false; + $gateway->beforeGetPermissions = static function (string $group) use ($release, $cancelled, $error, &$seen, &$held): void { + $seen[$group] = true; + if ($group === '-1' && !$held) { + $held = true; + $release->getFuture()->await(); + } elseif ($group === '-10') { + $cancelled->complete(); + throw $error; + } + }; + $tick = \Amp\async($app->permissionTick(...)); + $tick->ignore(); + try { + $cancelled->getFuture()->await(new TimeoutCancellation(30)); + self::assertFalse($tick->isComplete(), 'Cancellation must not abandon an in-flight peer.'); + self::assertCount(2, $seen, 'A cancelled worker must stop all new peer claims.'); + self::assertNull($this->heartbeat($app)); + } finally { + $release->complete(); + try { + $tick->await(new TimeoutCancellation(30)); + self::fail('Cancellation was swallowed.'); + } catch (CancelledException $caught) { + self::assertSame($error, $caught); + } + } + self::assertNull($this->heartbeat($app)); + self::assertFalse((new \ReflectionProperty(Application::class, 'ticking'))->getValue($app)); + self::assertNull((new \ReflectionProperty(Application::class, 'tickCompletion'))->getValue($app)); + } + + public function testShutdownDuringAReadDoesNotStartRemainingPeersOrPublishHealth(): void + { + [$app, $gateway] = $this->fixture(150); + $seen = []; + $gateway->beforeGetPermissions = static function (string $group) use (&$seen): void { + $seen[$group] = true; + Runner::requestShutdown(); + }; + try { + $app->permissionTick(); + } catch (CancelledException) { + // An explicit shutdown can propagate after joining started workers. + } + self::assertCount(1, $seen); + self::assertSame(0, $gateway->mutations); + self::assertNull($this->heartbeat($app)); + self::assertFalse((new \ReflectionProperty(Application::class, 'ticking'))->getValue($app)); + } + + public function testEmptyScanCompletesWithoutInventingGroupsOrLeavingWorkers(): void + { + [$app, $gateway, $db, $clock] = $this->fixture(0); + $app->permissionTick(); + self::assertSame(0, $gateway->mutations); + self::assertSame($clock->now()->getTimestamp(), $this->heartbeat($app)); + self::assertSame(0, (new \ReflectionProperty(Application::class, 'cycleMetrics'))->getValue($app)['permissions']['groups']); + self::assertFalse((new \ReflectionProperty(Application::class, 'ticking'))->getValue($app)); + } +} diff --git a/tests/PermissionIdleReconcileTest.php b/tests/PermissionIdleReconcileTest.php new file mode 100644 index 0000000..11cbf67 --- /dev/null +++ b/tests/PermissionIdleReconcileTest.php @@ -0,0 +1,56 @@ +migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T12:00:00Z')); + (new SettingsRepository($db, $clock))->ensureGroup('-1'); + $gateway = new PermissionRecoveryGateway(); + $reads = 0; + $gateway->onRead = static function () use (&$reads): void { + $reads++; + }; + $locks = new LockCoordinator($db, $clock, $gateway); + self::assertSame('locked', $locks->reconcile('-1', 'week1', true)['status']); + self::assertSame('restored', $locks->reconcile('-1', 'week1', false)['status']); + $before = $reads; + $gateway->current['send_photos'] = true; + $original = $gateway->current; + for ($i = 0; $i < 1000; $i++) { + self::assertSame(['status' => 'restored', 'changed' => false], $locks->reconcile('-1', 'weekday', false)); + } + self::assertSame($before, $reads); + self::assertSame('locked', $locks->reconcile('-1', 'week2', true)['status']); + self::assertGreaterThan($before, $reads); + self::assertSame('restored', $locks->reconcile('-1', 'week2', false)['status']); + self::assertSame($original, $gateway->current); + } + public function testPrelockedWeekdayStateDoesNotReadOrOpenAnAdministratorsLock(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T12:00:00Z')); + (new SettingsRepository($db, $clock))->ensureGroup('-1'); + $gateway = new PermissionRecoveryGateway(); + $gateway->current = Permissions::locked($gateway->current); + $locks = new LockCoordinator($db, $clock, $gateway); + self::assertSame('prelocked', $locks->reconcile('-1', 'week1', true)['status']); + $gateway->onRead = static fn() => throw new \LogicException('No remote no-op read expected.'); + self::assertSame(['status' => 'prelocked', 'changed' => false], $locks->reconcile('-1', 'weekday', false)); + self::assertSame([], $gateway->writes); + } +} diff --git a/tests/PermissionRecoveryGateway.php b/tests/PermissionRecoveryGateway.php new file mode 100644 index 0000000..f372ff4 --- /dev/null +++ b/tests/PermissionRecoveryGateway.php @@ -0,0 +1,35 @@ + 'chatBannedRights','until_date' => 0]; + public array $writes = []; + public ?\Closure $onRead = null; + public ?\Closure $onWrite = null; + public function getPermissions(string $groupId): array + { + if ($this->onRead !== null) { + ($this->onRead)(); + } + return $this->current; + } + public function setPermissions(string $groupId, array $permissions): void + { + $this->writes[] = $permissions; + $this->current = $permissions; + if ($this->onWrite !== null) { + ($this->onWrite)(); + } + } + public function send(string $peer, array $payload, string $randomId): array + { + throw new \LogicException('Recovery must never send messages.'); + } +} diff --git a/tests/PermissionRecoveryTest.php b/tests/PermissionRecoveryTest.php new file mode 100644 index 0000000..1e9c74c --- /dev/null +++ b/tests/PermissionRecoveryTest.php @@ -0,0 +1,347 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-13T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1'); + $this->settings->updateGroup('-1', 1, ['lock_block_flags' => ['send_plain']], '42'); + $this->gateway = new PermissionRecoveryGateway(); + $this->locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->recovery = new PermissionRecovery($this->db, $this->clock, $this->gateway, $this->locks, fn(string $actor): bool => $this->authorized && $actor === '42', fn(): bool => $this->safe); + } + private function conflict(): void + { + $this->locks->reconcile('-1', 'first-week', true); + $this->gateway->current['send_plain'] = false; + $this->gateway->current['send_photos'] = true; + self::assertSame('conflict', $this->locks->reconcile('-1', 'first-week', false)['status']); + } + public function testAutomaticThreeWayRecoveryPreservesExternalUntouchedRestrictionsThroughRestartAndNextWeek(): void + { + $this->locks->reconcile('-1', 'first-week', true); + $original = $this->locks->state('-1')['snapshot_json']; + $this->gateway->current['send_photos'] = true; + self::assertSame('locked', $this->locks->reconcile('-1', 'first-week', true)['status']); + self::assertSame($original, $this->locks->state('-1')['snapshot_json']); + $this->gateway->current['send_videos'] = true; + $restart = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame('restored', $restart->reconcile('-1', 'first-week', false)['status']); + self::assertFalse($this->gateway->current['send_plain'] ?? false); + self::assertTrue($this->gateway->current['send_photos']); + self::assertTrue($this->gateway->current['send_videos']); + $this->clock->set(new \DateTimeImmutable('2026-10-23T16:00:00Z')); + self::assertSame('locked', $restart->reconcile('-1', 'next-week', true)['status']); + self::assertSame('restored', $restart->reconcile('-1', 'next-week', false)['status']); + self::assertTrue($this->gateway->current['send_photos']); + self::assertTrue($this->gateway->current['send_videos']); + } + public function testBotTouchedAndUnknownAndFiniteExpiryChangesNeverAutomaticallyAdopt(): void + { + $this->conflict(); + $original = $this->locks->state('-1')['snapshot_json']; + $this->clock->set(new \DateTimeImmutable('2026-10-23T16:00:00Z')); + self::assertSame('conflict', $this->locks->reconcile('-1', 'next-week', true)['status']); + self::assertSame($original, $this->locks->state('-1')['snapshot_json']); + self::assertCount(1, $this->gateway->writes); + $base = ['until_date' => 0]; + $applied = Permissions::locked($base, ['send_plain']); + self::assertNull(Permissions::externalBaseline($base, $applied, $applied + ['future_flag' => true])); + self::assertNull(Permissions::externalBaseline($base, $applied, array_replace($applied, ['until_date' => 123456]))); + } + public function testAdministratorAdoptsCurrentThenNextWeekClosesAndRestoresNewNormal(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'adopt'); + self::assertTrue($preview['can_adopt']); + $original = $this->locks->state('-1')['snapshot_json']; + self::assertSame('adopted', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + self::assertSame($original, $this->locks->state('-1')['snapshot_json']); + self::assertSame('expired', $this->recovery->confirm($preview['token'], '42')['status']); + $audit = $this->db->pdo()->query("SELECT details_json FROM audit_log WHERE action='permissions.recovery.adopt'")->fetchColumn(); + self::assertSame($original, json_decode($audit, true)['original_json']); + $this->clock->set(new \DateTimeImmutable('2026-10-23T16:00:00Z')); + self::assertSame('locked', $this->locks->reconcile('-1', 'next-week', true)['status']); + self::assertSame('restored', $this->locks->reconcile('-1', 'next-week', false)['status']); + self::assertTrue($this->gateway->current['send_photos']); + self::assertFalse($this->gateway->current['send_plain']); + } + public function testAdministratorRestoreUsesExactOriginalAndPersistsDecision(): void + { + $this->conflict(); + $original = json_decode($this->locks->state('-1')['snapshot_json'], true); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + self::assertSame(['status' => 'restored','changed' => true], $this->recovery->confirm($preview['token'], '42')); + self::assertSame($original, $this->gateway->current); + self::assertSame('confirmed', (new PermissionRecoveryJournal($this->db, $this->clock))->latest('-1')['outcome']); + } + public function testActiveReligiousOrManualLockBlocksBothChoices(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->safe = false; + self::assertSame('active_lock', $this->recovery->confirm($preview['token'], '42')['status']); + foreach (['adopt','restore'] as $action) { + $denied = $this->recovery->prepare('-1', '42', $action); + self::assertNull($denied['token']); + self::assertSame('active_lock', $denied['block_reason']); + } + self::assertCount(1, $this->gateway->writes); + } + public function testLockedCurrentCannotBecomeNormalByAdoption(): void + { + $this->locks->reconcile('-1', 'week', true); + $preview = $this->recovery->prepare('-1', '42', 'adopt'); + self::assertFalse($preview['can_adopt']); + self::assertSame('locked_current', $preview['adopt_block_reason']); + self::assertNull($preview['token']); + } + public function testExpiryAndWrongActorAndReplacementPreviewAreRejected(): void + { + $this->conflict(); + $first = $this->recovery->prepare('-1', '42', 'restore'); + self::assertSame('expired', $this->recovery->confirm($first['token'], '43')['status']); + $second = $this->recovery->prepare('-1', '42', 'restore'); + self::assertSame('expired', $this->recovery->confirm($first['token'], '42')['status']); + $this->clock->set($this->clock->now()->modify('+301 seconds')); + self::assertSame('expired', $this->recovery->confirm($second['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testRemoteAndSettingsAndLockStateChangesInvalidateConfirmation(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->current['send_videos'] = true; + self::assertSame('stale', $this->recovery->confirm($preview['token'], '42')['status']); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->settings->updateGroup('-1', 2, ['candle_minutes' => 30], '42'); + self::assertSame('stale', $this->recovery->confirm($preview['token'], '42')['status']); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->db->pdo()->exec("UPDATE group_lock_state SET revision=revision+1 WHERE group_id='-1'"); + self::assertSame('stale', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testAuthorityLossDuringRemoteReadStopsMutationAndTokenCannotReplay(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onRead = function (): void { + $this->authorized = false; + }; + self::assertSame('forbidden', $this->recovery->confirm($preview['token'], '42')['status']); + $this->authorized = true; + self::assertSame('expired', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testConcurrentCoordinatorAndExpiredLeaseCannotOverwriteRecovery(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onRead = function (): void { + $other = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame('busy', $other->reconcile('-1', 'other', true)['status']); + }; + self::assertSame('restored', $this->recovery->confirm($preview['token'], '42')['status']); + $this->gateway->onRead = null; + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onRead = function (): void { + $this->clock->set($this->clock->now()->modify('+121 seconds')); + }; + self::assertSame('busy', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertCount(3, $this->gateway->writes); + } + public function testCancellationPropagatesAndLeavesDurableUnknownOutcomeRecoverable(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onWrite = static fn() => throw new \Amp\CancelledException(); + try { + $this->recovery->confirm($preview['token'], '42'); + self::fail('Cancellation must propagate.'); + } catch (\Amp\CancelledException) { + self::assertNull($this->locks->state('-1')['lease_owner']); + } + self::assertSame('unknown', (new PermissionRecoveryJournal($this->db, $this->clock))->latest('-1')['outcome']); + $this->gateway->onWrite = null; + self::assertSame('restored', $this->locks->reconcile('-1', 'recovery', false)['status']); + self::assertCount(2, $this->gateway->writes); + } + public function testNoRemoteCallRunsInsideDatabaseTransactionAndJournalIsBounded(): void + { + $this->gateway->onRead = function (): void { + $this->db->transaction(static fn() => true); + }; + $this->gateway->onWrite = $this->gateway->onRead; + for ($i = 0; $i < 15; $i++) { + self::assertSame('locked', $this->locks->reconcile('-1', 'week-' . $i, true)['status']); + self::assertSame('restored', $this->locks->reconcile('-1', 'week-' . $i, false)['status']); + } + self::assertSame(20, (int)$this->db->pdo()->query('SELECT count(*) FROM permission_operations')->fetchColumn()); + } + public function testShutdownOrHolyBoundaryReachedDuringReadPreventsRecoveryWrite(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onRead = function (): void { + $this->safe = false; + }; + self::assertSame('active_lock', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testAuthorityRefreshCrossingHolyBoundaryIsRecheckedBeforeMutation(): void + { + $this->conflict(); + $calls = 0; + $flipAt = PHP_INT_MAX; + $recovery = new PermissionRecovery($this->db, $this->clock, $this->gateway, $this->locks, function () use (&$calls, &$flipAt): bool { + $calls++; + if ($calls === $flipAt) { + $this->safe = false; + } + return true; + }, fn(): bool => $this->safe); + $preview = $recovery->prepare('-1', '42', 'restore'); + $flipAt = $calls + 2; + self::assertSame('active_lock', $recovery->confirm($preview['token'], '42')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testFinalAsyncAuthorizationCannotHideNewRemoteLockDuringAdoption(): void + { + $this->conflict(); + $calls = 0; + $flipAt = PHP_INT_MAX; + $recovery = new PermissionRecovery($this->db, $this->clock, $this->gateway, $this->locks, function () use (&$calls, &$flipAt): bool { + $calls++; + if ($calls === $flipAt) { + $this->gateway->current['send_plain'] = true; + } + return true; + }, static fn(): bool => true); + $preview = $recovery->prepare('-1', '42', 'adopt'); + $flipAt = $calls + 8; + self::assertSame('conflict', $recovery->confirm($preview['token'], '42')['status']); + self::assertSame('conflict', $this->locks->state('-1')['status']); + self::assertTrue($this->gateway->current['send_plain']); + self::assertTrue($recovery->inspect('-1', '42')['can_restore']); + self::assertCount(1, $this->gateway->writes); + } + public function testFinalAuthorizationCannotHideRemoteChangeAfterExplicitRestore(): void + { + $this->conflict(); + $calls = 0; + $flipAt = PHP_INT_MAX; + $recovery = new PermissionRecovery($this->db, $this->clock, $this->gateway, $this->locks, function () use (&$calls, &$flipAt): bool { + $calls++; + if ($calls === $flipAt) { + $this->gateway->current['send_plain'] = true; + } + return true; + }, static fn(): bool => true); + $preview = $recovery->prepare('-1', '42', 'restore'); + $flipAt = $calls + 8; + self::assertSame('conflict', $recovery->confirm($preview['token'], '42')['status']); + self::assertSame('conflict', $this->locks->state('-1')['status']); + self::assertTrue($this->gateway->current['send_plain']); + self::assertTrue($recovery->inspect('-1', '42')['can_restore']); + self::assertCount(2, $this->gateway->writes); + } + public function testFuturePersistedQuotaReturnsRetryWaitWithoutMutation(): void + { + $this->conflict(); + $retry = $this->clock->now()->getTimestamp() + 60; + $this->db->pdo()->prepare("UPDATE group_lock_state SET retry_at=? WHERE group_id='-1'")->execute([$retry]); + $preview = $this->recovery->prepare('-1', '42', 'restore'); + self::assertSame(['status' => 'retry_wait','changed' => false,'retry_at' => $retry], $this->recovery->confirm($preview['token'], '42')); + self::assertCount(1, $this->gateway->writes); + } + public function testFinalRemoteReadCrossingTimeCannotCommitWithoutLocalSafetyContext(): void + { + $this->conflict(); + $preview = $this->recovery->prepare('-1', '42', 'adopt'); + $reads = 0; + $this->gateway->onRead = function () use (&$reads): void { + $reads++; + if ($reads === 3) { + $this->clock->set($this->clock->now()->modify('+1 second')); + } + }; + self::assertSame('stale', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertSame('conflict', $this->locks->state('-1')['status']); + self::assertCount(1, $this->gateway->writes); + } + public function testProvenDeferredFirstAttemptRetiresWithoutResettingAmbiguousDispatchedDebt(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, static fn(): bool => false); + self::assertSame('deferred', $locks->reconcile('-1', 'week', true)['status']); + $oldOriginal = $locks->state('-1')['snapshot_json']; + $this->gateway->current['send_photos'] = true; + $this->clock->set($this->clock->now()->modify('+7 days')); + self::assertSame('locked', $this->locks->reconcile('-1', 'next-week', true)['status']); + self::assertSame($oldOriginal, $this->db->pdo()->query("SELECT original_json FROM permission_operations WHERE stage='retired_unwritten'")->fetchColumn()); + self::assertSame('restored', $this->locks->reconcile('-1', 'next-week', false)['status']); + self::assertTrue($this->gateway->current['send_photos']); + self::assertFalse($this->gateway->current['send_plain'] ?? false); + } + public function testDeferredBeforeWriteIsDistinguishedFromUnknownDispatch(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, static fn(): bool => false); + self::assertSame('deferred', $locks->reconcile('-1', 'week', true)['status']); + $journal = new PermissionRecoveryJournal($this->db, $this->clock); + self::assertSame('not_dispatched', $journal->latest('-1')['outcome']); + self::assertSame('deferred', $journal->latest('-1')['stage']); + self::assertCount(0, $this->gateway->writes); + self::assertSame('restored', $locks->reconcile('-1', 'week', false)['status']); + $this->gateway->current['send_photos'] = true; + self::assertSame('locked', $this->locks->reconcile('-1', 'next-week', true)['status']); + self::assertSame('restored', $this->locks->reconcile('-1', 'next-week', false)['status']); + self::assertTrue($this->gateway->current['send_photos']); + } + public function testPartialRestoreNeverClaimsSuccessOrClearsOriginal(): void + { + $this->conflict(); + $original = $this->locks->state('-1')['snapshot_json']; + $preview = $this->recovery->prepare('-1', '42', 'restore'); + $this->gateway->onWrite = function (): void { + $this->gateway->current['send_plain'] = true; + $this->gateway->current['send_photos'] = true; + }; + self::assertSame('conflict', $this->recovery->confirm($preview['token'], '42')['status']); + self::assertSame($original, $this->locks->state('-1')['snapshot_json']); + self::assertSame('conflict', (new PermissionRecoveryJournal($this->db, $this->clock))->latest('-1')['outcome']); + } + public function testMissingOrCorruptOriginalAllowsExplicitAdoptButNeverRestore(): void + { + $this->conflict(); + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_sha256='invalid' WHERE group_id='-1'"); + $inspect = $this->recovery->inspect('-1', '42'); + self::assertFalse($inspect['can_restore']); + self::assertSame('missing_snapshot', $inspect['restore_block_reason']); + self::assertTrue($inspect['can_adopt']); + self::assertSame('adopted', $this->recovery->confirm($this->recovery->prepare('-1', '42', 'adopt')['token'], '42')['status']); + self::assertSame('locked', $this->locks->reconcile('-1', 'next-week', true)['status']); + } +} diff --git a/tests/PermissionRecoveryUiTest.php b/tests/PermissionRecoveryUiTest.php new file mode 100644 index 0000000..c2cf468 --- /dev/null +++ b/tests/PermissionRecoveryUiTest.php @@ -0,0 +1,362 @@ + 'chatBannedRights', 'until_date' => 0, 'send_polls' => true]; + + protected function setUp(): void + { + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->flows = new FlowTokenRepository($this->db, $this->clock); + $this->settings->ensureGroup('-1001', 'Recovery group'); + $this->gateway = new UiNavigationFakeGateway(); + $this->telegram = new PermissionRetryFakeGateway(); + $this->telegram->permissions['-1001'] = $this->original; + $this->locks = new LockCoordinator($this->db, $this->clock, $this->telegram); + self::assertSame('locked', $this->locks->reconcile('-1001', 'old-event', true)['status']); + $this->db->pdo()->exec("UPDATE group_lock_state SET status='conflict',revision=revision+1 WHERE group_id='-1001'"); + $this->telegram->permissions['-1001'] = $this->original + ['send_photos' => true]; + $this->telegram->resetActivity(); + $recovery = new PermissionRecovery( + $this->db, + $this->clock, + $this->telegram, + $this->locks, + fn(string $actor, string $group): bool => $this->gateway->isGroupAdmin($actor, $group), + fn(string $group): bool => $this->safe + ); + $http = new FullHebcalFixtureClient(); + $this->ui = new BotController( + $this->settings, + $this->flows, + new CalendarService(new HebcalProvider($http), $this->clock), + $this->gateway, + new GeoNames($http, $this->clock, ''), + $this->clock, + permissionRecovery: $recovery + ); + } + + public static function languageActions(): array + { + $cases = []; + foreach (Lang::CODES as $language) { + foreach (['adopt', 'restore'] as $action) { + $cases[$language . '-' . $action] = [$language, $action]; + } + } + return $cases; + } + + #[DataProvider('languageActions')] + public function testReviewedActionsUseGeneratedButtonsAndEditTheSamePrivatePanel(string $language, string $action): void + { + $user = $this->settings->getUser('1'); + $this->settings->updateUser('1', $user['revision'], ['language' => $language]); + $this->open('shabbat'); + self::assertStringContainsString('Recovery <named> group', $this->last()['text']); + self::assertStringContainsString('-1001', $this->last()['text']); + $lang = new Lang(); + self::assertStringContainsString($lang->text($language, 'permission_send_photos'), $this->last()['text']); + self::assertStringContainsString($lang->text($language, 'recovery_blocked'), $this->last()['text']); + $this->click($this->button('sb:pa:', ':' . $action)); + self::assertStringContainsString($lang->text($language, 'recovery_preview_' . $action), $this->last()['text']); + self::assertSame(0, $this->telegram->writes); + $confirm = $this->button('sb:pc:'); + $this->click($confirm); + self::assertSame($action === 'restore' ? 1 : 0, $this->telegram->writes); + self::assertSame('restored', $this->locks->state('-1001')['status']); + self::assertStringContainsString($lang->text($language, 'recovery_result_' . ($action === 'adopt' ? 'adopted' : 'restored')), $this->last()['text']); + self::assertTrue(Permissions::equal($action === 'restore' ? $this->original : $this->original + ['send_photos' => true], $this->telegram->permissions['-1001'])); + $this->click($this->button('sb:v:', ':shabbat')); + self::assertStringContainsString($lang->text($language, 'settings_shabbat_timing'), json_encode($this->last()['markup'], JSON_UNESCAPED_UNICODE)); + $edits = count($this->gateway->edited); + $this->click($confirm); + self::assertCount($edits, $this->gateway->edited); + self::assertSame($lang->text($language, 'denied'), $this->lastAnswer()); + self::assertSame([], $this->gateway->sent); + foreach ($this->gateway->edited as $edit) { + self::assertSame('1', $edit['userId']); + self::assertSame(101, $edit['messageId']); + self::assertLessThan(4096, mb_strlen(strip_tags($edit['text']), 'UTF-8')); + foreach ($edit['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + self::assertLessThanOrEqual(64, strlen($button['data'])); + } + } + } + } + + public static function screens(): array + { + return array_map(static fn(string $screen): array => [$screen], ['group', 'shabbat', 'moderation', 'restrictions', 'botpermissions', 'status']); + } + + #[DataProvider('screens')] + public function testRecoveryIsDiscoverableAndReturnsToItsCategory(string $screen): void + { + $this->open($screen); + $back = $this->button($screen === 'group' ? 'sb:group:' : 'sb:v:', $screen === 'group' ? null : ':' . $screen); + $this->click($back); + self::assertNotSame('', $this->button('sb:pr:')); + self::assertSame(0, $this->telegram->writes); + self::assertSame([], $this->gateway->sent); + } + + public function testRevokedAuthorityAndForeignUserNeverExecuteConfirmation(): void + { + $this->open(); + $this->click($this->button('sb:pa:', ':restore')); + $confirm = $this->button('sb:pc:'); + $this->click($confirm, ['user_id' => '2', 'chat_id' => '2']); + self::assertSame(0, $this->telegram->writes); + $this->gateway->admin = false; + $this->click($confirm); + self::assertSame(0, $this->telegram->writes); + self::assertSame('conflict', $this->locks->state('-1001')['status']); + } + + public function testRevocationDuringFreshReadDisclosesNoRecoveryData(): void + { + $this->telegram->onGet = function (): void { + $this->gateway->admin = false; + }; + $panel = $this->panelToken(); + $this->click('sb:pr:' . $panel); + self::assertSame([], $this->gateway->edited); + self::assertSame((new Lang())->text('he', 'denied'), $this->lastAnswer()); + } + + public static function invalidQueries(): array + { + return [ + 'group' => [['private' => false]], 'foreign-chat' => [['chat_id' => '2']], + 'inline' => [['inline_selection' => true, 'private' => false]], + 'no-message' => [['message_id' => null]], 'bad-message' => [['message_id' => 0]], + ]; + } + + #[DataProvider('invalidQueries')] + public function testForgedContextsNeverReadOrMutateRights(array $overrides): void + { + $this->click('sb:pr:' . $this->panelToken(), $overrides); + self::assertSame(0, $this->telegram->reads); + self::assertSame(0, $this->telegram->writes); + self::assertSame([], $this->gateway->sent); + } + + public static function staleCases(): array + { + return [['expiry'], ['settings'], ['remote'], ['protected']]; + } + + #[DataProvider('staleCases')] + public function testChangesAfterReviewRequireNewConfirmation(string $case): void + { + $this->open(); + $this->click($this->button('sb:pa:', ':restore')); + $confirm = $this->button('sb:pc:'); + if ($case === 'expiry') { + $this->clock->set($this->clock->now()->modify('+301 seconds')); + } elseif ($case === 'settings') { + $group = $this->settings->getGroup('-1001'); + $this->settings->updateGroup('-1001', $group['revision'], ['candle_minutes' => 40], '1'); + } elseif ($case === 'remote') { + $this->telegram->permissions['-1001']['send_videos'] = true; + } else { + $this->safe = false; + } + $this->click($confirm); + self::assertSame(0, $this->telegram->writes); + self::assertSame('conflict', $this->locks->state('-1001')['status']); + if ($case === 'protected') { + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_active_lock'), $this->last()['text']); + self::assertFalse($this->hasButton('sb:pa:')); + } + } + + public function testCancelAndNavigatingAwayInvalidateReviewedAction(): void + { + $this->open(); + $this->click($this->button('sb:pa:', ':restore')); + $first = $this->button('sb:pc:'); + $this->click($this->button('sb:px:')); + $this->click($first); + self::assertSame(0, $this->telegram->writes); + $this->click($this->button('sb:pa:', ':restore')); + $second = $this->button('sb:pc:'); + $this->click('sb:start'); + $this->click($second); + self::assertSame(0, $this->telegram->writes); + } + + public function testLockedStateCannotBeAdoptedAndFailedEditsNeverSendReplacement(): void + { + $this->telegram->permissions['-1001'] = Permissions::locked($this->original); + $this->open(); + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_locked_current'), $this->last()['text']); + self::assertFalse($this->hasButton('sb:pa:', ':adopt')); + self::assertTrue($this->hasButton('sb:pa:', ':restore')); + $refresh = $this->button('sb:pr:'); + $this->gateway->onEdit = static function (): void { + throw new \RuntimeException('Message no longer exists.'); + }; + $this->click($refresh); + self::assertSame((new Lang())->text('he', 'callback_panel_unavailable'), $this->lastAnswer()); + self::assertSame([], $this->gateway->sent); + self::assertSame(0, $this->telegram->writes); + } + + public function testComparisonIsBoundedAndUnknownPermissionNamesAreNeverDisclosed(): void + { + $this->telegram->permissions['-1001'] = array_replace($this->original, array_fill_keys([ + 'send_photos', 'send_videos', 'send_roundvideos', 'send_audios', 'send_voices', 'send_docs', + 'send_stickers', 'send_gifs', 'send_games', 'send_inline', 'embed_links', 'send_reactions', + 'unsafepermission', + ], true)); + $this->open(); + $text = $this->last()['text']; + self::assertSame(8, substr_count($text, "\nโ€ข ")); + self::assertStringContainsString((new Lang())->text('he', 'recovery_more', ['count' => 5]), $text); + self::assertStringNotContainsString('unsafe', $text); + self::assertStringNotContainsString('send_photos', $text); + self::assertLessThan(2500, mb_strlen($text, 'UTF-8')); + } + + public function testMissingOriginalHasClearWarningAndOffersNoRestore(): void + { + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_sha256='invalid' WHERE group_id='-1001'"); + $this->open(); + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_missing_snapshot'), $this->last()['text']); + self::assertFalse($this->hasButton('sb:pa:', ':restore')); + self::assertTrue($this->hasButton('sb:pa:', ':adopt')); + self::assertStringNotContainsString((new Lang())->text('he', 'recovery_compare'), $this->last()['text']); + } + + public function testActivePeriodOffersOnlyReadOnlyRefreshAndBack(): void + { + $this->safe = false; + $this->open('moderation'); + self::assertFalse($this->hasButton('sb:pa:')); + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_active_lock'), $this->last()['text']); + $this->click($this->button('sb:pr:')); + self::assertSame(0, $this->telegram->writes); + self::assertSame([], $this->gateway->sent); + } + + public static function languages(): array + { + return array_map(static fn(string $language): array => [$language], Lang::CODES); + } + + #[DataProvider('languages')] + public function testLegacySnapshotRequiresFreshExplicitAdoptionInEveryLanguage(string $language): void + { + $user = $this->settings->getUser('1'); + $this->settings->updateUser('1', $user['revision'], ['language' => $language]); + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_provenance='legacy_unverified',snapshot_evidence_json=NULL,revision=revision+1 WHERE group_id='-1001'"); + $before = $this->locks->state('-1001')['snapshot_json']; + $this->open('shabbat'); + $lang = new Lang(); + self::assertStringContainsString($lang->text($language, 'recovery_block_unverified_snapshot'), $this->last()['text']); + self::assertFalse($this->hasButton('sb:pa:', ':restore')); + self::assertTrue($this->hasButton('sb:pa:', ':adopt')); + $this->click($this->button('sb:pa:', ':adopt')); + self::assertStringContainsString($lang->text($language, 'recovery_preview_adopt'), $this->last()['text']); + self::assertStringContainsString($lang->text($language, 'recovery_block_unverified_snapshot'), $this->last()['text']); + $this->click($this->button('sb:pc:')); + self::assertSame(0, $this->telegram->writes); + self::assertSame('restored', $this->locks->state('-1001')['status']); + self::assertSame($before, $this->locks->state('-1001')['snapshot_json']); + self::assertStringContainsString($lang->text($language, 'recovery_result_adopted'), $this->last()['text']); + self::assertSame([], $this->gateway->sent); + } + + public function testLegacyWarningRemainsVisibleWhileRecoveryIsBlocked(): void + { + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_provenance='legacy_unverified',snapshot_evidence_json=NULL WHERE group_id='-1001'"); + $this->safe = false; + $this->open(); + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_active_lock'), $this->last()['text']); + self::assertStringContainsString((new Lang())->text('he', 'recovery_block_unverified_snapshot'), $this->last()['text']); + self::assertFalse($this->hasButton('sb:pa:')); + self::assertSame(0, $this->telegram->writes); + } + private function panelToken(): string + { + return $this->flows->create('1', 'group', '-1001', $this->settings->getGroup('-1001')['revision'], 'panel'); + } + + private function open(string $screen = 'group'): void + { + $token = $this->panelToken(); + $this->click($screen === 'group' ? 'sb:group:' . $token : 'sb:v:' . $token . ':' . $screen); + $this->click($this->button('sb:pr:')); + } + + private function click(string $data, array $overrides = []): void + { + self::assertTrue($this->ui->handleCallback(array_replace(['user_id' => '1', 'chat_id' => '1', 'private' => true, 'message_id' => 101, 'query_id' => 'query', 'data' => $data], $overrides))); + } + + private function last(): array + { + return $this->gateway->edited[array_key_last($this->gateway->edited)]; + } + + private function lastAnswer(): string + { + return $this->gateway->answers[array_key_last($this->gateway->answers)]['text']; + } + + private function hasButton(string $prefix, ?string $suffix = null): bool + { + foreach ($this->last()['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', $prefix) && ($suffix === null || str_ends_with($button['data'], $suffix))) { + return true; + } + } + } + return false; + } + + private function button(string $prefix, ?string $suffix = null): string + { + foreach ($this->last()['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (str_starts_with($button['data'] ?? '', $prefix) && ($suffix === null || str_ends_with($button['data'], $suffix))) { + return $button['data']; + } + } + } + self::fail('Missing generated recovery button ' . $prefix . ($suffix ?? '')); + } +} diff --git a/tests/PermissionRetryFakeGateway.php b/tests/PermissionRetryFakeGateway.php new file mode 100644 index 0000000..4872e2f --- /dev/null +++ b/tests/PermissionRetryFakeGateway.php @@ -0,0 +1,45 @@ +reads++; + if ($this->onGet !== null) { + ($this->onGet)($group, $this->reads); + } + return $this->permissions[$group] ?? ['_' => 'chatBannedRights','until_date' => 0,'send_polls' => true]; + } + public function setPermissions(string $group, array $permissions): void + { + $this->writes++; + if ($this->onSet !== null) { + ($this->onSet)($group, $permissions, $this->writes); + } + $this->permissions[$group] = $permissions; + $this->changes[] = [$group,$permissions]; + } + public function send(string $peer, array $payload, string $randomId): array + { + throw new \RuntimeException('No message sends in permission tests.'); + } + public function resetActivity(): void + { + $this->reads = 0; + $this->writes = 0; + $this->changes = []; + } +} diff --git a/tests/PermissionRetryTest.php b/tests/PermissionRetryTest.php new file mode 100644 index 0000000..2e22099 --- /dev/null +++ b/tests/PermissionRetryTest.php @@ -0,0 +1,338 @@ +path = $path; + $this->db = Database::open($path); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-13T09:00:00Z')); + (new SettingsRepository($this->db, $this->clock))->ensureGroup('-1'); + $this->gateway = new PermissionRetryFakeGateway(); + } + protected function tearDown(): void + { + unset($this->db); + foreach ([$this->path,$this->path . '-wal',$this->path . '-shm'] as $path) { + if (is_file($path)) { + unlink($path); + } + } + } + public static function quotaPhases(): array + { + $cases = []; + foreach (['read','preflight','mutation','readback'] as $phase) { + foreach ([false,true] as $restore) { + $cases[$phase . ($restore ? ' restore' : ' close')] = [$phase,$restore]; + } + }return $cases; + } + #[DataProvider('quotaPhases')] + public function testQuotaDeadlineSurvivesRestartAndNoPermissionRpcRunsBeforeIt(string $phase, bool $restore): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $original = $this->gateway->getPermissions('-1'); + if ($restore) { + $locks->reconcile('-1', 'old-event', true); + }$this->gateway->resetActivity(); + $this->gateway->onGet = static function (string $group, int $reads) use ($phase): void { + if (($phase === 'read' && $reads === 1) || ($phase === 'preflight' && $reads === 2) || ($phase === 'readback' && $reads === 3)) { + throw new RetryableTelegramException('FLOOD_WAIT_30', 30); + } + }; + $this->gateway->onSet = static function (string $group, array $rights, int $writes) use ($phase): void { + if ($phase === 'mutation' && $writes === 1) { + throw new RetryableTelegramException('FLOOD_WAIT_30', 30); + } + }; + $first = $locks->reconcile('-1', 'event', !$restore); + self::assertSame('retry_wait', $first['status']); + self::assertFalse($first['changed']); + self::assertSame($this->clock->now()->getTimestamp() + 30, $first['retry_at']); + $snapshot = $locks->state('-1')['snapshot_json']; + $signature = $locks->state('-1')['snapshot_sha256']; + $calls = $this->gateway->reads + $this->gateway->writes; + if ($restore || $phase !== 'read') { + self::assertNotNull($snapshot); + self::assertSame(hash('sha256', $snapshot), $signature); + } + // A new PDO connection and coordinator simulate a process restart against committed state. + $reopened = Database::open($this->path); + $reopened->migrate(); + $afterRestart = new LockCoordinator($reopened, $this->clock, $this->gateway); + self::assertSame('retry_wait', $afterRestart->reconcile('-1', 'different-event', !$restore)['status']); + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:00:29Z')); + self::assertSame('retry_wait', $afterRestart->reconcile('-1', 'event', !$restore)['status']); + self::assertSame($calls, $this->gateway->reads + $this->gateway->writes); + $this->gateway->onGet = null; + $this->gateway->onSet = null; + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:00:30Z')); + $result = $afterRestart->reconcile('-1', 'event', !$restore); + self::assertSame($restore ? 'restored' : 'locked', $result['status']); + self::assertNull($afterRestart->state('-1')['retry_at']); + if ($snapshot !== null) { + self::assertSame($snapshot, $afterRestart->state('-1')['snapshot_json']); + self::assertSame($signature, $afterRestart->state('-1')['snapshot_sha256']); + } + if ($phase === 'readback') { + self::assertFalse($result['changed']); + } + if ($restore) { + self::assertSame($original, $this->gateway->permissions['-1']); + } else { + $afterRestart->reconcile('-1', 'event', false); + self::assertSame($original, $this->gateway->permissions['-1']); + } + unset($afterRestart, $reopened); + } + public function testMissingSnapshotAndPartialFailuresNeverGuessOrDiscardRecovery(): void + { + $this->db->pdo()->exec("INSERT INTO group_lock_state(group_id,event_key,status,prepared_at) VALUES('-1','unknown','recovery_required',0)"); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onGet = static function (): void { + throw new RetryableTelegramException('FLOOD_WAIT_15', 15); + }; + self::assertSame('retry_wait', $locks->reconcile('-1', 'new', false)['status']); + $this->gateway->onGet = null; + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:00:15Z')); + self::assertSame('recovery_required', $locks->reconcile('-1', 'new', false)['status']); + self::assertSame(0, $this->gateway->writes); + self::assertNull($locks->state('-1')['snapshot_json']); + } + public function testPartialMutationFailurePreservesSnapshotAndRecoversFromObservedState(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onSet = function (string $group, array $rights): void { + $this->gateway->permissions[$group] = $rights; + throw new \RuntimeException('Lost RPC response after application'); + }; + self::assertSame('error', $locks->reconcile('-1', 'event', true)['status']); + $snapshot = $locks->state('-1')['snapshot_json']; + self::assertNotNull($snapshot); + $this->gateway->onSet = null; + $restarted = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame(['status' => 'locked','changed' => false], $restarted->reconcile('-1', 'event', true)); + self::assertSame($snapshot, $restarted->state('-1')['snapshot_json']); + self::assertSame('restored', $restarted->reconcile('-1', 'event', false)['status']); + } + public function testNewHolySequenceWhilePermissionReadAwaitsPreventsPrematureRestore(): void + { + $desired = true; + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, function (string $group) use (&$desired): bool { + self::assertFalse($this->db->pdo()->inTransaction()); + return $desired; + }); + $locks->reconcile('-1', 'first-event', true); + $snapshot = $locks->state('-1')['snapshot_json']; + $this->gateway->resetActivity(); + $desired = false; + $entered = new DeferredFuture(); + $release = new DeferredFuture(); + $this->gateway->onGet = static function (string $group, int $reads) use ($entered, $release): void { + if ($reads === 1) { + $entered->complete(); + $release->getFuture()->await(); + } + }; + $restore = \Amp\async(fn(): array=>$locks->reconcile('-1', 'previous-event', false)); + $entered->getFuture()->await(); + $desired = true; + $release->complete(); + $result = $restore->await(); + self::assertSame(['status' => 'deferred','changed' => false], $result); + self::assertSame(0, $this->gateway->writes); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + self::assertTrue(Permissions::alreadyLocked($this->gateway->permissions['-1'])); + $this->gateway->onGet = null; + self::assertSame(['status' => 'locked','changed' => false], $locks->reconcile('-1', 'next-festival', true)); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + } + public function testDesiredCloseChangedDuringReadDoesNotApplyOutdatedLock(): void + { + $desired = true; + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, static function (string $group) use (&$desired): bool { + return $desired; + }); + $this->gateway->onGet = static function () use (&$desired): void { + $desired = false; + }; + self::assertSame(['status' => 'deferred','changed' => false], $locks->reconcile('-1', 'ending-event', true)); + self::assertSame(0, $this->gateway->writes); + $snapshot = $locks->state('-1')['snapshot_json']; + self::assertNotNull($snapshot); + $this->gateway->onGet = null; + self::assertSame(['status' => 'restored','changed' => false], $locks->reconcile('-1', 'ending-event', false)); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + } + public function testLeaseExpiryDuringMutationNeverClaimsSuccessfulChange(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onSet = function (string $group, array $rights): void { + $this->gateway->permissions[$group] = $rights; + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:02:01Z')); + }; + $result = $locks->reconcile('-1', 'event', true); + self::assertSame('busy', $result['status']); + self::assertFalse($result['changed']); + self::assertSame(2, $this->gateway->reads); + self::assertSame('prepared', $locks->state('-1')['status']); + $snapshot = $locks->state('-1')['snapshot_json']; + self::assertNotNull($snapshot); + $this->gateway->onSet = null; + $restarted = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame(['status' => 'locked','changed' => false], $restarted->reconcile('-1', 'event', true)); + self::assertSame($snapshot, $restarted->state('-1')['snapshot_json']); + self::assertSame(1, $this->gateway->writes); + } + public function testLeaseExpiryDuringReadbackCannotCommitStaleSuccess(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onGet = function (string $group, int $reads): void { + if ($reads === 3) { + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:02:01Z')); + } + }; + $result = $locks->reconcile('-1', 'event', true); + self::assertSame('busy', $result['status']); + self::assertFalse($result['changed']); + self::assertSame('prepared', $locks->state('-1')['status']); + self::assertNotNull($locks->state('-1')['snapshot_json']); + } + public function testExpiredOwnerCannotOverwriteANewCoordinatorsConfirmedState(): void + { + $first = new LockCoordinator($this->db, $this->clock, $this->gateway); + $second = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onSet = function (string $group, array $rights) use ($second): void { + $this->gateway->permissions[$group] = $rights; + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:02:01Z')); + $this->gateway->onSet = null; + self::assertSame(['status' => 'locked','changed' => false], $second->reconcile($group, 'new-event', true)); + }; + $result = $first->reconcile('-1', 'old-event', true); + self::assertSame('busy', $result['status']); + self::assertFalse($result['changed']); + self::assertSame('locked', $first->state('-1')['status']); + self::assertSame('new-event', $first->state('-1')['event_key']); + self::assertNull($first->state('-1')['last_error']); + } + public function testFreshDesiredEvaluationCancellationCannotMutatePermissions(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway, static function (): bool { + throw new \Amp\CancelledException(); + }); + try { + $locks->reconcile('-1', 'event', true); + self::fail('Cancellation swallowed'); + } catch (\Amp\CancelledException) { + self::assertSame(0, $this->gateway->writes); + self::assertNotNull($locks->state('-1')['snapshot_json']); + self::assertNull($locks->state('-1')['lease_owner']); + } + } + public function testTypedRetryColumnRejectsInvalidDeadlines(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + foreach (["'invalid'","-1","1.5"] as $value) { + try { + $this->db->pdo()->exec("UPDATE group_lock_state SET retry_at=" . $value . " WHERE group_id='-1'"); + self::fail('Invalid deadline accepted'); + } catch (\PDOException) { + self::assertNull($locks->state('-1')['retry_at']); + } + } + } + + public function testQuotaDeadlinePublishedDuringAnAwaitedReadBlocksTheNextRpc(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onGet = function (): void { + $this->db->pdo()->prepare("UPDATE group_lock_state SET retry_at=? WHERE group_id='-1'")->execute([$this->clock->now()->getTimestamp() + 60]); + }; + $result = $locks->reconcile('-1', 'event', true); + self::assertSame('retry_wait', $result['status']); + self::assertSame(1, $this->gateway->reads); + self::assertSame(0, $this->gateway->writes); + $this->gateway->onGet = null; + $locks->reconcile('-1', 'event', true); + self::assertSame(1, $this->gateway->reads); + self::assertSame(0, $this->gateway->writes); + } + public function testPrelockedStateRemainsPrelockedAcrossAQuotaDelay(): void + { + $this->gateway->permissions['-1'] = ['_' => 'chatBannedRights','until_date' => 0,'send_messages' => true]; + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame('prelocked', $locks->reconcile('-1', 'event', true)['status']); + $this->gateway->onGet = static function (): void { + throw new RetryableTelegramException('FLOOD_WAIT_15', 15); + }; + $reads = $this->gateway->reads; + self::assertSame('prelocked', $locks->reconcile('-1', 'event', false)['status']); + self::assertSame($reads, $this->gateway->reads); + self::assertNull($locks->state('-1')['retry_at']); + + // A new closing event still needs fresh evidence and must honor a real RPC quota. + self::assertSame('retry_wait', $locks->reconcile('-1', 'next-event', true)['status']); + self::assertSame($reads + 1, $this->gateway->reads); + self::assertSame('prelocked', $locks->state('-1')['status']); + self::assertNull($locks->state('-1')['snapshot_json']); + self::assertSame($this->clock->now()->getTimestamp() + 15, $locks->state('-1')['retry_at']); + self::assertSame('retry_wait', $locks->reconcile('-1', 'next-event', false)['status']); + self::assertSame($reads + 1, $this->gateway->reads); + self::assertSame(0, $this->gateway->writes); + + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:00:15Z')); + $this->gateway->onGet = null; + self::assertSame('prelocked', $locks->reconcile('-1', 'next-event', false)['status']); + self::assertSame($reads + 1, $this->gateway->reads); + self::assertSame(0, $this->gateway->writes); + self::assertNull($locks->state('-1')['snapshot_json']); + self::assertNull($locks->state('-1')['retry_at']); + } + public function testExpiredRestoreLeasePreservesSnapshotAndCannotReportConfirmedOpening(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $original = $this->gateway->getPermissions('-1'); + $locks->reconcile('-1', 'event', true); + $snapshot = $locks->state('-1')['snapshot_json']; + $this->gateway->resetActivity(); + $this->gateway->onSet = function (string $group, array $rights): void { + $this->gateway->permissions[$group] = $rights; + $this->clock->set(new \DateTimeImmutable('2026-10-13T09:02:01Z')); + }; + $result = $locks->reconcile('-1', 'event', false); + self::assertSame('busy', $result['status']); + self::assertFalse($result['changed']); + self::assertSame('restoring', $locks->state('-1')['status']); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + $this->gateway->onSet = null; + $restarted = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame(['status' => 'restored','changed' => false], $restarted->reconcile('-1', 'event', false)); + self::assertSame($original, $this->gateway->permissions['-1']); + self::assertSame(1, $this->gateway->writes); + } +} diff --git a/tests/PermissionSnapshotProvenanceTest.php b/tests/PermissionSnapshotProvenanceTest.php new file mode 100644 index 0000000..c4bb166 --- /dev/null +++ b/tests/PermissionSnapshotProvenanceTest.php @@ -0,0 +1,225 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00Z')); + (new SettingsRepository($this->db, $this->clock))->ensureGroup('-1'); + $this->gateway = new PermissionRecoveryGateway(); + $this->locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + } + private function restrictiveOriginal(): array + { + return ['_' => 'chatBannedRights','until_date' => 2147483647,'send_plain' => false,'send_photos' => true, + 'send_videos' => true,'send_docs' => true,'send_polls' => true,'invite_users' => true,'pin_messages' => true, + 'change_info' => true,'manage_topics' => false,'edit_rank' => true,'send_reactions' => false]; + } + private function legacy(string $status = 'conflict'): array + { + $original = ['_' => 'chatBannedRights','until_date' => 2147483647]; + foreach (Permissions::FLAGS as $flag) { + $original[$flag] = $flag === 'edit_rank'; + } + $snapshot = Database::encode($original); + $applied = Permissions::locked($original); + $this->db->pdo()->prepare('INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,snapshot_sha256,applied_json,prepared_at,last_error) VALUES(?,?,?,?,?,?,?,?)') + ->execute(['-1','old-event',$status,$snapshot,hash('sha256', $snapshot),Database::encode($applied),1,'Observed manage_topics differs']); + $this->gateway->current = $applied; + return $this->locks->state('-1'); + } + public function testNewRestrictiveSnapshotRoundTripsExactlyAndRecordsVerifiedOrigin(): void + { + $original = $this->restrictiveOriginal(); + $this->gateway->current = $original; + self::assertSame('locked', $this->locks->reconcile('-1', 'week', true)['status']); + $state = $this->locks->state('-1'); + self::assertTrue(PermissionRecoveryProvenance::verified($state)); + self::assertSame(PermissionRecoveryProvenance::VERIFIED, $state['snapshot_provenance']); + self::assertSame(Database::encode($original), $state['snapshot_json']); + self::assertSame('restored', $this->locks->reconcile('-1', 'week', false)['status']); + self::assertSame($original, $this->gateway->writes[1]); + self::assertSame($original, $this->gateway->current); + self::assertSame($state['snapshot_evidence_json'], $this->locks->state('-1')['snapshot_evidence_json']); + } + public static function unresolved(): array + { + return array_map(static fn(string $status): array => [$status], ['prepared','locked','restoring','conflict','error','recovery_required']); + } + #[DataProvider('unresolved')] + public function testUnverifiedOriginalCannotBeAutomaticallyRestoredOrReplacedNextWeek(string $status): void + { + $this->legacy($status); + $baseline = Database::encode($this->restrictiveOriginal()); + $this->db->pdo()->prepare('UPDATE group_lock_state SET previous_applied_json=applied_json,recovery_baseline_json=?,recovery_baseline_sha256=?')->execute([$baseline,hash('sha256', $baseline)]); + $before = $this->locks->state('-1'); + $this->gateway->onRead = static fn() => throw new \LogicException('No read is needed to reject unverified stored origin.'); + foreach ([false,true,false] as $close) { + $result = $this->locks->reconcile('-1', 'later-week', $close); + self::assertSame('recovery_required', $result['status']); + self::assertSame('unverified_snapshot', $result['recovery_reason']); + $this->clock->set($this->clock->now()->modify('+7 days')); + } + $after = $this->locks->state('-1'); + foreach (['snapshot_json','snapshot_sha256','applied_json','previous_applied_json','recovery_baseline_json','recovery_baseline_sha256','prepared_at','event_key','revision'] as $field) { + self::assertSame($before[$field], $after[$field]); + } + self::assertSame([], $this->gateway->writes); + $audit = $this->db->pdo()->query("SELECT details_json FROM audit_log WHERE action='permissions.unverified_original'")->fetchAll(); + self::assertCount(1, $audit); + self::assertSame($before['last_error'], json_decode($audit[0]['details_json'], true)['last_error']); + } + public function testLegacyRestoreIsUnavailableButExplicitVerifiedAdoptEnablesFutureExactCycles(): void + { + $before = $this->legacy(); + $recovery = new PermissionRecovery($this->db, $this->clock, $this->gateway, $this->locks, static fn(): bool => true, static fn(): bool => true); + $inspect = $recovery->inspect('-1', '42'); + self::assertFalse($inspect['original_verified']); + self::assertFalse($inspect['can_restore']); + self::assertSame('unverified_snapshot', $inspect['restore_block_reason']); + self::assertNull($recovery->prepare('-1', '42', 'restore')['token']); + self::assertFalse($inspect['can_adopt']); + // The administrator first configures the intended normal permissions in Telegram. + $this->gateway->current = $this->restrictiveOriginal(); + $preview = $recovery->prepare('-1', '42', 'adopt'); + self::assertTrue($preview['can_adopt']); + self::assertSame('adopted', $recovery->confirm($preview['token'], '42')['status']); + self::assertSame([], $this->gateway->writes); + self::assertSame($before['snapshot_json'], $this->locks->state('-1')['snapshot_json']); + self::assertSame('locked', $this->locks->reconcile('-1', 'verified-new-cycle', true)['status']); + self::assertTrue(PermissionRecoveryProvenance::verified($this->locks->state('-1'))); + self::assertSame('restored', $this->locks->reconcile('-1', 'verified-new-cycle', false)['status']); + self::assertSame($this->restrictiveOriginal(), $this->gateway->current); + $audit = json_decode($this->db->pdo()->query("SELECT details_json FROM audit_log WHERE action='permissions.recovery.adopt'")->fetchColumn(), true); + self::assertSame('legacy_unverified', $audit['original_provenance']); + self::assertSame($before['snapshot_json'], $audit['original_json']); + } + public static function completed(): array + { + return [['restored'],['prelocked']]; + } + #[DataProvider('completed')] + public function testCompletedLegacyCycleCapturesFreshNormalWithoutUsingOldSnapshot(string $status): void + { + $this->legacy($status); + $this->gateway->current = $this->restrictiveOriginal(); + self::assertSame('locked', $this->locks->reconcile('-1', 'new-cycle', true)['status']); + self::assertSame(Database::encode($this->restrictiveOriginal()), $this->locks->state('-1')['snapshot_json']); + self::assertSame('restored', $this->locks->reconcile('-1', 'new-cycle', false)['status']); + self::assertSame($this->restrictiveOriginal(), $this->gateway->current); + } + public function testRejectedFreshReadOrSnapshotPersistenceFailureNeverWritesPermissions(): void + { + $this->gateway->onRead = static fn() => throw new \RuntimeException('Incomplete current default permissions'); + self::assertSame('error', $this->locks->reconcile('-1', 'week', true)['status']); + self::assertNull($this->locks->state('-1')['snapshot_json']); + self::assertFalse(PermissionRecoveryProvenance::verified($this->locks->state('-1'))); + self::assertSame([], $this->gateway->writes); + $this->gateway->onRead = null; + $this->gateway->current = $this->restrictiveOriginal(); + $this->db->pdo()->exec("CREATE TRIGGER reject_provenance BEFORE UPDATE OF snapshot_evidence_json ON group_lock_state BEGIN SELECT RAISE(FAIL,'disk fault'); END"); + self::assertSame('error', $this->locks->reconcile('-1', 'week', true)['status']); + self::assertNull($this->locks->state('-1')['snapshot_json']); + self::assertSame([], $this->gateway->writes); + } + public function testManageTopicsMismatchDoesNotRemoveAnyOriginalRestriction(): void + { + $original = $this->restrictiveOriginal(); + $this->gateway->current = $original; + $this->locks->reconcile('-1', 'week', true); + $this->gateway->onWrite = function (): void { + $this->gateway->current['manage_topics'] = true; + }; + $result = $this->locks->reconcile('-1', 'week', false); + self::assertSame('conflict', $result['status']); + self::assertSame(['manage_topics'], $result['different_fields']); + self::assertTrue($result['mutation_attempted']); + self::assertTrue($result['verification_failed']); + self::assertSame($original, $this->gateway->writes[1]); + foreach (['send_photos','send_videos','send_docs','send_polls','invite_users','pin_messages','change_info','edit_rank'] as $field) { + self::assertTrue($this->gateway->current[$field], $field); + } + } + public static function tamperedEvidence(): array + { + return [['group_id','-2'],['snapshot_sha256','copied-checksum'],['captured_at',1], + ['reader_contract','cached-read'],['schema',2]]; + } + #[DataProvider('tamperedEvidence')] + public function testEvidenceMustBindExactGroupSnapshotCaptureAndReader(string $field, mixed $value): void + { + $this->gateway->current = $this->restrictiveOriginal(); + $this->locks->reconcile('-1', 'week', true); + $before = $this->locks->state('-1'); + $evidence = json_decode($before['snapshot_evidence_json'], true); + $evidence[$field] = $value; + $this->db->pdo()->prepare('UPDATE group_lock_state SET snapshot_evidence_json=?') + ->execute([Database::encode($evidence)]); + self::assertFalse(PermissionRecoveryProvenance::verified($this->locks->state('-1'))); + self::assertSame('recovery_required', $this->locks->reconcile('-1', 'week', false)['status']); + self::assertSame($before['snapshot_json'], $this->locks->state('-1')['snapshot_json']); + self::assertCount(1, $this->gateway->writes); + } + public static function upgradeVersions(): array + { + return [[12],[14]]; + } + #[DataProvider('upgradeVersions')] + public function testUpgradePreservesLegacyEvidenceAndMigrationRepeatsSafely(int $from): void + { + $directory = sys_get_temp_dir() . '/shabbat-dev8-provenance-' . bin2hex(random_bytes(10)); + mkdir($directory, 0700); + $copies = []; + try { + foreach (glob(dirname(__DIR__) . '/migrations/*.sql') as $file) { + if ((int)basename($file) <= $from) { + $copy = $directory . '/' . basename($file); + self::assertTrue(copy($file, $copy)); + $copies[] = $copy; + } + } + $db = Database::open(':memory:'); + self::assertCount($from, $db->migrate($directory)); + (new SettingsRepository($db, $this->clock))->ensureGroup('-1'); + $oldSnapshot = Database::encode(['_' => 'chatBannedRights','edit_rank' => true,'until_date' => 2147483647]); + $db->pdo()->prepare("INSERT INTO group_lock_state(group_id,event_key,status,snapshot_json,snapshot_sha256,applied_json,prepared_at,last_error) VALUES('-1','old','conflict',?,?,?,1,'old topic mismatch')") + ->execute([$oldSnapshot,hash('sha256', $oldSnapshot),Database::encode(Permissions::locked(json_decode($oldSnapshot, true)))]); + $before = $db->pdo()->query('SELECT * FROM group_lock_state')->fetch(); + self::assertCount(16 - $from, $db->migrate()); + self::assertSame([], $db->migrate()); + $after = $db->pdo()->query('SELECT * FROM group_lock_state')->fetch(); + self::assertSame($before, array_intersect_key($after, $before)); + self::assertSame('legacy_unverified', $after['snapshot_provenance']); + self::assertNull($after['snapshot_evidence_json']); + $locks = new LockCoordinator($db, $this->clock, $this->gateway); + self::assertSame('recovery_required', $locks->reconcile('-1', 'later', false)['status']); + self::assertSame([], $this->gateway->writes); + self::assertSame(['ok'], $db->integrity()['integrity_check']); + self::assertSame([], $db->integrity()['foreign_key_check']); + } finally { + foreach ($copies as $copy) { + if (is_file($copy)) { + unlink($copy); + } + } + rmdir($directory); + } + } +} diff --git a/tests/PermissionWriteFenceTest.php b/tests/PermissionWriteFenceTest.php new file mode 100644 index 0000000..a06ad7b --- /dev/null +++ b/tests/PermissionWriteFenceTest.php @@ -0,0 +1,194 @@ +setValue(null, false); + foreach (['database','clock','root'] as $name) { + $this->adminState[$name] = (new \ReflectionProperty(AdminStateFiles::class, $name))->getValue(); + } + } + protected function tearDown(): void + { + foreach ($this->adminState as $name => $value) { + (new \ReflectionProperty(AdminStateFiles::class, $name))->setValue(null, $value); + } + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function app(string $date = '2026-10-15T12:00:00+03:00', ?JsonClient $http = null): Application + { + $clock = new FrozenClock(new \DateTimeImmutable($date)); + $db = Database::open(':memory:'); + $db->migrate(); + $gateway = new RuntimeFakeGateway(); + $gateway->rights['-1'] = ['_' => 'chatBannedRights','until_date' => 2147483647,'send_photos' => true,'edit_rank' => true]; + $app = new Application($gateway, $clock, $db, new CalendarService(new HebcalProvider($http ?? new RuntimeFakeJson()), $clock), authorizeOperator: static fn(): bool => true); + $app->settings->ensureGroup('-1', 'Permission fence', ['mode' => 'lock_messages']); + return $app; + } + public static function directions(): array + { + return [['lock'],['open']]; + } + #[DataProvider('directions')] + public function testFinalReadCrossingManualOverrideExpiryNeverWrites(string $mode): void + { + $app = $this->app(); + if ($mode === 'open') { + self::assertSame(1, $app->manualGroups->apply(true, '42')['changed']); + } + $writes = $app->gateway->mutations; + $reads = 0; + $app->gateway->beforeGetPermissions = static function () use ($app, &$reads): void { + if (++$reads === 2) { + $app->clock->set($app->clock->now()->modify('+2 seconds')); + } + }; + $result = $app->manualGroups->apply($mode === 'lock', '42', 1); + self::assertSame('deferred', $result['groups'][0]['status']); + self::assertSame($writes, $app->gateway->mutations); + self::assertSame('not_dispatched', (new PermissionRecoveryJournal($app->db, $app->clock))->latest('-1')['outcome']); + } + #[DataProvider('directions')] + public function testSettingsChangeDuringFinalReadNeverWrites(string $mode): void + { + $app = $this->app(); + if ($mode === 'open') { + $app->manualGroups->apply(true, '42'); + } + $writes = $app->gateway->mutations; + $reads = 0; + $app->gateway->beforeGetPermissions = static function () use ($app, &$reads): void { + if (++$reads === 2) { + $settings = $app->settings->getGroup('-1'); + $app->settings->updateGroup('-1', $settings['revision'], ['candle_minutes' => 45], '42'); + } + }; + $result = $app->manualGroups->apply($mode === 'lock', '42'); + self::assertSame('deferred', $result['groups'][0]['status']); + self::assertSame($writes, $app->gateway->mutations); + } + public function testAdministratorMediaChangeDuringProviderWaitIsNotOverwrittenAndFreshNextCycleRecovers(): void + { + $http = new class implements JsonClient { + public ?\Closure $onGet = null; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + ($this->onGet)?->__invoke(); + return (new RuntimeFakeJson())->get($url, $query, $cancellation); + } + }; + $app = $this->app('2026-10-16T17:30:00+03:00', $http); + $before = $app->gateway->rights['-1']; + $http->onGet = static function () use ($app): void { + $app->gateway->rights['-1']['send_videos'] = true; + }; + self::assertSame('deferred', $app->locks->reconcile('-1', 'shabbat', true)['status']); + self::assertSame(0, $app->gateway->mutations); + self::assertSame(Database::encode($before), $app->locks->state('-1')['snapshot_json']); + $http->onGet = null; + self::assertSame('locked', $app->locks->reconcile('-1', 'shabbat', true)['status']); + self::assertTrue(json_decode($app->locks->state('-1')['snapshot_json'], true)['send_videos']); + $app->clock->set(new \DateTimeImmutable('2026-10-18T10:00:00+03:00')); + self::assertSame('restored', $app->locks->reconcile('-1', 'shabbat', false)['status']); + self::assertTrue($app->gateway->rights['-1']['send_photos']); + self::assertTrue($app->gateway->rights['-1']['send_videos']); + self::assertTrue($app->gateway->rights['-1']['edit_rank']); + } + public function testAdministratorEditWhileRestoreProviderAwaitsNeverOverwritesCurrentOrOriginal(): void + { + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $http = new class ($entered, $release) implements JsonClient { + private bool $waited = false; + public function __construct(private \Amp\DeferredFuture $entered, private \Amp\DeferredFuture $release) + { + } + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + if (!$this->waited) { + $this->waited = true; + $this->entered->complete(); + $this->release->getFuture()->await(); + } + return (new RuntimeFakeJson())->get($url, $query, $cancellation); + } + }; + $app = $this->app('2026-10-15T12:00:00+03:00', $http); + $app->manualGroups->apply(true, '42', 1); + $original = $app->locks->state('-1')['snapshot_json']; + $app->clock->set($app->clock->now()->modify('+2 seconds')); + $opening = \Amp\async(fn(): array => $app->locks->reconcile('-1', 'opening', false)); + $entered->getFuture()->await(); + self::assertFalse($app->db->pdo()->inTransaction()); + $app->gateway->rights['-1']['send_photos'] = false; + $release->complete(); + self::assertSame('deferred', $opening->await()['status']); + self::assertSame(1, $app->gateway->mutations); + self::assertFalse($app->gateway->rights['-1']['send_photos']); + self::assertSame($original, $app->locks->state('-1')['snapshot_json']); + self::assertSame('not_dispatched', (new PermissionRecoveryJournal($app->db, $app->clock))->latest('-1')['outcome']); + self::assertSame('conflict', $app->locks->reconcile('-1', 'opening', false)['status']); + self::assertSame(1, $app->gateway->mutations); + } + public function testAutomaticOpeningCrossingNextCloseBoundaryRetainsLockedState(): void + { + $app = $this->app('2026-10-15T12:00:00+03:00'); + $app->manualGroups->apply(true, '42', 1); + $app->clock->set(new \DateTimeImmutable('2026-10-16T17:19:59+03:00')); + $reads = 0; + $app->gateway->beforeGetPermissions = static function () use ($app, &$reads): void { + if (++$reads === 2) { + $app->clock->set(new \DateTimeImmutable('2026-10-16T17:20:00+03:00')); + } + }; + self::assertSame('deferred', $app->locks->reconcile('-1', 'opening', false)['status']); + self::assertSame(1, $app->gateway->mutations); + self::assertTrue($app->gateway->rights['-1']['send_plain']); + } + public function testAutomaticClosingCrossingOpeningBoundaryDoesNotApplyExpiredLock(): void + { + $app = $this->app('2026-10-17T18:39:59+03:00'); + $reads = 0; + $app->gateway->beforeGetPermissions = static function () use ($app, &$reads): void { + if (++$reads === 2) { + $app->clock->set(new \DateTimeImmutable('2026-10-17T18:40:00+03:00')); + } + }; + self::assertSame('deferred', $app->locks->reconcile('-1', 'closing', true)['status']); + self::assertSame(0, $app->gateway->mutations); + } + public function testShutdownAtFinalReadPropagatesCancellationWithoutMutation(): void + { + $app = $this->app(); + $reads = 0; + $app->gateway->beforeGetPermissions = static function () use (&$reads): void { + if (++$reads === 2) { + Runner::requestShutdown(); + } + }; + try { + $app->manualGroups->apply(true, '42'); + self::fail('Shutdown must propagate cancellation.'); + } catch (\Amp\CancelledException) { + self::assertSame(0, $app->gateway->mutations); + self::assertSame('not_dispatched', (new PermissionRecoveryJournal($app->db, $app->clock))->latest('-1')['outcome']); + } + } +} diff --git a/tests/PinnedPeerRefreshHarness.php b/tests/PinnedPeerRefreshHarness.php new file mode 100644 index 0000000..7bfb699 --- /dev/null +++ b/tests/PinnedPeerRefreshHarness.php @@ -0,0 +1,47 @@ +cached = FreshPeerFixture::info($type === 'chat' ? '-1' : '-1000000000001', $rights, $type); + $this->server = $this->cached; + $this->peerDatabase = new class { + public array $full; + public function getFull(int $id): array + { + return $this->full; + } + }; + $this->peerDatabase->full = ['inserted' => time(), 'full' => $this->cached['full']]; + } + public function getInfo(mixed $id, int $type = \danog\MadelineProto\API::INFO_TYPE_ALL): array + { + return $this->cached; + } + public function getSettings(): \danog\MadelineProto\Settings + { + return \Shabbat\Runtime\Runner::settings(); + } + public function methodCallAsyncRead(string $method, array $parameters): array + { + $this->calls[] = $method; + $this->cached = $this->server; + $this->peerDatabase->full['inserted'] = time(); + $reply = $this->server[\Shabbat\Runtime\FreshPeerReader::EVIDENCE_KEY]; + $reply['chats'] = [$this->server['Chat']]; + return $reply; + } +} diff --git a/tests/PresentationTest.php b/tests/PresentationTest.php new file mode 100644 index 0000000..5269850 --- /dev/null +++ b/tests/PresentationTest.php @@ -0,0 +1,248 @@ +text('he', 'welcome'); + $parsed = Entities::fromHtml(Presentation::html($html, true, EmojiFixtureTransport::alternatives())); + self::assertSame($expected, $parsed->message); + self::assertSame( + ['type' => 'custom_emoji', 'custom_emoji_id' => 5947339492212875204, 'offset' => 0, 'length' => 2], + array_values(array_filter($parsed->entities, static fn(array $e): bool => $e['type'] === 'custom_emoji'))[0] + ); + self::assertContains('bold', array_column($parsed->entities, 'type')); + self::assertSame($expected, Entities::fromHtml(Presentation::html($html, false))->message); + } + + public function testEverySuppliedAssetHasAUniqueSemanticMappingAndNativeInt64(): void + { + self::assertCount(12, Presentation::ICONS); + self::assertCount(12, array_unique(Presentation::ICONS)); + foreach (['settings' => 'preferences','support' => 'donate','info' => 'help','source_code' => 'source','groups' => 'groups','add_to_group' => 'add','shabbat_holidays' => 'shabbat','tefillin' => 'tefillin','daily_zmanim' => 'zmanim','daf_yomi' => 'daf','omer' => 'omer','brand_coin' => 'welcome'] as $semantic => $key) { + self::assertSame(Presentation::ICONS[$semantic], Presentation::iconForKey($key)); + $native = MadelineGateway::normalizeMarkup(['inline_keyboard' => [[Presentation::button(['text' => 'Label', 'callback_data' => 'x', 'style' => 'primary'], true, $key)]]]); + self::assertSame((int)Presentation::ICONS[$semantic], $native['rows'][0]['buttons'][0]['style']['icon']); + self::assertTrue($native['rows'][0]['buttons'][0]['style']['bg_primary']); + } + } + + public function testHtmlPreservesLinksCodeExistingEmojiAndIsIdempotent(): void + { + $html = 'โš™๏ธ Settings โ€” ๐Ÿ“– Study โš™๏ธ /start
โญ
๐ŸŒพ'; + $once = Presentation::html($html, true, EmojiFixtureTransport::alternatives()); + self::assertSame($once, Presentation::html($once, true, EmojiFixtureTransport::alternatives())); + self::assertSame(3, substr_count($once, 'โš™๏ธ /start', $once); + self::assertStringContainsString('
โญ
', $once); + self::assertStringContainsString('๐ŸŒพ', $once); + self::assertStringNotContainsString('โ€”', $once); + self::assertStringNotContainsString(' 'messageEntityBold', 'offset' => 3, 'length' => 2], + ['_' => 'messageEntityCustomEmoji', 'offset' => 8, 'length' => 2, 'document_id' => 42], + ]; + $payload = Presentation::payload(['text' => $text, 'entities' => $entities], true, EmojiFixtureTransport::alternatives()); + self::assertSame($text, $payload['text']); + self::assertSame($entities, array_slice($payload['entities'], 0, 2)); + self::assertCount(3, $payload['entities']); + self::assertSame(['_' => 'messageEntityCustomEmoji', 'offset' => 3, 'length' => 2, 'document_id' => 5944760643524500583], $payload['entities'][2]); + self::assertSame($payload, Presentation::payload($payload, true, EmojiFixtureTransport::alternatives())); + $plain = Presentation::payload(['text' => 'โญ', 'entities' => [['_' => 'messageEntityCustomEmoji', 'offset' => 3, 'length' => 1, 'document_id' => 42]]], false); + self::assertSame([], $plain['entities']); + self::assertSame('โญ', $plain['text'], 'Native empty entities keep literal HTML-looking text.'); + } + + public function testBotApiIconNormalizesWithoutLosingColorsAndGlobalOptOutKeepsActions(): void + { + $markup = ['inline_keyboard' => [[['text' => 'Other', 'url' => 'https://example.org', 'icon_custom_emoji_id' => Presentation::ICONS['support'], 'style' => 'danger']]]]; + $native = MadelineGateway::normalizeMarkup($markup); + $button = $native['rows'][0]['buttons'][0]; + self::assertSame('keyboardButtonUrl', $button['_']); + self::assertSame('https://example.org', $button['url']); + self::assertTrue($button['style']['bg_danger']); + self::assertSame(5945186253308699040, $button['style']['icon']); + self::assertArrayNotHasKey('icon_custom_emoji_id', $button); + $plain = MadelineGateway::normalizeMarkup($native, false)['rows'][0]['buttons'][0]; + self::assertArrayNotHasKey('icon', $plain['style']); + self::assertSame('Other', $plain['text']); + self::assertSame($button['url'], $plain['url']); + self::assertTrue($plain['style']['bg_danger']); + } + + public function testNativeGatewayDecoratesInitialEditsScheduledAndInlineWithoutResends(): void + { + $transport = new GatewayFixtureTransport(); + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $markup = ['inline_keyboard' => [[['text' => 'โš™๏ธ Settings', 'callback_data' => 'settings', 'style' => 'primary']]]]; + $gateway->sendPrivate('1', 'โœก๏ธ Welcome', $markup); + $gateway->editPrivate('1', 42, '๐Ÿ“– Study', $markup); + $gateway->send('-1001', ['text' => '๐Ÿ”” Tefillin', 'buttons' => $markup['inline_keyboard']], '123'); + $gateway->answerInline('456', [['_' => 'inputBotInlineResult', 'id' => 'i', 'type' => 'article', 'send_message' => ['_' => 'inputBotInlineMessageText', 'message' => '๐ŸŒพ Omer', 'parse_mode' => 'HTML']]]); + self::assertSame(['messages.sendMessage','messages.editMessage','messages.sendMessage','messages.setInlineBotResults'], array_column($transport->calls, 0)); + foreach (array_slice($transport->calls, 0, 3) as [, $parameters]) { + self::assertStringContainsString('calls[3][1]['results'][0]['send_message']; + self::assertStringContainsString('5945033816329428634', $inline['message']); + self::assertArrayNotHasKey('reply_markup', $inline); + $plainTransport = new GatewayFixtureTransport(); + (new MadelineGateway($plainTransport, false))->sendPrivate('1', 'โš™๏ธ Settings', $markup); + self::assertSame('โš™๏ธ Settings', $plainTransport->calls[0][1]['message']); + self::assertArrayNotHasKey('icon', $plainTransport->calls[0][1]['reply_markup']['rows'][0]['buttons'][0]['style']); + } + + public function testOwnedButtonsUseOneIconRestoreBasicPrefixAndSendNoInternalTlFields(): void + { + $error = (new \ReflectionClass(\danog\MadelineProto\RPCErrorException::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty(\danog\MadelineProto\RPCErrorException::class, 'rpc'))->setValue($error, 'PREMIUM_ACCOUNT_REQUIRED'); + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.sendMessage'] = [$error]; + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $button = Presentation::button(['text' => '๐ŸŒ Language','callback_data' => 'language','style' => 'primary'], true, 'language'); + $gateway->sendPrivate('1', 'Plain text', ['inline_keyboard' => [[$button]]]); + self::assertSame('Language', $transport->calls[0][1]['reply_markup']['rows'][0]['buttons'][0]['text']); + self::assertSame('๐ŸŒ Language', $transport->calls[1][1]['reply_markup']['rows'][0]['buttons'][0]['text']); + foreach ($transport->calls as $call) { + self::assertStringNotContainsString('_presentation_', json_encode($call, JSON_THROW_ON_ERROR)); + } + $transport = new EmojiFixtureTransport(); + (new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()))->sendPrivate( + '1', + 'Plain text', + ['inline_keyboard' => [[['text' => 'โš™๏ธ User-chosen label','callback_data' => 'custom','style' => ['icon' => 5944760643524500583]]]]] + ); + self::assertSame('โš™๏ธ User-chosen label', $transport->calls[0][1]['reply_markup']['rows'][0]['buttons'][0]['text']); + } + + public function testActualStartMenuHasOneIconPerOwnedButtonBeforeNativeRpc(): void + { + $db = \Shabbat\Storage\Database::open(':memory:'); + $db->migrate(); + $clock = new \Shabbat\Time\FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $http = new FullHebcalFixtureClient(); + $transport = new EmojiFixtureTransport(); + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $ui = new \Shabbat\UI\BotController( + new \Shabbat\Storage\SettingsRepository($db, $clock), + new \Shabbat\Storage\FlowTokenRepository($db, $clock), + new \Shabbat\Calendar\CalendarService(new \Shabbat\Calendar\HebcalProvider($http), $clock), + $gateway, + new \Shabbat\Location\GeoNames($http, $clock, ''), + $clock + ); + $ui->start('1'); + self::assertCount(1, $transport->calls); + foreach ($transport->calls[0][1]['reply_markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + if (isset($button['style']['icon'])) { + self::assertDoesNotMatchRegularExpression('/^[\p{Extended_Pictographic}\p{Regional_Indicator}]/u', $button['text']); + } + } + } + self::assertStringNotContainsString('_presentation_', json_encode($transport->calls, JSON_THROW_ON_ERROR)); + } + public function testGroupCounterEditsOnlyMarkupAndRetainsIdentityDuringBasicFallback(): void + { + $error = (new \ReflectionClass(\danog\MadelineProto\RPCErrorException::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty(\danog\MadelineProto\RPCErrorException::class, 'rpc'))->setValue($error, 'PREMIUM_ACCOUNT_REQUIRED'); + $transport = new EmojiFixtureTransport(); + $transport->failures['messages.editMessage'] = [$error]; + (new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()))->editGroupMarkup( + '-1001', + 42, + ['inline_keyboard' => [[['text' => 'ื”ื ื—ืชื™ (1)','callback_data' => 'tf:fixture','style' => ['bg_primary' => true,'icon' => 5944970388252402183]]]]] + ); + self::assertCount(2, $transport->calls); + foreach ($transport->calls as [$method,$parameters]) { + self::assertSame('messages.editMessage', $method); + self::assertSame('-1001', $parameters['peer']); + self::assertSame(42, $parameters['id']); + self::assertArrayNotHasKey('message', $parameters); + self::assertArrayNotHasKey('entities', $parameters); + self::assertArrayNotHasKey('media', $parameters); + self::assertSame('ื”ื ื—ืชื™ (1)', $parameters['reply_markup']['rows'][0]['buttons'][0]['text']); + } + self::assertArrayNotHasKey('icon', $transport->calls[1][1]['reply_markup']['rows'][0]['buttons'][0]['style']); + } + public function testPlainHyphensPreserveUtf16EntitiesInMediaCaptionsAndButtonLabels(): void + { + $payload = ['text' => '๐Ÿ˜€ โ€” โš™๏ธ text','entities' => [ + ['_' => 'messageEntityBold','offset' => 0,'length' => 12], + ['_' => 'messageEntityCustomEmoji','offset' => 5,'length' => 2,'document_id' => 42]],'media' => 'fixture-file-id', + 'buttons' => [[['text' => 'Label โ€” value','callback_data' => 'x','style' => 'primary']]]]; + $original = $payload; + $transport = new EmojiFixtureTransport(); + (new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()))->send('-1001', $payload, '123'); + self::assertSame($original, $payload, 'Rendering must not mutate the saved custom payload.'); + self::assertSame('messages.sendMedia', $transport->calls[0][0]); + self::assertSame('๐Ÿ˜€ - โš™๏ธ text', $transport->calls[0][1]['message']); + self::assertSame($original['entities'], $transport->calls[0][1]['entities']); + self::assertSame('Label - value', $transport->calls[0][1]['reply_markup']['rows'][0]['buttons'][0]['text']); + self::assertSame(12, intdiv(strlen(mb_convert_encoding($transport->calls[0][1]['message'], 'UTF-16LE', 'UTF-8')), 2)); + } + public function testNativeEmptyEntitiesPreserveLiteralHtmlInSendsEditsMediaAndInline(): void + { + $text = ' & {{location}}'; + $transport = new EmojiFixtureTransport(); + $gateway = new MadelineGateway($transport, true, EmojiFixtureTransport::alternatives()); + $payload = ['text' => $text,'entities' => []]; + $gateway->send('-1001', $payload, '123'); + $gateway->editPrivatePayload('1', 42, $payload); + $gateway->send('-1001', $payload + ['media' => 'fixture-file-id'], '456'); + $gateway->answerInline('789', [['_' => 'inputBotInlineResult','id' => 'native','type' => 'article', + 'send_message' => ['_' => 'inputBotInlineMessageText','message' => $text,'entities' => []]]]); + foreach (array_slice($transport->calls, 0, 3) as [$method,$parameters]) { + self::assertSame($text, $parameters['message']); + self::assertSame([], $parameters['entities']); + self::assertArrayNotHasKey('parse_mode', $parameters); + } + $message = $transport->calls[3][1]['results'][0]['send_message']; + self::assertSame($text, $message['message']); + self::assertSame([], $message['entities']); + self::assertArrayNotHasKey('parse_mode', $message); + self::assertSame(['text' => $text,'entities' => []], $payload); + } + public function testSixLanguageHelpUsesFormattingAndContainsNoEmDash(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + self::assertStringStartsWith('', $lang->text($language, 'info_intro')); + foreach (['personal','calendar','groups','permissions','notifications','messages','privacy','operators'] as $category) { + $text = $lang->text($language, 'info_' . $category . '_text'); + self::assertStringNotContainsString('โ€”', $text); + self::assertSame( + html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + Entities::fromHtml(Presentation::html($text))->message + ); + } + self::assertStringContainsString('10000', str_replace([',',' ','.','ึพ'], '', $lang->text($language, 'donate_custom_invalid'))); + } + } +} diff --git a/tests/ProviderCircuitTest.php b/tests/ProviderCircuitTest.php new file mode 100644 index 0000000..f392e33 --- /dev/null +++ b/tests/ProviderCircuitTest.php @@ -0,0 +1,146 @@ +clock(); + $http = new class implements JsonClient { + public int $calls = 0; + public bool $fail = true; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + if ($this->fail) { + throw new ProviderException('fixture offline'); + } + return ['ok' => true]; + } + }; + $client = new ProviderCircuitClient($http, $clock); + for ($i = 0; $i < 1000; $i++) { + try { + $client->get('https://www.hebcal.com/zmanim', ['location' => $i]); + self::fail('Outage was hidden.'); + } catch (ProviderException) { + self::assertTrue(true); + } + } + self::assertSame(3, $http->calls); + $clock->set($clock->now()->modify('+30 seconds')); + $http->fail = false; + self::assertSame(['ok' => true], $client->get('https://www.hebcal.com/zmanim')); + self::assertSame(4, $http->calls); + } + public function testCircuitLivesBelowPersistentCacheSoVerifiedStaleDataRemainsAvailable(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = $this->clock(); + $http = new class implements JsonClient { + public bool $fail = false; + public int $calls = 0; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + if ($this->fail) { + throw new ProviderException('fixture offline'); + } + return ['range' => ['start' => '2026-10-01', 'end' => '2026-10-31'], 'items' => []]; + } + }; + $client = new SqliteJsonClient(new ProviderCircuitClient($http, $clock, 1), $db, $clock, 1); + $client->get('https://www.hebcal.com/hebcal', ['city' => 1]); + $clock->set($clock->now()->modify('+2 seconds')); + $http->fail = true; + $first = $client->get('https://www.hebcal.com/hebcal', ['city' => 1]); + $second = $client->get('https://www.hebcal.com/hebcal', ['city' => 1]); + self::assertTrue($first['__shabbat_cache']['stale']); + self::assertTrue($second['__shabbat_cache']['stale']); + self::assertSame(2, $http->calls); + } + public function testSharedFailureIsDeduplicatedAndCallerCancellationDoesNotPoisonOthers(): void + { + $clock = $this->clock(); + $entered = new \Amp\DeferredFuture(); + $release = new \Amp\DeferredFuture(); + $http = new class ($entered, $release) implements JsonClient { + public int $calls = 0; + public function __construct(private \Amp\DeferredFuture $entered, private \Amp\DeferredFuture $release) + { + } + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + $this->entered->complete(); + $this->release->getFuture()->await(); + return ['ok' => true]; + } + }; + $client = new SharedJsonClient($http, $clock); + $cancel = new \Amp\DeferredCancellation(); + $first = \Amp\async(fn() => $client->get('https://example.invalid/calendar', [], $cancel->getCancellation())); + $entered->getFuture()->await(); + $second = \Amp\async(fn() => $client->get('https://example.invalid/calendar')); + $cancel->cancel(); + try { + $first->await(); + self::fail('Caller cancellation was ignored.'); + } catch (\Amp\CancelledException) { + self::assertTrue(true); + } + $release->complete(); + self::assertSame(['ok' => true], $second->await()); + self::assertSame(1, $http->calls); + self::assertSame(['ok' => true], $client->get('https://example.invalid/calendar')); + } + public function testConcurrentErrorsRecordedOnceThenCooldownBoundsSequentialRetries(): void + { + $clock = $this->clock(); + $http = new class implements JsonClient { + public int $calls = 0; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + \Amp\delay(0.001); + throw new ProviderException('fixture failure'); + } + }; + $client = new SharedJsonClient($http, $clock); + $futures = []; + for ($i = 0; $i < 100; $i++) { + $futures[] = \Amp\async(fn() => $client->get('https://example.invalid/calendar')); + } + [$errors, $values] = \Amp\Future\awaitAll($futures); + self::assertCount(100, $errors); + self::assertSame([], $values); + self::assertSame(1, $http->calls); + for ($i = 0; $i < 100; $i++) { + try { + $client->get('https://example.invalid/calendar'); + } catch (ProviderException) { + } + } + self::assertSame(1, $http->calls); + $clock->set($clock->now()->modify('+15 seconds')); + try { + $client->get('https://example.invalid/calendar'); + } catch (ProviderException) { + } + self::assertSame(2, $http->calls); + } +} diff --git a/tests/QueueMaintenanceTest.php b/tests/QueueMaintenanceTest.php new file mode 100644 index 0000000..02ffbf4 --- /dev/null +++ b/tests/QueueMaintenanceTest.php @@ -0,0 +1,140 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-01-01T09:00:00Z')); + (new SettingsRepository($this->db, $this->clock))->ensureGroup('-1'); + $this->jobs = new JobRepository($this->db, $this->clock); + } + private function add(string $event, string $module = 'daf'): int + { + $now = $this->clock->now()->getTimestamp(); + return $this->jobs->enqueue('-1', $module, $event, $now, $now + 3600, 1, ['text' => $event]); + } + public function testCleanupIsBoundedAndPreservesUncertainActiveAndCurrentPointerEvidence(): void + { + $safe = $this->add('safe'); + $this->jobs->lease('a'); + $this->jobs->sent($safe, 'a', ['id' => 1]); + $uncertain = $this->add('uncertain'); + $this->jobs->lease('a'); + $this->jobs->markDispatch($uncertain, 'a'); + $this->jobs->fail($uncertain, 'a', 'lost response'); + $pointer = $this->add('pointer'); + $this->jobs->lease('a'); + $this->jobs->sent($pointer, 'a', ['id' => 3]); + $this->db->pdo()->exec("INSERT INTO module_last_messages VALUES('-1','daf','[3]',3,'pointer',1,1,$pointer,1)"); + $pending = $this->add('pending'); + $this->clock->set($this->clock->now()->modify('+100 days')); + $result = $this->jobs->maintain(90, 1); + self::assertSame(1, $result['jobs']); + self::assertSame(1, $result['delivery_log']); + self::assertNull($this->jobs->get($safe)); + foreach ([$uncertain,$pointer,$pending] as $id) { + self::assertNotNull($this->jobs->get($id)); + } + self::assertSame(0, $this->jobs->maintain(90, 1)['jobs']); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } + public function testOneOldJobWithManyAttemptsCannotExceedLogCleanupBatch(): void + { + $id = $this->add('many-deferrals'); + $this->jobs->lease('a'); + $this->jobs->sent($id, 'a', ['id' => 1]); + $query = $this->db->pdo()->prepare("INSERT INTO delivery_log(job_id,attempt,random_id,started_at,finished_at,outcome) VALUES(?,?,?,1,2,'retry')"); + for ($attempt = 2; $attempt <= 250; $attempt++) { + $query->execute([$id,$attempt,$this->jobs->get($id)['random_id']]); + } + $this->clock->set($this->clock->now()->modify('+100 days')); + $deleted = 0; + for ($pass = 0; $pass < 25; $pass++) { + $result = $this->jobs->maintain(90, 10); + self::assertSame(10, $result['delivery_log']); + $deleted += $result['jobs']; + } + self::assertSame(1, $deleted); + self::assertNull($this->jobs->get($id)); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } + public function testCleanupPreservesInteractiveAcknowledgementsAndRecoveryAudit(): void + { + $id = $this->add('interactive', 'tefillin'); + $this->jobs->lease('a'); + $this->jobs->sent($id, 'a', ['id' => 1]); + $this->jobs->markDeliveryProcessed($id); + $this->db->pdo()->exec("INSERT INTO tefillin_ack_rounds VALUES('123456789012345678901234',$id,'-1','Done','en',1,'[]',1)"); + $this->db->pdo()->exec("INSERT INTO audit_log(group_id,action,details_json,created_at) VALUES('-1','permission.recovery','{}',1),('-1','settings.update','{}',1),('-1','operator.broadcast.scheduled','{}',1)"); + $this->db->pdo()->exec("INSERT INTO group_lock_state(group_id,event_key,status,prepared_at) VALUES('-1','event','conflict',1)"); + $this->clock->set($this->clock->now()->modify('+100 days')); + $result = $this->jobs->maintain(); + self::assertSame(0, $result['jobs']); + self::assertSame(0, $result['audit_log']); + $this->db->pdo()->exec("UPDATE group_lock_state SET status='restored'"); + self::assertSame(1, $this->jobs->maintain()['audit_log']); + self::assertSame(2, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM audit_log')->fetchColumn()); + self::assertSame(1, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM tefillin_ack_rounds')->fetchColumn()); + } + public function testCleanupDisabledAndRetentionBounds(): void + { + $this->db->pdo()->exec("INSERT INTO calendar_cache VALUES('expired','fixture','{}',1,2,NULL,NULL)"); + self::assertSame(0, $this->jobs->maintain(0)['calendar_cache']); + self::assertSame(1, $this->jobs->maintain(30)['calendar_cache']); + $this->expectException(\InvalidArgumentException::class); + $this->jobs->maintain(1); + } + public function testQueueMetricsExposeRealDelayBacklogAndUncertainty(): void + { + $id = $this->add('delayed'); + $this->clock->set($this->clock->now()->modify('+5 minutes')); + self::assertSame(300, $this->jobs->queueMetrics()['oldest_due_age_seconds']); + $this->jobs->lease('a'); + $this->jobs->markDispatch($id, 'a'); + self::assertSame(1, $this->jobs->queueMetrics()['uncertain']); + $this->jobs->sent($id, 'a', ['id' => 1]); + $metrics = $this->jobs->queueMetrics(); + self::assertSame(0, $metrics['uncertain']); + self::assertSame(0, $metrics['active']); + self::assertSame(1, $metrics['sent_last_24h']); + self::assertSame(300, $metrics['max_delivery_delay_last_24h']); + } + public function testOneHundredEightyDaySimulationKeepsRoutineHistoryWithinRetentionWindow(): void + { + $maxRows = 0; + for ($day = 0; $day < 180; $day++) { + for ($message = 0; $message < 40; $message++) { + $id = $this->add("day-$day-$message"); + $this->jobs->lease('simulation'); + $this->jobs->sent($id, 'simulation', ['id' => $message + 1]); + } + $this->jobs->maintain(30, 200); + $rows = (int)$this->db->pdo()->query('SELECT COUNT(*) FROM scheduled_jobs')->fetchColumn(); + $maxRows = max($maxRows, $rows); + self::assertLessThanOrEqual(1280, $rows); + $this->clock->set($this->clock->now()->modify('+1 day')); + } + self::assertGreaterThan(1000, $maxRows); + self::assertLessThanOrEqual(1280, (int)$this->db->pdo()->query('SELECT COUNT(*) FROM delivery_log')->fetchColumn()); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + self::assertSame(['ok'], $this->db->integrity()['integrity_check']); + } +} diff --git a/tests/QuietHoursTest.php b/tests/QuietHoursTest.php new file mode 100644 index 0000000..0f759a9 --- /dev/null +++ b/tests/QuietHoursTest.php @@ -0,0 +1,42 @@ +getTimestamp(); + } + public function testDayAndOvernightBoundariesAreExclusiveAtEnd(): void + { + self::assertFalse(QuietHours::contains($this->at('2026-10-15T09:14:59+03:00'), 'Asia/Jerusalem', '09:15', '10:00')); + self::assertTrue(QuietHours::contains($this->at('2026-10-15T09:15:00+03:00'), 'Asia/Jerusalem', '09:15', '10:00')); + self::assertFalse(QuietHours::contains($this->at('2026-10-15T10:00:00+03:00'), 'Asia/Jerusalem', '09:15', '10:00')); + self::assertTrue(QuietHours::contains($this->at('2026-10-16T01:00:00+03:00'), 'Asia/Jerusalem', '22:00', '07:00')); + self::assertSame($this->at('2026-10-16T07:00:00+03:00'), QuietHours::nextBoundary($this->at('2026-10-15T23:00:00+03:00'), 'Asia/Jerusalem', '22:00', '07:00')); + self::assertNull(QuietHours::nextBoundary($this->at('2026-10-15T23:00:00Z'), 'UTC', '00:00', '00:00')); + } + public function testMissingDstEndTransitionsToAllowedAtFirstExistingTime(): void + { + $at = $this->at('2026-03-08T01:45:00-05:00'); + self::assertTrue(QuietHours::contains($at, 'America/New_York', '01:00', '02:30')); + self::assertSame($this->at('2026-03-08T03:00:00-04:00'), QuietHours::nextBoundary($at, 'America/New_York', '01:00', '02:30')); + } + public function testRepeatedDstHourCanReenterQuietIntervalAndFindSecondEnd(): void + { + $zone = 'America/New_York'; + $firstEnd = $this->at('2026-11-01T01:45:00-04:00'); + self::assertFalse(QuietHours::contains($firstEnd, $zone, '01:00', '01:30')); + $fold = $this->at('2026-11-01T01:00:00-05:00'); + self::assertSame($fold, QuietHours::nextBoundary($firstEnd, $zone, '01:00', '01:30')); + self::assertTrue(QuietHours::contains($fold, $zone, '01:00', '01:30')); + self::assertSame($this->at('2026-11-01T01:30:00-05:00'), QuietHours::nextBoundary($fold, $zone, '01:00', '01:30')); + } +} diff --git a/tests/RecoveryCalendarGuardTest.php b/tests/RecoveryCalendarGuardTest.php new file mode 100644 index 0000000..7e1a14c --- /dev/null +++ b/tests/RecoveryCalendarGuardTest.php @@ -0,0 +1,140 @@ +setValue(null, false); + foreach (['database', 'clock', 'root'] as $name) { + $this->adminState[$name] = (new \ReflectionProperty(AdminStateFiles::class, $name))->getValue(); + } + } + protected function tearDown(): void + { + foreach ($this->adminState as $name => $value) { + (new \ReflectionProperty(AdminStateFiles::class, $name))->setValue(null, $value); + } + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function app(string $date, array $changes = [], ?JsonClient $http = null): Application + { + $clock = new FrozenClock(new \DateTimeImmutable($date)); + $db = Database::open(':memory:'); + $db->migrate(); + $app = new Application(new RuntimeFakeGateway(), $clock, $db, new CalendarService(new HebcalProvider($http ?? new RuntimeFakeJson()), $clock)); + $app->settings->ensureGroup('-1001', 'Recovery calendar fixture', $changes); + return $app; + } + private function allowed(Application $app): bool + { + return (new \ReflectionMethod($app, 'permissionRecoveryAllowed'))->invoke($app, '-1001'); + } + public function testWeekdayRecoveryIsAllowedAndExpiredManualLockDoesNotLinger(): void + { + $app = $this->app('2026-10-15T12:00:00+03:00', ['manual_override_mode' => 'lock', 'manual_override_until' => 1]); + self::assertTrue($this->allowed($app)); + self::assertSame(0, $app->gateway->mutations); + } + public function testActiveManualLockBlocksRecoveryEvenOnWeekday(): void + { + $app = $this->app('2026-10-15T12:00:00+03:00', ['manual_override_mode' => 'lock', 'manual_override_until' => (new \DateTimeImmutable('2026-10-15T13:00:00+03:00'))->getTimestamp()]); + self::assertFalse($this->allowed($app)); + self::assertSame(0, $app->gateway->mutations); + } + public function testManualOpenCannotBypassShabbatWhenModeIsOff(): void + { + $app = $this->app('2026-10-17T12:00:00+03:00', ['mode' => 'off', 'manual_override_mode' => 'open', 'manual_override_until' => (new \DateTimeImmutable('2026-10-17T13:00:00+03:00'))->getTimestamp()]); + self::assertFalse($this->allowed($app)); + self::assertSame(0, $app->gateway->mutations); + } + public function testOperationalEarlyCloseAndLateOpenWindowsAlsoBlockRecovery(): void + { + $app = $this->app('2026-10-16T17:15:00+03:00', ['mode' => 'off', 'close_before_candles_minutes' => 10, 'open_after_havdalah_minutes' => 15]); + self::assertFalse($this->allowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-17T18:50:00+03:00')); + self::assertFalse($this->allowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-17T18:55:00+03:00')); + self::assertTrue($this->allowed($app)); + } + private function stillAllowed(Application $app): bool + { + return (new \ReflectionMethod($app, 'permissionRecoveryStillAllowed'))->invoke($app, '-1001'); + } + public function testSynchronousFenceAllowsSlowReadOnlyUntilVerifiedReligiousBoundary(): void + { + $app = $this->app('2026-10-16T17:19:55+03:00', ['mode' => 'off', 'shabbat_enabled' => false]); + self::assertTrue($this->allowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-16T17:19:59+03:00')); + self::assertTrue($this->stillAllowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-16T17:20:00+03:00')); + self::assertFalse($this->stillAllowed($app)); + } + public function testSynchronousFenceRejectsChangedSettingsAndExpiredOrBackwardClock(): void + { + $app = $this->app('2026-10-15T12:00:00+03:00'); + self::assertFalse($this->stillAllowed($app)); + self::assertTrue($this->allowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-15T12:01:59+03:00')); + self::assertTrue($this->stillAllowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-15T12:02:00+03:00')); + self::assertFalse($this->stillAllowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-15T11:59:59+03:00')); + self::assertFalse($this->stillAllowed($app)); + $app->clock->set(new \DateTimeImmutable('2026-10-15T12:00:30+03:00')); + $group = $app->settings->getGroup('-1001'); + $app->settings->updateGroup('-1001', $group['revision'], ['candle_minutes' => 50], '42'); + self::assertFalse($this->stillAllowed($app)); + } + public function testSynchronousFenceDoesNotFetchProviderAgain(): void + { + $http = new class implements JsonClient { + public int $calls = 0; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + return (new RuntimeFakeJson())->get($url, $query, $cancellation); + } + }; + $app = $this->app('2026-10-15T12:00:00+03:00', [], $http); + self::assertTrue($this->allowed($app)); + $calls = $http->calls; + self::assertGreaterThan(0, $calls); + self::assertTrue($this->stillAllowed($app)); + self::assertSame($calls, $http->calls); + } + public function testUnknownCalendarCannotGrantRecovery(): void + { + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + throw new \RuntimeException('Offline provider fixture'); + } + }; + $app = $this->app('2026-10-15T12:00:00+03:00', [], $http); + $this->expectException(\Throwable::class); + $this->allowed($app); + } + public function testShutdownCancellationIsNotConvertedToPermissionToRecover(): void + { + $app = $this->app('2026-10-15T12:00:00+03:00'); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, true); + $this->expectException(\Amp\CancelledException::class); + $this->allowed($app); + } +} diff --git a/tests/ReminderTest.php b/tests/ReminderTest.php new file mode 100644 index 0000000..947e324 --- /dev/null +++ b/tests/ReminderTest.php @@ -0,0 +1,174 @@ +clock = new FrozenClock(new \DateTimeImmutable('2026-04-12T12:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $this->settings = (new SettingsRepository($db, $this->clock))->ensureGroup('-1'); + $this->calendar = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $this->clock); + } + public function testOmerTwilightMidnightDawnDayCompletionAndOffSeason(): void + { + $this->settings['language'] = 'en'; + $this->settings['omer_day_completion'] = true; + $twilight = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-12T18:10:00+03:00')); + self::assertSame(11, $twilight['day']); + self::assertSame('twilight', $twilight['phase']); + self::assertFalse($twilight['eligible']); + self::assertFalse($twilight['day_completion']); + $night = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-12T19:00:00+03:00')); + $midnight = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-13T01:00:00+03:00')); + self::assertSame(11, $night['day']); + self::assertSame(11, $midnight['day']); + self::assertTrue($midnight['eligible']); + self::assertStringContainsString('Today is 11 days', $midnight['count_text']); + $day = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-13T05:00:00+03:00')); + self::assertFalse($day['eligible']); + self::assertTrue($day['day_completion']); + self::assertSame(11, $day['day']); + $off = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-06-12T20:00:00+03:00')); + self::assertFalse($off['season']); + self::assertNull($off['day']); + self::assertNull($off['count_text']); + self::assertFalse($off['eligible']); + } + public function testOmerCountAdvancesThroughShabbatWithoutSending(): void + { + $this->settings['omer_day_completion'] = true; + $saturday = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-11T10:00:00+03:00')); + $sunday = $this->calendar->omer($this->settings, new \DateTimeImmutable('2026-04-12T10:00:00+03:00')); + self::assertSame(9, $saturday['day']); + self::assertSame(10, $sunday['day']); + self::assertFalse($saturday['eligible']); + self::assertFalse($saturday['day_completion']); + } + public function testOmerAll49DaysComeFromCalendarRatherThanDeliveryCounter(): void + { + $start = new \DateTimeImmutable('2026-04-03T12:00:00+03:00'); + for ($day = 1; $day <= 49; $day++) { + $value = $this->calendar->omer($this->settings, $start->modify('+' . ($day - 1) . ' days')); + self::assertSame($day, $value['day']); + } + } + public function testOmerAfterMidnightFixedTimeUsesSameNightAndCutoffAtDawn(): void + { + $date = '2026-04-12'; + $sun = $this->calendar->zmanim($this->settings, $date)['times']; + $state = $this->calendar->snapshot($this->settings, new \DateTimeImmutable($date . 'T12:00:00+03:00')); + $jobs = (new ReminderPlanner())->omer($this->settings, ['enabled' => true,'schedule' => 'fixed','time' => '00:30'], $date, $sun, $state); + self::assertCount(1, $jobs); + self::assertSame(LocalTime::resolve('2026-04-13', '00:30', 'Asia/Jerusalem'), $jobs[0]['run_at']); + self::assertLessThanOrEqual($state['next_dawn_at'], $jobs[0]['expires_at']); + self::assertSame([], (new ReminderPlanner())->omer($this->settings, ['enabled' => true,'schedule' => 'fixed','time' => '06:00'], $date, $sun, $state)); + } + public function testEarlyOmerDefersOrSkipsAndSaturdayWaitsForVerifiedGroupOpening(): void + { + $date = '2026-04-12'; + $sun = $this->calendar->zmanim($this->settings, $date)['times']; + $state = $this->calendar->snapshot($this->settings, new \DateTimeImmutable($date . 'T12:00:00+03:00')); + $planner = new ReminderPlanner(); + $early = ['enabled' => true,'schedule' => 'fixed','time' => '17:00','early_policy' => 'defer']; + self::assertSame('delayed_until_nightfall', $planner->omer($this->settings, $early, $date, $sun, $state)[0]['reason']); + $early['early_policy'] = 'skip'; + self::assertSame([], $planner->omer($this->settings, $early, $date, $sun, $state)); + $date = '2026-04-11'; + $this->settings['open_after_havdalah_minutes'] = 10; + $sun = $this->calendar->zmanim($this->settings, $date)['times']; + $state = $this->calendar->snapshot($this->settings, new \DateTimeImmutable($date . 'T12:00:00+03:00')); + $jobs = $planner->omer($this->settings, ['enabled' => true,'schedule' => 'fixed','time' => '18:40','after_open_minutes' => 5], $date, $sun, $state); + self::assertSame($state['next_open_at'] + 300, $jobs[0]['run_at']); + self::assertSame('delayed_until_group_open', $jobs[0]['reason']); + } + public function testStorageSundayDayZeroAndTishaObservedAfternoonPolicy(): void + { + $planner = new ReminderPlanner(); + $date = '2019-08-11'; + $sun = $this->calendar->zmanim($this->settings, $date)['times']; + $state = $this->calendar->snapshot($this->settings, new \DateTimeImmutable($date . 'T12:00:00+03:00')); + self::assertTrue($state['is_tisha_bav']); + $rule = ['enabled' => true,'days' => [0],'times' => ['09:00','13:00'],'tisha_bav' => 'mincha','window_start' => '08:00','window_end' => '17:00']; + $jobs = $planner->tefillin($this->settings, $rule, $date, $sun, $state); + self::assertCount(1, $jobs); + self::assertSame(LocalTime::resolve($date, '13:00', 'Asia/Jerusalem'), $jobs[0]['run_at']); + $rule['tisha_bav'] = 'off'; + self::assertSame([], $planner->tefillin($this->settings, $rule, $date, $sun, $state)); + } + public function testTefillinIntervalsSpacingLimitsQuietAndExclusiveEnd(): void + { + $date = '2026-04-12'; + $sun = $this->calendar->zmanim($this->settings, $date)['times']; + $state = $this->calendar->snapshot($this->settings, new \DateTimeImmutable($date . 'T12:00:00+03:00')); + $rule = ['enabled' => true,'days' => [0],'times' => ['09:00','10:00'],'interval_minutes' => 5,'window_start' => '09:00','window_end' => '10:00', + 'min_gap_minutes' => 5,'max_per_day' => 24,'quiet_start' => '09:15','quiet_end' => '09:30']; + $jobs = (new ReminderPlanner())->tefillin($this->settings, $rule, $date, $sun, $state); + self::assertCount(9, $jobs); + self::assertSame(array_unique(array_column($jobs, 'run_at')), array_column($jobs, 'run_at')); + foreach ($jobs as $job) { + self::assertLessThan(LocalTime::resolve($date, '10:00', 'Asia/Jerusalem'), $job['run_at']); + self::assertGreaterThan($job['run_at'], $job['expires_at']); + } + } + public function testDynamicTemplatePreservesUtf16EntityOffsetsAndPlainSpecialCharacters(): void + { + $payload = ['text' => '๐Ÿ˜€ {{location}} tail','media' => ['bot_api_file_id' => 'fixture'],'buttons' => [], + 'entities' => [['_' => 'messageEntityCustomEmoji','offset' => 0,'length' => 2,'document_id' => '123'], + ['_' => 'messageEntityBold','offset' => 3,'length' => 12],['_' => 'messageEntityItalic','offset' => 16,'length' => 4], + ['_' => 'messageEntityUnderline','offset' => 0,'length' => 20]]]; + $rendered = (new TemplateRenderer())->renderPayload($payload, ['location' => 'ื—ื™ืคื”']); + self::assertSame('๐Ÿ˜€ ื—ื™ืคื” tail', $rendered['text']); + self::assertSame([2,4,4,12], array_column($rendered['entities'], 'length')); + self::assertSame([0,3,8,0], array_column($rendered['entities'], 'offset')); + self::assertSame($payload['media'], $rendered['media']); + self::assertSame('123', $rendered['entities'][0]['document_id']); + $literal = (new TemplateRenderer())->renderPayload(['text' => '{{location}}','entities' => [['_' => 'messageEntityBold','offset' => 0,'length' => 12]]], ['location' => '<ื—ื™ืคื”>']); + self::assertSame('<ื—ื™ืคื”>', $literal['text']); + self::assertSame(6, $literal['entities'][0]['length']); + $html = (new TemplateRenderer())->renderPayload(['text' => '{{location}}'], ['location' => '<ื—ื™ืคื”>']); + self::assertSame('<ื—ื™ืคื”>', $html['text']); + } + public function testEntityCannotSplitAnEmojiSurrogatePair(): void + { + $this->expectException(\InvalidArgumentException::class); + (new TemplateRenderer())->renderPayload(['text' => '๐Ÿ˜€','entities' => [['_' => 'messageEntityBold','offset' => 1,'length' => 1]]], []); + } + public function testCustomTemplateButtonColorsAreNativeValidatedAndUnknownStylesRejected(): void + { + foreach (['primary' => 'bg_primary','success' => 'bg_success','danger' => 'bg_danger'] as $name => $flag) { + $style = TemplateRenderer::nativeStyle($name); + self::assertTrue($style[$flag]); + TemplateRenderer::validateButtons([[['text' => 'Fixture','callback_data' => 'fixture','style' => $style]]]); + } + self::assertSame('1234567890123456789', TemplateRenderer::nativeStyle(['icon' => '1234567890123456789'])['icon']); + foreach ([['bg_primary' => true,'bg_danger' => true],['bg_success' => 'yes'],['unknown' => true],['icon' => '9223372036854775808']] as $invalid) { + try { + TemplateRenderer::nativeStyle($invalid); + self::fail('Invalid button style accepted.'); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + } + } + self::assertSame('<city>', (new TemplateRenderer())->render('{{location}}', ['location' => ''])); + } +} diff --git a/tests/ReplyScopeFixture.php b/tests/ReplyScopeFixture.php new file mode 100644 index 0000000..3f08e85 --- /dev/null +++ b/tests/ReplyScopeFixture.php @@ -0,0 +1,48 @@ +newInstanceWithoutConstructor(); + self::$applications ??= new \WeakMap(); + self::$applications[$fixture] = $app; + return $fixture; + } + private function parseButtons(string $input): array + { + return []; + } + public function language(Message $message): string + { + return $this->replyScopeLanguage($message); + } + public function sourceForMessage(Message $message, string $kind): string + { + return $this->v3Source($kind, (string)$message->senderId, $this->replyScopeLanguage($message)); + } + public function statsForMessage(Message $message): string + { + return $this->v3StatsText((string)$message->senderId, $this->replyScopeLanguage($message)); + } + public function privateSourceCallback(string $user, string $kind = 'source'): string + { + return $this->v3Source($kind, $user); + } + + public function rawLocation(array $update, int $self, callable $replyLookup): bool + { + return $this->v3RawLocation($update, $self, $replyLookup); + } +} diff --git a/tests/ReplyScopeTest.php b/tests/ReplyScopeTest.php new file mode 100644 index 0000000..b09562e --- /dev/null +++ b/tests/ReplyScopeTest.php @@ -0,0 +1,104 @@ +migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-13T12:00:00Z')); + $this->gateway = new RuntimeFakeGateway(); + $http = new class implements JsonClient { + public function get(string $url, array $query = [], ?Cancellation $cancellation = null): array + { + throw new \RuntimeException('No network in reply scope tests.'); + } + }; + $this->app = new Application($this->gateway, $clock, $db, new CalendarService(new HebcalProvider($http), $clock), new GeoNames($http, $clock, '')); + $this->app->settings->getUser('1'); + foreach (Lang::CODES as $index => $code) { + $group = '-' . (1001 + $index); + $this->app->settings->ensureGroup($group, 'Locale ' . $code); + $this->app->settings->updateGroup($group, 1, ['language' => $code], '1'); + } + $this->bridge = ReplyScopeFixture::create($this->app); + } + private function message(bool $private, int $chat, int $sender = 1): Message + { + $type = $private ? PrivateMessage::class : GroupMessage::class; + $class = new \ReflectionClass($type); + $message = $class->newInstanceWithoutConstructor(); + $class->getProperty('chatId')->getDeclaringClass()->getProperty('chatId')->setValue($message, $chat); + $class->getProperty('senderId')->getDeclaringClass()->getProperty('senderId')->setValue($message, $sender); + return $message; + } + private function expectedSource(string $code, string $kind): string + { + $lang = new Lang(); + $url = Version::sourceUrl(); + $values = ['version' => Version::current(),'commit' => Version::commit(),'license' => 'AGPL-3.0-only','url' => $url !== '' ? $url : '-']; + $key = 'source_info'; + return $lang->text($code, $key, $values) . ($url === '' ? "\n" . $lang->text($code, 'source_unpublished') : ''); + } + public function testRealBridgeSourceLicenseVersionAndStatsUseAllSixGroupLanguages(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $index => $code) { + $message = $this->message(false, -(1001 + $index)); + self::assertSame($code, $this->bridge->language($message)); + foreach (['source','license','version'] as $kind) { + self::assertSame($this->expectedSource($code, $kind), $this->bridge->sourceForMessage($message, $kind)); + } + $actual = $this->bridge->statsForMessage($message); + self::assertStringContainsString(\Shabbat\UI\AdminDirectoryText::text($code, 'stats_overview'), $actual); + self::assertStringContainsString(\Shabbat\UI\AdminDirectoryText::text($code, 'locking') . ': 6', $actual); + self::assertStringContainsString(\Shabbat\UI\AdminDirectoryText::text($code, 'locked') . ': 0', $actual); + self::assertSame('he', $this->app->settings->getUser('1')['language']); + } + self::assertSame([], $this->gateway->sent); + self::assertSame(0, $this->gateway->mutations); + } + public function testPrivateMessagesAndPrivateSourceCallbacksStayInPersonalLanguage(): void + { + $private = $this->message(true, 1); + $group = $this->message(false, -1004); + self::assertSame('fr', $this->bridge->language($group)); + self::assertSame('he', $this->bridge->language($private)); + self::assertSame($this->expectedSource('he', 'source'), $this->bridge->privateSourceCallback('1')); + self::assertSame($this->expectedSource('he', 'license'), $this->bridge->sourceForMessage($private, 'license')); + $user = $this->app->settings->getUser('1'); + $this->app->settings->updateUser('1', $user['revision'], ['language' => 'pt-br']); + self::assertSame('pt-br', $this->bridge->language($private)); + self::assertSame('fr', $this->bridge->language($group)); + self::assertSame($this->expectedSource('pt-br', 'version'), $this->bridge->privateSourceCallback('1', 'version')); + } + public function testUnknownGroupFallsBackToUserAndAnonymousKnownGroupUsesItsLocale(): void + { + self::assertSame('he', $this->bridge->language($this->message(false, -999))); + $anonymous = $this->message(false, -1004, -1004); + self::assertSame('fr', $this->bridge->language($anonymous)); + self::assertSame($this->expectedSource('fr', 'source'), $this->bridge->sourceForMessage($anonymous, 'source')); + self::assertSame('he', $this->bridge->language($this->message(false, -999, -999))); + self::assertSame('he', $this->app->settings->getUser('1')['language']); + } +} diff --git a/tests/RuntimeApplicationTest.php b/tests/RuntimeApplicationTest.php new file mode 100644 index 0000000..9bb3d96 --- /dev/null +++ b/tests/RuntimeApplicationTest.php @@ -0,0 +1,289 @@ +setValue(null, false); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T17:45:00+03:00')); + $this->gateway = new RuntimeFakeGateway(); + $this->calendar = new CalendarService(new HebcalProvider(new RuntimeFakeJson()), $this->clock); + $this->heartbeat = sys_get_temp_dir() . '/shabbat-heartbeat-' . bin2hex(random_bytes(8)) . '.json'; + putenv('HEARTBEAT_PATH=' . $this->heartbeat); + } + protected function tearDown(): void + { + if (is_file($this->heartbeat)) { + unlink($this->heartbeat); + }putenv('HEARTBEAT_PATH'); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function app(): Application + { + return new Application($this->gateway, $this->clock, $this->db, $this->calendar); + } + public function testRuntimeClosesAndRestoresAcrossRestartLongAfterExit(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $app->tick(); + self::assertTrue($this->gateway->rights['-1001']['send_messages']); + self::assertSame('locked', $app->locks->state('-1001')['status']); + $original = $app->locks->state('-1001')['snapshot_json']; + $app->tick(); + self::assertSame(1, $this->gateway->mutations); + $this->clock->set(new \DateTimeImmutable('2026-10-18T10:00:00+03:00')); + $restarted = $this->app(); + $restarted->tick(); + self::assertFalse($this->gateway->rights['-1001']['send_messages']); + self::assertSame('restored', $restarted->locks->state('-1001')['status']); + self::assertSame($original, $restarted->locks->state('-1001')['snapshot_json']); + self::assertSame(2, $this->gateway->mutations); + self::assertNotFalse(file_get_contents($this->heartbeat)); + } + public function testOneGroupFailureDoesNotPreventOtherGroupsLocking(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Failure'); + $app->settings->ensureGroup('-1002', 'Success'); + $this->gateway->fail = '-1001'; + $app->tick(); + self::assertSame('error', $app->locks->state('-1001')['status']); + self::assertSame('locked', $app->locks->state('-1002')['status']); + self::assertTrue($this->gateway->rights['-1002']['send_messages']); + self::assertSame([], $this->gateway->sent); + } + public function testNoNewModulesAreSentWithoutConsent(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $this->clock->set(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $app->tick(); + self::assertSame([], $this->gateway->sent); + self::assertSame(0, $this->db->pdo()->query("SELECT count(*) FROM scheduled_jobs WHERE module IN ('zmanim','daf','tefillin','omer')")->fetchColumn()); + } + public function testGroupLanguageIndependentFromUserForAutomaticPayload(): void + { + $app = $this->app(); + $g = $app->settings->ensureGroup('-1001', 'Example'); + $u = $app->settings->getUser('42'); + $app->settings->updateUser('42', $u['revision'], ['language' => 'en']); + $payload = $app->payload($g, 'zmanim', new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + self::assertStringContainsString('ืฉืงื™ืขื”', $payload['text']); + self::assertStringNotContainsString('Sunset:', $payload['text']); + } + public function testTemporaryManualClosureSuppressesRoutineMessages(): void + { + $this->clock->set(new \DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar, authorizeOperator:static fn(string $user): bool=>$user === '42'); + $g = $app->settings->ensureGroup('-1001', 'Example'); + $app->settings->updateGroup('-1001', $g['revision'], ['tefillin_enabled' => true,'tefillin_fixed_times' => ['09:00']], '42'); + $app->manualGroups->apply(true, '42'); + $app->tick(); + self::assertSame('locked', $app->locks->state('-1001')['status']); + self::assertSame([], $this->gateway->sent); + } + public function testManualOpeningDoesNotAuthorizeReligiousQuietPeriodReminders(): void + { + $app = new Application($this->gateway, $this->clock, $this->db, $this->calendar, authorizeOperator:static fn(string $user): bool=>$user === '42'); + $g = $app->settings->ensureGroup('-1001', 'Example'); + $app->settings->updateGroup('-1001', $g['revision'], ['zmanim_enabled' => true,'zmanim_time' => '17:45'], '42'); + $app->tick(); + $app->manualGroups->apply(false, '42'); + $app->tick(); + self::assertFalse($this->gateway->rights['-1001']['send_messages']); + self::assertSame([], $this->gateway->sent); + } + public function testAllSixGroupLanguagesRenderAutomaticNoticesWithoutPersonalFallback(): void + { + $app = $this->app(); + $g = $app->settings->ensureGroup('-1001', 'Example'); + $lang = new \Shabbat\UI\Lang(); + foreach (\Shabbat\UI\Lang::CODES as $code) { + $g = $app->settings->updateGroup('-1001', $g['revision'], ['language' => $code], '42'); + self::assertSame($lang->text($code, 'group_close_notice'), $app->payload($g, 'close', $this->clock->now())['text']); + self::assertSame($lang->text($code, 'group_open_notice'), $app->payload($g, 'open', $this->clock->now())['text']); + self::assertStringContainsString($lang->text($code, 'sunset'), $app->payload($g, 'zmanim', $this->clock->now())['text']); + } + } + public function testPermissionDesireIsReevaluatedWhenReadCrossesNextClosingBoundary(): void + { + $app = $this->app(); + $group = $app->settings->ensureGroup('-1001', 'Example'); + $this->clock->set(new \DateTimeImmutable('2026-10-09T17:45:00+03:00')); + $app->tick(); + self::assertSame(1, $this->gateway->mutations); + $state = $this->calendar->snapshot($group, new \DateTimeImmutable('2026-10-16T12:00:00+03:00')); + $closing = $state['next_event']['close_at']; + $this->clock->set(new \DateTimeImmutable('@' . ($closing - 10))); + $this->gateway->beforeGetPermissions = function () use ($closing): void { + $this->clock->set(new \DateTimeImmutable('@' . ($closing + 10))); + }; + $app->tick(); + self::assertSame(1, $this->gateway->mutations); + self::assertTrue($this->gateway->rights['-1001']['send_messages']); + self::assertNotSame('restored', $app->locks->state('-1001')['status']); + } + public function testRoutineMessagesWaitForUnresolvedBotOwnedPermissionSnapshot(): void + { + $app = $this->app(); + $group = $app->settings->ensureGroup('-1001', 'Example'); + $app->settings->updateGroup('-1001', $group['revision'], ['zmanim_enabled' => true, 'zmanim_time' => '09:00'], '42'); + $app->tick(); + $this->clock->set(new \DateTimeImmutable('2026-10-18T09:00:00+03:00')); + $this->gateway->fail = '-1001'; + $app->tick(); + self::assertSame([], $this->gateway->sent); + $job = $this->db->pdo()->query("SELECT * FROM scheduled_jobs WHERE module='zmanim' AND event_key='zmanim:2026-10-18'")->fetch(); + self::assertIsArray($job); + self::assertSame('pending', $job['status']); + self::assertGreaterThanOrEqual(1, $job['deferrals']); + $this->gateway->fail = null; + $this->clock->set($this->clock->now()->modify('+60 seconds')); + $app->tick(); + self::assertSame('restored', $app->locks->state('-1001')['status']); + self::assertCount(1, $this->gateway->sent); + } + + public function testLateActualRestorationDefersOmerUntilConfiguredGap(): void + { + require_once __DIR__ . '/Fixtures/CalendarFixtureClient.php'; + $calendar = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $this->clock); + $app = new Application($this->gateway, $this->clock, $this->db, $calendar); + $group = $app->settings->ensureGroup('-1001', 'Example'); + $app->settings->updateGroup('-1001', $group['revision'], ['omer_enabled' => true, 'omer_mode' => 'nightfall', 'omer_after_nightfall_minutes' => 0, 'omer_after_open_minutes' => 10], '42'); + $this->clock->set(new \DateTimeImmutable('2026-04-03T17:45:00+03:00')); + $app->tick(); + $this->clock->set(new \DateTimeImmutable('2026-04-04T20:00:00+03:00')); + $app->tick(); + $restored = $app->locks->state('-1001'); + self::assertSame('restored', $restored['status']); + self::assertSame([], $this->gateway->sent); + $job = $this->db->pdo()->query("SELECT * FROM scheduled_jobs WHERE module='omer' AND event_key='omer-night:2026-04-04'")->fetch(); + self::assertIsArray($job); + self::assertSame('pending', $job['status']); + self::assertSame($restored['restored_at'] + 600, $job['retry_at']); + $this->clock->set($this->clock->now()->modify('+9 minutes')); + $app->tick(); + self::assertSame([], $this->gateway->sent); + $this->clock->set($this->clock->now()->modify('+1 minute')); + $app->tick(); + self::assertCount(1, $this->gateway->sent); + self::assertStringContainsString('ื”ื™ื•ื', str_replace(['ึท', 'ึธ', 'ึน', 'ึต', 'ึถ', 'ึฐ', 'ึด', 'ึผ', 'ึป', 'ึฝ'], '', $this->gateway->sent[0][1]['text'])); + } + + public function testBroadcastPriorityWaitsForRunningTickAndFreshPermissionRead(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $entered = new \Amp\DeferredFuture(); + $gate = new \Amp\DeferredFuture(); + $events = []; + $this->gateway->beforeGetPermissions = static function () use (&$events): void { + $events[] = 'permission-read'; + }; + $this->gateway->beforeSetPermissions = function () use ($entered, $gate, &$events): void { + self::assertFalse($this->db->pdo()->inTransaction()); + $events[] = 'lock-rpc-start'; + $entered->complete(); + $gate->getFuture()->await(); + $events[] = 'lock-rpc-complete'; + }; + $tick = \Amp\async($app->tick(...)); + $entered->getFuture()->await(); + $priority = \Amp\async(static function () use ($app, &$events): bool { + $allowed = $app->prioritizePermissions(); + $events[] = 'broadcast-ready'; + return $allowed; + }); + \Amp\delay(0.001); + self::assertFalse($priority->isComplete()); + self::assertSame(0, $this->gateway->mutations); + $gate->complete(); + $tick->await(); + self::assertTrue($priority->await()); + self::assertSame(1, $this->gateway->mutations); + self::assertSame(4, count(array_filter($events, static fn(string $event): bool => $event === 'permission-read'))); + self::assertSame('permission-read', $events[count($events) - 2]); + self::assertSame('broadcast-ready', end($events)); + } + + public function testBroadcastPriorityDoesNotStartFreshWorkAfterShutdown(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $app->tick(); + $reads = 0; + $this->gateway->beforeGetPermissions = static function () use (&$reads): void { + $reads++; + }; + Runner::requestShutdown(); + self::assertFalse($app->prioritizePermissions()); + self::assertSame(0, $reads); + self::assertSame(1, $this->gateway->mutations); + } + + public function testRunningTickCancellationPropagatesToWaitingBroadcast(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $entered = new \Amp\DeferredFuture(); + $gate = new \Amp\DeferredFuture(); + $attempts = 0; + $this->gateway->beforeSetPermissions = static function () use ($entered, $gate, &$attempts): void { + $attempts++; + $entered->complete(); + $gate->getFuture()->await(); + throw new \Amp\CancelledException(new \RuntimeException('deliberate fixture cancellation')); + }; + $tick = \Amp\async($app->tick(...)); + $entered->getFuture()->await(); + $priority = \Amp\async($app->prioritizePermissions(...)); + \Amp\delay(0.001); + $gate->complete(); + foreach ([$tick, $priority] as $future) { + try { + $future->await(); + self::fail('Cancellation was swallowed.'); + } catch (\Amp\CancelledException) { + self::assertTrue(true); + } + } + self::assertSame(1, $attempts); + self::assertSame(0, $this->gateway->mutations); + } + + public function testShutdownStopsNewWorkAndKeepsStoredSnapshot(): void + { + $app = $this->app(); + $app->settings->ensureGroup('-1001', 'Example'); + $app->tick(); + $state = $app->locks->state('-1001'); + Runner::requestShutdown(); + $this->clock->set(new \DateTimeImmutable('2026-10-18T10:00:00+03:00')); + $app->tick(); + self::assertSame(1, $this->gateway->mutations); + self::assertSame($state['snapshot_json'], $app->locks->state('-1001')['snapshot_json']); + } +} diff --git a/tests/RuntimeDispatchSafetyTest.php b/tests/RuntimeDispatchSafetyTest.php new file mode 100644 index 0000000..49a8737 --- /dev/null +++ b/tests/RuntimeDispatchSafetyTest.php @@ -0,0 +1,207 @@ +setValue(null, false); + $this->heartbeat = sys_get_temp_dir() . '/shabbat-dev9-' . bin2hex(random_bytes(8)) . '.json'; + putenv('HEARTBEAT_PATH=' . $this->heartbeat); + } + protected function tearDown(): void + { + if (is_file($this->heartbeat)) { + unlink($this->heartbeat); + } + putenv('HEARTBEAT_PATH'); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function app(string $date, array $settings = [], ?JsonClient $http = null): array + { + $clock = new FrozenClock(new \DateTimeImmutable($date, new \DateTimeZone('Asia/Jerusalem'))); + $db = Database::open(':memory:'); + $db->migrate(); + $gateway = new RuntimeFakeGateway(); + $calendar = new CalendarService(new HebcalProvider($http ?? new RuntimeFakeJson()), $clock); + $app = new Application($gateway, $clock, $db, $calendar); + $group = $app->settings->ensureGroup('-1001', 'Safety fixture', $settings); + return [$app, $clock, $db, $gateway, $group]; + } + private function worker(Application $app): OutboxWorker + { + return (new \ReflectionProperty($app, 'worker'))->getValue($app); + } + private function job(Application $app, string $kind, string $event = 'regression', ?int $runAt = null, ?int $expires = null): int + { + $group = $app->settings->getGroup('-1001'); + $now = $app->clock->now()->getTimestamp(); + return (new JobRepository($app->db, $app->clock))->enqueue('-1001', $kind, $event, $runAt ?? $now, $expires ?? $now + 7200, $group['revision'], ['text' => 'fixture']); + } + public function testCalendarEvaluatesAfterSlowProviderCrossesShabbatEntry(): void + { + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-16T17:19:59+03:00')); + $http = new class ($clock) implements JsonClient { + public function __construct(private FrozenClock $clock) + { + } + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + if (str_ends_with($url, '/zmanim')) { + $this->clock->set($this->clock->now()->modify('+2 seconds')); + } + return (new RuntimeFakeJson())->get($url, $query); + } + }; + $db = Database::open(':memory:'); + $db->migrate(); + $settings = new \Shabbat\Storage\SettingsRepository($db, $clock); + $group = $settings->ensureGroup('-1'); + $calendar = new CalendarService(new HebcalProvider($http), $clock); + $historical = $clock->now(); + self::assertTrue($calendar->snapshotNow($group)['quiet_now']); + self::assertFalse($calendar->snapshot($group, $historical)['quiet_now']); + } + public function testSecondEligibilityCacheMissCannotSendUsingOldTimestamp(): void + { + [$app, $clock, $db, $gateway, $group] = $this->app('2026-10-16 17:19:59', ['zmanim_enabled' => true, 'zmanim_time' => '17:00']); + $boundary = (new \DateTimeImmutable('2026-10-16T17:20:00+03:00'))->getTimestamp(); + $client = new class ($clock) implements JsonClient { + public bool $advance = false; + public function __construct(private FrozenClock $clock) + { + } + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + if ($this->advance) { + $this->advance = false; + $this->clock->set($this->clock->now()->modify('+2 seconds')); + } + return (new RuntimeFakeJson())->get($url, $query); + } + }; + $calendar = new CalendarService(new HebcalProvider($client), $clock); + $app = new Application($gateway, $clock, $db, $calendar); + $id = $this->job($app, 'zmanim'); + $eligible = (new \ReflectionMethod($app, 'eligible'))->getClosure($app); + $dispatch = (new \ReflectionMethod($app, 'dispatchEligible'))->getClosure($app); + $jobs = new JobRepository($db, $clock); + $worker = new OutboxWorker($jobs, $app->settings, $gateway, $clock, $eligible, static function () use ($client, $calendar): array { + (new \ReflectionProperty($calendar, 'cache'))->setValue($calendar, []); + $client->advance = true; + return ['text' => 'ready']; + }, null, $dispatch); + self::assertSame('cancelled', $worker->runOnce('owner')['status']); + self::assertSame($boundary + 1, $clock->now()->getTimestamp()); + self::assertSame([], $gateway->sent); + self::assertSame('cancelled', $jobs->get($id)['status']); + } + public function testActualQuietHoursDeferPreviouslyPlannedReminderAndResume(): void + { + [$app, $clock, $db, $gateway] = $this->app('2026-10-15 09:20:00', ['tefillin_enabled' => true, 'quiet_start' => '09:15', 'quiet_end' => '10:00']); + $id = $this->job($app, 'tefillin', runAt: $clock->now()->modify('-20 minutes')->getTimestamp()); + self::assertSame('deferred', $this->worker($app)->runOnce('owner')['status']); + self::assertSame([], $gateway->sent); + $jobs = new JobRepository($db, $clock); + self::assertSame($clock->now()->modify('+40 minutes')->getTimestamp(), $jobs->get($id)['retry_at']); + $clock->set($clock->now()->modify('+40 minutes')); + self::assertSame('sent', $this->worker($app)->runOnce('owner')['status']); + self::assertCount(1, $gateway->sent); + } + public function testFinalEvidenceRejectsBoundaryRevisionRollbackAndShutdown(): void + { + [$app, $clock, $db, , $group] = $this->app('2026-10-16 17:19:59', ['zmanim_enabled' => true]); + $id = $this->job($app, 'zmanim'); + $job = (new JobRepository($db, $clock))->get($id); + $eligible = new \ReflectionMethod($app, 'eligible'); + $dispatch = new \ReflectionMethod($app, 'dispatchEligible'); + self::assertTrue($eligible->invoke($app, $job, $group, $clock->now())); + self::assertTrue($dispatch->invoke($app, $job, $group, $clock->now())); + self::assertFalse($dispatch->invoke($app, $job, $group, $clock->now()->modify('+1 second'))); + self::assertFalse($dispatch->invoke($app, $job, $group, $clock->now()->modify('-1 second'))); + $changed = $group; + $changed['revision']++; + self::assertFalse($dispatch->invoke($app, $job, $changed, $clock->now())); + $app->stop(); + self::assertFalse($dispatch->invoke($app, $job, $group, $clock->now())); + } + public function testPersistedTefillinBacklogCoalescesThenActualGapApplies(): void + { + [$app, $clock, $db, $gateway] = $this->app('2026-10-15 11:30:00', ['tefillin_enabled' => true, 'tefillin_min_spacing_minutes' => 30]); + foreach (['09:00', '10:00', '11:00'] as $time) { + $this->job($app, 'tefillin', $time, (new \DateTimeImmutable('2026-10-15 ' . $time, new \DateTimeZone('Asia/Jerusalem')))->getTimestamp()); + } + self::assertSame('sent', $this->worker($app)->runOnce('owner')['status']); + self::assertNull($this->worker($app)->runOnce('owner')); + self::assertCount(1, $gateway->sent); + $clock->set($clock->now()->modify('+10 minutes')); + $this->job($app, 'tefillin', 'new-reminder'); + self::assertSame('deferred', $this->worker($app)->runOnce('owner')['status']); + self::assertCount(1, $gateway->sent); + $clock->set($clock->now()->modify('+20 minutes')); + self::assertSame('sent', $this->worker($app)->runOnce('owner')['status']); + } + public function testNotificationsTraverseAllGroupsFairlyAndCannotRefreshPermissionHeartbeat(): void + { + [$app, $clock, $db, $gateway] = $this->app('2026-10-15 09:00:00', ['zmanim_enabled' => true, 'zmanim_time' => '09:00']); + for ($i = 2; $i <= 61; $i++) { + $app->settings->ensureGroup('-' . (1000 + $i), 'Group ' . $i, ['zmanim_enabled' => true, 'zmanim_time' => '09:00']); + } + $app->permissionTick(); + $firstHeartbeat = json_decode(file_get_contents($this->heartbeat), true); + for ($i = 0; $i < 20 && count($gateway->sent) < 61; $i++) { + $clock->set($clock->now()->modify('+1 second')); + $app->notificationTick(); + } + self::assertCount(61, $gateway->sent); + self::assertCount(61, array_unique(array_column($gateway->sent, 0))); + $lastHeartbeat = json_decode(file_get_contents($this->heartbeat), true); + self::assertSame($firstHeartbeat['permission_timestamp'], $lastHeartbeat['permission_timestamp']); + self::assertGreaterThan($firstHeartbeat['permission_timestamp'], $lastHeartbeat['notification_timestamp']); + self::assertLessThanOrEqual(20, $lastHeartbeat['cycles']['notifications']['groups_planned']); + } + public function testPermissionPassBoundsConcurrentNetworkWorkAndJoinsIt(): void + { + [$app, , , $gateway] = $this->app('2026-10-16 17:25:00'); + for ($i = 2; $i <= 12; $i++) { + $app->settings->ensureGroup('-' . (1000 + $i)); + } + $active = 0; + $max = 0; + $gateway->beforeSetPermissions = static function () use (&$active, &$max): void { + $active++; + $max = max($max, $active); + \Amp\delay(0.001); + $active--; + }; + $app->permissionTick(); + self::assertSame(4, $max); + self::assertSame(0, $active); + self::assertSame(12, $gateway->mutations); + self::assertSame([], $gateway->sent); + } + public function testOmerPlannerReceivesQuietHoursAndShutdownStopsEveryLane(): void + { + require_once __DIR__ . '/Fixtures/CalendarFixtureClient.php'; + [$app, , $db] = $this->app('2026-04-13 17:00:00', ['omer_enabled' => true, 'omer_mode' => 'nightfall', 'omer_after_nightfall_minutes' => 0, 'quiet_start' => '18:30', 'quiet_end' => '19:30'], new CalendarFixtureClient()); + $app->notificationTick(); + self::assertSame(0, $db->pdo()->query("SELECT COUNT(*) FROM scheduled_jobs WHERE module='omer'")->fetchColumn()); + $app->stop(); + $app->permissionTick(); + $app->notificationTick(); + self::assertSame(0, $db->pdo()->query('SELECT COUNT(*) FROM group_lock_state')->fetchColumn()); + } +} diff --git a/tests/RuntimeFakeGateway.php b/tests/RuntimeFakeGateway.php new file mode 100644 index 0000000..5a1c443 --- /dev/null +++ b/tests/RuntimeFakeGateway.php @@ -0,0 +1,61 @@ +beforeGetPermissions !== null) { + ($this->beforeGetPermissions)($group); + } + return $this->rights[$group] ??= ['_' => 'chatBannedRights','until_date' => 0,'send_messages' => false,'send_media' => false]; + } + public function setPermissions(string $group, array $rights): void + { + if ($this->beforeSetPermissions !== null) { + ($this->beforeSetPermissions)($group, $rights); + } + if ($group === $this->fail) { + throw new \RuntimeException('Fake failure'); + }$this->rights[$group] = $rights; + $this->mutations++; + } + public function send(string $peer, array $payload, string $randomId): array + { + $this->sent[] = [$peer,$payload,$randomId]; + return ['_' => 'updates','updates' => []]; + } + public function isGroupAdmin(string $user, string $group): bool + { + return $user === '42'; + } + public function sendPrivate(string $user, string $text, array $markup = []): void + { + } + public function editPrivate(string $user, int $id, string $text, array $markup = []): void + { + } + public function answerCallback(string $id, string $text): void + { + } + public function answerInline(string $id, array $results, array $options = []): void + { + } + public function botUsername(): string + { + return 'ExampleBot'; + } +} diff --git a/tests/RuntimeFakeJson.php b/tests/RuntimeFakeJson.php new file mode 100644 index 0000000..1c14727 --- /dev/null +++ b/tests/RuntimeFakeJson.php @@ -0,0 +1,29 @@ + ['start' => $start,'end' => $end],'items' => []]; + } + $times = []; + $zone = new \DateTimeZone($query['tzid']); + foreach (['sunset' => '18:00','tzeit85deg' => '18:40','tzeit7083deg' => '18:30','alotHaShachar' => '05:30','misheyakir' => '05:45','sunrise' => '06:30','minchaGedola' => '12:30'] as $name => $hour) { + for ($date = new \DateTimeImmutable($start, $zone); $date->format('Y-m-d') <= $end; $date = $date->modify('+1 day')) { + $key = $date->format('Y-m-d'); + $times[$name][$key] = (new \DateTimeImmutable($key . ' ' . $hour, $zone))->format(DATE_ATOM); + } + } + return ['date' => ['start' => $start,'end' => $end],'times' => $times]; + } +} diff --git a/tests/RuntimeGatewayTest.php b/tests/RuntimeGatewayTest.php new file mode 100644 index 0000000..ed1dcd8 --- /dev/null +++ b/tests/RuntimeGatewayTest.php @@ -0,0 +1,172 @@ +peerInfo['Chat']['default_banned_rights'] = ['_' => 'chatBannedRights', 'until_date' => 0, 'send_messages' => true]; + $current = ['_' => 'chatBannedRights', 'until_date' => 0, 'send_messages' => false]; + $transport->responses['getFullInfo'] = FreshPeerFixture::info('-100123', $current); + self::assertSame($current, (new MadelineGateway($transport))->getPermissions('-100123')); + self::assertSame('getFullInfo', $transport->calls[0][0]); + } + + public function testBasicChatPermissionsAndAdministratorLookup(): void + { + $transport = new GatewayFixtureTransport(); + $transport->peerInfo['type'] = 'chat'; + $transport->responses['getFullInfo'] = FreshPeerFixture::info('-123', null, 'chat'); + $transport->responses['getFullInfo']['full']['participants']['participants'] = [['_' => 'chatParticipantAdmin', 'user_id' => 7]]; + $gateway = new MadelineGateway($transport); + self::assertTrue($gateway->isGroupAdmin('7', '-123')); + self::assertFalse($gateway->isGroupAdmin('8', '-123')); + self::assertSame(['_' => 'chatBannedRights', 'until_date' => 0], $gateway->getPermissions('-123')); + } + + public function testMissingCurrentChatFailsClosed(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['getFullInfo'] = ['Chat' => ['_' => 'channel','id' => 999]]; + $this->expectException(RuntimeException::class); + (new MadelineGateway($transport))->getPermissions('-100123'); + } + + public function testAdminAuthorizationIsCheckedAgainByRpc(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipantAdmin']]; + $gateway = new MadelineGateway($transport); + self::assertTrue($gateway->isGroupAdmin('7', '-100123')); + $transport->responses['channels.getParticipant'] = ['participant' => ['_' => 'channelParticipant']]; + self::assertFalse($gateway->isGroupAdmin('7', '-100123')); + self::assertCount(2, $transport->calls); + } + + public function testMediaEntitiesMarkupAndStableRandomIdArePreserved(): void + { + $transport = new GatewayFixtureTransport(); + $gateway = new MadelineGateway($transport); + $payload = ['text' => 'fixture', 'media' => 'fixture-file-id', 'entities' => [['_' => 'messageEntityBold','offset' => 0,'length' => 7]], 'buttons' => [[['text' => 'Button','callback_data' => 'fixture:callback','style' => 'primary']]]]; + $gateway->send('-100123', $payload, '91234567'); + $gateway->send('-100123', $payload, '91234567'); + [$method, $args] = $transport->calls[0]; + self::assertSame('messages.sendMedia', $method); + self::assertSame($transport->mediaInfo['InputMedia'], $args['media']); + self::assertSame($payload['entities'], $args['entities']); + self::assertArrayNotHasKey('parse_mode', $args); + self::assertSame(91234567, $args['random_id']); + self::assertSame($args['random_id'], $transport->calls[1][1]['random_id']); + self::assertSame(['_' => 'keyboardButtonStyle','bg_primary' => true], $args['reply_markup']['rows'][0]['buttons'][0]['style']); + self::assertSame(0, $args['floodWaitLimit']); + self::assertArrayNotHasKey('allow_paid_stars', $args); + self::assertArrayNotHasKey('no_webpage', $args); + } + + public function testInlineSwitchUsesMtprotoSamePeer(): void + { + $markup = MadelineGateway::normalizeMarkup(['inline_keyboard' => [[['text' => 'Search','switch_inline_query_current_chat' => 'loc fixturetoken ']]]]); + self::assertSame('keyboardButtonSwitchInline', $markup['rows'][0]['buttons'][0]['_']); + self::assertTrue($markup['rows'][0]['buttons'][0]['same_peer']); + self::assertSame('loc fixturetoken ', $markup['rows'][0]['buttons'][0]['query']); + } + + public function testClosedTopicNeverFallsBackToGeneral(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['messages.getForumTopicsByID'] = ['topics' => [['id' => 7,'closed' => true]]]; + try { + (new MadelineGateway($transport))->send('-100123', ['text' => 'fixture','topic_id' => 7], '42'); + self::fail('Closed topic sent'); + } catch (RuntimeException) { + self::assertCount(1, $transport->calls); + } + } + + public function testOpenTopicUsesTheAvailableNativeMessagesApi(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['messages.getForumTopicsByID'] = ['topics' => [['id' => 7, 'closed' => false]]]; + (new MadelineGateway($transport))->send('-100123', ['text' => 'fixture', 'topic_id' => 7], '42'); + self::assertSame('messages.getForumTopicsByID', $transport->calls[0][0]); + self::assertSame('-100123', $transport->calls[0][1]['peer']); + self::assertSame([7], $transport->calls[0][1]['topics']); + self::assertSame('messages.sendMessage', $transport->calls[1][0]); + self::assertSame(7, $transport->calls[1][1]['reply_to']['top_msg_id']); + } + + public function testFreshHelperFloodWaitIsReturnedToScheduler(): void + { + $transport = new GatewayFixtureTransport(); + $transport->error = new danog\MadelineProto\RPCError\FloodWaitError('FLOOD_WAIT_12', 12, 420, 'channels.getFullChannel'); + $this->expectException(RetryableTelegramException::class); + (new MadelineGateway($transport))->getPermissions('-100123'); + } + + public function testFloodWaitIsReturnedToScheduler(): void + { + $transport = new GatewayFixtureTransport(); + $transport->error = new danog\MadelineProto\RPCError\FloodWaitError('FLOOD_WAIT_12', 12, 420, 'messages.sendMessage'); + try { + (new MadelineGateway($transport))->send('-100123', ['text' => 'fixture'], '42'); + self::fail('Flood wait ignored'); + } catch (RetryableTelegramException $error) { + self::assertGreaterThan(0, $error->retryAfterSeconds); + self::assertLessThanOrEqual(12, $error->retryAfterSeconds); + self::assertCount(1, $transport->calls); + } + } + + public function testAdministratorLookupPreservesIntentionalCancellation(): void + { + $transport = new GatewayFixtureTransport(); + $transport->error = new Amp\CancelledException(); + $this->expectException(Amp\CancelledException::class); + (new MadelineGateway($transport))->isGroupAdmin('7', '-100123'); + } + + public function testAllNativeRateLimitFamiliesReturnTheRemainingDeadline(): void + { + foreach ([danog\MadelineProto\RPCError\RateLimitError::class, danog\MadelineProto\RPCError\FloodPremiumWaitError::class] as $class) { + $transport = new GatewayFixtureTransport(); + $native = new $class('FLOOD_PREMIUM_WAIT_12', 12, 420, 'messages.sendMessage'); + $transport->error = $native; + try { + (new MadelineGateway($transport))->send('-100123', ['text' => 'fixture'], '42'); + self::fail('Native rate limit escaped deadline conversion'); + } catch (RetryableTelegramException $error) { + self::assertSame($native, $error->getPrevious()); + self::assertGreaterThanOrEqual(1, $error->retryAfterSeconds); + self::assertLessThanOrEqual(12, $error->retryAfterSeconds); + self::assertCount(1, $transport->calls); + } + } + } + + public function testFalsePermissionAcknowledgementFails(): void + { + $transport = new GatewayFixtureTransport(); + $transport->responses['messages.editChatDefaultBannedRights'] = false; + $this->expectException(RuntimeException::class); + (new MadelineGateway($transport))->setPermissions('-100123', ['_' => 'chatBannedRights','until_date' => 0]); + } +} diff --git a/tests/RuntimeLoadGateway.php b/tests/RuntimeLoadGateway.php new file mode 100644 index 0000000..0280957 --- /dev/null +++ b/tests/RuntimeLoadGateway.php @@ -0,0 +1,92 @@ + 'chatBannedRights','until_date' => 0,'send_messages' => false,'send_media' => false,'send_photos' => true,'invite_users' => true]; + } + private function permissionIo(string $group, string $kind): void + { + $this->activePermissionIo++; + $this->permissionRequests++; + $this->maxPermissionIo = max($this->maxPermissionIo, $this->activePermissionIo); + try { + if ($this->onPermissionIo !== null) { + ($this->onPermissionIo)($group, $kind); + } + if ($this->permissionDelay > 0) { + \Amp\delay($this->permissionDelay); + } + } finally { + $this->activePermissionIo--; + } + } + public function getPermissions(string $groupId): array + { + $this->permissionIo($groupId, 'read'); + return $this->rights[$groupId] ??= self::original(); + } + public function setPermissions(string $groupId, array $permissions): void + { + $this->permissionIo($groupId, 'write'); + if ($this->failGroup === $groupId) { + throw new \RuntimeException('Simulated isolated permission failure.'); + } + $this->rights[$groupId] = $permissions; + $this->mutations[] = [$groupId,$permissions]; + } + public function send(string $peer, array $payload, string $randomId): array + { + return $this->sendGuarded($peer, $payload, $randomId, static fn() => null); + } + public function sendGuarded(string $peer, array $payload, string $randomId, callable $guard): array + { + if ($this->beforeSend !== null) { + ($this->beforeSend)($peer); + } + $guard(); + $this->sent[] = [$peer,$payload,$randomId]; + return ['_' => 'updateShortSentMessage','id' => count($this->sent)]; + } + public function isGroupAdmin(string $userId, string $groupId): bool + { + return true; + } + public function sendPrivate(string $userId, string $text, array $markup = []): void + { + } + public function editPrivate(string $userId, int $messageId, string $text, array $markup = []): void + { + } + public function answerCallback(string $queryId, string $text): void + { + } + public function answerInline(string $queryId, array $results, array $options = []): void + { + } + public function botUsername(): string + { + return 'SyntheticLoadFixtureBot'; + } +} diff --git a/tests/RuntimeLoadSoakTest.php b/tests/RuntimeLoadSoakTest.php new file mode 100644 index 0000000..c6fd185 --- /dev/null +++ b/tests/RuntimeLoadSoakTest.php @@ -0,0 +1,196 @@ +setValue(null, false); + $this->db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-15T09:00:00+03:00')); + $this->gateway = new RuntimeLoadGateway(); + require_once __DIR__ . '/Fixtures/CalendarFixtureClient.php'; + $this->calendar = new CalendarService(new HebcalProvider(new CalendarFixtureClient()), $this->clock); + $this->heartbeat = sys_get_temp_dir() . '/shabbat-load-' . bin2hex(random_bytes(8)) . '.json'; + $this->previousHeartbeat = getenv('HEARTBEAT_PATH'); + putenv('HEARTBEAT_PATH=' . $this->heartbeat); + $this->app = new Application($this->gateway, $this->clock, $this->db, $this->calendar); + } + protected function tearDown(): void + { + $this->app->stop(); + if (is_file($this->heartbeat)) { + unlink($this->heartbeat); + } + putenv($this->previousHeartbeat === false ? 'HEARTBEAT_PATH' : 'HEARTBEAT_PATH=' . $this->previousHeartbeat); + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + } + private function seedGroups(int $count, array $changes = []): void + { + for ($i = 1; $i <= $count; $i++) { + $group = $this->app->settings->ensureGroup('-' . $i, 'Synthetic group ' . $i); + if ($changes !== []) { + $this->app->settings->updateGroup($group['group_id'], $group['revision'], $changes, '42'); + } + } + } + public function testFiveHundredGroupsUseFourConcurrentPermissionSlotsAndRestoreExactly(): void + { + $this->seedGroups(500); + $this->clock->set(new DateTimeImmutable('2026-10-16T17:45:00+03:00')); + $this->gateway->permissionDelay = 0.001; + $this->gateway->failGroup = '-125'; + $this->gateway->onPermissionIo = function (): void { + // Opening a local transaction here would throw if a scheduler transaction spanned I/O. + $this->db->transaction(static fn() => null); + }; + $this->app->permissionTick(); + self::assertSame(4, $this->gateway->maxPermissionIo); + self::assertSame(0, $this->gateway->activePermissionIo); + self::assertCount(499, $this->gateway->mutations); + self::assertSame('error', $this->app->locks->state('-125')['status']); + self::assertSame(499, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM group_lock_state WHERE status='locked'")->fetchColumn()); + self::assertSame([], $this->gateway->sent); + $this->clock->set(new DateTimeImmutable('2026-10-18T10:00:00+03:00')); + $this->gateway->failGroup = null; + $this->app->permissionTick(); + self::assertCount(998, $this->gateway->mutations); + foreach ($this->gateway->rights as $rights) { + self::assertSame(RuntimeLoadGateway::original(), $rights); + } + self::assertSame(4, $this->gateway->maxPermissionIo); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + } + public function testFiveHundredReminderGroupsArePlannedFairlyAndRepeatedCyclesDoNotDuplicate(): void + { + $this->seedGroups(500, ['zmanim_enabled' => true,'zmanim_time' => '09:00']); + $cycles = 0; + do { + $this->app->notificationTick(); + $heartbeat = json_decode(file_get_contents($this->heartbeat), true, 512, JSON_THROW_ON_ERROR); + self::assertLessThanOrEqual(20, $heartbeat['cycles']['notifications']['groups_planned']); + self::assertLessThanOrEqual(20, $heartbeat['cycles']['notifications']['jobs_processed']); + $cycles++; + } while (count($this->gateway->sent) < 500 && $cycles < 100); + self::assertCount(500, $this->gateway->sent); + self::assertCount(500, array_unique(array_column($this->gateway->sent, 0))); + self::assertCount(500, array_unique(array_column($this->gateway->sent, 2))); + for ($i = 0; $i < 30; $i++) { + $this->app->notificationTick(); + } + self::assertCount(500, $this->gateway->sent); + self::assertSame(500, (int)$this->db->pdo()->query("SELECT COUNT(*) FROM scheduled_jobs WHERE status='sent'")->fetchColumn()); + } + public function testBlockedNotificationPreflightDoesNotBlockPermissionCycle(): void + { + $this->seedGroups(2); + $g = $this->app->settings->getGroup('-1'); + $this->app->settings->updateGroup('-1', $g['revision'], ['zmanim_enabled' => true,'zmanim_time' => '09:00'], '42'); + $g = $this->app->settings->getGroup('-2'); + $this->app->settings->updateGroup('-2', $g['revision'], ['manual_override_mode' => 'lock','manual_override_until' => $this->clock->now()->getTimestamp() + 3600], '42'); + $entered = new DeferredFuture(); + $release = new DeferredFuture(); + $this->gateway->beforeSend = static function () use ($entered, $release): void { + if (!$entered->isComplete()) { + $entered->complete(); + } + $release->getFuture()->await(); + }; + $notification = \Amp\async($this->app->notificationTick(...)); + try { + $entered->getFuture()->await(new \Amp\TimeoutCancellation(10)); + $permission = \Amp\async($this->app->permissionTick(...)); + $permission->await(new \Amp\TimeoutCancellation(10)); + self::assertFalse($notification->isComplete()); + self::assertSame('locked', $this->app->locks->state('-2')['status']); + self::assertSame([], $this->gateway->sent); + } finally { + $release->complete(); + $notification->await(); + } + self::assertCount(1, $this->gateway->sent); + } + public function testPermissionBatchWaitDoesNotBlockIndependentNotificationCycle(): void + { + $this->seedGroups(2); + $g = $this->app->settings->getGroup('-1'); + $this->app->settings->updateGroup('-1', $g['revision'], ['zmanim_enabled' => true,'zmanim_time' => '09:00'], '42'); + $g = $this->app->settings->getGroup('-2'); + $this->app->settings->updateGroup('-2', $g['revision'], ['manual_override_mode' => 'lock','manual_override_until' => $this->clock->now()->getTimestamp() + 3600], '42'); + $entered = new DeferredFuture(); + $release = new DeferredFuture(); + $this->gateway->onPermissionIo = static function (string $group, string $kind) use ($entered, $release): void { + if ($group === '-2' && $kind === 'write') { + $entered->complete(); + $release->getFuture()->await(); + } + }; + $permission = \Amp\async($this->app->permissionTick(...)); + try { + $entered->getFuture()->await(new \Amp\TimeoutCancellation(10)); + $notification = \Amp\async($this->app->notificationTick(...)); + $notification->await(new \Amp\TimeoutCancellation(10)); + self::assertFalse($permission->isComplete()); + self::assertCount(1, $this->gateway->sent); + } finally { + $release->complete(); + $permission->await(); + } + self::assertSame('locked', $this->app->locks->state('-2')['status']); + } + public function testFortyFiveDayApplicationSoakNeverSendsRoutineMessagesDuringSyntheticHolyWindows(): void + { + $this->seedGroups(4, ['zmanim_enabled' => true,'zmanim_time' => '09:00']); + $jobs = new JobRepository($this->db, $this->clock); + $expected = 0; + $maxRetained = 0; + $date = new DateTimeImmutable('2026-05-10T09:00:00+03:00'); + for ($day = 0; $day < 45; $day++) { + $this->clock->set($date->modify('+' . $day . ' days')); + $state = $this->calendar->snapshotNow($this->app->settings->getGroup('-1')); + if (!$state['quiet_now'] && !$state['is_work_prohibited_now']) { + $expected += 4; + } + $this->app->tick(); + $this->app->tick(); + self::assertCount($expected, $this->gateway->sent); + $this->clock->set($this->clock->now()->setTimezone(new \DateTimeZone('Asia/Jerusalem'))->setTime(17, 45)); + $this->app->tick(); + $this->clock->set($this->clock->now()->setTimezone(new \DateTimeZone('Asia/Jerusalem'))->setTime(20, 0)); + $this->app->tick(); + self::assertCount($expected, $this->gateway->sent); + $jobs->maintain(30, 200); + $retained = (int)$this->db->pdo()->query('SELECT COUNT(*) FROM scheduled_jobs')->fetchColumn(); + $maxRetained = max($maxRetained, $retained); + self::assertLessThan(220, $retained); + } + self::assertGreaterThan(100, $expected); + self::assertLessThan(220, $maxRetained); + self::assertSame([], $this->db->integrity()['foreign_key_check']); + self::assertSame(['ok'], $this->db->integrity()['integrity_check']); + } +} diff --git a/tests/RuntimeMaintenancePathsTest.php b/tests/RuntimeMaintenancePathsTest.php new file mode 100644 index 0000000..ab98afe --- /dev/null +++ b/tests/RuntimeMaintenancePathsTest.php @@ -0,0 +1,100 @@ +fixture = sys_get_temp_dir() . '/shabbat-cli-' . bin2hex(random_bytes(8)); + mkdir($this->fixture, 0700); + foreach (['DATA_DIR', 'APP_DB_PATH', 'SESSION_PATH', 'HEARTBEAT_PATH'] as $name) { + $this->saved[$name] = getenv($name); + putenv($name); + } + putenv('DATA_DIR=' . $this->fixture . '/state'); + } + + protected function tearDown(): void + { + foreach ($this->saved as $name => $value) { + putenv($value === false ? $name : $name . '=' . $value); + } + $resolved = realpath($this->fixture); + $allowed = realpath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . 'shabbat-cli-'; + if ($resolved === false || !str_starts_with($resolved, $allowed)) { + throw new \RuntimeException('Test cleanup target is outside its isolated fixture.'); + } + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($resolved, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($resolved); + } + + private function command(string $script, array $arguments = [], int $expectedExit = 0): array + { + $process = proc_open([PHP_BINARY, dirname(__DIR__) . '/bin/' . $script, ...$arguments], [ + 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], + ], $pipes, dirname(__DIR__)); + if (!is_resource($process)) { + throw new \RuntimeException('Unable to launch isolated maintenance test.'); + } + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame($expectedExit, proc_close($process), (string) $errors); + foreach (explode("\n", (string) $output) as $line) { + if (str_starts_with(trim($line), '{')) { + return json_decode($line, true, flags: JSON_THROW_ON_ERROR); + } + } + throw new \RuntimeException('Maintenance command did not return its JSON report.'); + } + + public function testDataDirectoryOnlyDeploymentUsesOneDatabaseAcrossMaintenanceAndHealth(): void + { + $migration = $this->command('migrate.php', ['--writers-stopped']); + self::assertContains('005_known_broadcast_peers.sql', $migration['migrations']); + $database = $this->fixture . '/state/shabbat.sqlite'; + self::assertFileExists($database); + self::assertSame(['ok'], $migration['checks']['integrity_check']); + $this->command('backup.php', ['--directory=' . $this->fixture . '/backups', '--keep=2']); + $backups = glob($this->fixture . '/backups/*.sqlite'); + self::assertIsArray($backups); + self::assertCount(1, $backups); + $restored = $this->fixture . '/restored.sqlite'; + $this->command('restore.php', ['--backup=' . $backups[0], '--db=' . $restored, '--writers-stopped']); + self::assertFileExists($restored); + self::assertSame(['ok'], Database::open($restored)->integrity()['integrity_check']); + $this->command('import-legacy.php', ['--legacy=' . dirname(__DIR__) . '/tests/Fixtures/legacy', '--dry-run']); + self::assertSame(0, Database::open($database)->pdo()->query('SELECT count(*) FROM groups')->fetchColumn()); + $health = $this->command('health.php', expectedExit: 1); + self::assertFalse($health['healthy']); + file_put_contents($this->fixture . '/state/scheduler-heartbeat.json', json_encode(['timestamp' => time(), 'notification_timestamp' => time()], JSON_THROW_ON_ERROR)); + $health = $this->command('health.php'); + self::assertTrue($health['healthy']); + self::assertFalse($health['deep_check']); + self::assertTrue($health['checks']['connection_check']); + $deep = $this->command('health.php', ['--deep']); + self::assertTrue($deep['healthy']); + self::assertSame(['ok'], $deep['checks']['integrity_check']); + self::assertSame([], $deep['checks']['foreign_key_check']); + file_put_contents($this->fixture . '/state/scheduler-heartbeat.json', json_encode(['timestamp' => time() - 181, 'notification_timestamp' => time()], JSON_THROW_ON_ERROR)); + self::assertFalse($this->command('health.php', expectedExit: 1)['healthy']); + } +} diff --git a/tests/RuntimePathsTest.php b/tests/RuntimePathsTest.php new file mode 100644 index 0000000..d280718 --- /dev/null +++ b/tests/RuntimePathsTest.php @@ -0,0 +1,79 @@ +root = str_replace('\\', '/', sys_get_temp_dir()) . '/shabbat-paths-fixture'; + foreach (['DATA_DIR', 'APP_DB_PATH', 'SESSION_PATH', 'HEARTBEAT_PATH'] as $name) { + $this->saved[$name] = getenv($name); + putenv($name); + } + } + + protected function tearDown(): void + { + foreach ($this->saved as $name => $value) { + putenv($value === false ? $name : $name . '=' . $value); + } + } + + public function testDataDirectoryDefinesDatabaseHeartbeatAndWriterLock(): void + { + putenv('DATA_DIR=custom-storage'); + $paths = RuntimePaths::resolve($this->root, []); + self::assertSame($this->root . '/custom-storage', $paths->dataDirectory); + self::assertSame($paths->dataDirectory . '/shabbat.sqlite', $paths->databasePath); + self::assertSame($paths->dataDirectory . '/scheduler-heartbeat.json', $paths->heartbeatPath); + self::assertSame($paths->databasePath . '.writer.lock', $paths->writerLockPath()); + self::assertSame($this->root . '/bot.shabbat', $paths->sessionPath); + } + + public function testEnvironmentOverridesConfigurationAndExplicitPathsStayIndependent(): void + { + putenv('DATA_DIR=D:\\Telegram storage'); + putenv('APP_DB_PATH=/isolated/custom.sqlite'); + $paths = RuntimePaths::resolve($this->root, [ + 'DATA_DIR' => 'ignored-data', + 'APP_DB_PATH' => 'ignored.sqlite', + 'SESSION_PATH' => 'runtime/custom.session', + 'HEARTBEAT_PATH' => 'health.json', + ]); + self::assertSame('D:/Telegram storage', $paths->dataDirectory); + self::assertSame('/isolated/custom.sqlite', $paths->databasePath); + self::assertSame($this->root . '/runtime/custom.session', $paths->sessionPath); + self::assertSame($this->root . '/health.json', $paths->heartbeatPath); + } + + public function testExportSharesTheExactResolvedPathsWithAllServices(): void + { + $paths = RuntimePaths::resolve($this->root, ['DATA_DIR' => 'state']); + $paths->export(); + self::assertSame($paths->dataDirectory, getenv('DATA_DIR')); + self::assertSame($paths->databasePath, getenv('APP_DB_PATH')); + self::assertSame($paths->sessionPath, getenv('SESSION_PATH')); + self::assertSame($paths->heartbeatPath, getenv('HEARTBEAT_PATH')); + self::assertEquals($paths, RuntimePaths::resolve($this->root, [])); + } + + public function testEmptyConfigurationPreservesLegacySessionAndRejectsInvalidPath(): void + { + $paths = RuntimePaths::resolve($this->root, ['DATA_DIR' => '', 'SESSION_PATH' => '']); + self::assertSame($this->root . '/data/shabbat.sqlite', $paths->databasePath); + self::assertSame($this->root . '/bot.shabbat', $paths->sessionPath); + $this->expectException(\InvalidArgumentException::class); + RuntimePaths::resolve($this->root, ['DATA_DIR' => "bad\0path"]); + } +} diff --git a/tests/RuntimePermissionsTest.php b/tests/RuntimePermissionsTest.php new file mode 100644 index 0000000..f582756 --- /dev/null +++ b/tests/RuntimePermissionsTest.php @@ -0,0 +1,205 @@ +fixture = sys_get_temp_dir() . '/shabbat-permissions-test-' . bin2hex(random_bytes(8)); + foreach (['', '/data', '/runtime'] as $directory) { + mkdir($this->fixture . $directory, 0700); + } + } + + protected function tearDown(): void + { + chmod($this->fixture, 0700); + $resolved = realpath($this->fixture); + $temporary = realpath(sys_get_temp_dir()); + if ( + $resolved === false + || $temporary === false + || !str_starts_with($resolved, $temporary . DIRECTORY_SEPARATOR . 'shabbat-permissions-test-') + ) { + throw new RuntimeException('Permission smoke fixture cleanup target escaped its fixture.'); + } + foreach ( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($resolved, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ) as $file + ) { + $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($resolved); + } + + private function runProbe(array $arguments = [], array $overrides = []): array + { + $environment = getenv(); + if (!is_array($environment)) { + throw new RuntimeException('The subprocess environment is unavailable.'); + } + $environment = array_replace($environment, [ + 'DATA_DIR' => $this->fixture . '/data', + 'APP_DB_PATH' => $this->fixture . '/data/never-created.sqlite', + 'SESSION_PATH' => $this->fixture . '/runtime/never-created.session', + 'HEARTBEAT_PATH' => $this->fixture . '/data/never-created-heartbeat.json', + 'API_HASH' => 'DO_NOT_PRINT_SECRET', + 'BOT_TOKEN' => 'DO_NOT_PRINT_SECRET', + ], $overrides); + $process = proc_open( + [PHP_BINARY, dirname(__DIR__) . '/bin/runtime-permissions.php', ...$arguments], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $this->fixture, + $environment, + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + throw new RuntimeException('Unable to start the offline permission smoke subprocess.'); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $result = ['exit' => proc_close($process), 'stdout' => $stdout, 'stderr' => $stderr]; + self::assertStringNotContainsString('DO_NOT_PRINT_SECRET', $stdout . $stderr); + self::assertStringNotContainsString($this->fixture, $stdout . $stderr); + self::assertFileDoesNotExist($this->fixture . '/data/never-created.sqlite'); + self::assertFileDoesNotExist($this->fixture . '/runtime/never-created.session'); + self::assertFileDoesNotExist($this->fixture . '/data/never-created-heartbeat.json'); + foreach ([$this->fixture, $this->fixture . '/data', $this->fixture . '/runtime'] as $directory) { + self::assertSame([], glob($directory . '/.shabbat-permissions-*')); + } + return $result; + } + + public function testActualWorkerLoggerAndPrimeCopiesUseTheProcessWorkingDirectory(): void + { + file_put_contents($this->fixture . '/.prime.py', 'operator prime sentinel'); + file_put_contents($this->fixture . '/.alt_prime.py', 'operator alternate sentinel'); + $result = $this->runProbe(); + self::assertSame(0, $result['exit'], $result['stderr']); + self::assertSame('', $result['stderr']); + self::assertStringContainsString('Offline runtime permissions verified:', $result['stdout']); + self::assertFileDoesNotExist($this->fixture . '/MadelineProto.log'); + self::assertSame('operator prime sentinel', file_get_contents($this->fixture . '/.prime.py')); + self::assertSame('operator alternate sentinel', file_get_contents($this->fixture . '/.alt_prime.py')); + self::assertEqualsCanonicalizing(['.', '..', 'data', 'runtime', '.prime.py', '.alt_prime.py'], scandir($this->fixture)); + } + + public function testExistingLargeOperatorLogIsAppendedAndNeverTruncatedOrDeleted(): void + { + $before = str_repeat('operator log retained; ', 60000); + $log = $this->fixture . '/MadelineProto.log'; + file_put_contents($log, $before); + $result = $this->runProbe(); + self::assertSame(0, $result['exit'], $result['stderr']); + $after = file_get_contents($log); + self::assertStringStartsWith($before, $after); + self::assertStringContainsString('MadelineProto ', substr($after, strlen($before))); + self::assertStringContainsString('Shabbat offline runtime permission probe.', substr($after, strlen($before))); + $again = $this->runProbe(); + self::assertSame(0, $again['exit'], $again['stderr']); + self::assertStringStartsWith($after, file_get_contents($log)); + } + + public function testUnknownArgumentsFailBeforeTouchingRuntimeFiles(): void + { + $result = $this->runProbe(['--unsupported']); + self::assertSame(1, $result['exit']); + self::assertSame('', $result['stdout']); + self::assertSame("Offline runtime permission check failed: arguments.\n", $result['stderr']); + self::assertFileDoesNotExist($this->fixture . '/MadelineProto.log'); + } + + public function testMissingDataDirectoryFailsAndIsNotCreated(): void + { + $result = $this->runProbe([], ['DATA_DIR' => $this->fixture . '/missing-data']); + self::assertSame(1, $result['exit']); + self::assertStringContainsString('configured data directory', $result['stderr']); + self::assertDirectoryDoesNotExist($this->fixture . '/missing-data'); + self::assertFileDoesNotExist($this->fixture . '/MadelineProto.log'); + } + + public function testDataPathPointingToAFileIsRejectedWithoutChangingIt(): void + { + file_put_contents($this->fixture . '/operator.txt', 'operator data sentinel'); + $result = $this->runProbe([], ['DATA_DIR' => $this->fixture . '/operator.txt']); + self::assertSame(1, $result['exit']); + self::assertStringContainsString('configured data directory', $result['stderr']); + self::assertSame('operator data sentinel', file_get_contents($this->fixture . '/operator.txt')); + } + + public function testMissingSessionParentIsRejectedAndNotCreated(): void + { + $result = $this->runProbe([], ['SESSION_PATH' => $this->fixture . '/missing-runtime/session']); + self::assertSame(1, $result['exit']); + self::assertStringContainsString('configured session parent', $result['stderr']); + self::assertDirectoryDoesNotExist($this->fixture . '/missing-runtime'); + self::assertFileDoesNotExist($this->fixture . '/MadelineProto.log'); + } + + public function testDirectoryAtTheDefaultLogPathIsRejected(): void + { + mkdir($this->fixture . '/MadelineProto.log', 0700); + $result = $this->runProbe(); + self::assertSame(1, $result['exit']); + self::assertStringContainsString('default MadelineProto worker logger', $result['stderr']); + self::assertDirectoryExists($this->fixture . '/MadelineProto.log'); + } + + public function testRequiredNonRootIdentityFailsClosedWhenNotVerifiableOrRoot(): void + { + $result = $this->runProbe(['--require-non-root']); + if (function_exists('posix_geteuid') && posix_geteuid() !== 0) { + self::assertSame(0, $result['exit'], $result['stderr']); + } else { + self::assertSame(1, $result['exit']); + self::assertStringContainsString('non-root identity', $result['stderr']); + self::assertFileDoesNotExist($this->fixture . '/MadelineProto.log'); + } + } + + public function testReadOnlyRuntimePathsFailForANonRootPosixUser(): void + { + if (PHP_OS_FAMILY === 'Windows' || !function_exists('posix_geteuid') || posix_geteuid() === 0) { + self::markTestSkipped('POSIX non-root identity is required to enforce directory mode bits.'); + } + $log = $this->fixture . '/MadelineProto.log'; + file_put_contents($log, 'operator log retained'); + foreach ( + [ + [$this->fixture, 0555, 0700, 'working directory and PrimeModule copies'], + [$this->fixture . '/data', 0555, 0700, 'configured data directory'], + [$this->fixture . '/runtime', 0555, 0700, 'configured session parent'], + [$log, 0444, 0600, 'default MadelineProto worker logger'], + ] as [$path, $readOnlyMode, $restoreMode, $stage] + ) { + chmod($path, $readOnlyMode); + try { + $result = $this->runProbe(); + self::assertSame(1, $result['exit']); + self::assertStringContainsString($stage, $result['stderr']); + self::assertSame('operator log retained', file_get_contents($log)); + } finally { + chmod($path, $restoreMode); + } + } + } +} diff --git a/tests/RuntimeStabilityTest.php b/tests/RuntimeStabilityTest.php new file mode 100644 index 0000000..89d9677 --- /dev/null +++ b/tests/RuntimeStabilityTest.php @@ -0,0 +1,464 @@ + false, 'restartPending' => false, 'quiesce' => null, 'handler' => null] as $name => $value) { + (new \ReflectionProperty(Runner::class, $name))->setValue(null, $value); + } + } + protected function tearDown(): void + { + $this->setUp(); + } + private function app(?callable $authorize = null): array + { + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T09:00:00Z')); + $db = Database::open(':memory:'); + $db->migrate(); + $http = new class implements \Shabbat\Http\JsonClient { + public int $calls = 0; + public function get(string $url, array $query = [], ?\Amp\Cancellation $cancellation = null): array + { + $this->calls++; + throw new \Shabbat\Http\ProviderException('provider offline'); + } + }; + $gateway = new RuntimeFakeGateway(); + $app = new Application($gateway, $clock, $db, new CalendarService(new HebcalProvider($http), $clock), authorizeOperator: $authorize ?? static fn(string $id): bool => $id === '42'); + return [$app, $gateway, $http, $clock]; + } + private function rpc(string $name): \danog\MadelineProto\RPCErrorException + { + $error = (new \ReflectionClass(\danog\MadelineProto\RPCErrorException::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty($error, 'rpc'))->setValue($error, $name); + return $error; + } + public function testManualCloseAndOpenWorkWhenCalendarProviderIsOffline(): void + { + [$app, $gateway, $http] = $this->app(); + $app->settings->ensureGroup('-1', 'Basic group'); + $original = $gateway->getPermissions('-1'); + self::assertSame(1, $app->manualGroups->apply(true, '42')['changed']); + self::assertTrue($gateway->rights['-1']['send_reactions']); + self::assertTrue($gateway->rights['-1']['edit_rank']); + self::assertSame(1, $app->manualGroups->apply(false, '42')['changed']); + self::assertSame($original, $gateway->rights['-1']); + self::assertSame(0, $http->calls); + } + public function testManyGroupsCrossPagesFailureIsolationAndExactRestoration(): void + { + [$app, $gateway, $http] = $this->app(); + for ($i = 1; $i <= 250; $i++) { + $app->settings->ensureGroup('-' . $i, 'Group ' . $i); + } + $gateway->fail = '-125'; + $closed = $app->manualGroups->apply(true, '42'); + self::assertCount(250, $closed['groups']); + self::assertSame(249, $closed['changed']); + self::assertSame(1, $closed['failed']); + $gateway->fail = null; + $opened = $app->manualGroups->apply(false, '42'); + self::assertSame(249, $opened['changed']); + self::assertSame(0, $opened['failed']); + self::assertSame(498, $gateway->mutations); + foreach ($gateway->rights as $rights) { + self::assertFalse($rights['send_messages']); + self::assertFalse($rights['send_media']); + self::assertArrayNotHasKey('send_reactions', $rights); + } + self::assertSame(0, $http->calls); + } + public function testShutdownDuringReadStopsBatchWithoutMutationAndRetainsRecoveryRecord(): void + { + [$app, $gateway] = $this->app(); + $app->settings->ensureGroup('-1'); + $app->settings->ensureGroup('-2'); + $gateway->beforeGetPermissions = Runner::requestShutdown(...); + try { + $app->manualGroups->apply(true, '42'); + self::fail('Shutdown continued manual work.'); + } catch (\Amp\CancelledException) { + self::assertSame(0, $gateway->mutations); + self::assertNotNull($app->locks->state('-1')['snapshot_json']); + self::assertNull($app->locks->state('-2')); + } + } + public function testExpiredOverrideUsesCalendarInsteadOfKeepingManualLock(): void + { + [$app, $gateway, $http, $clock] = $this->app(); + $app->settings->ensureGroup('-1'); + $gateway->beforeGetPermissions = static function () use ($clock): void { + $clock->set($clock->now()->modify('+2 seconds')); + }; + $result = $app->manualGroups->apply(true, '42', 1); + self::assertSame(1, $result['failed']); + self::assertSame(0, $gateway->mutations); + self::assertGreaterThan(0, $http->calls); + } + public function testKnownRpcFailureIsActionableAndArbitraryTextIsNotLeaked(): void + { + [$app, $gateway] = $this->app(); + $app->settings->ensureGroup('-1', 'Group'); + $gateway->beforeSetPermissions = function (): void { + throw $this->rpc('CHAT_ADMIN_REQUIRED'); + }; + $result = $app->manualGroups->apply(true, '42'); + self::assertSame('CHAT_ADMIN_REQUIRED', $result['groups'][0]['error_code']); + foreach (\Shabbat\UI\Lang::CODES as $language) { + $text = ManualGroupReport::format($result, $language, 'Asia/Jerusalem'); + self::assertStringContainsString('CHAT_ADMIN_REQUIRED', $text); + self::assertStringContainsString('<b>Group</b>', $text); + self::assertStringContainsString((new \Shabbat\UI\Lang())->text($language, 'permission_error_admin'), $text); + } + self::assertSame('OPERATION_UNCONFIRMED', PermissionFailure::code(new \RuntimeException('secret-token'))); + $result['groups'][0]['error'] = 'secret-token'; + $result['groups'][0]['error_code'] = 'OPERATION_UNCONFIRMED'; + self::assertStringNotContainsString('secret-token', ManualGroupReport::format($result, 'en', 'UTC')); + } + public function testGroupSettingsRegistersOnlyFreshlyAuthorizedGroupsAndDoesNotReactivate(): void + { + [$app] = $this->app(); + $info = static fn(): array => ['type' => 'chat', 'Chat' => ['title' => 'A basic group']]; + self::assertFalse(GroupRegistration::refresh($app, '7', '-1', $info)); + self::assertNull($app->settings->getGroup('-1')); + self::assertTrue(GroupRegistration::refresh($app, '42', '-1', $info)); + self::assertSame('A basic group', $app->settings->getGroup('-1')['title']); + $app->settings->deactivateGroup('-1', '42'); + self::assertTrue(GroupRegistration::refresh($app, '42', '-1', $info)); + self::assertSame(0, $app->settings->getGroup('-1')['active']); + self::assertFalse(GroupRegistration::refresh($app, '42', '-2', static fn(): array => ['type' => 'channel', 'Chat' => []])); + self::assertNull($app->settings->getGroup('-2')); + } + public function testNativeNoChangeResponseIsAllowedButOtherRpcErrorsPropagate(): void + { + $transport = new GatewayFixtureTransport(); + $gateway = new MadelineGateway($transport); + $transport->error = $this->rpc('CHAT_NOT_MODIFIED'); + $gateway->setPermissions('-123', ['_' => 'chatBannedRights', 'until_date' => 0]); + self::assertCount(1, $transport->calls); + $transport->error = $this->rpc('CHAT_ADMIN_REQUIRED'); + $this->expectException(\danog\MadelineProto\RPCErrorException::class); + $gateway->setPermissions('-123', ['_' => 'chatBannedRights', 'until_date' => 0]); + } + public function testUnsupportedGlobalViewRestrictionNeverReachesTelegram(): void + { + $transport = new GatewayFixtureTransport(); + try { + (new MadelineGateway($transport))->setPermissions('-123', ['_' => 'chatBannedRights', 'view_messages' => true]); + self::fail('Forbidden global flag was accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame([], $transport->calls); + } + $this->expectException(\InvalidArgumentException::class); + Permissions::validateBlockFlags(['view_messages']); + } + public function testOneSharedIpcInitializationUnderConcurrentWorkAndRetryAfterFailure(): void + { + $owner = new \stdClass(); + $calls = 0; + $factory = static function () use (&$calls): object { + $calls++; + \Amp\delay(0.001); + return new \stdClass(); + }; + $futures = []; + for ($i = 0; $i < 100; $i++) { + $futures[] = \Amp\async(static fn(): object => RuntimeClientPool::get($owner, $factory)); + } + $results = \Amp\Future\await($futures); + self::assertSame(1, $calls); + foreach ($results as $result) { + self::assertSame($results[0], $result); + } + RuntimeClientPool::release($owner); + self::assertNotSame($results[0], RuntimeClientPool::get($owner, $factory)); + self::assertSame(2, $calls); + RuntimeClientPool::release($owner); + try { + RuntimeClientPool::get($owner, static fn() => throw new \RuntimeException('fixture')); + } catch (\RuntimeException) { + } + self::assertInstanceOf(\stdClass::class, RuntimeClientPool::get($owner, $factory)); + self::assertSame(3, $calls); + RuntimeClientPool::release($owner); + } + public function testInvalidClientFactoryReturnIsRejectedAndDoesNotPoisonSharedCache(): void + { + $owner = new \stdClass(); + try { + RuntimeClientPool::get($owner, static fn(): int => 42); + self::fail('Invalid runtime client factory result was accepted.'); + } catch (\UnexpectedValueException $error) { + self::assertSame('Runtime client factory must return an object.', $error->getMessage()); + } + $client = new \stdClass(); + self::assertSame($client, RuntimeClientPool::get($owner, static fn(): object => $client)); + RuntimeClientPool::release($owner); + } + public function testRestartQuiescesBeforeSdkExitAndRepeatedRequestDoesNothing(): void + { + $events = []; + (new \ReflectionProperty(Runner::class, 'quiesce'))->setValue(null, static function () use (&$events): void { + self::assertTrue(Runner::isStopping()); + $events[] = 'quiesce'; + }); + $ack = static function () use (&$events): void { + self::assertFalse(Runner::isStopping()); + $events[] = 'ack'; + }; + $restart = static function () use (&$events): void { + self::assertTrue(Runner::isStopping()); + $events[] = 'sdk-restart'; + }; + self::assertTrue(Runner::restart($ack, $restart)); + self::assertFalse(Runner::restart($ack, $restart)); + self::assertSame(['ack','quiesce','sdk-restart'], $events); + } + public function testConcurrentRestartCannotIssueSecondSdkRestartWhileAcknowledgementIsPending(): void + { + $gate = new \Amp\DeferredFuture(); + $entered = new \Amp\DeferredFuture(); + $restarts = 0; + $first = \Amp\async(static function () use ($gate, $entered, &$restarts): bool { + return Runner::restart( + static function () use ($gate, $entered): void { + $entered->complete(); + $gate->getFuture()->await(); + }, + static function () use (&$restarts): void { + $restarts++; + } + ); + }); + $entered->getFuture()->await(); + self::assertFalse(Runner::restart(static fn() => null, static fn() => throw new \LogicException('duplicate'))); + $gate->complete(); + self::assertTrue($first->await()); + self::assertSame(1, $restarts); + } + public function testRestartAcknowledgementTimeoutDoesNotBlockButIntentionalCancellationPropagates(): void + { + self::assertTrue(Runner::restart( + static fn() => throw new \Amp\CancelledException(new \Amp\TimeoutException()), + static fn() => null + )); + self::assertTrue(Runner::isStopping()); + $this->setUp(); + try { + Runner::restart(static fn() => throw new \Amp\CancelledException(), static fn() => self::fail('Restarted after intentional cancellation')); + self::fail('Cancellation was swallowed.'); + } catch (\Amp\CancelledException) { + self::assertFalse(Runner::isStopping()); + } + } + public function testPinnedSdkRestartCompletesItsMainLoopWithoutReenteringOnStart(): void + { + $logger = new class { + public array $lines = []; + public function logger(string $line, int $level = 0): void + { + $this->lines[] = $line; + } + }; + $sdk = new class ($logger) { + use \danog\MadelineProto\Wrappers\Loop; + + public function __construct(public object $logger) + { + } + public function getAuthorization(): bool + { + return true; + } + public function hasEventHandler(): bool + { + return true; + } + public function initSelfRestart(): void + { + } + }; + $loop = \Amp\async($sdk->loop(...)); + \Amp\delay(0.001); + self::assertFalse($loop->isComplete()); + self::assertTrue(Runner::restart(static fn() => null, $sdk->restart(...))); + $loop->await(); + self::assertContains('Exiting update loop!', $logger->lines); + self::assertSame(1, count(array_filter($logger->lines, static fn(string $line): bool => $line === 'Started update loop'))); + self::assertTrue(Runner::isStopping()); + } + public function testScheduledManualQuotaRetryCanCloseWithoutCalendarAndExpiryDoesNotGuess(): void + { + [$app, $gateway, $http, $clock] = $this->app(); + $app->settings->ensureGroup('-1'); + $gateway->beforeSetPermissions = static function (): void { + throw new \Shabbat\Telegram\RetryableTelegramException('fixture quota', 10); + }; + $result = $app->manualGroups->apply(true, '42', 60); + self::assertSame('retry_wait', $result['groups'][0]['status']); + self::assertSame(0, $gateway->mutations); + $snapshot = $app->locks->state('-1')['snapshot_json']; + $gateway->beforeSetPermissions = null; + $heartbeat = tempnam(sys_get_temp_dir(), 'shabbat-retry-heartbeat-'); + $previous = getenv('HEARTBEAT_PATH'); + putenv('HEARTBEAT_PATH=' . $heartbeat); + try { + $clock->set($clock->now()->modify('+11 seconds')); + $app->tick(); + self::assertSame('locked', $app->locks->state('-1')['status']); + self::assertSame(1, $gateway->mutations); + self::assertSame($snapshot, $app->locks->state('-1')['snapshot_json']); + self::assertSame(0, $http->calls); + self::assertSame([], $gateway->sent); + $clock->set($clock->now()->modify('+61 seconds')); + $app->tick(); + self::assertGreaterThan(0, $http->calls); + self::assertSame(1, $gateway->mutations); + self::assertSame('locked', $app->locks->state('-1')['status']); + self::assertSame($snapshot, $app->locks->state('-1')['snapshot_json']); + self::assertSame([], $gateway->sent); + } finally { + unlink($heartbeat); + putenv($previous === false ? 'HEARTBEAT_PATH' : 'HEARTBEAT_PATH=' . $previous); + } + } + public function testShutdownAtCooperativeYieldStopsBeforeNextGroupWrite(): void + { + [$app, $gateway] = $this->app(); + for ($i = 1; $i <= 40; $i++) { + $app->settings->ensureGroup('-' . $i); + } + \Revolt\EventLoop::defer(Runner::requestShutdown(...)); + try { + $app->manualGroups->apply(true, '42'); + self::fail('Queued shutdown was ignored after the yield.'); + } catch (\Amp\CancelledException) { + self::assertSame(20, $gateway->mutations); + self::assertSame(20, (int)$app->db->pdo()->query("SELECT COUNT(*) FROM group_settings WHERE manual_override_mode='lock'")->fetchColumn()); + } + } + public function testFinalizationKeepsWriterLockUntilSuccessAndReleasesItAfterFailure(): void + { + foreach ([false,true] as $fail) { + $path = tempnam(sys_get_temp_dir(), 'shabbat-finalize-'); + $lock = \Shabbat\Runtime\SingleInstance::acquire($path); + $flushed = false; + try { + try { + Runner::finalizeSession($lock, static function () use ($path, $fail, &$flushed): void { + try { + \Shabbat\Runtime\SingleInstance::acquire($path); + self::fail('Writer lock released before final session flush.'); + } catch (\RuntimeException) { + self::assertTrue(true); + } + if ($fail) { + throw new \RuntimeException('fixture finalization failure'); + } + $flushed = true; + }); + self::assertFalse($fail); + } catch (\RuntimeException $error) { + self::assertTrue($fail); + self::assertSame('fixture finalization failure', $error->getMessage()); + } + self::assertSame(!$fail, $flushed); + $next = \Shabbat\Runtime\SingleInstance::acquire($path); + $next->release(); + } finally { + $lock->release(); + unlink($path); + } + } + } + public function testNativeSdkFinalizerCanBeCalledAgainAfterPendingDestructorCompletes(): void + { + $property = new \ReflectionProperty(\danog\MadelineProto\API::class, 'destructors'); + $previous = $property->getValue(); + $calls = 0; + try { + $property->setValue(null, [314159 => \Amp\async(static function () use ($property, &$calls): void { + $calls++; + $property->setValue(null, []); + })]); + \danog\MadelineProto\API::finalize(); + \danog\MadelineProto\API::finalize(); + self::assertSame(1, $calls); + self::assertSame([], $property->getValue()); + } finally { + $property->setValue(null, $previous); + } + } + public function testStartupNoticeHasOneSharedDeadlineAndIntentionalCancellationIsPreserved(): void + { + self::assertFalse(Runner::notifyStartup(static function (\Amp\Cancellation $cancellation): void { + \Amp\delay(1, cancellation: $cancellation); + }, 0.001)); + self::assertFalse(Runner::isStopping()); + self::assertTrue(Runner::notifyStartup(static function (\Amp\Cancellation $cancellation): void { + $cancellation->throwIfRequested(); + })); + $this->expectException(\Amp\CancelledException::class); + Runner::notifyStartup(static fn() => throw new \Amp\CancelledException()); + } + public function testCleanupFailureStillCompletesSdkRestartAndPropagatesFailure(): void + { + $stopped = false; + (new \ReflectionProperty(Runner::class, 'quiesce'))->setValue(null, static fn() => throw new \RuntimeException('fixture cleanup failure')); + try { + Runner::restart(static fn() => null, static function () use (&$stopped): void { + $stopped = true; + }); + self::fail('Cleanup failure was reported as a clean restart.'); + } catch (\RuntimeException $error) { + self::assertSame('fixture cleanup failure', $error->getMessage()); + self::assertTrue($stopped); + self::assertTrue(Runner::isStopping()); + } + } + public function testAllNativeRestrictionBitsRoundTripThroughLockedSerializerIncludingReactionAndRank(): void + { + $previousLogger = \danog\MadelineProto\Logger::$default; + $adapter = (new \ReflectionClass(\danog\MadelineProto\MTProto::class))->newInstanceWithoutConstructor(); + $adapter->settings = new \danog\MadelineProto\Settings(); + $adapter->logger = new \danog\MadelineProto\Logger((new \danog\MadelineProto\Settings\Logger())->setType(\danog\MadelineProto\Logger::CALLABLE_LOGGER)->setExtra(static function (): void { + })); + try { + $tl = new \danog\MadelineProto\TL\TL($adapter); + $tl->init(new \danog\MadelineProto\Settings\TLSchema()); + $rights = Permissions::locked(['_' => 'chatBannedRights', 'until_date' => 0]); + $wire = $tl->serializeObject(['type' => 'ChatBannedRights'], $rights, 'rights-test'); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $wire); + rewind($stream); + $actual = $tl->deserialize($stream, ['type' => 'ChatBannedRights', 'connection' => null, 'encrypted' => false]); + fclose($stream); + self::assertTrue(Permissions::equal($rights, $actual)); + self::assertTrue($actual['edit_rank']); + self::assertTrue($actual['send_reactions']); + self::assertFalse($actual['view_messages']); + } finally { + $tl = null; + $adapter = null; + \danog\MadelineProto\Logger::$default = $previousLogger; + } + } +} diff --git a/tests/SchedulerFakeGateway.php b/tests/SchedulerFakeGateway.php new file mode 100644 index 0000000..448a210 --- /dev/null +++ b/tests/SchedulerFakeGateway.php @@ -0,0 +1,39 @@ +permissions[$groupId] ?? ['_' => 'chatBannedRights','send_polls' => true]; + } + public function setPermissions(string $groupId, array $permissions): void + { + if ($this->onSet !== null) { + ($this->onSet)($groupId, $permissions); + } + $this->changes[] = [$groupId,$permissions]; + $this->permissions[$groupId] = $permissions; + } + public function send(string $peer, array $payload, string $randomId): array + { + $this->sends[] = [$peer,$payload,$randomId]; + if ($this->onSend !== null) { + return ($this->onSend)($peer, $payload, $randomId); + } + return ['_' => 'updates','message_id' => count($this->sends)]; + } +} diff --git a/tests/SchedulerTest.php b/tests/SchedulerTest.php new file mode 100644 index 0000000..f9e809b --- /dev/null +++ b/tests/SchedulerTest.php @@ -0,0 +1,269 @@ +db = Database::open(':memory:'); + $this->db->migrate(); + $this->clock = new FrozenClock(new DateTimeImmutable('2026-10-13T09:00:00Z')); + $this->settings = new SettingsRepository($this->db, $this->clock); + $this->gateway = new SchedulerFakeGateway(); + $this->settings->ensureGroup('-1'); + $this->settings->ensureGroup('-2'); + } + public function testDurableSnapshotExistsBeforeFirstLockAndRestoreKeepsOriginalRestrictions(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $original = $this->gateway->getPermissions('-1'); + $this->gateway->onSet = function (string $id, array $rights): void { + $state = $this->db->pdo()->query("SELECT * FROM group_lock_state WHERE group_id='-1'")->fetch(); + self::assertNotNull($state['snapshot_json']); + self::assertNotNull($state['snapshot_sha256']); + }; + self::assertSame(['status' => 'locked','changed' => true], $locks->reconcile('-1', 'event-current', true)); + $this->clock->set(new DateTimeImmutable('2026-10-20T09:00:00Z')); + self::assertSame(['status' => 'restored','changed' => true], $locks->reconcile('-1', 'event-old', false)); + self::assertSame($original, $this->gateway->getPermissions('-1')); + } + public function testNewEventDoesNotOpenOldLockInMiddleOfHolidaySequence(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'shabbat', true); + $snapshot = $locks->state('-1')['snapshot_json']; + $this->clock->set(new DateTimeImmutable('2026-10-14T09:00:00Z')); + self::assertFalse($locks->reconcile('-1', 'yom-tov', true)['changed']); + self::assertSame($snapshot, $locks->state('-1')['snapshot_json']); + self::assertCount(1, $this->gateway->changes); + self::assertSame('yom-tov', $locks->state('-1')['event_key']); + } + public function testSameCoordinatorCannotOverlapPermissionChanges(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onSet = function () use ($locks): void { + self::assertSame('busy', $locks->reconcile('-1', 'event', true)['status']); + }; + self::assertSame('locked', $locks->reconcile('-1', 'event', true)['status']); + self::assertCount(1, $this->gateway->changes); + } + public function testPrelockedGroupIsNeverOpenedByBot(): void + { + $this->gateway->permissions['-1'] = ['_' => 'chatBannedRights','send_messages' => true]; + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + self::assertSame('prelocked', $locks->reconcile('-1', 'event', true)['status']); + self::assertSame('prelocked', $locks->reconcile('-1', 'old-event', false)['status']); + self::assertCount(0, $this->gateway->changes); + } + public function testManualPermissionConflictRemainsVisibleAndIsNotOverwritten(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $this->gateway->permissions['-1']['send_photos'] = false; + self::assertSame('conflict', $locks->reconcile('-1', 'event', false)['status']); + self::assertCount(1, $this->gateway->changes); + self::assertNotNull($locks->state('-1')['snapshot_json']); + } + public function testCorruptSnapshotAndDatabaseWriteFailureNeverGuessPermissions(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $this->db->pdo()->exec("UPDATE group_lock_state SET snapshot_json='{}' WHERE group_id='-1'"); + self::assertSame('error', $locks->reconcile('-1', 'event', false)['status']); + self::assertCount(1, $this->gateway->changes); + $this->db->pdo()->exec("CREATE TRIGGER reject_snapshot BEFORE UPDATE OF snapshot_json ON group_lock_state WHEN NEW.group_id='-2' BEGIN SELECT RAISE(FAIL,'simulated disk failure'); END"); + self::assertSame('error', $locks->reconcile('-2', 'event', true)['status']); + self::assertCount(1, $this->gateway->changes); + } + public function testPartialGroupFailureDoesNotBlockOtherGroupAndCrashAfterRpcIsRecoverable(): void + { + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $this->gateway->onSet = function (string $id, array $rights): void { + if ($id === '-1') { + $this->gateway->permissions[$id] = $rights; + throw new RuntimeException('lost Telegram response'); + } + }; + self::assertSame('error', $locks->reconcile('-1', 'event', true)['status']); + self::assertSame('locked', $locks->reconcile('-2', 'event', true)['status']); + $this->gateway->onSet = null; + self::assertSame(['status' => 'locked','changed' => false], $locks->reconcile('-1', 'event', true)); + self::assertSame('restored', $locks->reconcile('-1', 'event', false)['status']); + } + public function testLateDueDeliveryAndExpiredDeliveryHaveDifferentOutcomes(): void + { + $settings = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $expired = $jobs->enqueue('-1', 'daf', 'old-date', $now - 3600, $now - 1, $settings['revision'], ['text' => 'Expired']); + $due = $jobs->enqueue('-1', 'daf', 'today', $now - 600, $now + 60, $settings['revision'], ['text' => 'Late but valid']); + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true); + self::assertSame('sent', $worker->runOnce('worker')['status']); + self::assertSame('expired', $jobs->get($expired)['status']); + self::assertSame('sent', $jobs->get($due)['status']); + self::assertCount(1, $this->gateway->sends); + } + public function testLostResponseRetriesReuseRandomIdAndDoNotBlockOtherJobs(): void + { + $settings = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, $settings['revision'], ['text' => 'Daf']); + $this->gateway->onSend = function (): array { + throw new RetryableTelegramException('FLOOD_WAIT_30', 30); + }; + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true); + self::assertSame('retry', $worker->runOnce('worker')['status']); + self::assertNull($worker->runOnce('worker')); + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:31Z')); + $this->gateway->onSend = null; + self::assertSame('sent', $worker->runOnce('worker')['status']); + self::assertSame($this->gateway->sends[0][2], $this->gateway->sends[1][2]); + self::assertSame(2, $jobs->get($id)['attempts']); + } + public function testRevisionChangeCancelsPendingContentAndKeepsLockRecovery(): void + { + $settings = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, $settings['revision'], ['text' => 'Daf']); + $locks = new LockCoordinator($this->db, $this->clock, $this->gateway); + $locks->reconcile('-1', 'event', true); + $this->settings->updateGroup('-1', 2, ['location_name' => 'New city'], '18'); + self::assertSame('cancelled', $jobs->get($id)['status']); + self::assertSame('locked', $locks->state('-1')['status']); + self::assertNull((new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true))->runOnce('worker')); + } + public function testCutoffIsRecheckedAfterEligibilityImmediatelyBeforeTelegram(): void + { + $settings = $this->settings->updateGroup('-1', 1, ['tefillin_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'tefillin', 'today', $now, $now + 30, $settings['revision'], ['text' => 'Tefillin']); + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, function (): bool { + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:31Z')); + return true; + }); + self::assertSame('cancelled', $worker->runOnce('worker')['status']); + self::assertSame('cancelled', $jobs->get($id)['status']); + self::assertCount(0, $this->gateway->sends); + } + public function testLeasesRecoverAfterCrashAndLogicalEventIsUnique(): void + { + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, 1, ['text' => 'One']); + self::assertSame($id, $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, 1, ['text' => 'Duplicate'])); + $first = $jobs->lease('one', 20); + self::assertNull($jobs->lease('two')); + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:21Z')); + $second = $jobs->lease('two'); + self::assertSame($first['random_id'], $second['random_id']); + self::assertSame(2, $second['attempts']); + self::assertSame('uncertain', $this->db->pdo()->query('SELECT outcome FROM delivery_log ORDER BY delivery_id LIMIT 1')->fetchColumn()); + } + public function testSendTimePayloadPersistsOnceAndRetriesRetainSameTextAndRandomId(): void + { + $g = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, $g['revision'], ['text' => 'enqueue-time']); + $factoryCalls = 0; + $factory = function (array $job, array $settings, DateTimeImmutable $now) use (&$factoryCalls): array { + $this->db->transaction(static fn(): null=>null); + $factoryCalls++; + return ['text' => 'send-time ' . $now->format('H:i:s')]; + }; + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true, $factory); + $this->gateway->onSend = static function (): array { + throw new RetryableTelegramException('lost response', 30); + }; + self::assertSame('retry', $worker->runOnce('worker')['status']); + $prepared = $jobs->get($id); + self::assertNotNull($prepared['payload_prepared_at']); + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:31Z')); + $this->gateway->onSend = null; + self::assertSame('sent', $worker->runOnce('worker')['status']); + self::assertSame(1, $factoryCalls); + self::assertSame($this->gateway->sends[0][1], $this->gateway->sends[1][1]); + self::assertSame($this->gateway->sends[0][2], $this->gateway->sends[1][2]); + } + public function testPayloadFactoryPassingCutoffDoesNotStartTelegramRpc(): void + { + $g = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $jobs->enqueue('-1', 'daf', 'today', $now, $now + 30, $g['revision'], ['text' => 'enqueue-time']); + $worker = new OutboxWorker( + $jobs, + $this->settings, + $this->gateway, + $this->clock, + static fn(): bool=>true, + function (): array { + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:31Z')); + return ['text' => 'too late']; + } + ); + $worker->runOnce('worker'); + self::assertCount(0, $this->gateway->sends); + } + public function testFailedFinalPayloadPersistenceCannotSendAndCanRecoverBeforeCutoff(): void + { + $g = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, $g['revision'], ['text' => 'enqueue-time']); + $this->db->pdo()->exec("CREATE TRIGGER reject_payload BEFORE UPDATE OF payload_json ON scheduled_jobs BEGIN SELECT RAISE(FAIL,'simulated disk failure'); END"); + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true, static fn(): array=>['text' => 'send-time']); + self::assertSame('retry', $worker->runOnce('worker')['status']); + self::assertCount(0, $this->gateway->sends); + self::assertNull($jobs->get($id)['payload_prepared_at']); + $this->db->pdo()->exec('DROP TRIGGER reject_payload'); + $this->clock->set(new DateTimeImmutable('2026-10-13T09:00:31Z')); + self::assertSame('sent', $worker->runOnce('worker')['status']); + self::assertSame('send-time', $this->gateway->sends[0][1]['text']); + } + public function testShutdownStopsNewWorkAndCancellationPropagates(): void + { + $settings = $this->settings->updateGroup('-1', 1, ['daf_enabled' => true], '17'); + $jobs = new JobRepository($this->db, $this->clock); + $now = $this->clock->now()->getTimestamp(); + $id = $jobs->enqueue('-1', 'daf', 'today', $now, $now + 1000, $settings['revision'], ['text' => 'One']); + $worker = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true); + $worker->stop(); + self::assertNull($worker->runOnce('worker')); + self::assertSame('pending', $jobs->get($id)['status']); + $this->gateway->onSend = static function (): array { + throw new Amp\CancelledException(); + }; + $active = new OutboxWorker($jobs, $this->settings, $this->gateway, $this->clock, static fn(): bool=>true); + $this->expectException(Amp\CancelledException::class); + $active->runOnce('worker'); + } +} diff --git a/tests/SchemaValidationTest.php b/tests/SchemaValidationTest.php new file mode 100644 index 0000000..c23b89a --- /dev/null +++ b/tests/SchemaValidationTest.php @@ -0,0 +1,111 @@ +temporary as $directory) { + foreach (glob($directory . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($directory); + } + } + + private function definitions(bool $all = true): string + { + $path = sys_get_temp_dir() . '/shabbat-schema-' . bin2hex(random_bytes(8)); + mkdir($path, 0700); + $this->temporary[] = $path; + $files = $all ? glob(dirname(__DIR__) . '/migrations/*.sql') : [dirname(__DIR__) . '/migrations/001_initial.sql']; + foreach ($files as $file) { + copy($file, $path . '/' . basename($file)); + } + return $path; + } + + private function rejectReadOnly(Database $database, ?string $directory = null): string + { + $before = $database->pdo()->query('SELECT total_changes()')->fetchColumn(); + $schema = $database->pdo()->query('SELECT name,sql FROM sqlite_master ORDER BY name')->fetchAll(); + $database->pdo()->exec('PRAGMA query_only=ON'); + try { + $database->assertCurrentSchema($directory); + self::fail('Partial or mismatched schema was accepted.'); + } catch (RuntimeException $error) { + self::assertSame($before, $database->pdo()->query('SELECT total_changes()')->fetchColumn()); + self::assertSame($schema, $database->pdo()->query('SELECT name,sql FROM sqlite_master ORDER BY name')->fetchAll()); + return $error->getMessage(); + } + } + + public function testFullyMigratedCurrentDefinitionsPassWithQueryOnlyAndNoWrites(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $expected = array_map('basename', glob(dirname(__DIR__) . '/migrations/*.sql')); + sort($expected, SORT_STRING); + self::assertSame($expected, $db->pdo()->query('SELECT version FROM schema_migrations ORDER BY version')->fetchAll(PDO::FETCH_COLUMN)); + $before = $db->pdo()->query('SELECT total_changes()')->fetchColumn(); + $db->pdo()->exec('PRAGMA query_only=ON'); + $db->assertCurrentSchema(); + self::assertSame($before, $db->pdo()->query('SELECT total_changes()')->fetchColumn()); + } + + public function testOnlyInitialMigrationFailsBeforeAnyAutoMigrationOrWrite(): void + { + $db = Database::open(':memory:'); + $db->migrate($this->definitions(false)); + self::assertStringContainsString('Missing required schema migrations', $this->rejectReadOnly($db)); + self::assertSame(['001_initial.sql'], $db->pdo()->query('SELECT version FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN)); + } + + public function testMissingHistoryIsRejectedWithoutCreatingAnyTable(): void + { + $db = Database::open(':memory:'); + self::assertStringContainsString('Schema migration history is missing', $this->rejectReadOnly($db)); + self::assertSame(0, (int) $db->pdo()->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")->fetchColumn()); + } + + public function testTamperedRecordedChecksumIsRejectedWithoutRepair(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $db->pdo()->prepare("UPDATE schema_migrations SET checksum=? WHERE version='001_initial.sql'")->execute([str_repeat('0', 64)]); + self::assertStringContainsString('checksum does not match required source', $this->rejectReadOnly($db)); + self::assertSame(str_repeat('0', 64), $db->pdo()->query("SELECT checksum FROM schema_migrations WHERE version='001_initial.sql'")->fetchColumn()); + } + + public function testTamperedAppliedSourceAndMissingDefinitionsAreRejected(): void + { + $directory = $this->definitions(); + $db = Database::open(':memory:'); + $db->migrate($directory); + file_put_contents($directory . '/001_initial.sql', "\n-- changed applied source\n", FILE_APPEND); + self::assertStringContainsString('checksum does not match required source', $this->rejectReadOnly($db, $directory)); + $partial = $this->definitions(false); + self::assertStringContainsString('Unknown schema migration version', $this->rejectReadOnly($db, $partial)); + } + + public function testUnknownFutureSchemaCannotBeOpenedByOlderCode(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $db->pdo()->prepare('INSERT INTO schema_migrations(version,checksum,applied_at) VALUES(?,?,?)')->execute(['999_future.sql', str_repeat('0', 64), 0]); + self::assertStringContainsString('Unknown schema migration version', $this->rejectReadOnly($db)); + self::assertSame(1, (int) $db->pdo()->query("SELECT COUNT(*) FROM schema_migrations WHERE version='999_future.sql'")->fetchColumn()); + } +} diff --git a/tests/SentMessageIdsTest.php b/tests/SentMessageIdsTest.php new file mode 100644 index 0000000..76bc400 --- /dev/null +++ b/tests/SentMessageIdsTest.php @@ -0,0 +1,104 @@ + 'updatesCombined', 'updates' => [ + ['_' => 'updateMessageID', 'id' => 11, 'random_id' => 42], + ['_' => 'updateMessageID', 'id' => 11, 'random_id' => 42], + ['_' => 'updateNewMessage', 'message' => ['_' => 'message', 'id' => 999, 'out' => false]], + ['_' => 'updateNewMessage', 'message' => ['_' => 'message', 'id' => 998, 'out' => true]], + ]]; + self::assertSame([11], SentMessageIds::extract($response)); + } + + public function testKnownJobMappingRejectsUnrelatedConcurrentAcknowledgements(): void + { + $response = ['_' => 'updates', 'updates' => [ + ['_' => 'updateMessageID', 'id' => 10, 'random_id' => 41], + ['_' => 'updateMessageID', 'id' => 11, 'random_id' => 42], + ['_' => 'updateNewChannelMessage', 'message' => ['_' => 'message', 'id' => 10, 'out' => true, 'peer_id' => -100123]], + ]]; + self::assertSame([11], SentMessageIds::extract($response, '42', '-100123')); + self::assertSame([], SentMessageIds::extract($response, '43', '-100123')); + } + + public function testPackedWireLongMatchesTheCanonicalJobId(): void + { + $tl = (new \ReflectionClass(\danog\MadelineProto\TL\TL::class))->newInstanceWithoutConstructor(); + $native = $tl->serializeObject(['type' => 'long'], 12345678, 'random_id'); + self::assertSame(pack('P', 12345678), $native); + $response = ['_' => 'updates', 'updates' => [['_' => 'updateMessageID', 'id' => 11, 'random_id' => $native]]]; + self::assertSame([11], SentMessageIds::extract($response, '12345678', '-100123')); + self::assertSame([], SentMessageIds::extract($response, '12345679', '-100123')); + $response['updates'][0]['random_id'] = '12345678'; + self::assertSame([], SentMessageIds::extract($response, '12345678', '-100123')); + } + + public static function contextualPeers(): array + { + return [ + [-100123, '-100123', [11]], + [-100124, '-100123', []], + [null, '-100123', []], + ['-100123', '-100123', []], + [['_' => 'peerChat','chat_id' => 123], '-123', [11]], + [['_' => 'peerChat','chat_id' => 124], '-123', []], + [['_' => 'peerChannel','channel_id' => 123], '-1000000000123', [11]], + [['_' => 'peerChannel','channel_id' => 124], '-1000000000123', []], + [['_' => 'peerUser','user_id' => 123], '123', [11]], + [['_' => 'peerUser','user_id' => '123'], '123', []], + [['_' => 'inputPeerChannel','channel_id' => 123], '-1000000000123', []], + ]; + } + + #[DataProvider('contextualPeers')] + public function testOutgoingFallbackRequiresExactNativePeer(mixed $nativePeer, string $expectedPeer, array $ids): void + { + $response = ['_' => 'updates', 'updates' => [['_' => 'updateNewChannelMessage', 'message' => [ + '_' => 'message', 'id' => 11, 'out' => true, 'peer_id' => $nativePeer, + ]]]]; + self::assertSame($ids, SentMessageIds::extract($response, '42', $expectedPeer)); + } + + public function testTrustedShortAcknowledgementDoesNotInventPeerMetadata(): void + { + self::assertSame([11], SentMessageIds::extract(['_' => 'updateShortSentMessage', 'id' => 11], '42', '-100123')); + self::assertSame([], SentMessageIds::extract(['_' => 'updateShortSentMessage', 'id' => 11], '0', '-100123')); + self::assertSame([], SentMessageIds::extract(['_' => 'updateShortSentMessage', 'id' => 11], '42', '0')); + self::assertSame([], SentMessageIds::extract(['_' => 'updateShortSentMessage', 'id' => 11], 'not-an-id', '-100123')); + } + public static function nativeResponses(): array + { + return [ + [['_' => 'updateShortSentMessage', 'id' => 11], [11]], + [['_' => 'updateShort', 'update' => ['_' => 'updateNewMessage', 'message' => ['_' => 'message', 'id' => 12, 'out' => true]]], [12]], + [['_' => 'updates', 'updates' => [['_' => 'updateNewChannelMessage', 'message' => ['_' => 'message', 'id' => 13, 'out' => true]]]], [13]], + [['_' => 'updates', 'updates' => [['_' => 'updateNewChannelMessage', 'message' => ['_' => 'messageService', 'id' => 13, 'out' => true]]]], []], + [['_' => 'updates', 'updates' => [['_' => 'updateMessageID', 'id' => 14, 'random_id' => "12345678"]]], [14]], + [['_' => 'updates', 'updates' => [['_' => 'updateMessageID', 'id' => 14, 'random_id' => []]]], []], + [['id' => 11], []], + [['_' => 'updateShortSentMessage', 'id' => '11'], []], + [['_' => 'updateShortSentMessage', 'id' => 0], []], + [['_' => 'updateShortSentMessage', 'id' => 2147483648], []], + [['_' => 'updatesTooLong'], []], + ]; + } + + #[DataProvider('nativeResponses')] + public function testSupportedNativeShapesAndUnknownResponses(array $response, array $ids): void + { + self::assertSame($ids, SentMessageIds::extract($response)); + } +} diff --git a/tests/SettingsWizardTest.php b/tests/SettingsWizardTest.php new file mode 100644 index 0000000..15699b2 --- /dev/null +++ b/tests/SettingsWizardTest.php @@ -0,0 +1,121 @@ +migrate(); + $this->settings = new SettingsRepository($db, new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00Z'))); + $this->context = $this->settings->ensureGroup('-1', 'Fixture group'); + $this->parser = (new \ReflectionClass(BotController::class))->newInstanceWithoutConstructor(); + } + + public function testDefinitionsCoverEveryExistingFormFieldAndPrivateScopeIsLimited(): void + { + $reference = (new \ReflectionClass(BotController::class))->getConstant('FORM_FIELDS'); + foreach ($reference as $module => $fields) { + self::assertSame($fields, SettingsWizard::fields($module)); + foreach ($fields as $field) { + $definition = SettingsWizard::definition($module, $field); + self::assertContains($definition['kind'], ['bool','enum','time','times','days','integer','float','text','minutes_list','flags']); + self::assertIsArray($definition['options']); + self::assertIsBool($definition['nullable']); + } + } + self::assertSame(['candle_minutes','havdalah_method','havdalah_value'], SettingsWizard::fields('shabbat', false)); + self::assertSame([], SettingsWizard::fields('tefillin', false)); + self::assertCount(17, SettingsWizard::fields('tefillin')); + } + + public function testEveryPresetPassesExistingCanonicalParserAndRepositoryValidation(): void + { + foreach (['shabbat','zmanim','daf','tefillin','omer'] as $module) { + foreach (SettingsWizard::fields($module) as $field) { + $definition = SettingsWizard::definition($module, $field); + if (in_array($definition['kind'], ['days','flags'], true)) { + $inputs = [$definition['options'],[]]; + } elseif ($definition['kind'] === 'text') { + $inputs = $field === 'ack_label' ? ['Custom {{location}}', null] : ['Custom {{location}}']; + } else { + $inputs = $definition['options']; + } + foreach ($inputs as $input) { + $changes = SettingsWizard::changes($module, $field, $input, $this->context, $this->parser->parseForm(...)); + self::assertArrayHasKey(SettingsWizard::key($module, $field), $changes); + $this->context = $this->settings->updateGroup('-1', $this->context['revision'], $changes, '1'); + self::assertSame($changes[SettingsWizard::key($module, $field)], $this->context[SettingsWizard::key($module, $field)]); + } + } + } + } + + public function testNightfallMethodAndValueStayCompatibleForBothModules(): void + { + foreach (['shabbat' => ['havdalah_method','havdalah_value'], 'omer' => ['nightfall_method','nightfall_value']] as $module => [$method,$value]) { + $changes = SettingsWizard::changes($module, $value, 72, $this->context, $this->parser->parseForm(...)); + self::assertSame('minutes', $changes[SettingsWizard::key($module, $method)]); + self::assertSame(72.0, $changes[SettingsWizard::key($module, $value)]); + $this->context = $this->settings->updateGroup('-1', $this->context['revision'], $changes, '1'); + $changes = SettingsWizard::changes($module, $method, 'degrees', $this->context, $this->parser->parseForm(...)); + self::assertSame(8.5, $changes[SettingsWizard::key($module, $value)]); + $this->context = $this->settings->updateGroup('-1', $this->context['revision'], $changes, '1'); + $changes = SettingsWizard::changes($module, $method, 'minutes', $this->context, $this->parser->parseForm(...)); + self::assertSame(42.0, $changes[SettingsWizard::key($module, $value)]); + } + } + + public function testHumanValuesShowCityOnlyForCandlesAndOffForNullableReminders(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + self::assertSame($lang->text($language, 'city_custom'), SettingsWizard::current('shabbat', 'candle_minutes', $this->context, $language)); + self::assertSame($lang->text($language, 'off'), SettingsWizard::current('tefillin', 'interval_minutes', $this->context, $language)); + self::assertSame($lang->text($language, 'off'), SettingsWizard::current('tefillin', 'quiet_start', $this->context, $language)); + self::assertSame($lang->text($language, 'off'), SettingsWizard::current('tefillin', 'quiet_end', $this->context, $language)); + self::assertSame($lang->text($language, 'tefillin_ack'), SettingsWizard::current('tefillin', 'ack_label', $this->context, $language)); + $days = SettingsWizard::current('tefillin', 'days', $this->context, $language); + self::assertStringContainsString(SettingsWizard::dayLabel(0, $language), $days); + self::assertStringNotContainsString('0,1,2', $days); + foreach (['havdalah_method','times_alert_mode'] as $field) { + self::assertStringNotContainsString((string)$this->context[$field], SettingsWizard::current('shabbat', $field, $this->context, $language)); + } + } + self::assertSame(['10:00','11:00','12:00','12:30','13:00','13:30','14:00'], SettingsWizard::definition('shabbat', 'times_alert_time')['options']); + self::assertContains(40, SettingsWizard::definition('shabbat', 'candle_minutes')['options']); + } + + public function testInvalidTypedInputUsesCanonicalParserAndNeverWrites(): void + { + $revision = $this->context['revision']; + foreach ( + [ + ['tefillin','interval_minutes',4],['tefillin','days',[0,7]],['tefillin','fixed_times',['25:00']], + ['tefillin','sunset_offsets',[0]],['tefillin','max_per_day',25],['shabbat','havdalah_value',20.5], + ['shabbat','times_alert_time','25:00'],['omer','format','invented'],['tefillin','chol_hamoed','true'], + ['shabbat','havdalah_method',null], + ] as [$module,$field,$input] + ) { + try { + SettingsWizard::changes($module, $field, $input, $this->context, $this->parser->parseForm(...)); + self::fail('Invalid wizard input accepted.'); + } catch (\InvalidArgumentException) { + self::assertSame($revision, $this->settings->getGroup('-1')['revision']); + } + } + } +} diff --git a/tests/ShabbatHolidayOverviewTest.php b/tests/ShabbatHolidayOverviewTest.php new file mode 100644 index 0000000..38dbfbd --- /dev/null +++ b/tests/ShabbatHolidayOverviewTest.php @@ -0,0 +1,253 @@ + [$language, '2026-04-01T12:00:00+03:00']; + yield $language . '-active' => [$language, '2026-04-02T12:00:00+03:00']; + } + } + + private function context(string $date, string $language = 'he'): array + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable($date)); + $settings = new SettingsRepository($db, $clock); + $user = $settings->getUser('1'); + $user = $settings->updateUser('1', $user['revision'], ['language' => $language]); + $http = new FullHebcalFixtureClient(); + $calendar = new CalendarService(new HebcalProvider($http), $clock); + return [$db, $clock, $settings, $user, $http, $calendar]; + } + + #[DataProvider('languagesAndFestivalPhases')] + public function testIsolatedYomTovBeforeShabbatAppearsWithBoundariesInEveryDefaultPath(string $language, string $date): void + { + [$db, $clock, $settings, $user, $http, $calendar] = $this->context($date, $language); + $group = $settings->ensureGroup('-1001', 'Festival fixture', ['language' => $language, 'mode' => 'messages', 'times_alert_enabled' => true]); + $geo = new GeoNames($http, $clock, ''); + $flows = new FlowTokenRepository($db, $clock); + $gateway = new UiNavigationFakeGateway(); + $ui = new BotController($settings, $flows, $calendar, $gateway, $geo, $clock); + $sendGateway = new GroupMessageButtonsGateway(); + $app = new Application($sendGateway, $clock, $db, $calendar, $geo); + $snapshot = $calendar->snapshot($user); + $reference = $snapshot['current_event'] ?? $snapshot['next_event']; + self::assertSame(['2026-04-02'], $reference['days'], 'Actual Israel Pesach fixture has Thursday Yom Tov, ordinary Friday and separate Shabbat.'); + self::assertTrue($reference['is_yom_tov']); + self::assertFalse($reference['is_shabbat']); + $expected = (new ShabbatTimesFormatter())->overview($user, $snapshot)['text']; + $lang = new Lang(); + [$main, $holidays] = explode('' . $lang->text($language, 'times_upcoming_calendar') . '', $expected); + foreach (['2026-04-03 18:20', '2026-04-04 19:37'] as $time) { + self::assertStringContainsString($time, $main); + self::assertSame(1, substr_count($expected, $time)); + } + foreach (['2026-04-01 18:19', '2026-04-02 19:36'] as $time) { + self::assertStringContainsString($time, $holidays); + self::assertStringNotContainsString($time, $main); + self::assertSame(1, substr_count($expected, $time)); + } + self::assertStringContainsString('' . $lang->text($language, 'times_shabbat_title') . '', $main); + self::assertSame(1, substr_count($expected, $lang->text($language, 'times_heading'))); + self::assertSame(1, substr_count($expected, 'Asia/Jerusalem')); + foreach (['2026-04-10 ', '2026-04-11 ', 'Hebcal', '(+03:00)', '(+02:00)'] as $hidden) { + self::assertStringNotContainsString($hidden, $expected); + } + + self::assertSame($expected, $ui->renderView('1', $group, 'shabbat')); + self::assertTrue($ui->handleMessage(['user_id' => '1', 'private' => true, 'text' => '/shabbat'])); + self::assertSame($expected, $gateway->sent[0]['text']); + $query = ['user_id' => '1', 'chat_id' => '1', 'private' => true, 'message_id' => 101, 'query_id' => '123']; + self::assertTrue($ui->handleCallback($query + ['data' => 'sb:show:shabbat'])); + self::assertSame($expected, $gateway->edited[0]['text']); + $token = $flows->create('1', 'group', '-1001', $group['revision'], 'panel'); + self::assertTrue($ui->handleCallback($query + ['data' => 'sb:ap:' . $token])); + self::assertSame($expected, $gateway->edited[1]['text']); + self::assertTrue($ui->handleInline(['user_id' => '1', 'query_id' => '456', 'query' => 'shabat'])); + self::assertSame($expected, $gateway->inline[0]['results'][0]['send_message']['message']); + $share = $flows->create('1', 'share', '-1001', $group['revision'], 'share_snapshot', payload: ['settings' => $group, 'module' => 'shabbat', 'date' => null]); + self::assertTrue($ui->handleInline(['user_id' => '1', 'query_id' => '457', 'query' => 'share ' . $share])); + self::assertSame("Festival fixture\n" . $expected, $gateway->inline[1]['results'][0]['send_message']['message']); + self::assertSame($expected, $app->payload($group, 'times_alert', $clock->now())['text']); + $template = $app->templates->save('-1001', 'times_alert', ['text' => '', 'buttons_mode' => 'none'], '1'); + self::assertSame($expected, $app->payload($group, 'times_alert', $clock->now(), templateOverride: $template)['text']); + + (new \ReflectionProperty(Runner::class, 'stopping'))->setValue(null, false); + $now = $clock->now()->getTimestamp(); + (new JobRepository($db, $clock))->enqueue('-1001', 'times_alert', 'festival-layout', $now, $now + 300, $group['revision'], []); + $worker = (new \ReflectionProperty(Application::class, 'worker'))->getValue($app); + $delivery = $worker->runOnce('festival-layout'); + if ($snapshot['current_event'] === null) { + self::assertSame('sent', $delivery['status']); + self::assertSame($expected, $sendGateway->sent[0]['payload']['text']); + } else { + self::assertSame('cancelled', $delivery['status'], 'Existing quiet-period policy still prevents scheduled notices during Yom Tov.'); + self::assertSame([], $sendGateway->sent); + } + self::assertLessThanOrEqual(3900, strlen(mb_convert_encoding(html_entity_decode(strip_tags($expected), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'UTF-16LE', 'UTF-8')) / 2); + } + + public function testCholHamoedIsNotYomTovAndHasNoInventedFestivalBoundaries(): void + { + [, , , $user, , $calendar] = $this->context('2026-04-03T12:00:00+03:00'); + $snapshot = $calendar->snapshot($user); + self::assertTrue($snapshot['is_chol_hamoed']); + self::assertFalse($snapshot['is_yom_tov']); + self::assertFalse($snapshot['is_work_prohibited_now']); + self::assertNull($snapshot['lock_event']); + $text = (new ShabbatTimesFormatter())->overview($user, $snapshot)['text']; + $holidays = explode('' . (new Lang())->text('he', 'times_upcoming_calendar') . '', $text)[1]; + self::assertStringContainsString('ืคืกื— ื‘ืณ (ื—ื•ื”ืดืž) ยท 2026-04-03', $holidays); + self::assertStringNotContainsString('2026-04-01 18:19', $holidays); + self::assertStringNotContainsString('2026-04-02 19:36', $holidays); + self::assertSame(1, substr_count($holidays, 'ื›ื ื™ืกื” / ื”ื“ืœืงืช ื ืจื•ืช:'), 'Only actual Pesach VII has festival boundaries.'); + self::assertStringContainsString('2026-04-07 18:23', $holidays); + self::assertStringContainsString('2026-04-08 19:40', $holidays); + } + + public function testOldCacheUsesEvaluationDateAndAnEndedFestivalIsNotKeptUntilNextFetch(): void + { + [, $clock, , $user, $http, $calendar] = $this->context('2026-04-01T23:00:00+03:00'); + $first = $calendar->snapshot($user); + $clock->set(new \DateTimeImmutable('2026-04-02T01:00:00+03:00')); + $second = $calendar->snapshot($user); + self::assertSame($first['fetched_at'], $second['fetched_at']); + self::assertSame('2026-04-02', $second['local_date']); + self::assertSame($clock->now()->getTimestamp(), $second['evaluated_at']); + self::assertCount(2, $http->calls); + $text = (new ShabbatTimesFormatter())->overview($user, $second)['text']; + self::assertStringNotContainsString('ืขืจื‘ ืคืกื—', $text); + self::assertStringContainsString('2026-04-02 19:36', $text); + + $clock->set(new \DateTimeImmutable('2026-04-02T18:00:00+03:00')); + $beforeExit = $calendar->snapshot($user); + $clock->set(new \DateTimeImmutable('2026-04-02T20:00:00+03:00')); + $afterExit = $calendar->snapshot($user); + self::assertSame($beforeExit['fetched_at'], $afterExit['fetched_at']); + self::assertStringNotContainsString('2026-04-02 19:36', (new ShabbatTimesFormatter())->overview($user, $afterExit)['text']); + self::assertStringContainsString('2026-04-04 19:37', (new ShabbatTimesFormatter())->overview($user, $afterExit)['text']); + + $legacy = $second; + unset($legacy['evaluated_at'], $legacy['local_date']); + $legacy['fetched_at'] = 1; + $legacyText = (new ShabbatTimesFormatter())->overview($user, $legacy)['text']; + $legacy['fetched_at'] = 4102444800; + self::assertSame($legacyText, (new ShabbatTimesFormatter())->overview($user, $legacy)['text'], 'Legacy rendering is deterministic and ignores fetch time.'); + } + + public function testReligiousFinalExitControlsPrimaryNotDelayedGroupReopening(): void + { + [, , , $user, , $calendar] = $this->context('2026-10-17T18:42:00+03:00'); + $user['mode'] = 'lock_messages'; + $user['open_after_havdalah_minutes'] = 60; + $snapshot = $calendar->snapshot($user); + self::assertNull($snapshot['current_event']); + self::assertSame('2026-10-17', $snapshot['lock_event']['event_key']); + $text = (new ShabbatTimesFormatter())->overview($user, $snapshot)['text']; + self::assertStringContainsString('2026-10-23 17:18', $text); + self::assertStringContainsString('2026-10-24 18:34', $text); + self::assertStringNotContainsString('2026-10-16 17:26', $text); + } + + public function testCurrentContinuousFestivalAndShabbatHasOnePrimaryAndNoIntermediateExit(): void + { + [, , , $user, , $calendar] = $this->context('2026-05-22T12:00:00+03:00'); + $snapshot = $calendar->snapshot($user); + self::assertSame(['2026-05-22', '2026-05-23'], $snapshot['current_event']['days']); + $text = (new ShabbatTimesFormatter())->overview($user, $snapshot)['text']; + foreach (['2026-05-21 18:53', '2026-05-22 18:54', '2026-05-23 20:15'] as $time) { + self::assertSame(1, substr_count($text, $time)); + } + self::assertSame(1, substr_count($text, 'ื–ืžื ื™ ืฉื‘ืช ื•ื—ื’')); + self::assertSame(1, substr_count($text, 'ื™ืฆื™ืื”/ื”ื‘ื“ืœื”:')); + self::assertStringNotContainsString('2026-05-29 ', $text); + } + + public function testActiveYomTovCrossingCivilMidnightRemainsUntilItsExactFinalExit(): void + { + [, , , $settings] = $this->context('2026-04-01T12:00:00+03:00'); + $settings = array_replace($settings, ['language' => 'en', 'timezone_id' => 'Europe/Stockholm', 'havdalah_method' => 'minutes', 'havdalah_value' => 180]); + $zone = new \DateTimeZone('Europe/Stockholm'); + $stamp = static fn(string $date): int => (new \DateTimeImmutable($date, $zone))->getTimestamp(); + // Deliberately synthetic late-sunset fixture exercises the permitted fixed-minute method. + $solar = []; + foreach (range(29, 31) as $day) { + $solar['2028-05-' . $day] = ['sunset' => $stamp('2028-05-' . $day . ' 21:50')]; + } + foreach (range(1, 5) as $day) { + $date = '2028-06-0' . $day; + $solar[$date] = ['sunset' => $stamp($date . ' 21:50')]; + } + $calendar = ['range' => ['start' => '2028-05-29','end' => '2028-06-05'], 'items' => [ + ['date' => '2028-05-31','category' => 'holiday','title' => 'Active Shavuot fixture','hebrew' => 'ืฉื‘ื•ืขื•ืช','yomtov' => true], + ]]; + foreach (['00:30' => true, '00:50' => false] as $hour => $active) { + $now = new \DateTimeImmutable('2028-06-01 ' . $hour, $zone); + $snapshot = (new \Shabbat\Calendar\CalendarEngine())->evaluate($settings, $calendar, $solar, $now) + + ['evaluated_at' => $now->getTimestamp(),'local_date' => '2028-06-01','calendar_items' => $calendar['items']]; + self::assertSame($active, $snapshot['is_yom_tov']); + $text = (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']; + self::assertStringContainsString('2028-06-02 21:10', $text); + self::assertStringContainsString('2028-06-04 00:50', $text); + if ($active) { + self::assertStringContainsString('Active Shavuot fixture', $text); + self::assertStringContainsString('2028-06-01 00:50', $text); + } else { + self::assertStringNotContainsString('Active Shavuot fixture', $text); + self::assertStringNotContainsString('2028-06-01 00:50', $text); + } + } + } + + public function testLongOptionalMetadataCannotHideNearestShabbatOrCurrentFestivalTimes(): void + { + [, , , $settings, , $calendar] = $this->context('2026-04-02T12:00:00+03:00'); + $snapshot = $calendar->snapshot($settings); + $snapshot['calendar_items'] = array_values(array_filter($snapshot['calendar_items'], static fn(array $item): bool => !in_array($item['category'], ['holiday','roshchodesh'], true))); + for ($i = 0; $i < 20; $i++) { + $snapshot['calendar_items'][] = ['date' => '2026-04-04','category' => 'molad','title' => str_repeat('๐Ÿ””', 400) . $i,'hebrew' => str_repeat('๐Ÿ””', 400) . $i]; + } + $snapshot['calendar_items'][] = ['date' => '2026-04-02','category' => 'holiday','title' => 'Current Yom Tov','hebrew' => 'ื™ื•ื ื˜ื•ื‘ ืคืขื™ืœ','yomtov' => true]; + $stamp = static fn(string $date): int => (new \DateTimeImmutable($date, new \DateTimeZone('Asia/Jerusalem')))->getTimestamp(); + // Synthetic unusually long future metadata; real primary/current times are untouched. + foreach (['2026-04-08','2026-04-15','2026-04-22','2026-04-29'] as $date) { + $day = new \DateTimeImmutable($date, new \DateTimeZone('Asia/Jerusalem')); + $days = [$date,$day->modify('+1 day')->format('Y-m-d'),$day->modify('+2 days')->format('Y-m-d')]; + $period = ['event_key' => implode('+', $days),'days' => $days,'is_shabbat' => false,'is_yom_tov' => true, + 'candles_at' => $stamp($day->modify('-1 day')->format('Y-m-d') . ' 18:00'),'havdalah_at' => $stamp($days[2] . ' 20:00'),'intermediate' => []]; + foreach (array_slice($days, 1) as $next) { + $period['intermediate'][] = ['candles_at' => $stamp($next . ' 20:00'),'is_yom_tov' => true,'is_shabbat' => false]; + } + $snapshot['events'][] = $period; + $snapshot['calendar_items'][] = ['date' => $date,'category' => 'holiday','title' => str_repeat('๐Ÿ””', 120),'hebrew' => str_repeat('๐Ÿ””', 120),'yomtov' => true]; + } + foreach (Lang::CODES as $language) { + $settings['language'] = $language; + $text = (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']; + foreach (['2026-04-03 18:20','2026-04-04 19:37','2026-04-01 18:19','2026-04-02 19:36'] as $boundary) { + self::assertStringContainsString($boundary, $text); + } + self::assertLessThanOrEqual(3900, strlen(mb_convert_encoding(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'UTF-16LE', 'UTF-8')) / 2); + } + } +} diff --git a/tests/ShabbatOverviewTest.php b/tests/ShabbatOverviewTest.php new file mode 100644 index 0000000..8960717 --- /dev/null +++ b/tests/ShabbatOverviewTest.php @@ -0,0 +1,161 @@ +migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-05-20T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->getUser('1'); + $calendar = new CalendarService(new HebcalProvider(new FullHebcalFixtureClient()), $clock); + $snapshot = $calendar->snapshot($settings); + $formatter = new ShabbatTimesFormatter(); + $anchor = $snapshot['next_event']; + self::assertCount(2, $anchor['days'], 'Fixture exercises Shavuot followed immediately by Shabbat.'); + $page = $formatter->overview($settings, $snapshot); + foreach ([$anchor, ...$anchor['intermediate']] as $segment) { + $time = (new \DateTimeImmutable('@' . $segment['candles_at']))->setTimezone(new \DateTimeZone('Asia/Jerusalem'))->format('Y-m-d H:i'); + self::assertStringContainsString($time, $page['text']); + } + $exit = (new \DateTimeImmutable('@' . $anchor['havdalah_at']))->setTimezone(new \DateTimeZone('Asia/Jerusalem'))->format('Y-m-d H:i'); + self::assertStringContainsString($exit, $page['text']); + self::assertSame($page, $formatter->overview($settings, $snapshot, 100), 'Old page buttons must refresh the current view.'); + self::assertSame($page, $formatter->overview($settings, $snapshot, -10)); + self::assertStringContainsString('ืฉื‘ื•ืขื•ืช', $page['text']); + self::assertStringNotContainsString('ื”ืงื‘ื•ืฆื” ื ืฉืืจืช ืกื’ื•ืจื”', $page['text']); + self::assertStringNotContainsString('ืขืžื•ื“', $page['text']); + self::assertLessThanOrEqual(4000, strlen(mb_convert_encoding(html_entity_decode(strip_tags($page['text']), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'UTF-16LE', 'UTF-8')) / 2); + } + + public function testCalendarRefreshEditsSamePanelWithoutNextOrPreviousButtons(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-05-20T12:00:00+03:00')); + $settings = new SettingsRepository($db, $clock); + $http = new FullHebcalFixtureClient(); + $gateway = new UiNavigationFakeGateway(); + $ui = new BotController($settings, new FlowTokenRepository($db, $clock), new CalendarService(new HebcalProvider($http), $clock), $gateway, new GeoNames($http, $clock, ''), $clock); + $query = ['user_id' => '1','chat_id' => '1','message_id' => 101,'private' => true,'query_id' => '123']; + $ui->handleCallback($query + ['data' => 'sb:show:shabbat']); + $first = $gateway->edited[0]; + foreach ($first['markup']['rows'] as $row) { + foreach ($row['buttons'] as $button) { + self::assertStringNotContainsString('sb:calendar:', $button['data'] ?? ''); + } + } + $ui->handleCallback($query + ['data' => 'sb:show:shabbat']); + self::assertCount(2, $gateway->edited); + self::assertSame(101, $gateway->edited[1]['messageId']); + self::assertSame($first['text'], $gateway->edited[1]['text']); + self::assertSame([], $gateway->sent); + self::assertSame(1, $settings->getUser('1')['revision']); + } + + public function testOctoberOverviewListsHanukkahWithoutInventingFestivalLockingTimes(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->getUser('1'); + $calendar = new CalendarService(new HebcalProvider(new FullHebcalFixtureClient()), $clock); + $text = (new ShabbatTimesFormatter())->overview($settings, $calendar->snapshot($settings))['text']; + foreach (['Jerusalem / ื™ืจื•ืฉืœื™ื','2026-10-16 17:26','2026-10-17 18:41','ื™ื•ื ืฉื™ืฉื™','ืคืจืฉืช ื ื—','ื—ื ื•ื›ื”'] as $value) { + self::assertStringContainsString($value, $text); + } + $holidays = explode((new Lang())->text('he', 'times_upcoming_calendar'), $text)[1]; + foreach (['2026-10-23 17:18','2026-10-30 16:12','2026-11-06 16:06','Hebcal','Genesis','Isaiah',(new Lang())->text('he', 'times_upcoming_shabbat')] as $hidden) { + self::assertStringNotContainsString($hidden, $text); + } + self::assertStringNotContainsString('ื›ื ื™ืกื” / ื”ื“ืœืงืช ื ืจื•ืช', $holidays); + self::assertStringNotContainsString('ื™ืฆื™ืื”', $holidays); + self::assertStringNotContainsString('ืขืžื•ื“', $text); + } + + public function testUpcomingYomTovUsesVerifiedContinuousPeriodAndNeverIntermediateExit(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-05-01T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->getUser('1'); + $settings['language'] = 'en'; + $stamp = static fn(string $date): int => (new \DateTimeImmutable($date, new \DateTimeZone('Asia/Jerusalem')))->getTimestamp(); + $saturday = ['event_key' => '2026-05-02','days' => ['2026-05-02'],'title' => 'Shabbat','is_shabbat' => true,'is_yom_tov' => false,'candles_at' => $stamp('2026-05-01 18:00'),'havdalah_at' => $stamp('2026-05-02 20:00')]; + $festival = ['event_key' => '2026-05-22+2026-05-23','days' => ['2026-05-22','2026-05-23'],'title' => 'Shavuot I / Shavuot II','is_shabbat' => true,'is_yom_tov' => true,'candles_at' => $stamp('2026-05-21 18:10'),'havdalah_at' => $stamp('2026-05-23 20:20'),'intermediate' => [ + ['days' => ['2026-05-23'],'is_shabbat' => true,'is_yom_tov' => true,'candles_at' => $stamp('2026-05-22 18:11'),'havdalah_at' => $stamp('2026-05-22 20:12')], + ]]; + $snapshot = ['next_event' => $saturday,'events' => [$saturday,$festival],'calendar_items' => [ + ['date' => '2026-05-22','category' => 'holiday','title' => 'Shavuot I','yomtov' => true], + ['date' => '2026-05-23','category' => 'holiday','title' => 'Shavuot II','yomtov' => true], + ]]; + $text = (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']; + foreach (['Shavuot I','2026-05-21 18:10','2026-05-22 18:11','2026-05-23 20:20'] as $value) { + self::assertStringContainsString($value, $text); + } + self::assertStringNotContainsString('2026-05-22 20:12', $text); + $snapshot['coverage'] = ['start' => '2026-04-23', 'end' => '2026-05-23']; + $boundary = (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']; + self::assertStringContainsString((new Lang())->text('en', 'times_yomtov_unavailable'), $boundary); + self::assertStringNotContainsString('2026-05-23 20:20', $boundary, 'A coverage-boundary period may continue on an unverified following day.'); + unset($snapshot['coverage']); + $snapshot['events'] = [$saturday]; + self::assertStringContainsString((new Lang())->text('en', 'times_yomtov_unavailable'), (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']); + } + + public function testAllSixOverviewsRemainValidHtmlAndPreserveTimesWithLongMetadata(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-05-20T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->getUser('1'); + $snapshot = (new CalendarService(new HebcalProvider(new FullHebcalFixtureClient()), $clock))->snapshot($settings); + $event = $snapshot['next_event']; + for ($i = 0; $i < 30; $i++) { + $snapshot['calendar_items'][] = ['date' => $event['days'][0], 'category' => 'molad', 'title' => str_repeat('๐€<&>', 400) . $i, 'hebrew' => str_repeat('๐€<&>', 400) . $i]; + } + $exit = (new \DateTimeImmutable('@' . $event['havdalah_at']))->setTimezone(new \DateTimeZone('Asia/Jerusalem'))->format('Y-m-d H:i'); + foreach (Lang::CODES as $language) { + $settings['language'] = $language; + $html = (new ShabbatTimesFormatter())->overview($settings, $snapshot)['text']; + $parsed = \danog\TelegramEntities\Entities::fromHtml(\Shabbat\UI\Presentation::html($html)); + self::assertStringContainsString($exit, $parsed->message); + self::assertStringNotContainsString('', $parsed->message); + self::assertLessThanOrEqual(4000, strlen(mb_convert_encoding($parsed->message, 'UTF-16LE', 'UTF-8')) / 2); + self::assertNotEmpty(array_filter($parsed->entities, static fn(array $entity): bool => $entity['type'] === 'bold')); + } + } + + public function testDailyViewUsesRealBoldEntitiesInsteadOfVisibleHtmlTags(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $settings = new SettingsRepository($db, $clock); + $http = new FullHebcalFixtureClient(); + $ui = new BotController($settings, new FlowTokenRepository($db, $clock), new CalendarService(new HebcalProvider($http), $clock), new UiNavigationFakeGateway(), new GeoNames($http, $clock, ''), $clock); + $html = $ui->renderView('1', $settings->getUser('1'), 'zmanim'); + $parsed = \danog\TelegramEntities\Entities::fromHtml($html); + self::assertStringNotContainsString('', $parsed->message); + self::assertStringNotContainsString('<b>', $html); + self::assertGreaterThan(10, count(array_filter($parsed->entities, static fn(array $entity): bool=>$entity['type'] === 'bold'))); + self::assertStringContainsString('06:42', $parsed->message); + } +} diff --git a/tests/ShabbatPresentationPathsTest.php b/tests/ShabbatPresentationPathsTest.php new file mode 100644 index 0000000..5e6d4ad --- /dev/null +++ b/tests/ShabbatPresentationPathsTest.php @@ -0,0 +1,147 @@ + [$language, 'degrees', 8.5, '18:41']; + yield $language . '-fixed50' => [$language, 'minutes', 50.0, '18:55']; + } + } + + #[DataProvider('languagesAndExitPolicies')] + public function testEveryDefaultPathUsesSameConciseLocalOverview(string $language, string $method, float $value, string $exit): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $settings = new SettingsRepository($db, $clock); + $user = $settings->getUser('1'); + $changes = ['language' => $language, 'havdalah_method' => $method, 'havdalah_value' => $value]; + $user = $settings->updateUser('1', $user['revision'], $changes); + $group = $settings->ensureGroup('-1001', 'Calendar fixture', $changes + ['mode' => 'messages', 'times_alert_enabled' => true]); + $http = new FullHebcalFixtureClient(); + $calendar = new CalendarService(new HebcalProvider($http), $clock); + $flows = new FlowTokenRepository($db, $clock); + $gateway = new UiNavigationFakeGateway(); + $geo = new GeoNames($http, $clock, ''); + $ui = new BotController($settings, $flows, $calendar, $gateway, $geo, $clock); + $app = new Application(new GroupMessageButtonsGateway(), $clock, $db, $calendar, $geo); + $snapshot = $calendar->snapshot($user); + $expected = (new ShabbatTimesFormatter())->overview($user, $snapshot)['text']; + self::assertSame($expected, $ui->renderView('1', $group, 'shabbat'), 'Group command view uses group settings.'); + self::assertSame($expected, $app->formatShabbat($group, $calendar->snapshot($group))); + self::assertSame($expected, $app->payload($group, 'times_alert', $clock->now())['text']); + + self::assertTrue($ui->handleMessage(['user_id' => '1', 'private' => true, 'text' => '/shabbat'])); + self::assertSame($expected, $gateway->sent[0]['text']); + $query = ['user_id' => '1', 'chat_id' => '1', 'private' => true, 'message_id' => 101, 'query_id' => '123']; + self::assertTrue($ui->handleCallback($query + ['data' => 'sb:show:shabbat'])); + self::assertSame($expected, $gateway->edited[0]['text']); + $token = $flows->create('1', 'group', '-1001', $group['revision'], 'panel'); + self::assertTrue($ui->handleCallback($query + ['data' => 'sb:ap:' . $token])); + self::assertSame($expected, $gateway->edited[1]['text'], 'Group announcement preview matches delivery.'); + + self::assertTrue($ui->handleInline(['user_id' => '1', 'query_id' => '456', 'query' => 'shabat'])); + self::assertSame($expected, $gateway->inline[0]['results'][0]['send_message']['message']); + $share = $flows->create('1', 'share', '-1001', $group['revision'], 'share_snapshot', payload: ['settings' => $group, 'module' => 'shabbat', 'date' => null]); + self::assertTrue($ui->handleInline(['user_id' => '1', 'query_id' => '457', 'query' => 'share ' . $share])); + self::assertSame("Calendar fixture\n" . $expected, $gateway->inline[1]['results'][0]['send_message']['message']); + + $buttons = [[['text' => 'Custom', 'url' => 'https://example.com/']]]; + $template = $app->templates->save('-1001', 'times_alert', ['text' => '', 'buttons' => $buttons, 'buttons_mode' => 'custom'], '1'); + $payload = $app->payload($group, 'times_alert', $clock->now()); + self::assertSame($expected, $payload['text'], 'Editing only buttons preserves the default calendar body.'); + self::assertSame($buttons, $payload['buttons']); + self::assertSame($expected, $app->payload($group, 'times_alert', $clock->now(), templateOverride: $template)['text'], 'Template editor preview uses the same body.'); + $rich = ['text' => 'Personal message', 'entities' => [['_' => 'messageEntityBold', 'offset' => 0, 'length' => 8]], 'buttons_mode' => 'none']; + $app->templates->save('-1001', 'times_alert', $rich, '1'); + self::assertSame($rich['text'], $app->payload($group, 'times_alert', $clock->now())['text'], 'Intentional custom bodies are not rewritten.'); + + $lang = new Lang(); + self::assertStringContainsString('' . $lang->text($language, 'times_shabbat_title') . '', $expected); + self::assertStringContainsString('Jerusalem / ื™ืจื•ืฉืœื™ื', $expected); + self::assertStringContainsString('Asia/Jerusalem', $expected); + self::assertStringContainsString('2026-10-16 17:26', $expected); + self::assertStringContainsString('2026-10-17 ' . $exit, $expected); + self::assertStringContainsString($lang->text($language, 'method_' . $method) . ' ' . $value, $expected); + self::assertStringContainsString($lang->text($language, 'times_day_name_5'), $expected); + self::assertStringContainsString($lang->text($language, 'times_upcoming_calendar'), $expected); + foreach (['2026-12-04','2026-12-05','2026-12-06','2026-12-07','2026-12-08'] as $date) { + self::assertStringContainsString($date, $expected); + } + foreach (['2026-10-24', '2026-10-31', '2026-11-07', 'Genesis', 'Isaiah', 'Hebcal', '(+03:00)', '(+02:00)', $lang->text($language, 'times_upcoming_shabbat')] as $hidden) { + self::assertStringNotContainsString($hidden, $expected); + } + self::assertSame($value, (float)$settings->getUser('1')['havdalah_value']); + self::assertSame($method, $settings->getGroup('-1001')['havdalah_method']); + self::assertArrayHasKey('fetched_at', $snapshot, 'Internal freshness metadata remains available.'); + + $animated = Presentation::html($expected, true, [Presentation::ICONS['shabbat_holidays'] => '๐Ÿ•ฏ']); + $parsed = \danog\TelegramEntities\Entities::fromHtml($animated); + self::assertNotEmpty(array_filter($parsed->entities, static fn(array $entity): bool => ($entity['type'] ?? '') === 'custom_emoji')); + $plain = \danog\TelegramEntities\Entities::fromHtml(Presentation::html($animated, false)); + self::assertSame($parsed->message, $plain->message, 'Custom emoji fallback preserves visible text.'); + self::assertEmpty(array_filter($plain->entities, static fn(array $entity): bool => ($entity['type'] ?? '') === 'custom_emoji')); + self::assertLessThanOrEqual(4000, strlen(mb_convert_encoding($plain->message, 'UTF-16LE', 'UTF-8')) / 2); + } + + public function testLocationAliasesOnlyComeFromMatchingCuratedJerusalemAndNeverOverwriteCustomNames(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->getUser('1'); + $formatter = new ShabbatTimesFormatter(); + self::assertStringContainsString('Jerusalem / ื™ืจื•ืฉืœื™ื', $formatter->format($settings, [])['text']); + foreach ( + [ + ['location_name' => 'My neighborhood'], + ['location_name' => 'London', 'geoname_id' => 2643743, 'timezone_id' => 'Europe/London'], + ['latitude' => 30.0], + ['geoname_id' => 999], + ] as $changed + ) { + $context = array_replace($settings, $changed); + $text = $formatter->format($context, [])['text']; + self::assertStringNotContainsString('Jerusalem / ื™ืจื•ืฉืœื™ื', $text); + self::assertStringContainsString($context['location_name'], $text); + } + } + + public function testDefaultGroupOffsetsStayQuietAndConfiguredOffsetsRemainVisible(): void + { + $db = Database::open(':memory:'); + $db->migrate(); + $clock = new FrozenClock(new \DateTimeImmutable('2026-10-15T12:00:00+03:00')); + $settings = (new SettingsRepository($db, $clock))->ensureGroup('-1001', 'Group', ['mode' => 'lock_messages']); + $calendar = new CalendarService(new HebcalProvider(new FullHebcalFixtureClient()), $clock); + $formatter = new ShabbatTimesFormatter(); + $plain = $formatter->overview($settings, $calendar->snapshot($settings))['text']; + self::assertStringNotContainsString('ืกื’ื™ืจืช ื”ืงื‘ื•ืฆื”:', $plain); + self::assertStringNotContainsString('ืคืชื™ื—ืช ื”ืงื‘ื•ืฆื”:', $plain); + $settings['close_before_candles_minutes'] = 10; + $settings['open_after_havdalah_minutes'] = 15; + $changed = $formatter->overview($settings, $calendar->snapshot($settings))['text']; + self::assertStringContainsString('ืกื’ื™ืจืช ื”ืงื‘ื•ืฆื”: 2026-10-16 17:16', $changed); + self::assertStringContainsString('ืคืชื™ื—ืช ื”ืงื‘ื•ืฆื”: 2026-10-17 18:56', $changed); + self::assertStringContainsString('2026-10-17 18:41', $changed); + } +} diff --git a/tests/ShabbatTimesFormatterTest.php b/tests/ShabbatTimesFormatterTest.php new file mode 100644 index 0000000..bffb737 --- /dev/null +++ b/tests/ShabbatTimesFormatterTest.php @@ -0,0 +1,160 @@ + $language,'location_name' => 'Jerusalem','timezone_id' => 'Asia/Jerusalem','geoname_id' => 281184,'israel' => false,'candle_minutes' => 40,'havdalah_method' => 'minutes','havdalah_value' => 72,'mode' => 'lock_messages']; + } + + private function timestamp(string $date): int + { + return (new \DateTimeImmutable($date, new \DateTimeZone('Asia/Jerusalem')))->getTimestamp(); + } + + private function event(): array + { + return ['event_key' => '2026-05-22+2026-05-23','days' => ['2026-05-22','2026-05-23'],'candles_at' => $this->timestamp('2026-05-21 17:20'),'havdalah_at' => $this->timestamp('2026-05-23 19:12'),'close_at' => $this->timestamp('2026-05-21 17:10'),'open_at' => $this->timestamp('2026-05-23 19:27'),'intermediate' => [ + ['event_key' => '2026-05-23','days' => ['2026-05-23'],'candles_at' => $this->timestamp('2026-05-22 17:20'),'havdalah_at' => $this->timestamp('2026-05-22 19:12')], + ]]; + } + + private function snapshot(): array + { + return ['next_event' => $this->event(),'fetched_at' => $this->timestamp('2026-05-20 10:00'),'calendar_items' => [ + ['date' => '2026-05-22','category' => 'holiday','title' => 'Shavuot I','hebrew' => 'ืฉื‘ื•ืขื•ืช ืืณ','hdate' => '6 Sivan 5786','memo' => 'Giving of the Torah'], + ['date' => '2026-05-23','category' => 'holiday','title' => 'Shavuot II','hebrew' => 'ืฉื‘ื•ืขื•ืช ื‘ืณ','hdate' => '7 Sivan 5786'], + ['date' => '2026-05-23','category' => 'parashat','title' => 'Bamidbar','hebrew' => 'ื‘ืžื“ื‘ืจ','leyning' => ['torah' => 'Numbers 1:1โ€“4:20']], + ['date' => '2026-05-23','category' => 'mevarchim','title' => 'Mevarchim Chodesh Sivan','hebrew' => 'ืžื‘ืจื›ื™ื ื—ื•ื“ืฉ ืกื™ื•ื•ืŸ'], + ['date' => '2026-05-30','category' => 'holiday','title' => 'Unrelated event'], + ]]; + } + + public function testSequenceUsesEveryCandleTimeAndFinalHavdalahOnly(): void + { + $payload = (new ShabbatTimesFormatter())->format($this->settings(), $this->snapshot()); + $text = html_entity_decode(strip_tags($payload['text']), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + foreach (['Jerusalem','Asia/Jerusalem','2026-05-21 17:20','2026-05-22 17:20','2026-05-23 19:12','2026-05-23 19:27','2026-05-21 17:10','6 Sivan 5786','7 Sivan 5786','Bamidbar','Mevarchim Chodesh Sivan','40 minutes','72'] as $value) { + self::assertStringContainsString($value, $text); + } + self::assertStringNotContainsString('2026-05-22 19:12', $text); + self::assertStringNotContainsString('Unrelated event', $text); + foreach (['Hebcal', 'Giving of the Torah', 'Numbers 1:1โ€“4:20', '2026-05-20 10:00'] as $hidden) { + self::assertStringNotContainsString($hidden, $text); + } + self::assertStringNotContainsString('Candle lighting for 2026-05-22+2026-05-23', $text); + self::assertArrayNotHasKey('entities', $payload, 'Built-in HTML must remain distinct from native empty-entity text.'); + self::assertSame([], $payload['buttons']); + self::assertNull($payload['media']); + } + + public function testExplicitScheduledEventOverridesCurrentEvent(): void + { + $snapshot = $this->snapshot(); + $snapshot['current_event'] = ['event_key' => '2026-05-01','days' => ['2026-05-01'],'candles_at' => $this->timestamp('2026-04-30 17:00')]; + $text = (new ShabbatTimesFormatter())->format($this->settings(), $snapshot, $this->event())['text']; + self::assertStringContainsString('2026-05-23 19:12', $text); + self::assertStringNotContainsString('2026-04-30', $text); + } + + public function testAllSixGroupLanguagesAndSafeProviderMetadata(): void + { + $lang = new Lang(); + foreach (Lang::CODES as $language) { + $snapshot = $this->snapshot(); + $snapshot['calendar_items'][0]['title'] = ''; + $snapshot['calendar_items'][0]['hebrew'] = ''; + $payload = (new ShabbatTimesFormatter($lang))->format($this->settings($language), $snapshot); + self::assertStringContainsString($lang->text($language, 'times_heading'), $payload['text']); + self::assertStringContainsString($lang->text($language, 'times_sequence'), $payload['text']); + self::assertStringNotContainsString('