l4879c172995s1664k

Generated by CAI (Codebase AI Interface)
https://github.com/AfazTech/cai

LLM INSTRUCTIONS:
This file contains the complete project structure and the content of all source files.
- The "PROJECT STRUCTURE" section shows the project tree.
- The "FILES" section lists each file's content, separated by "@@@FILE: filename@@@".
- Use this information to understand the codebase, answer questions, or generate new code.
- Pay attention to file paths, dependencies, and the overall architecture.

The following section contains the complete project structure:

# PROJECT STRUCTURE

.
├── .cai.json (554B)
├── Archive.zip (1MB)
├── calendar.php (2KB)
├── commands.php (43KB)
├── composer.json (601B)
├── config.php (2KB)
├── cron.php (2KB)
├── database.php (26KB)
├── database.sqlite (40KB)
├── database.sqlite-shm (32KB)
├── database.sqlite-wal (28KB)
├── handlers.php (42KB)
├── helpers.php (14KB)
├── main.php (4KB)
├── phpunit.xml (627B)
├── reminder.php (6KB)
├── tests
│   ├── CalendarTest.php (2KB)
│   ├── TimeParserTest.php (5KB)
│   └── bootstrap.php (547B)
├── time_parser.php (9KB)
└── webhook.php (4KB)

The following section contains the content of project files.

@@@FILE: tests/bootstrap.php@@@

<?php

declare(strict_types=1);

/**
 * PHPUnit bootstrap for ToBeDo.
 *
 * Defines APP_TIMEZONE before loading the files under test, because both
 * time_parser.php and helpers.php use it as a default parameter value —
 * PHP evaluates that default at function-definition time, so the constant
 * must exist before the file is included.
 */

if (!defined('APP_TIMEZONE')) {
    define('APP_TIMEZONE', 'Asia/Tehran');
}

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../calendar.php';
require __DIR__ . '/../time_parser.php';


@@@FILE: .cai.json@@@

{
  "name": "ToBeDo",
  "description": "",
  "maxSizeMB": 50,
  "ignore": [
    ".git",
    "vendor",
    "node_modules",
    "dist",
    "build",
    "storage",
    ".env",
    "cai.json",
    "go.mod",
    "go.sum",
    ".cai"
  ],
  "include": [
    "*",
    ".gitignore",
    "README.md",
    "LICENSE",
    "Makefile",
    "Dockerfile"
  ],
  "useGitignore": false,
  "tree": true,
  "files": true,
  "chunkSize": 0,
  "tokenBudget": 0,
  "outputMode": "file",
  "compressEmpty": true,
  "orderBy": "size",
  "diffMode": false,
  "showStats": true
}


@@@FILE: composer.json@@@

{
    "name": "tobedo/tobedo",
    "description": "ToBeDo — a personal Telegram Todo bot for individuals, built on nili.",
    "type": "project",
    "license": "MIT",

    "require": {
        "php": ">=8.1",
        "ext-pdo": "*",
        "ext-pdo_sqlite": "*",
        "ext-mbstring": "*",
        "afaztech/neili": "^2.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.5"
    },
    "autoload-dev": {
        "psr-4": {
            "ToBeDo\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "phpunit"
    },
    "config": {
        "sort-packages": true
    }
}


@@@FILE: phpunit.xml@@@

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true"
         failOnRisky="true">
    <testsuites>
        <testsuite name="ToBeDo">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <file>calendar.php</file>
            <file>time_parser.php</file>
        </include>
    </source>
</phpunit>


@@@FILE: calendar.php@@@

<?php

declare(strict_types=1);

/**
 * Convert a Jalali (Solar Hijri) date to Gregorian.
 *
 * @return array{0:int,1:int,2:int} [year, month, day]
 */
function jalaliToGregorian(int $jy, int $jm, int $jd): array
{
    $jy += 1595;
    $days = -355668
        + (365 * $jy)
        + ((int) ($jy / 33) * 8)
        + (int) ((($jy % 33) + 3) / 4)
        + $jd
        + (($jm < 7) ? ($jm - 1) * 31 : (($jm - 7) * 30) + 186);

    $gy = 400 * (int) ($days / 146097);
    $days %= 146097;
    if ($days > 36524) {
        $gy += 100 * (int) (--$days / 36524);
        $days %= 36524;
        if ($days >= 365) {
            $days++;
        }
    }
    $gy += 4 * (int) ($days / 1461);
    $days %= 1461;
    if ($days > 365) {
        $gy += (int) (($days - 1) / 365);
        $days = ($days - 1) % 365;
    }
    $gd = $days + 1;

    $leap = (($gy % 4 === 0) && ($gy % 100 !== 0)) || ($gy % 400 === 0);
    $monthDays = [0, 31, $leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    $gm = 0;
    while ($gm < 13 && $gd > $monthDays[$gm]) {
        $gd -= $monthDays[$gm];
        $gm++;
    }

    return [$gy, $gm, $gd];
}

/**
 * Convert a Gregorian date to Jalali (Solar Hijri).
 *
 * @return array{0:int,1:int,2:int} [year, month, day]
 */
function gregorianToJalali(int $gy, int $gm, int $gd): array
{
    $gDaysInMonth = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];

    $jy = ($gy <= 1600) ? 0 : 979;
    $gy -= ($gy <= 1600) ? 621 : 1600;
    $gy2 = ($gm > 2) ? ($gy + 1) : $gy;
    $days = (365 * $gy)
        + ((int) (($gy2 + 3) / 4))
        - ((int) (($gy2 + 99) / 100))
        + ((int) (($gy2 + 399) / 400))
        - 80
        + $gd
        + $gDaysInMonth[$gm - 1];

    $jy += 33 * ((int) ($days / 12053));
    $days %= 12053;
    $jy += 4 * ((int) ($days / 1461));
    $days %= 1461;

    if ($days > 365) {
        $jy += (int) (($days - 1) / 365);
        $days = ($days - 1) % 365;
    }

    if ($days < 186) {
        $jm = 1 + (int) ($days / 31);
        $jd = 1 + ($days % 31);
    } else {
        $jm = 7 + (int) (($days - 186) / 30);
        $jd = 1 + (($days - 186) % 30);
    }

    return [$jy, $jm, $jd];
}


@@@FILE: tests/CalendarTest.php@@@

<?php

declare(strict_types=1);

namespace ToBeDo\Tests;

use PHPUnit\Framework\TestCase;

final class CalendarTest extends TestCase
{
    /**
     * Nowruz (Jalali new year) has a well-known Gregorian date each year.
     * These are canonical reference points.
     *
     * @return array<string,array{0:int,1:int,2:int,3:int,4:int,5:int}>
     */
    public static function nowruzProvider(): array
    {
        return [
            'Nowruz 1400' => [2021, 3, 21, 1400, 1, 1],
            'Nowruz 1402' => [2023, 3, 21, 1402, 1, 1],
            'Nowruz 1403' => [2024, 3, 20, 1403, 1, 1],
        ];
    }

    /**
     * @dataProvider nowruzProvider
     */
    public function testGregorianToJalaliAtNowruz(
        int $gy,
        int $gm,
        int $gd,
        int $jy,
        int $jm,
        int $jd
    ): void {
        $this->assertSame([$jy, $jm, $jd], gregorianToJalali($gy, $gm, $gd));
    }

    /**
     * @dataProvider nowruzProvider
     */
    public function testJalaliToGregorianAtNowruz(
        int $gy,
        int $gm,
        int $gd,
        int $jy,
        int $jm,
        int $jd
    ): void {
        $this->assertSame([$gy, $gm, $gd], jalaliToGregorian($jy, $jm, $jd));
    }

    public function testRoundTripAcrossRange(): void
    {
        // Sample dates spread across ~150 Jalali years. Day is capped at 28
        // to sidestep Esfand-length edge cases for non-leap years.
        for ($jy = 1300; $jy <= 1450; $jy += 7) {
            for ($jm = 1; $jm <= 12; $jm++) {
                for ($jd = 1; $jd <= 28; $jd += 9) {
                    [$gy, $gm, $gd] = jalaliToGregorian($jy, $jm, $jd);
                    [$ry, $rm, $rd] = gregorianToJalali($gy, $gm, $gd);
                    $this->assertSame(
                        [$jy, $jm, $jd],
                        [$ry, $rm, $rd],
                        "Round-trip mismatch for Jalali {$jy}/{$jm}/{$jd}"
                    );
                }
            }
        }
    }

    public function testLeapJalaliYearHasThirtyDayEsfand(): void
    {
        // 1403 is a leap Jalali year: Esfand has 30 days, so 1403/12/30
        // exists and maps to a real Gregorian date.
        [$gy, $gm, $gd] = jalaliToGregorian(1403, 12, 30);
        [$jy, $jm, $jd] = gregorianToJalali($gy, $gm, $gd);

        $this->assertSame([1403, 12, 30], [$jy, $jm, $jd]);
    }
}


@@@FILE: config.php@@@

<?php

declare(strict_types=1);

/**
 * Default bot timezone used when the user has not customized it.
 */
const APP_TIMEZONE = 'Asia/Tehran';

/** Seconds after which bot messages in groups are auto-deleted. */
const GROUP_AUTO_DELETE_SECONDS = 60;

/**
 * Default name assigned to a todo created from a voice message.
 */
const VOICE_TODO_DEFAULT_NAME = 'voice';

/** Support contact shown in the menu. */
const SUPPORT_USERNAME = '@MrAfaz';

/** Support URL used by the panel's URL button. */
const SUPPORT_URL = 'https://t.me/mrafaz';

/**
 * Telegram Bot API endpoint used by the Neili client.
 */
const BOT_API_URL = 'https://t.afaztech.ir/bot';

/**
 * Interval (seconds) between periodic SQLite WAL checkpoints.
 */
const WAL_CHECKPOINT_INTERVAL_SECONDS = 300;

/**
 * Words that open the user menu in group chats.
 * When a member sends one of these words, the bot opens the user panel.
 * If the message is a reply to a voice message, a voice todo is
 * registered for the replier instead.
 */
const PANEL_TRIGGER_WORDS = ['توبیدو', 'تودو'];

/**
 * Preset timezones offered as inline buttons in /settings.
 * Keys are valid PHP timezone identifiers; values are Persian labels.
 */
const TIMEZONE_PRESETS = [
    'Asia/Tehran'         => 'تهران',
    'Asia/Baghdad'        => 'بغداد',
    'Asia/Dubai'          => 'دبی',
    'Asia/Kabul'          => 'کابل',
    'Asia/Karachi'        => 'کراچی',
    'Asia/Istanbul'       => 'استانبول',
    'Europe/London'       => 'لندن',
    'Europe/Berlin'       => 'برلین',
    'Europe/Moscow'       => 'مسکو',
    'America/New_York'    => 'نیویورک',
    'America/Los_Angeles' => 'لس‌آنجلس',
    'Asia/Tokyo'          => 'توکیو',
    'Asia/Shanghai'       => 'شانگهای',
    'Australia/Sydney'    => 'سیدنی',
    'UTC'                 => 'UTC',
];

/**
 * Minimal .env loader. Existing environment variables are NOT overridden.
 */
$envFile = __DIR__ . '/.env';
if (is_file($envFile)) {
    foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
        $line = trim($line);
        if ($line === '' || $line[0] === '#') {
            continue;
        }
        if (!str_contains($line, '=')) {
            continue;
        }
        [$key, $value] = explode('=', $line, 2);
        $key   = trim($key);
        $value = trim($value, " \t\"'");
        if (getenv($key) === false) {
            putenv($key . '=' . $value);
            $_ENV[$key] = $value;
        }
    }
}

$token = (string) (getenv('BOT_TOKEN') ?: '');
if ($token === '') {
    fwrite(STDERR, "ERROR: BOT_TOKEN environment variable is not set.\n");
    fwrite(STDERR, "Copy .env.example to .env and fill in your bot token.\n");
    exit(1);
}

return [
    'bot_token'   => $token,
    'bot_api_url' => BOT_API_URL,
    'db_path'     => __DIR__ . '/database.sqlite',
];


@@@FILE: cron.php@@@

<?php

declare(strict_types=1);

/**
 * One-shot worker entry point for cron / webhook mode.
 *
 * In polling mode (main.php), the reminder worker runs inside the
 * long-lived Revolt event loop via EventLoop::repeat(). In webhook mode
 * there is no long-lived loop — the process only runs when an update
 * arrives — so periodic work must be scheduled externally (e.g. system
 * cron).
 *
 * This script runs the reminder worker (interval reminders + due
 * notifications + recurring cycle rollover) exactly once, plus a WAL
 * checkpoint, then exits.
 *
 * Suggested crontab entry (every minute):
 *   * * * * * /usr/bin/php /path/to/cron.php >> /var/log/tobedo-cron.log 2>&1
 *
 * The worker is idempotent: Database::claimReminderSent() and
 * claimDueNotification() are atomic UPDATE claims, so running this more
 * frequently than every 30s is safe. A non-blocking flock guards against
 * overlap if a single tick runs longer than the cron interval.
 */

require __DIR__ . '/vendor/autoload.php';

/* config.php must load first: it defines APP_TIMEZONE, which both
 * helpers.php and time_parser.php evaluate at function-definition time. */
$config = require __DIR__ . '/config.php';

// Only the files the worker actually needs — commands.php is not loaded.
require __DIR__ . '/database.php';
require __DIR__ . '/calendar.php';
require __DIR__ . '/helpers.php';
require __DIR__ . '/time_parser.php';
require __DIR__ . '/reminder.php';

use Neili\Client;
use Neili\Settings;

date_default_timezone_set(APP_TIMEZONE);

/* -------------------------------------------------------------------------
 | Single-instance lock
 |
 | Held for the lifetime of the script (released automatically on exit).
 | If a previous tick is still running, exit silently with a success code
 | so cron does not flag the run as failed.
 * ---------------------------------------------------------------------- */
$lockPath = sys_get_temp_dir() . '/tobedo-cron.lock';
$lock     = fopen($lockPath, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "cron worker: another instance is already running; exiting.\n");
    exit(0);
}

$db = new Database($config['db_path']);

$settings = (new Settings())->setApiUrl($config['bot_api_url'])
    ->setAccessToken($config['bot_token']);

$client = new Client($settings);
$logger = $settings->getLogger();

$logger->info('cron worker: tick start');

try {
    runReminderWorker($client, $db, $logger);
} catch (\Throwable $e) {
    $logger->error('cron worker: runReminderWorker failed: ' . $e->getMessage());
}

/* Keep the WAL sidecar bounded when the long-lived main.php loop is not
 * running (i.e. webhook deployments). */
try {
    $db->checkpoint();
} catch (\Throwable $e) {
    $logger->error('cron worker: WAL checkpoint failed: ' . $e->getMessage());
}

$logger->info('cron worker: tick done');

flock($lock, LOCK_UN);
fclose($lock);


@@@FILE: main.php@@@

<?php

declare(strict_types=1);

/**
 * ToBeDo — long-polling entry point (event-loop mode).
 *
 * Starts a Neili Poller and registers every update handler on it. The
 * background reminder worker and WAL checkpoints are scheduled on the
 * same Revolt event loop, so a single long-running process handles
 * updates, reminders, and housekeeping.
 *
 * For webhook deployments use webhook.php + cron.php instead.
 */

require __DIR__ . '/vendor/autoload.php';

$config = require __DIR__ . '/config.php';

require __DIR__ . '/database.php';
require __DIR__ . '/calendar.php';
require __DIR__ . '/helpers.php';
require __DIR__ . '/time_parser.php';
require __DIR__ . '/commands.php';
require __DIR__ . '/reminder.php';
require __DIR__ . '/handlers.php';

use Neili\Client;
use Neili\Poller;
use Neili\Settings;
use Revolt\EventLoop;

date_default_timezone_set(APP_TIMEZONE);

$db = new Database($config['db_path']);

$settings = (new Settings())->setApiUrl($config['bot_api_url'])
    ->setAccessToken($config['bot_token'])
    ->setPollerTimeout(30)
    ->setPollerBackoffBase(1)
    ->setPollerMaxBackoff(32)
    ->setPollerMaxConcurrency(20);

$client = new Client($settings);
$logger = $settings->getLogger();

try {
    $me = $client->getMe()->await();
    $logger->info('ToBeDo started as @' . ($me['result']['username'] ?? '?'));
} catch (\Throwable $e) {
    $logger->error('getMe failed: ' . $e->getMessage());
}

/**
 * In-memory FSM state for text/description/due editing, keyed by Telegram
 * user id. Entries: ['mode' => 'text'|'description'|'due', 'todoId' => int,
 *                   'filter' => string, 'page' => int,
 *                   'chatType' => string,
 *                   'listMessageId' => int|null,
 *                   'promptMessageId' => int|null]
 *
 * @var array<int,array<string,mixed>> $editState
 */
$editState = [];

/**
 * In-memory state for the button-driven "add task" wizard, keyed by
 * Telegram user id. Entries:
 *   ['step' => 'name'|'description'|'type'|'due'|'recurrence',
 *    'name' => string, 'description' => ?string,
 *    'dueAt' => ?int, 'recurrence' => ?string,
 *    'chatType' => string, 'personal' => bool,
 *    'from' => ?array, 'promptId' => int]
 *
 * @var array<int,array<string,mixed>> $addState
 */
$addState = [];

$poller = new Poller($client);
registerToBeDoHandlers($poller, $client, $db, $logger, $editState, $addState);

/* -------------------------------------------------------------------------
 | Background reminder loop scheduling
 |
 | We use Revolt's EventLoop::repeat() rather than Amp\async(). The latter
 | returns a Future which, if not referenced, can be garbage collected —
 | cancelling the underlying fiber and silently stopping the reminder loop.
 | EventLoop::repeat keeps the callback alive for the event loop's lifetime.
 |
 | The worker is guarded by an in-process flag so two overlapping ticks
 | cannot run concurrently. The DB-level claim in reminder.php is the real
 | concurrency safety net; the flag merely avoids wasting work.
 * ---------------------------------------------------------------------- */
$reminderRunning = false;
$reminderWorker  = static function () use ($client, $db, $logger, &$reminderRunning): void {
    if ($reminderRunning) {
        return;
    }
    $reminderRunning = true;
    try {
        runReminderWorker($client, $db, $logger);
    } finally {
        $reminderRunning = false;
    }
};

// Kick off the worker: first run 5 seconds after startup, then every 30 seconds.
EventLoop::delay(5, $reminderWorker);
EventLoop::repeat(30, $reminderWorker);
$logger->info('Reminder worker scheduled (first run in 5s, then every 30s).');

/* -------------------------------------------------------------------------
 | Periodic WAL checkpoint
 |
 | SQLite's WAL file grows as writes accumulate. Without an explicit
 | checkpoint the -wal sidecar can reach several megabytes. Truncate it
 | every WAL_CHECKPOINT_INTERVAL_SECONDS to keep it bounded.
 * ---------------------------------------------------------------------- */
EventLoop::repeat(WAL_CHECKPOINT_INTERVAL_SECONDS, static function () use ($db, $logger): void {
    try {
        $db->checkpoint();
    } catch (\Throwable $e) {
        $logger->error('WAL checkpoint failed: ' . $e->getMessage());
    }
});

$poller->start();


@@@FILE: webhook.php@@@

<?php

declare(strict_types=1);

/**
 * ToBeDo — webhook entry point.
 *
 * Two roles share this file:
 *
 *   1. CLI setup / inspection:
 *        php webhook.php set <public-url>   — register the webhook URL
 *        php webhook.php remove             — delete the webhook
 *        php webhook.php info               — print getWebhookInfo
 *
 *      When registering, the optional WEBHOOK_SECRET env var is passed as
 *      Telegram's secret_token; the same value must then be set for the
 *      HTTP endpoint so incoming requests can be verified.
 *
 *   2. HTTP endpoint (called by Telegram):
 *      A single update arrives per request, is dispatched through the
 *      shared handlers, and a 200 is always returned so Telegram does not
 *      retry. Reminder / due notifications and WAL checkpoints are NOT
 *      handled here — schedule cron.php every minute for those (see
 *      cron.php for the crontab line).
 *
 * The same handlers registered in main.php (polling mode) are reused
 * here via handlers.php, so both deployment modes stay in sync.
 */

require __DIR__ . '/vendor/autoload.php';

$config = require __DIR__ . '/config.php';

require __DIR__ . '/database.php';
require __DIR__ . '/calendar.php';
require __DIR__ . '/helpers.php';
require __DIR__ . '/time_parser.php';
require __DIR__ . '/commands.php';
require __DIR__ . '/reminder.php';
require __DIR__ . '/handlers.php';

use Neili\Client;
use Neili\Settings;

date_default_timezone_set(APP_TIMEZONE);

/**
 * Minimal webhook dispatcher with the same registration surface as
 * Neili\Poller (onMessage / onCallbackQuery / onMyChatMember). Only the
 * update types ToBeDo actually handles are exposed.
 */
final class WebhookDispatcher
{
    /** @var array<string,array<int,callable>> */
    private array $handlers = [];

    public function onMessage(callable $cb): void { $this->handlers['message'][] = $cb; }
    public function onCallbackQuery(callable $cb): void { $this->handlers['callback_query'][] = $cb; }
    public function onMyChatMember(callable $cb): void { $this->handlers['my_chat_member'][] = $cb; }

    /**
     * Dispatch an update to every registered handler for its type.
     * Unknown update types are silently ignored.
     */
    public function dispatch(array $update): void
    {
        foreach ($this->handlers as $type => $callbacks) {
            if (isset($update[$type])) {
                foreach ($callbacks as $cb) {
                    $cb($update);
                }
                return;
            }
        }
    }
}

$db = new Database($config['db_path']);

$settings = (new Settings())->setApiUrl($config['bot_api_url'])
    ->setAccessToken($config['bot_token']);

$client = new Client($settings);
$logger = $settings->getLogger();

/* -------------------------------------------------------------------------
 | CLI mode — webhook setup / inspection
 * ---------------------------------------------------------------------- */
if (PHP_SAPI === 'cli') {
    $cmd = (string) ($argv[1] ?? '');

    try {
        switch ($cmd) {
            case 'set':
                $url = (string) ($argv[2] ?? '');
                if ($url === '') {
                    fwrite(STDERR, "Usage: php webhook.php set <public-url>\n");
                    exit(1);
                }
                $secret = getenv('WEBHOOK_SECRET') ?: null;
                $client->setWebhook($url, null, null, null, true, $secret)->await();
                $suffix = $secret !== null ? ' (with secret_token)' : '';
                echo "✅ Webhook set to {$url}{$suffix}\n";
                break;

            case 'remove':
                $client->deleteWebhook(true)->await();
                echo "✅ Webhook removed (pending updates dropped)\n";
                break;

            case 'info':
                $info = $client->getWebhookInfo()->await();
                print_r($info);
                break;

            default:
                fwrite(STDERR, "Usage: php webhook.php {set <url>|remove|info}\n");
                exit(1);
        }
    } catch (\Throwable $e) {
        fwrite(STDERR, "Error: {$e->getMessage()}\n");
        exit(1);
    }

    exit(0);
}

/* -------------------------------------------------------------------------
 | HTTP mode — handle one incoming update
 * ---------------------------------------------------------------------- */
$editState = [];
$addState  = [];

$dispatcher = new WebhookDispatcher();
registerToBeDoHandlers($dispatcher, $client, $db, $logger, $editState, $addState);

try {
    $secret = getenv('WEBHOOK_SECRET') ?: null;
    $update = $client->handleUpdate($secret);
    $dispatcher->dispatch($update);
} catch (\Throwable $e) {
    // Never let a dispatch error trigger a Telegram retry storm.
    $logger->error('webhook dispatch failed: ' . $e->getMessage());
}

http_response_code(200);
echo 'ok';


@@@FILE: tests/TimeParserTest.php@@@

<?php

declare(strict_types=1);

namespace ToBeDo\Tests;

use PHPUnit\Framework\TestCase;

final class TimeParserTest extends TestCase
{
    private const NOW = 1_700_000_000; // 2023-11-14 22:13:20 UTC

    public function testRelativeMinutesLatin(): void
    {
        $this->assertSame(self::NOW + 600, parseDueTime('10m', self::NOW));
    }

    public function testRelativeHoursLatin(): void
    {
        $this->assertSame(self::NOW + 7_200, parseDueTime('2h', self::NOW));
    }

    public function testRelativeDaysLatin(): void
    {
        $this->assertSame(self::NOW + 3 * 86_400, parseDueTime('3d', self::NOW));
    }

    public function testRelativeWeeksLatin(): void
    {
        $this->assertSame(self::NOW + 7 * 86_400, parseDueTime('1w', self::NOW));
    }

    public function testRelativeMinutesPersian(): void
    {
        $this->assertSame(self::NOW + 1_800, parseDueTime('30 دقیقه', self::NOW));
    }

    public function testRelativeHoursPersian(): void
    {
        $this->assertSame(self::NOW + 7_200, parseDueTime('2 ساعت', self::NOW));
    }

    public function testPersianDigitsAreNormalized(): void
    {
        // Same value as "10m", but written with Persian digits.
        $this->assertSame(self::NOW + 600, parseDueTime('۱۰m', self::NOW));
    }

    /**
     * @return array<string,array{0:string}>
     */
    public static function clearTokenProvider(): array
    {
        return [
            'dash'          => ['-'],
            'zero'          => ['0'],
            'english clear' => ['clear'],
            'persian حذف'   => ['حذف'],
            'persian پاک'   => ['پاک'],
        ];
    }

    /**
     * @dataProvider clearTokenProvider
     */
    public function testClearTokensReturnZero(string $token): void
    {
        $this->assertSame(0, parseDueTime($token, self::NOW));
    }

    /**
     * @return array<string,array{0:string}>
     */
    public static function invalidInputProvider(): array
    {
        return [
            'plain word'       => ['not-a-time'],
            'bad month'        => ['2026-99-01 10:00'],
            'bad hour'         => ['2026-01-01 25:00'],
            'lone number'      => ['5'],
            'empty-ish spaces' => ['   '],
        ];
    }

    /**
     * @dataProvider invalidInputProvider
     */
    public function testInvalidInputReturnsNull(string $input): void
    {
        $this->assertNull(parseDueTime($input, self::NOW, 'UTC'));
    }

    public function testAbsoluteGregorianDateTime(): void
    {
        $ts = parseDueTime('2030-06-15 10:00', self::NOW, 'UTC');

        $this->assertNotNull($ts);
        $this->assertSame('2030-06-15 10:00', gmdate('Y-m-d H:i', $ts));
    }

    public function testAbsoluteJalaliDateTime(): void
    {
        // 1405/06/29 14:30 Jalali is 2026-09-20 14:30 Gregorian.
        $ts = parseDueTime('1405-06-29 14:30', self::NOW, 'UTC');

        $this->assertNotNull($ts);
        $this->assertSame('2026-09-20 14:30', gmdate('Y-m-d H:i', $ts));
    }

    public function testPastAbsoluteDateReturnsNull(): void
    {
        // A date strictly before $now must be rejected.
        $this->assertNull(parseDueTime('2020-01-01 00:00', self::NOW, 'UTC'));
    }

    /**
     * @return array<string,array{0:string,1:array<string,mixed>}>
     */
    public static function recurrenceProvider(): array
    {
        return [
            'daily english'    => ['daily 14:30', ['type' => 'daily', 'day' => null, 'hour' => 14, 'minute' => 30]],
            'daily persian'    => ['روزانه 09:05', ['type' => 'daily', 'day' => null, 'hour' => 9, 'minute' => 5]],
            'weekly monday'    => ['weekly mon 09:00', ['type' => 'weekly', 'day' => 1, 'hour' => 9, 'minute' => 0]],
            'weekly persian دوشنبه' => ['هفتگی دوشنبه 09:00', ['type' => 'weekly', 'day' => 1, 'hour' => 9, 'minute' => 0]],
            'weekly saturday'  => ['weekly sat 23:59', ['type' => 'weekly', 'day' => 6, 'hour' => 23, 'minute' => 59]],
        ];
    }

    /**
     * @param array<string,mixed> $expected
     * @dataProvider recurrenceProvider
     */
    public function testParseRecurrencePattern(string $input, array $expected): void
    {
        $this->assertSame($expected, parseRecurrencePattern($input));
    }

    public function testParseRecurrenceRejectsBadHour(): void
    {
        $this->assertNull(parseRecurrencePattern('daily 25:00'));
    }

    public function testParseRecurrenceRejectsUnknownDay(): void
    {
        $this->assertNull(parseRecurrencePattern('weekly someday 09:00'));
    }

    public function testComputeNextOccurrenceDaily(): void
    {
        // From 2024-01-01 20:00 UTC, "daily 08:00" → next day at 08:00.
        $from = (new \DateTimeImmutable('2024-01-01 20:00:00', new \DateTimeZone('UTC')))->getTimestamp();
        $next = computeNextOccurrence(
            ['type' => 'daily', 'day' => null, 'hour' => 8, 'minute' => 0],
            $from,
            'UTC'
        );

        $this->assertSame('2024-01-02 08:00', gmdate('Y-m-d H:i', $next));
    }

    public function testComputeNextOccurrenceWeekly(): void
    {
        // 2024-01-01 is a Monday (dow=1). From Monday 10:00, "weekly mon 09:00"
        // → next Monday (2024-01-08) at 09:00, not today.
        $from = (new \DateTimeImmutable('2024-01-01 10:00:00', new \DateTimeZone('UTC')))->getTimestamp();
        $next = computeNextOccurrence(
            ['type' => 'weekly', 'day' => 1, 'hour' => 9, 'minute' => 0],
            $from,
            'UTC'
        );

        $this->assertSame('2024-01-08 09:00', gmdate('Y-m-d H:i', $next));
    }
}


@@@FILE: reminder.php@@@

<?php

declare(strict_types=1);

use Neili\Client;
use Neili\KeyboardBuilder;

/**
 * Background reminder loop (interval reminders + due-time notifications +
 * recurring cycle rollover).
 *
 * Invoked periodically from main.php via Revolt\EventLoop::repeat(). The
 * caller keeps a single reference to the closure so it is not GC'd.
 *
 * Reminders are always delivered to the user's private chat with the bot
 * (Telegram private chat id === user id). Group chats are never targeted,
 * even for todos that were originally created inside a group.
 *
 * Concurrency: each todo is claimed via an atomic UPDATE before delivery,
 * so overlapping worker ticks (or a second process) cannot double-send.
 * A failed delivery releases the claim so the next tick retries.
 */
function runReminderWorker(Client $client, Database $db, $logger): void
{
    /** @var array<int,array{reminder_hours:int,calendar:string,timezone:string}> $prefsCache */
    $prefsCache = [];
    $getPrefs = function (int $uid) use ($db, &$prefsCache): array {
        if (!isset($prefsCache[$uid])) {
            $prefsCache[$uid] = $db->getUserPrefs($uid);
        }
        return $prefsCache[$uid];
    };

    try {
        /* ---------- interval reminders ---------- */
        $todos = $db->getTodosNeedingReminder();
        foreach ($todos as $todo) {
            // Private chat only: user_id equals the private chat id.
            $targetChatId = (int) ($todo['user_id'] ?? 0);
            if ($targetChatId === 0) {
                continue;
            }

            $todoId = (int) $todo['id'];

            // Atomically claim this reminder. If another tick already
            // claimed it (or the user reset the cycle), skip.
            if (!$db->claimReminderSent($todoId)) {
                continue;
            }

            $hours = (int) ($todo['reminder_hours'] ?? Database::DEFAULT_REMINDER_HOURS);
            $icon  = statusEmoji((string) $todo['status']);
            $body  = "⏰ یادآوری\n\n"
                . "این کار {$hours} ساعت است که به‌روزرسانی نشده:\n\n"
                . "{$icon} " . (string) $todo['text'] . "\n\n"
                . "برای دیدن لیست: /todos";

            $kb = new KeyboardBuilder();
            $kb->inlineRow(['▶️ شروع کردم' => "b:{$todoId}"]);

            try {
                $client->sendMessage($targetChatId, $body, withNav($kb->build()))->await();
                $logger->info("Reminder sent for todo {$todoId} to user {$targetChatId}");
            } catch (\Throwable $e) {
                // Release the claim so the next tick retries delivery.
                $db->rollbackReminderSent($todoId);
                $logger->error("Reminder send failed for todo {$todoId}: " . $e->getMessage());
            }
        }

        /* ---------- due notifications ---------- */
        $dueTodos = $db->getDueTodos();
        foreach ($dueTodos as $todo) {
            // Private chat only.
            $targetChatId = (int) ($todo['user_id'] ?? 0);
            if ($targetChatId === 0) {
                continue;
            }

            $todoId = (int) $todo['id'];

            // Atomically claim the due notification.
            if (!$db->claimDueNotification($todoId)) {
                continue;
            }

            $prefs   = $getPrefs((int) $todo['user_id']);
            $icon    = statusEmoji((string) $todo['status']);
            $recText = formatRecurrence($todo['recurrence'] ?? null);
            $recLine = $recText !== null ? "\n🔁 " . $recText : '';

            $body = "🔔 زمان انجام این کار رسید:\n\n"
                . "{$icon} " . (string) $todo['text'] . $recLine . "\n\n"
                . "برای دیدن لیست: /todos";

            $kb = new KeyboardBuilder();
            $kb->inlineRow(['▶️ شروع کردم' => "b:{$todoId}"]);

            try {
                $client->sendMessage($targetChatId, $body, withNav($kb->build()))->await();

                $rec = $todo['recurrence'] ?? null;
                if (!empty($rec)) {
                    $recArr = json_decode((string) $rec, true);
                    if (is_array($recArr)) {
                        $nextDue = computeNextOccurrence($recArr, time(), $prefs['timezone']);
                        $db->advanceRecurringTodo($todoId, $nextDue);
                    }
                    // If JSON is corrupt, leave due_notified_at set (claim
                    // already written) so the same broken row isn't retried
                    // forever.
                }

                $logger->info("Due notification sent for todo {$todoId} to user {$targetChatId}");
            } catch (\Throwable $e) {
                // Release the claim so the next tick retries delivery.
                $db->rollbackDueNotification($todoId);
                $logger->error("Due send failed for todo {$todoId}: " . $e->getMessage());
            }
        }

        /* ---------- rollover of recurring todos done in current cycle ---------- */
        $cycleDone = $db->getRecurringCycleDoneReadyToAdvance();
        foreach ($cycleDone as $todo) {
            $rec = $todo['recurrence'] ?? null;
            if (empty($rec)) {
                continue;
            }

            $recArr = json_decode((string) $rec, true);
            if (!is_array($recArr)) {
                continue;
            }

            $prefs   = $getPrefs((int) $todo['user_id']);
            $nextDue = computeNextOccurrence($recArr, time(), $prefs['timezone']);
            $db->resetRecurringCycleDone((int) $todo['id'], $nextDue);

            // Private chat only.
            $targetChatId = (int) ($todo['user_id'] ?? 0);
            if ($targetChatId > 0) {
                $recText = formatRecurrence((string) $rec);
                $recLine = $recText !== null ? "\n🔁 " . $recText : '';

                $body = "🔂 دوره جدید شروع شد\n\n"
                    . "⏳ " . (string) $todo['text'] . $recLine . "\n\n"
                    . "⏰ موعد جدید: " . formatDueAt($nextDue, $prefs);

                try {
                    $client->sendMessage($targetChatId, $body, withNav(null))->await();
                } catch (\Throwable $e) {
                    $logger->error("Recurring cycle notification failed for todo {$todo['id']}: " . $e->getMessage());
                }
            }

            $logger->info("Recurring todo {$todo['id']} reset to pending for next cycle");
        }
    } catch (\Throwable $e) {
        $logger->error('Reminder worker error: ' . $e->getMessage());
    }
}


@@@FILE: time_parser.php@@@

<?php

declare(strict_types=1);

/**
 * Parse a human-friendly time expression into a Unix timestamp.
 *
 * Accepts:
 *   - relative: 10m, 1h, 2d, 1w, "30 دقیقه"
 *   - Gregorian absolute: "2026-09-20 14:30" or "2026/09/20"
 *   - Jalali absolute:    "1405-06-29 14:30" (years 1200..1599)
 *   - time-only:          "14:30" (today or tomorrow in user tz)
 *
 * Returns:
 *   - null   → the input could not be parsed.
 *   - 0      → explicit "clear" marker.
 *   - int>0  → resolved absolute timestamp.
 */
function parseDueTime(string $input, ?int $now = null, string $timezone = APP_TIMEZONE): ?int
{
    $input = trim($input);
    if ($input === '') {
        return null;
    }
    $now = $now ?? time();

    try {
        $tz = new DateTimeZone($timezone);
    } catch (\Throwable $e) {
        $tz = new DateTimeZone(APP_TIMEZONE);
    }

    $input = normalizeDigits($input);

    $clearTokens = ['-', '0', 'clear', 'none', 'null', 'حذف', 'پاک'];
    if (in_array(mb_strtolower($input, 'UTF-8'), $clearTokens, true)) {
        return 0;
    }

    // Relative single-unit form: "10m", "1h", "2d", "1w", "30 دقیقه", ...
    if (preg_match('/^(\d+)\s*([\p{L}]+)$/u', $input, $m)) {
        $n       = (int) $m[1];
        $unit    = mb_strtolower($m[2], 'UTF-8');
        $seconds = match (true) {
            in_array($unit, ['m', 'min', 'mins', 'minute', 'minutes', 'د', 'دقیقه'], true) => 60,
            in_array($unit, ['h', 'hr', 'hrs', 'hour', 'hours', 'س', 'ساعت'], true)      => 3600,
            in_array($unit, ['d', 'day', 'days', 'ر', 'روز'], true)                       => 86400,
            in_array($unit, ['w', 'week', 'weeks', 'هفته'], true)                          => 604800,
            default => null,
        };
        if ($seconds !== null) {
            return $now + $n * $seconds;
        }
    }

    // Absolute date with optional time (year / month / day).
    if (preg_match(
        '/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/',
        $input,
        $m
    )) {
        $y  = (int) $m[1];
        $mo = (int) $m[2];
        $d  = (int) $m[3];
        $h  = isset($m[4]) && $m[4] !== '' ? (int) $m[4] : 0;
        $mi = isset($m[5]) && $m[5] !== '' ? (int) $m[5] : 0;
        $s  = isset($m[6]) && $m[6] !== '' ? (int) $m[6] : 0;

        if ($mo < 1 || $mo > 12 || $d < 1 || $d > 31 || $h > 23 || $mi > 59 || $s > 59) {
            return null;
        }

        // Jalali year range: 1200..1599 (covers ~1821..2220 Gregorian).
        if ($y >= 1200 && $y <= 1599) {
            [$gy, $gm, $gd] = jalaliToGregorian($y, $mo, $d);
        } else {
            [$gy, $gm, $gd] = [$y, $mo, $d];
        }

        $dt = new DateTimeImmutable(
            sprintf('%04d-%02d-%02d %02d:%02d:%02d', $gy, $gm, $gd, $h, $mi, $s),
            $tz
        );
        $ts = $dt->getTimestamp();

        return $ts > $now ? $ts : null;
    }

    // Time-only "HH:MM" → today at that time (user tz), or tomorrow if past.
    if (preg_match('/^\d{1,2}:\d{2}$/', $input)) {
        $today = (new DateTimeImmutable('now', $tz))->format('Y-m-d');
        $dt    = new DateTimeImmutable($today . ' ' . $input, $tz);
        $ts    = $dt->getTimestamp();
        if ($ts <= $now) {
            $ts += 86400;
        }
        return $ts;
    }

    return null;
}

/**
 * Parse a recurrence pattern such as:
 *   "daily 14:30"       → every day at 14:30
 *   "daily 14:5"        → every day at 14:05 (single-digit minute allowed)
 *   "weekly mon 09:00"  → every Monday at 09:00
 *   Persian equivalents: "روزانه 14:30", "هفتگی دوشنبه 09:00"
 *
 * Returns ['type' => 'daily'|'weekly', 'day' => int|null, 'hour' => int, 'minute' => int]
 * or null on failure. `day` uses the same convention as PHP's date('w'):
 * 0 = Sunday ... 6 = Saturday.
 */
function parseRecurrencePattern(string $input): ?array
{
    $input = trim($input);
    if ($input === '') {
        return null;
    }
    $input = normalizeDigits($input);
    $input = str_replace("\xE2\x80\x8C", ' ', $input); // ZWNJ → space
    $input = preg_replace('/\s+/u', ' ', $input) ?? $input;

    $dayMap = [
        'sun' => 0, 'sunday' => 0, 'یک شنبه' => 0, 'یکشنبه' => 0,
        'mon' => 1, 'monday' => 1, 'دوشنبه' => 1,
        'tue' => 2, 'tuesday' => 2, 'سه شنبه' => 2,
        'wed' => 3, 'wednesday' => 3, 'چهارشنبه' => 3,
        'thu' => 4, 'thursday' => 4, 'پنج شنبه' => 4, 'پنجشنبه' => 4,
        'fri' => 5, 'friday' => 5, 'جمعه' => 5,
        'sat' => 6, 'saturday' => 6, 'شنبه' => 6,
    ];

    if (preg_match('/^(?:daily|روزانه)\s+(\d{1,2}):(\d{1,2})$/u', $input, $m)) {
        $h  = (int) $m[1];
        $mi = (int) $m[2];
        if ($h > 23 || $mi > 59) {
            return null;
        }
        return ['type' => 'daily', 'day' => null, 'hour' => $h, 'minute' => $mi];
    }

    if (preg_match('/^(?:weekly|هفتگی)\s+([\p{L}\x{200c}\s]+?)\s+(\d{1,2}):(\d{1,2})$/u', $input, $m)) {
        $dayStr = mb_strtolower(trim($m[1]), 'UTF-8');
        $dayStr = str_replace("\xE2\x80\x8C", ' ', $dayStr);
        $dayStr = preg_replace('/\s+/u', ' ', $dayStr) ?? $dayStr;
        if (!isset($dayMap[$dayStr])) {
            return null;
        }
        $h  = (int) $m[2];
        $mi = (int) $m[3];
        if ($h > 23 || $mi > 59) {
            return null;
        }
        return ['type' => 'weekly', 'day' => $dayMap[$dayStr], 'hour' => $h, 'minute' => $mi];
    }

    return null;
}

/**
 * Compute the next Unix timestamp for a recurrence pattern, strictly after
 * $fromTs, using the given timezone for wall-clock computation.
 */
function computeNextOccurrence(array $rec, int $fromTs, string $timezone = APP_TIMEZONE): int
{
    try {
        $tz = new DateTimeZone($timezone);
    } catch (\Throwable $e) {
        $tz = new DateTimeZone(APP_TIMEZONE);
    }

    $dt = (new DateTimeImmutable('@' . $fromTs))->setTimezone($tz);

    $hour   = (int) ($rec['hour'] ?? 0);
    $minute = (int) ($rec['minute'] ?? 0);

    if (($rec['type'] ?? 'daily') === 'weekly') {
        $targetDow = (int) ($rec['day'] ?? 0);
        $candidate = $dt->setTime($hour, $minute, 0);
        for ($i = 0; $i < 8; $i++) {
            if ((int) $candidate->format('w') === $targetDow && $candidate->getTimestamp() > $fromTs) {
                return $candidate->getTimestamp();
            }
            $candidate = $candidate->modify('+1 day');
        }
        return $candidate->getTimestamp();
    }

    $candidate = $dt->setTime($hour, $minute, 0);
    if ($candidate->getTimestamp() <= $fromTs) {
        $candidate = $candidate->modify('+1 day');
    }
    return $candidate->getTimestamp();
}

/**
 * Parse `/todo` argument string into [name, description, dueAt, recurrenceJson, errorMessage].
 *
 * Supported leading flags (any order):
 *   -t <time>   | --time "<time>"
 *   -r <pattern>| --repeat "<pattern>"
 *
 * Optional description may be appended after a `|` separator:
 *   /todo نام کار | توضیحات
 *
 * Where <pattern> is: "daily HH:MM" | "weekly <day> HH:MM"
 * (also Persian: "روزانه HH:MM" | "هفتگی <روز> HH:MM").
 *
 * @return array{0:string,1:?string,2:?int,3:?string,4:?string}
 */
function parseTodoArgs(string $raw, string $timezone = APP_TIMEZONE): array
{
    $dueAt      = null;
    $recurrence = null;

    while (true) {
        if (preg_match('/^\s*(?:-t|--time)\s+("[^"]+"|\'[^\']+\'|\S+)\s*/iu', $raw, $m)) {
            $timeStr = trim($m[1], "\"'");
            $parsed  = parseDueTime($timeStr, null, $timezone);
            if ($parsed === null) {
                return ['', null, null, null, 'زمان نامعتبر: ' . $timeStr];
            }
            $dueAt = $parsed > 0 ? $parsed : null;
            $raw   = substr($raw, strlen($m[0]));
            continue;
        }

        if (preg_match(
            '/^\s*(?:-r|--repeat)\s+((?:daily|روزانه)\s+\d{1,2}:\d{1,2}|(?:weekly|هفتگی)\s+[^\d]+\d{1,2}:\d{1,2})\s*/iu',
            $raw,
            $m
        )) {
            $pattern = trim(preg_replace('/\s+/u', ' ', $m[1]) ?? $m[1]);
            $parsed  = parseRecurrencePattern($pattern);
            if ($parsed === null) {
                return ['', null, null, null, 'الگوی تکرار نامعتبر: ' . $pattern];
            }
            $recurrence = json_encode($parsed, JSON_UNESCAPED_UNICODE);
            $raw        = substr($raw, strlen($m[0]));
            continue;
        }

        break;
    }

    // If a recognized flag remains unconsumed, the pattern after it was malformed.
    if (preg_match('/^\s*(?:-r|--repeat)\b/i', $raw)) {
        return ['', null, null, null, 'الگوی تکرار نامعتبر. فرمت: daily HH:MM یا weekly <day> HH:MM'];
    }
    if (preg_match('/^\s*(?:-t|--time)\b/i', $raw)) {
        return ['', null, null, null, 'فرمت زمان نامعتبر. نمونه: 1h یا 2026-09-20 14:30 یا 1405-06-29 14:30'];
    }

    // Optional description via `|` separator: "/todo نام | توضیحات".
    $description = null;
    $raw         = trim($raw);
    if (str_contains($raw, '|')) {
        $parts       = explode('|', $raw, 2);
        $raw         = trim($parts[0]);
        $description = trim($parts[1]);
        if ($description === '') {
            $description = null;
        }
    }

    return [$raw, $description, $dueAt, $recurrence, null];
}


@@@FILE: helpers.php@@@

<?php

declare(strict_types=1);

use Neili\Client;
use function Amp\async;
use function Amp\delay;

function isGroupChat(string $chatType): bool
{
    return $chatType === 'group' || $chatType === 'supergroup';
}

function displayName(array $from): string
{
    $name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
    return $name !== '' ? $name : 'کاربر';
}

function statusEmoji(string $status): string
{
    return match ($status) {
        'pending'         => '⏳',
        'in_progress'     => '🔄',
        'done'            => '✅',
        'done_this_cycle' => '🔂',
        default           => '❓',
    };
}

function statusLabel(string $status): string
{
    return match ($status) {
        'pending'         => '⏳ در انتظار',
        'in_progress'     => '🔄 در حال انجام',
        'done'            => '✅ انجام شده',
        'done_this_cycle' => '🔂 انجام‌شده در این دوره',
        default           => '❓',
    };
}

function filterLabel(string $filter): string
{
    return match ($filter) {
        'active'  => 'در جریان',
        'pending' => 'در انتظار',
        'done'    => 'انجام شده',
        default   => 'همه',
    };
}

/**
 * Normalize Persian / Arabic digits to ASCII.
 */
function normalizeDigits(string $input): string
{
    return strtr($input, [
        '۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9',
        '٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9',
    ]);
}

/**
 * Check whether a given timezone identifier is valid.
 */
function isValidTimezone(string $tz): bool
{
    try {
        new DateTimeZone($tz);
        return true;
    } catch (\Throwable $e) {
        return false;
    }
}

/**
 * Format a duration in seconds as a short Persian label (e.g. "5 دقیقه").
 */
function formatSeconds(int $seconds): string
{
    if ($seconds <= 0) {
        return 'خاموش';
    }
    if ($seconds < 60) {
        return "{$seconds} ثانیه";
    }
    if ($seconds < 3600) {
        return (int) ($seconds / 60) . ' دقیقه';
    }
    return (int) ($seconds / 3600) . ' ساعت';
}

/**
 * Format a Unix timestamp according to the user's calendar + timezone.
 */
function formatDateTime(int $ts, array $prefs): string
{
    $tzName = (string) ($prefs['timezone'] ?? APP_TIMEZONE);
    try {
        $tz = new DateTimeZone($tzName);
    } catch (\Throwable $e) {
        $tz = new DateTimeZone(APP_TIMEZONE);
    }

    $dt = (new DateTimeImmutable('@' . $ts))->setTimezone($tz);
    $y  = (int) $dt->format('Y');
    $m  = (int) $dt->format('n');
    $d  = (int) $dt->format('j');
    $h  = $dt->format('H');
    $i  = $dt->format('i');

    if (($prefs['calendar'] ?? 'gregorian') === 'jalali') {
        [$jy, $jm, $jd] = gregorianToJalali($y, $m, $d);
        return sprintf('%04d/%02d/%02d %s:%s', $jy, $jm, $jd, $h, $i);
    }

    return sprintf('%04d-%02d-%02d %s:%s', $y, $m, $d, $h, $i);
}

/**
 * Render a due timestamp with a short relative suffix, in user's tz/calendar.
 */
function formatDueAt(int $ts, array $prefs): string
{
    $diff = $ts - time();
    $abs  = abs($diff);

    if ($abs < 60) {
        $rel = $abs . ' ثانیه';
    } elseif ($abs < 3600) {
        $rel = (int) floor($abs / 60) . ' دقیقه';
    } elseif ($abs < 86400) {
        $rel = (int) floor($abs / 3600) . ' ساعت';
    } else {
        $rel = (int) floor($abs / 86400) . ' روز';
    }

    $suffix = $diff < 0 ? ' پیش' : ' دیگر';
    return formatDateTime($ts, $prefs) . ' (' . $rel . $suffix . ')';
}

/**
 * Render an absolute timestamp as a date string in the user's tz/calendar.
 */
function formatStamp(int $ts, array $prefs): string
{
    return formatDateTime($ts, $prefs);
}

/**
 * Render the stored recurrence JSON as a short Persian label.
 */
function formatRecurrence(?string $recurrence): ?string
{
    if ($recurrence === null || $recurrence === '') {
        return null;
    }
    $rec = json_decode($recurrence, true);
    if (!is_array($rec)) {
        return null;
    }

    $hour   = str_pad((string) ($rec['hour'] ?? 0), 2, '0', STR_PAD_LEFT);
    $minute = str_pad((string) ($rec['minute'] ?? 0), 2, '0', STR_PAD_LEFT);
    $time   = "{$hour}:{$minute}";

    if (($rec['type'] ?? '') === 'daily') {
        return "هر روز {$time}";
    }

    if (($rec['type'] ?? '') === 'weekly') {
        $dayNames = ['یک شنبه', 'دوشنبه', 'سه شنبه', 'چهارشنبه', 'پنج شنبه', 'جمعه', 'شنبه'];
        $day      = (int) ($rec['day'] ?? 0);
        return 'هر ' . ($dayNames[$day] ?? '?') . " {$time}";
    }

    return null;
}

/**
 * The navigation row appended to every outgoing keyboard except the panel
 * itself and the delete confirmation screen: a single back button so the
 * user can always return to the main menu with one tap.
 */
function navRow(): array
{
    return [
        ['text' => '🔙 بازگشت', 'callback_data' => 'pnl'],
    ];
}

/**
 * Append the back row to a Telegram inline-keyboard array. Skips the append
 * when the keyboard already contains a `pnl` button, to avoid a duplicated
 * row.
 */
function withNav(?array $keyboard): array
{
    $rows = $keyboard['inline_keyboard'] ?? [];

    foreach ($rows as $row) {
        foreach ($row as $button) {
            if (($button['callback_data'] ?? '') === 'pnl') {
                return ['inline_keyboard' => $rows];
            }
        }
    }

    $rows[] = navRow();

    return ['inline_keyboard' => $rows];
}

/**
 * Append a two-button navigation row (back + help) to a Telegram inline
 * keyboard array. Used specifically by the todo-list screen so the user
 * can always jump back to the menu or open the help from one place.
 * Skips the append when the keyboard already contains a `pnl` button.
 */
function withListNav(?array $keyboard): array
{
    $rows = $keyboard['inline_keyboard'] ?? [];

    foreach ($rows as $row) {
        foreach ($row as $button) {
            if (($button['callback_data'] ?? '') === 'pnl') {
                return ['inline_keyboard' => $rows];
            }
        }
    }

    $rows[] = [
        ['text' => '🔙 بازگشت', 'callback_data' => 'pnl'],
        ['text' => '❓ راهنما', 'callback_data' => 'hlp'],
    ];

    return ['inline_keyboard' => $rows];
}

/**
 * Fetch a todo by id and verify ownership by the given user. Answers the
 * callback query with an error toast and returns null when the todo does
 * not exist or belongs to another user.
 *
 * Must be called from within a fiber (all Neili callbacks are).
 */
function requireOwnedTodo(
    Client $client,
    Database $db,
    int $todoId,
    int $userId,
    string $cqId
): ?array {
    $todo = $db->getTodo($todoId);
    if ($todo === null || (int) $todo['user_id'] !== $userId) {
        $client->answerCallbackQuery($cqId, 'این کار متعلق به شما نیست.', true)->await();
        return null;
    }
    return $todo;
}

/**
 * Schedule deletion of a bot message after a delay. Runs in a background
 * fiber (via Amp\async + Amp\delay) so callers are not blocked.
 *
 * NOTE: Amp\delay() expects seconds (not milliseconds) and, under Amp v3,
 * fiber-based coroutines are plain callables — they must NOT contain a
 * `yield` statement, since a closure with `yield` becomes a Generator
 * that async() invokes but nobody iterates, so its body would never run.
 *
 * Also drops the MessageContentCache entry for the deleted message so a
 * future edit on the same (chat, message) pair is not mistakenly skipped.
 */
function scheduleMessageDeletion(Client $client, int $chatId, int $messageId, int $delaySeconds): void
{
    async(function () use ($client, $chatId, $messageId, $delaySeconds): void {
        delay($delaySeconds);
        try {
            $client->deleteMessage($chatId, $messageId)->await();
        } catch (\Throwable $e) {
            // Message may already be gone or bot lacks delete permission.
        }
        // Forget either way: if the message is gone, a stale cache entry
        // would otherwise cause later edits on that id to be skipped.
        MessageContentCache::forget($chatId, $messageId);
    });
}

/**
 * Send a message via the client, always attaching the navigation row, and
 * — when the target chat is a group with auto-delete enabled — schedule
 * its deletion.
 *
 * The delay is resolved as follows (in order):
 *   1. explicit $deleteSeconds argument (0 = never delete)
 *   2. the per-group setting from the `group_settings` table
 *
 * Pass `$skipNav = true` to send the message without the back button
 * (used for the main panel and for screens that must not have extra rows).
 *
 * On failure the returned array carries `['ok' => false, 'error' => ...]`
 * so callers can distinguish a genuine delivery failure from a Telegram
 * result with no message_id. Successful responses retain Telegram's own
 * payload unchanged.
 *
 * @return array<string,mixed>
 */
function sendAuto(
    Client $client,
    Database $db,
    int $chatId,
    string $chatType,
    string $text,
    ?array $keyboard = null,
    ?int $deleteSeconds = null,
    bool $skipNav = false
): array {
    if (!$skipNav) {
        $keyboard = withNav($keyboard);
    }

    try {
        $result = $client->sendMessage($chatId, $text, $keyboard)->await();
    } catch (\Throwable $e) {
        return ['ok' => false, 'error' => $e->getMessage()];
    }

    if (isGroupChat($chatType)) {
        $seconds = $deleteSeconds;
        if ($seconds === null) {
            $seconds = $db->getGroupAutoDelete($chatId);
        }

        if ($seconds > 0) {
            $messageId = (int) ($result['result']['message_id'] ?? 0);
            if ($messageId > 0) {
                scheduleMessageDeletion($client, $chatId, $messageId, $seconds);
            }
        }
    }

    return $result;
}

/**
 * Check whether the given user is an administrator or creator of a group.
 */
function isGroupAdmin(Client $client, int $chatId, int $userId): bool
{
    try {
        $result = $client->getChatMember($chatId, $userId)->await();
        $status = (string) ($result['result']['status'] ?? '');
        return in_array($status, ['administrator', 'creator'], true);
    } catch (\Throwable $e) {
        return false;
    }
}

/**
 * Send a stored voice file to the chat, answering the triggering callback
 * query and scheduling auto-deletion of the voice message when applicable
 * (group chats with auto-delete enabled).
 */
function playStoredVoice(
    Client $client,
    Database $db,
    $logger,
    string $cqId,
    int $chatId,
    string $chatType,
    string $fileId
): void {
    $client->answerCallbackQuery($cqId, '🎤 در حال ارسال ویس...')->await();

    try {
        $result = $client->sendVoice($chatId, $fileId)->await();

        if (isGroupChat($chatType)) {
            $seconds    = $db->getGroupAutoDelete($chatId);
            $voiceMsgId = (int) ($result['result']['message_id'] ?? 0);
            if ($seconds > 0 && $voiceMsgId > 0) {
                scheduleMessageDeletion($client, $chatId, $voiceMsgId, $seconds);
            }
        }
    } catch (\Throwable $e) {
        $logger->error('Voice replay failed: ' . $e->getMessage());
    }
}

/**
 * In-process cache of the last content written to each Telegram message.
 *
 * Telegram rejects edits whose new content (text + reply_markup) is
 * byte-identical to the current content with "message is not modified".
 * The Neili HTTP client logs that error before user code can intercept it,
 * so we avoid issuing the call altogether whenever we already know the
 * message holds the same content.
 *
 * IMPORTANT: every code path that edits a message must go through
 * editMessageCached() (or otherwise write to this cache). If a screen
 * edits a message directly via $client->editMessageText() without
 * updating the cache, a later edit through editMessageCached() can be
 * incorrectly short-circuited — the cache thinks the message still holds
 * an older screen while the actual message shows a different one, so the
 * user taps a button and nothing appears to happen.
 */
final class MessageContentCache
{
    /** @var array<string,string> "<chatId>:<messageId>" => md5(text|keyboard) */
    private static array $entries = [];

    /** Soft cap on the number of cached entries to keep memory bounded. */
    private const MAX_ENTRIES = 500;

    private static function key(int $chatId, int $messageId): string
    {
        return $chatId . ':' . $messageId;
    }

    public static function hash(string $text, ?array $keyboard): string
    {
        return md5($text . '|' . json_encode($keyboard));
    }

    public static function matches(int $chatId, int $messageId, string $hash): bool
    {
        return (self::$entries[self::key($chatId, $messageId)] ?? null) === $hash;
    }

    public static function put(int $chatId, int $messageId, string $hash): void
    {
        self::$entries[self::key($chatId, $messageId)] = $hash;

        if (count(self::$entries) > self::MAX_ENTRIES) {
            self::$entries = array_slice(self::$entries, -intdiv(self::MAX_ENTRIES, 2), null, true);
        }
    }

    public static function forget(int $chatId, int $messageId): void
    {
        unset(self::$entries[self::key($chatId, $messageId)]);
    }
}

/**
 * Edit a Telegram message, short-circuiting the API call when the content
 * is byte-identical to what was last written to that message by this
 * process (see MessageContentCache).
 *
 * Returns:
 *   true  → the message now holds the desired content (either the edit was
 *           issued, or the call was skipped/suppressed as a no-op)
 *   false → the edit failed for a reason other than "not modified"; the
 *           caller should fall back (typically to sending a fresh message)
 */
function editMessageCached(
    Client $client,
    int $chatId,
    int $messageId,
    string $text,
    ?array $keyboard = null
): bool {
    $hash = MessageContentCache::hash($text, $keyboard);

    if (MessageContentCache::matches($chatId, $messageId, $hash)) {
        return true;
    }

    try {
        $client->editMessageText($chatId, $messageId, $text, $keyboard)->await();
        MessageContentCache::put($chatId, $messageId, $hash);
        return true;
    } catch (\Throwable $e) {
        if (str_contains($e->getMessage(), 'message is not modified')) {
            // Telegram considers the content identical; remember the hash
            // so subsequent no-op edits skip the API entirely.
            MessageContentCache::put($chatId, $messageId, $hash);
            return true;
        }
        return false;
    }
}


@@@FILE: database.php@@@

<?php

declare(strict_types=1);

/**
 * Tiny SQLite layer for ToBeDo (per-user personal todos).
 * No ORM, no repository pattern.
 *
 * Convention: the `text` column stores the todo's display name; the
 * `description` column stores an optional longer description.
 */
class Database
{
    /** Default reminder interval in hours (used when user has no setting). */
    public const DEFAULT_REMINDER_HOURS = 24;

    /** Default calendar: 'gregorian' | 'jalali'. */
    public const DEFAULT_CALENDAR = 'gregorian';

    /** Default timezone used for parsing / rendering dates. */
    public const DEFAULT_TIMEZONE = 'Asia/Tehran';

    /** Default auto-delete delay (seconds) for bot messages in groups. */
    public const DEFAULT_GROUP_AUTO_DELETE_SECONDS = 60;

    /**
     * In-process set of user ids whose private-chat flag has already been
     * written during this process lifetime. Skips a redundant UPDATE on
     * every incoming private message.
     *
     * @var array<int,true>
     */
    private static array $privateChatMarked = [];

    private PDO $pdo;

    public function __construct(string $path)
    {
        $this->pdo = new PDO('sqlite:' . $path, null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ]);

        $this->pdo->exec('PRAGMA journal_mode = WAL');
        $this->pdo->exec('PRAGMA synchronous = NORMAL');
        $this->pdo->exec('PRAGMA foreign_keys = ON');
        $this->pdo->exec('PRAGMA busy_timeout = 5000');

        $this->migrate();
    }

    private function migrate(): void
    {
        $this->pdo->exec(
            "CREATE TABLE IF NOT EXISTS todos (
                id               INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id          INTEGER NOT NULL,
                chat_id          INTEGER NULL,
                text             TEXT    NOT NULL,
                description      TEXT    NULL,
                status           TEXT    NOT NULL DEFAULT 'pending',
                personal         INTEGER NOT NULL DEFAULT 0,
                created_at       INTEGER NOT NULL,
                updated_at       INTEGER NOT NULL,
                started_at       INTEGER NULL,
                completed_at     INTEGER NULL,
                reminder_sent_at INTEGER NULL,
                due_at           INTEGER NULL,
                due_notified_at  INTEGER NULL,
                recurrence       TEXT    NULL,
                voice_file_id    TEXT    NULL
            )"
        );

        // Ensure optional columns exist on legacy databases.
        $this->ensureColumn('todos', 'personal', 'INTEGER NOT NULL DEFAULT 0');
        $this->ensureColumn('todos', 'chat_id', 'INTEGER NULL');
        $this->ensureColumn('todos', 'started_at', 'INTEGER NULL');
        $this->ensureColumn('todos', 'reminder_sent_at', 'INTEGER NULL');
        $this->ensureColumn('todos', 'due_at', 'INTEGER NULL');
        $this->ensureColumn('todos', 'due_notified_at', 'INTEGER NULL');
        $this->ensureColumn('todos', 'recurrence', 'TEXT NULL');
        $this->ensureColumn('todos', 'description', 'TEXT NULL');
        $this->ensureColumn('todos', 'voice_file_id', 'TEXT NULL');

        $this->pdo->exec(
            "CREATE TABLE IF NOT EXISTS user_settings (
                user_id        INTEGER PRIMARY KEY,
                reminder_hours INTEGER NOT NULL DEFAULT 24,
                calendar       TEXT    NOT NULL DEFAULT 'gregorian',
                timezone       TEXT    NOT NULL DEFAULT 'Asia/Tehran',
                updated_at     INTEGER NOT NULL
            )"
        );

        // Ensure optional columns exist on legacy user_settings tables.
        $this->ensureColumn('user_settings', 'calendar', "TEXT NOT NULL DEFAULT 'gregorian'");
        $this->ensureColumn('user_settings', 'timezone', "TEXT NOT NULL DEFAULT 'Asia/Tehran'");
        // Tracks whether the user has ever opened a private chat with the bot.
        // Required because Telegram rejects sendMessage() to a user who has
        // never started a private conversation with the bot ("chat not found").
        $this->ensureColumn('user_settings', 'private_chat_started', 'INTEGER NOT NULL DEFAULT 0');

        $this->pdo->exec(
            "CREATE TABLE IF NOT EXISTS group_settings (
                chat_id             INTEGER PRIMARY KEY,
                auto_delete_seconds INTEGER NOT NULL DEFAULT 60,
                updated_at          INTEGER NOT NULL
            )"
        );

        // Base indexes.
        $this->pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos (user_id)'
        );
        $this->pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_todos_user_status ON todos (user_id, status)'
        );
        $this->pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_todos_due_at ON todos (due_at)'
        );

        // Composite indexes targeting the reminder worker's query shapes.
        // These avoid a full scan of `todos` every 30 seconds.
        $this->pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_todos_reminder_scan
             ON todos (status, reminder_sent_at, updated_at)'
        );
        $this->pdo->exec(
            'CREATE INDEX IF NOT EXISTS idx_todos_due_scan
             ON todos (status, due_notified_at, due_at)'
        );
    }

    /**
     * Add a column to a table if it is missing.
     */
    private function ensureColumn(string $table, string $column, string $definition): void
    {
        $columns = $this->pdo->query("PRAGMA table_info({$table})")->fetchAll();
        foreach ($columns as $c) {
            if (($c['name'] ?? '') === $column) {
                return;
            }
        }
        $this->pdo->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$definition}");
    }

    /**
     * Ensure a user_settings row exists for the given user (used before
     * updating individual flags like private_chat_started).
     */
    private function ensureUserRow(int $userId): void
    {
        $now  = time();
        $stmt = $this->pdo->prepare(
            'INSERT INTO user_settings (user_id, reminder_hours, calendar, timezone, private_chat_started, updated_at)
             VALUES (?, ?, ?, ?, 0, ?)
             ON CONFLICT(user_id) DO NOTHING'
        );
        $stmt->execute([$userId, self::DEFAULT_REMINDER_HOURS, self::DEFAULT_CALENDAR, self::DEFAULT_TIMEZONE, $now]);
    }

    /**
     * Mark that the user has opened a private chat with the bot. Call this
     * for any incoming update that originated from a private conversation.
     *
     * Uses an in-process set to skip the UPDATE once the flag has already
     * been written during this process lifetime — Telegram delivers
     * messages frequently, and re-writing the row on every message is
     * wasted I/O.
     */
    public function markPrivateChatStarted(int $userId): void
    {
        if (isset(self::$privateChatMarked[$userId])) {
            return;
        }

        $this->ensureUserRow($userId);

        $stmt = $this->pdo->prepare(
            'UPDATE user_settings SET private_chat_started = 1, updated_at = ?
             WHERE user_id = ? AND private_chat_started = 0'
        );
        $stmt->execute([time(), $userId]);

        // Remember even when rowCount was 0: the flag is already set in DB.
        self::$privateChatMarked[$userId] = true;
    }

    /**
     * Run a WAL checkpoint to keep the -wal sidecar file from growing
     * unbounded. Called periodically from the event loop.
     */
    public function checkpoint(): void
    {
        $this->pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
    }

    public function createTodo(
        int $userId,
        int $chatId,
        string $text,
        bool $personal = false,
        ?int $dueAt = null,
        ?string $recurrence = null,
        ?string $description = null,
        ?string $voiceFileId = null
    ): int {
        $now  = time();
        $stmt = $this->pdo->prepare(
            'INSERT INTO todos (user_id, chat_id, text, description, status, personal, created_at, updated_at, started_at, completed_at, reminder_sent_at, due_at, due_notified_at, recurrence, voice_file_id)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, NULL, ?, ?)'
        );
        $stmt->execute([$userId, $chatId, $text, $description, 'pending', $personal ? 1 : 0, $now, $now, $dueAt, $recurrence, $voiceFileId]);

        return (int) $this->pdo->lastInsertId();
    }

    public function getTodo(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM todos WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();

        return $row === false ? null : $row;
    }

    /**
     * Return all todos of a user filtered by:
     *   - "all"     : all statuses
     *   - "active"  : in_progress only (todos that have actually been started)
     *   - "pending" : pending only
     *   - "done"    : done + done_this_cycle
     *
     * Order: by id ascending (stable across status changes so that cycling
     * a todo's status does not visually jump the item to another page).
     */
    public function getUserTodos(int $userId, string $filter): array
    {
        $sql    = 'SELECT * FROM todos WHERE user_id = ?';
        $params = [$userId];

        if ($filter === 'active') {
            $sql .= " AND status = 'in_progress'";
        } elseif ($filter === 'pending') {
            $sql .= " AND status = 'pending'";
        } elseif ($filter === 'done') {
            $sql .= " AND status IN ('done','done_this_cycle')";
        }

        $sql .= ' ORDER BY id ASC';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        return $stmt->fetchAll();
    }

    /**
     * Map each of the user's todos (by id) to a stable 1-based display index
     * based on creation order.
     *
     * @return array<int,int> id => index (1-based)
     */
    public function getUserTodoIndexMap(int $userId): array
    {
        $stmt = $this->pdo->prepare('SELECT id FROM todos WHERE user_id = ? ORDER BY id ASC');
        $stmt->execute([$userId]);

        $map   = [];
        $index = 1;
        foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
            $map[(int) $id] = $index++;
        }

        return $map;
    }

    public function countActiveTodos(int $userId): int
    {
        $stmt = $this->pdo->prepare(
            "SELECT COUNT(*) FROM todos
             WHERE user_id = ? AND status IN ('pending','in_progress')"
        );
        $stmt->execute([$userId]);

        return (int) $stmt->fetchColumn();
    }

    /**
     * Advance a todo to the next state.
     *
     * Non-recurring:  pending → in_progress → done → pending
     * Recurring:      pending → in_progress → done_this_cycle → pending
     *
     * `done_this_cycle` marks a recurring todo as finished for the current
     * occurrence. The background worker rolls it back to `pending` (with a
     * fresh due time) once the current cycle's due time arrives.
     *
     * Also records:
     *   - started_at    when the todo enters `in_progress`
     *   - completed_at  when the todo enters `done` / `done_this_cycle`
     * Both are cleared when the todo returns to `pending` (fresh cycle).
     *
     * Resets `reminder_sent_at` and `due_notified_at` so both reminder cycles
     * restart from now.
     *
     * Concurrency: uses an optimistic lock on the previous status. If two
     * concurrent callers race, exactly one transition is applied; the loser
     * re-reads and returns the current state without double-cycling.
     */
    public function cycleTodo(int $id, int $userId): ?array
    {
        $todo = $this->getTodo($id);
        if ($todo === null || (int) $todo['user_id'] !== $userId) {
            return null;
        }

        $currentStatus = (string) $todo['status'];
        $isRecurring   = !empty($todo['recurrence']);

        $next = match ($currentStatus) {
            'pending'         => 'in_progress',
            'in_progress'     => $isRecurring ? 'done_this_cycle' : 'done',
            'done_this_cycle' => 'pending',
            'done'            => 'pending',
            default           => 'pending',
        };

        $now = time();

        $startedAt   = $todo['started_at']   ?? null;
        $completedAt = $todo['completed_at'] ?? null;

        switch ($next) {
            case 'in_progress':
                $startedAt   = $now;
                $completedAt = null;
                break;

            case 'done':
            case 'done_this_cycle':
                $completedAt = $now;
                break;

            case 'pending':
                $startedAt   = null;
                $completedAt = null;
                break;
        }

        $stmt = $this->pdo->prepare(
            'UPDATE todos SET status = ?, updated_at = ?, started_at = ?, completed_at = ?,
                    reminder_sent_at = NULL, due_notified_at = NULL
             WHERE id = ? AND user_id = ? AND status = ?'
        );
        $stmt->execute([$next, $now, $startedAt, $completedAt, $id, $userId, $currentStatus]);

        // rowCount() === 0 means a concurrent caller already advanced the
        // status. Return the freshly-read row so the UI reflects reality.
        return $this->getTodo($id);
    }

    /**
     * Flip the `personal` flag of a todo that belongs to the given user.
     */
    public function togglePersonal(int $id, int $userId): ?array
    {
        $todo = $this->getTodo($id);
        if ($todo === null || (int) $todo['user_id'] !== $userId) {
            return null;
        }

        $next = ((int) ($todo['personal'] ?? 0)) === 1 ? 0 : 1;
        $now  = time();

        $stmt = $this->pdo->prepare(
            'UPDATE todos SET personal = ?, updated_at = ? WHERE id = ? AND user_id = ?'
        );
        $stmt->execute([$next, $now, $id, $userId]);

        return $this->getTodo($id);
    }

    /**
     * Update the text (name) of a todo. Resets `reminder_sent_at` so the
     * reminder cycle restarts after each edit.
     */
    public function updateTodoText(int $id, int $userId, string $text): ?array
    {
        $todo = $this->getTodo($id);
        if ($todo === null || (int) $todo['user_id'] !== $userId) {
            return null;
        }

        $now  = time();
        $stmt = $this->pdo->prepare(
            'UPDATE todos SET text = ?, updated_at = ?, reminder_sent_at = NULL
             WHERE id = ? AND user_id = ?'
        );
        $stmt->execute([$text, $now, $id, $userId]);

        return $this->getTodo($id);
    }

    /**
     * Update (or clear) the description of a todo. Passing null clears the
     * description. Resets `reminder_sent_at` so the reminder cycle restarts.
     */
    public function updateTodoDescription(int $id, int $userId, ?string $description): ?array
    {
        $todo = $this->getTodo($id);
        if ($todo === null || (int) $todo['user_id'] !== $userId) {
            return null;
        }

        $now  = time();
        $stmt = $this->pdo->prepare(
            'UPDATE todos SET description = ?, updated_at = ?, reminder_sent_at = NULL
             WHERE id = ? AND user_id = ?'
        );
        $stmt->execute([$description, $now, $id, $userId]);

        return $this->getTodo($id);
    }

    /**
     * Set (or clear) the due time of a todo.
     * Passing null clears the due time and any previous notification mark.
     */
    public function setTodoDue(int $id, int $userId, ?int $dueAt): ?array
    {
        $todo = $this->getTodo($id);
        if ($todo === null || (int) $todo['user_id'] !== $userId) {
            return null;
        }

        $now  = time();
        $stmt = $this->pdo->prepare(
            'UPDATE todos SET due_at = ?, due_notified_at = NULL, updated_at = ?
             WHERE id = ? AND user_id = ?'
        );
        $stmt->execute([$dueAt, $now, $id, $userId]);

        return $this->getTodo($id);
    }

    public function deleteTodo(int $id, int $userId): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM todos WHERE id = ? AND user_id = ?');
        $stmt->execute([$id, $userId]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Get the user's reminder interval in hours.
     */
    public function getReminderHours(int $userId): int
    {
        return (int) $this->getUserPrefs($userId)['reminder_hours'];
    }

    /**
     * Get all user preferences in one call.
     *
     * @return array{reminder_hours:int,calendar:string,timezone:string}
     */
    public function getUserPrefs(int $userId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT reminder_hours, calendar, timezone FROM user_settings WHERE user_id = ?'
        );
        $stmt->execute([$userId]);
        $row = $stmt->fetch();

        if ($row === false) {
            return [
                'reminder_hours' => self::DEFAULT_REMINDER_HOURS,
                'calendar'       => self::DEFAULT_CALENDAR,
                'timezone'       => self::DEFAULT_TIMEZONE,
            ];
        }

        return [
            'reminder_hours' => (int) $row['reminder_hours'],
            'calendar'       => in_array((string) $row['calendar'], ['gregorian', 'jalali'], true)
                ? (string) $row['calendar']
                : self::DEFAULT_CALENDAR,
            'timezone'       => (string) $row['timezone'] !== ''
                ? (string) $row['timezone']
                : self::DEFAULT_TIMEZONE,
        ];
    }

    /**
     * Set the user's reminder interval in hours.
     */
    public function setReminderHours(int $userId, int $hours): void
    {
        $hours = max(0, min(720, $hours));
        $now   = time();

        $stmt = $this->pdo->prepare(
            'INSERT INTO user_settings (user_id, reminder_hours, calendar, timezone, updated_at)
             VALUES (?, ?, ?, ?, ?)
             ON CONFLICT(user_id) DO UPDATE SET
                 reminder_hours = excluded.reminder_hours,
                 updated_at     = excluded.updated_at'
        );
        $stmt->execute([$userId, $hours, self::DEFAULT_CALENDAR, self::DEFAULT_TIMEZONE, $now]);
    }

    /**
     * Set the user's preferred display calendar: 'gregorian' or 'jalali'.
     */
    public function setUserCalendar(int $userId, string $calendar): void
    {
        $calendar = in_array($calendar, ['gregorian', 'jalali'], true)
            ? $calendar
            : self::DEFAULT_CALENDAR;

        $now  = time();
        $stmt = $this->pdo->prepare(
            'INSERT INTO user_settings (user_id, reminder_hours, calendar, timezone, updated_at)
             VALUES (?, ?, ?, ?, ?)
             ON CONFLICT(user_id) DO UPDATE SET
                 calendar   = excluded.calendar,
                 updated_at = excluded.updated_at'
        );
        $stmt->execute([$userId, self::DEFAULT_REMINDER_HOURS, $calendar, self::DEFAULT_TIMEZONE, $now]);
    }

    /**
     * Set the user's display / parsing timezone (e.g. "Asia/Tehran").
     */
    public function setUserTimezone(int $userId, string $timezone): void
    {
        $now  = time();
        $stmt = $this->pdo->prepare(
            'INSERT INTO user_settings (user_id, reminder_hours, calendar, timezone, updated_at)
             VALUES (?, ?, ?, ?, ?)
             ON CONFLICT(user_id) DO UPDATE SET
                 timezone   = excluded.timezone,
                 updated_at = excluded.updated_at'
        );
        $stmt->execute([$userId, self::DEFAULT_REMINDER_HOURS, self::DEFAULT_CALENDAR, $timezone, $now]);
    }

    /**
     * Get the auto-delete delay (in seconds) for bot messages in a group.
     * Returns 0 when auto-delete is disabled for the group.
     */
    public function getGroupAutoDelete(int $chatId): int
    {
        $stmt = $this->pdo->prepare(
            'SELECT auto_delete_seconds FROM group_settings WHERE chat_id = ?'
        );
        $stmt->execute([$chatId]);
        $val = $stmt->fetchColumn();

        if ($val === false) {
            return self::DEFAULT_GROUP_AUTO_DELETE_SECONDS;
        }

        return max(0, (int) $val);
    }

    /**
     * Set the auto-delete delay (in seconds) for bot messages in a group.
     * 0 disables auto-deletion.
     */
    public function setGroupAutoDelete(int $chatId, int $seconds): void
    {
        $seconds = max(0, min(86400, $seconds));
        $now     = time();

        $stmt = $this->pdo->prepare(
            'INSERT INTO group_settings (chat_id, auto_delete_seconds, updated_at)
             VALUES (?, ?, ?)
             ON CONFLICT(chat_id) DO UPDATE SET
                 auto_delete_seconds = excluded.auto_delete_seconds,
                 updated_at          = excluded.updated_at'
        );
        $stmt->execute([$chatId, $seconds, $now]);
    }

    /**
     * Return active todos whose last activity exceeds the per-user reminder
     * threshold and which have not yet been reminded since their last update.
     *
     * Only users who have started a private chat with the bot are eligible:
     * Telegram rejects sendMessage() to users who never opened a DM.
     */
    public function getTodosNeedingReminder(): array
    {
        $now = time();
        $sql = 'SELECT t.*,
                    COALESCE(s.reminder_hours, ' . self::DEFAULT_REMINDER_HOURS . ') AS reminder_hours
                FROM todos t
                INNER JOIN user_settings s ON s.user_id = t.user_id
                WHERE t.status IN (\'pending\',\'in_progress\')
                  AND s.private_chat_started = 1
                  AND t.reminder_sent_at IS NULL
                  AND COALESCE(s.reminder_hours, ' . self::DEFAULT_REMINDER_HOURS . ') > 0
                  AND (? - t.updated_at) >= (COALESCE(s.reminder_hours, ' . self::DEFAULT_REMINDER_HOURS . ') * 3600)';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$now]);

        return $stmt->fetchAll();
    }

    /**
     * Atomically claim a reminder for the given todo: marks
     * `reminder_sent_at` only if it was NULL. Returns true when the claim
     * succeeded (i.e. this caller is the one that should deliver the
     * reminder). Prevents duplicate deliveries when two worker ticks
     * overlap.
     */
    public function claimReminderSent(int $id): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE todos SET reminder_sent_at = ? WHERE id = ? AND reminder_sent_at IS NULL'
        );
        $stmt->execute([time(), $id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Release a reminder claim that failed to be delivered, so the next
     * worker tick retries.
     */
    public function rollbackReminderSent(int $id): void
    {
        $stmt = $this->pdo->prepare('UPDATE todos SET reminder_sent_at = NULL WHERE id = ?');
        $stmt->execute([$id]);
    }

    public function markReminderSent(int $id): void
    {
        $stmt = $this->pdo->prepare('UPDATE todos SET reminder_sent_at = ? WHERE id = ?');
        $stmt->execute([time(), $id]);
    }

    /**
     * Active todos whose due time has just passed and which have not been
     * notified yet for the current due cycle.
     *
     * Filtered to users who have a private chat with the bot (see
     * getTodosNeedingReminder() for the reason).
     */
    public function getDueTodos(): array
    {
        $now = time();
        $sql = 'SELECT t.* FROM todos t
                INNER JOIN user_settings s ON s.user_id = t.user_id
                WHERE t.status IN (\'pending\',\'in_progress\')
                  AND s.private_chat_started = 1
                  AND t.due_at IS NOT NULL
                  AND t.due_at <= ?
                  AND t.due_notified_at IS NULL
                ORDER BY t.due_at ASC';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$now]);

        return $stmt->fetchAll();
    }

    /**
     * Atomically claim a due notification for the given todo: marks
     * `due_notified_at` only if it was NULL. Returns true when the claim
     * succeeded.
     */
    public function claimDueNotification(int $id): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE todos SET due_notified_at = ? WHERE id = ? AND due_notified_at IS NULL'
        );
        $stmt->execute([time(), $id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Release a due-notification claim that failed to be delivered, so the
     * next worker tick retries.
     */
    public function rollbackDueNotification(int $id): void
    {
        $stmt = $this->pdo->prepare('UPDATE todos SET due_notified_at = NULL WHERE id = ?');
        $stmt->execute([$id]);
    }

    public function markDueNotified(int $id): void
    {
        $stmt = $this->pdo->prepare('UPDATE todos SET due_notified_at = ? WHERE id = ?');
        $stmt->execute([time(), $id]);
    }

    /**
     * Advance a recurring todo to its next occurrence: schedule the new
     * due time, clear the notification mark and reset the reminder cycle.
     */
    public function advanceRecurringTodo(int $id, int $nextDueAt): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE todos
             SET due_at = ?, due_notified_at = NULL, reminder_sent_at = NULL, updated_at = ?
             WHERE id = ?'
        );
        $stmt->execute([$nextDueAt, time(), $id]);
    }

    /**
     * Recurring todos marked `done_this_cycle` whose current due time has
     * arrived — ready to be rolled over to the next occurrence.
     *
     * Filtered to users who have a private chat with the bot.
     */
    public function getRecurringCycleDoneReadyToAdvance(): array
    {
        $now = time();
        $sql = 'SELECT t.* FROM todos t
                INNER JOIN user_settings s ON s.user_id = t.user_id
                WHERE t.status = \'done_this_cycle\'
                  AND t.recurrence IS NOT NULL
                  AND s.private_chat_started = 1
                  AND t.due_at IS NOT NULL
                  AND t.due_at <= ?
                ORDER BY t.due_at ASC';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$now]);

        return $stmt->fetchAll();
    }

    /**
     * Roll a recurring todo that was done in the current cycle over to its
     * next occurrence: reset to `pending`, clear per-cycle marks and
     * timestamps, and schedule the new due time.
     */
    public function resetRecurringCycleDone(int $id, int $nextDueAt): void
    {
        $now  = time();
        $stmt = $this->pdo->prepare(
            'UPDATE todos
             SET status = \'pending\',
                 due_at = ?,
                 due_notified_at = NULL,
                 reminder_sent_at = NULL,
                 started_at = NULL,
                 completed_at = NULL,
                 updated_at = ?
             WHERE id = ?'
        );
        $stmt->execute([$nextDueAt, $now, $id]);
    }
}


@@@FILE: database.sqlite-wal@@@

[binary file omitted]


@@@FILE: database.sqlite-shm@@@

[binary file omitted]


@@@FILE: database.sqlite@@@

[binary file omitted]


@@@FILE: handlers.php@@@

<?php

declare(strict_types=1);

use Neili\Client;

/**
 * Register every ToBeDo update handler on the given dispatcher.
 *
 * The dispatcher must expose the same handler-registration surface as
 * Neili\Poller (onMessage / onCallbackQuery / onMyChatMember). Both the
 * long-polling Poller (main.php) and the webhook dispatcher
 * (webhook.php) satisfy this contract, so the handler bodies live in
 * exactly one place.
 *
 * @param object $dispatcher
 * @param array<int,array<string,mixed>> $editState
 * @param array<int,array<string,mixed>> $addState
 */
function registerToBeDoHandlers(
    object $dispatcher,
    Client $client,
    Database $db,
    $logger,
    array &$editState,
    array &$addState
): void {
    /* -------------------------------------------------------------------------
     | Message handler
     |
     | Only a handful of text commands remain (/start, /help, /todo, /todos,
     | /panel, /cancel) — everything else is reachable through inline buttons,
     | starting from the menu button attached to every bot message or from
     | /panel. In groups, the words «توبیدو» and «تودو» also open the menu.
     * ---------------------------------------------------------------------- */
    $dispatcher->onMessage(function (array $update) use ($client, $db, $logger, &$editState, &$addState): void {
        try {
            $message = $update['message'] ?? null;
            if (!is_array($message)) {
                return;
            }

            $chat = $message['chat'] ?? null;
            $from = $message['from'] ?? null;
            if (!is_array($chat) || !is_array($from)) {
                return;
            }

            $chatId   = (int) $chat['id'];
            $chatType = (string) ($chat['type'] ?? '');
            $userId   = (int) ($from['id'] ?? 0);
            $text     = (string) ($message['text'] ?? '');

            if (!in_array($chatType, ['private', 'group', 'supergroup'], true)) {
                return;
            }

            $isGroup = isGroupChat($chatType);

            // Track that the user has started a private chat with the bot. This
            // flag gates reminder delivery, since Telegram refuses sendMessage()
            // to users without an open private conversation.
            if (!$isGroup && $userId > 0) {
                $db->markPrivateChatStarted($userId);
            }

            // Reply context (used for voice-reply handling in groups).
            $replyTo    = $message['reply_to_message'] ?? null;
            $replyVoice = is_array($replyTo) ? ($replyTo['voice'] ?? null) : null;

            // Voice messages in private chats are converted to personal todos.
            // While an FSM session or the add-task wizard is active, voice is
            // ignored so the edit / wizard is not accidentally replaced.
            $voice = $message['voice'] ?? null;
            if (is_array($voice)) {
                if (!isset($editState[$userId]) && !isset($addState[$userId]) && $chatType === 'private') {
                    handleVoice($client, $db, $userId, $chatId, $chatType, $voice);
                }
                return;
            }

            // Plain text while an FSM session is active → commit as new task text,
            // description, or as due time, depending on the active mode.
            if ($text !== '' && $text[0] !== '/' && isset($editState[$userId])) {
                handleFsmCommit($client, $db, $userId, $chatId, $text, $editState);
                return;
            }

            // Plain text while the button-driven add-task wizard is active →
            // advance the wizard to its next step. Because the user sent a
            // text message, every follow-up prompt / final confirmation is
            // delivered as a NEW message (never as an edit of the previous
            // prompt).
            if ($text !== '' && $text[0] !== '/' && isset($addState[$userId])) {
                handleAddWizardText($client, $db, $userId, $chatId, $text, $addState);
                return;
            }

            $trimmed = trim($text);

            // Trigger words in groups: open the user menu (or register a voice
            // todo when the trigger is a reply to a voice message).
            if ($isGroup && $text !== '' && $text[0] !== '/') {
                if (in_array($trimmed, PANEL_TRIGGER_WORDS, true)) {
                    if (is_array($replyVoice)) {
                        handleVoiceReply($client, $db, $userId, $chatId, $chatType, $replyVoice, $from, '');
                        return;
                    }
                    sendPanel($client, $db, $chatId, $chatType);
                    return;
                }
            }

            // Non-command text: silently ignore. The bot only reacts to commands,
            // button callbacks, trigger words, or an active FSM session.
            if ($text === '' || $text[0] !== '/') {
                return;
            }

            $parts   = preg_split('/\s+/', trim($text), 2) ?: [];
            $command = $parts[0] ?? '';
            $args    = $parts[1] ?? '';

            if (str_contains($command, '@')) {
                $command = explode('@', $command, 2)[0];
            }

            switch ($command) {
                case '/start':
                    // /start is intentionally silent in groups; the menu can be
                    // opened with the trigger words or /panel instead.
                    if ($isGroup) {
                        break;
                    }
                    sendStart($client, $db, $chatId, $chatType);
                    break;

                case '/help':
                    sendHelp($client, $db, $chatId, $chatType);
                    break;

                case '/todo':
                    // In groups, /todo replied to a voice message creates a
                    // voice todo for the replier.
                    if ($isGroup && is_array($replyVoice)) {
                        handleVoiceReply($client, $db, $userId, $chatId, $chatType, $replyVoice, $from, $args);
                        break;
                    }
                    handleAdd($client, $db, $userId, $chatId, $args, $chatType, $from);
                    break;

                case '/todos':
                    handleList($client, $db, $userId, $chatId, 'all', 1, null, $chatType, $from);
                    break;

                case '/panel':
                    sendPanel($client, $db, $chatId, $chatType);
                    break;

                case '/cancel':
                    $had = false;
                    if (isset($editState[$userId])) {
                        unset($editState[$userId]);
                        $had = true;
                    }
                    if (isset($addState[$userId])) {
                        $promptId = (int) ($addState[$userId]['promptId'] ?? 0);
                        unset($addState[$userId]);
                        if ($promptId > 0) {
                            editMessageCached($client, $chatId, $promptId, '❌ افزودن کار لغو شد.', null);
                        }
                        $had = true;
                    }
                    if ($had) {
                        sendAuto($client, $db, $chatId, $chatType, 'لغو شد.');
                    } else {
                        sendAuto($client, $db, $chatId, $chatType, 'چیزی برای لغو نیست.');
                    }
                    break;

                default:
                    sendAuto(
                        $client,
                        $db,
                        $chatId,
                        $chatType,
                        "دستور نامعتبر.\n\nبرای دسترسی سریع از /panel یا از دکمه‌های زیر پیام‌ها استفاده کن."
                    );
            }
        } catch (\Throwable $e) {
            $logger->error('onMessage error: ' . $e->getMessage());
        }
    });

    /* -------------------------------------------------------------------------
     | my_chat_member — bot added to a group → welcome
     * ---------------------------------------------------------------------- */
    $dispatcher->onMyChatMember(function (array $update) use ($client, $db, $logger): void {
        try {
            $mcm = $update['my_chat_member'] ?? null;
            if (!is_array($mcm)) {
                return;
            }

            $chat = $mcm['chat'] ?? null;
            if (!is_array($chat)) {
                return;
            }

            $chatType = (string) ($chat['type'] ?? '');
            if ($chatType !== 'group' && $chatType !== 'supergroup') {
                return;
            }

            $chatId    = (int) $chat['id'];
            $oldStatus = (string) ($mcm['old_chat_member']['status'] ?? '');
            $newStatus = (string) ($mcm['new_chat_member']['status'] ?? '');

            $wasOut = in_array($oldStatus, ['left', 'kicked'], true);
            $isIn   = in_array($newStatus, ['member', 'administrator'], true);
            $isOut  = in_array($newStatus, ['left', 'kicked'], true);

            if ($wasOut && $isIn) {
                sendStart($client, $db, $chatId, $chatType);
                $logger->info("Bot added to chat {$chatId}");
            } elseif (!$wasOut && $isOut) {
                $logger->info("Bot removed from chat {$chatId}");
            }
        } catch (\Throwable $e) {
            $logger->error('onMyChatMember error: ' . $e->getMessage());
        }
    });

    /* -------------------------------------------------------------------------
     | callback_query — cycle / quick-cycle (from reminder) / toggle-personal /
     |                  edit-menu / edit-name / edit-description / due /
     |                  delete (+confirm) / settings / calendar / timezone /
     |                  play-voice (+confirm in group) / group settings /
     |                  add-task wizard /
     |                  menu / help / support / list
     |
     | Callback data formats (all < 64 bytes):
     |   n:<todoId>                   → display-only index label (no-op)
     |   t:<todoId>:<filter>:<page>   → cycle state + re-render list
     |   b:<todoId>                   → quick cycle (used by reminder buttons)
     |   p:<todoId>:<filter>:<page>   → toggle personal flag
     |   e:<todoId>:<filter>:<page>   → show edit menu (name/description)
     |   en:<todoId>:<filter>:<page>  → start editing the name (FSM)
     |   ed:<todoId>:<filter>:<page>  → start editing the description (FSM)
     |   u:<todoId>:<filter>:<page>   → start due-time FSM
     |   x:<todoId>:<filter>:<page>   → show delete confirmation
     |   xa:<todoId>:<filter>:<page>  → confirm delete
     |   c:<filter>:<page>            → cancel edit FSM
     |   vf:<todoId>                  → replay stored voice (asks confirmation
     |                                   first if the todo is personal and the
     |                                   chat is a group)
     |   vfa:<todoId>                 → confirmed replay of a personal voice
     |                                   in a group
     |   g:<seconds>                  → set group auto-delete delay
     |   add                          → start the add-task wizard
     |   addd:skip                    → wizard: skip the description
     |   addt:0|1|2                   → wizard: type (no due / with due / recurring)
     |   addc                         → wizard: cancel
     |   l:<filter>:<page>            → (re)render list
     |   s:<hours>                    → set reminder hours
     |   cal:<gregorian|jalali>       → set display calendar
     |   tzm:<page>                   → open timezone selection menu
     |   tz:<timezone>                → set timezone
     |   st                           → open / back to settings menu
     |   gst                          → open group settings (admin only)
     |   pnl                          → open main menu
     |   hlp                          → open quick help
     |   sup                          → legacy support screen (kept for old
     |                                   messages; the panel now uses a URL
     |                                   button pointing at t.me/mrafaz)
     * ---------------------------------------------------------------------- */
    $dispatcher->onCallbackQuery(function (array $update) use ($client, $db, $logger, &$editState, &$addState): void {
        try {
            $cq = $update['callback_query'] ?? null;
            if (!is_array($cq)) {
                return;
            }

            $cqId    = (string) ($cq['id'] ?? '');
            $data    = (string) ($cq['data'] ?? '');
            $message = $cq['message'] ?? null;
            $from    = $cq['from'] ?? null;

            if ($cqId === '' || !is_array($message) || !is_array($from)) {
                return;
            }

            $chat = $message['chat'] ?? null;
            if (!is_array($chat)) {
                return;
            }

            $chatId    = (int) $chat['id'];
            $chatType  = (string) ($chat['type'] ?? '');
            $userId    = (int) ($from['id'] ?? 0);
            $messageId = (int) ($message['message_id'] ?? 0);

            if (!in_array($chatType, ['private', 'group', 'supergroup'], true)) {
                $client->answerCallbackQuery($cqId)->await();
                return;
            }

            // Track private-chat presence (see onMessage for details).
            if ($chatType === 'private' && $userId > 0) {
                $db->markPrivateChatStarted($userId);
            }

            /* ---------- confirmed replay of a personal voice in a group ---------- */
            if (preg_match('/^vfa:(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $fileId = (string) ($todo['voice_file_id'] ?? '');
                if ($fileId === '') {
                    $client->answerCallbackQuery($cqId, 'ویس ذخیره نشده است.', true)->await();
                    return;
                }

                playStoredVoice($client, $db, $logger, $cqId, $chatId, $chatType, $fileId);
                return;
            }

            /* ---------- replay stored voice (confirms first if personal + group) ---------- */
            if (preg_match('/^vf:(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $fileId = (string) ($todo['voice_file_id'] ?? '');
                if ($fileId === '') {
                    $client->answerCallbackQuery($cqId, 'ویس ذخیره نشده است.', true)->await();
                    return;
                }

                $isPersonal = ((int) ($todo['personal'] ?? 0)) === 1;

                // A personal voice note is about to be played out loud in a
                // group — confirm with the owner before actually sending it.
                if ($isPersonal && isGroupChat($chatType)) {
                    $client->answerCallbackQuery($cqId)->await();

                    $kb = new KeyboardBuilder();
                    $kb->inlineRow([
                        '✅ بله، پخش کن' => "vfa:{$todoId}",
                        '❌ انصراف'      => 'l:all:1',
                    ]);

                    $text = "🔒 این ویس مربوط به یک تودوی شخصیه.\n\n"
                        . "مطمئنی می‌خوای همینجا توی گروه پخشش کنی؟";
                    $keyboard = withNav($kb->build());

                    if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                        sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                    }
                    return;
                }

                playStoredVoice($client, $db, $logger, $cqId, $chatId, $chatType, $fileId);
                return;
            }

            /* ---------- group auto-delete setting (admin only) ---------- */
            if (preg_match('/^g:(\d+)$/', $data, $m)) {
                if (!isGroupChat($chatType)) {
                    $client->answerCallbackQuery($cqId, '❌ فقط در گروه.', true)->await();
                    return;
                }

                if (!isGroupAdmin($client, $chatId, $userId)) {
                    $client->answerCallbackQuery($cqId, '❌ فقط ادمین‌های گروه.', true)->await();
                    return;
                }

                $seconds = (int) $m[1];
                $db->setGroupAutoDelete($chatId, $seconds);

                $label = formatSeconds($seconds);
                $client->answerCallbackQuery($cqId, "✅ {$label}")->await();

                $current  = $db->getGroupAutoDelete($chatId);
                $text     = groupSettingsText($current);
                $keyboard = withNav(groupSettingsKeyboard($current)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
                }
                return;
            }

            /* ---------- add-task wizard: start (button → edit source message) ---------- */
            if ($data === 'add') {
                // Drop any in-flight edit FSM so the wizard starts from a clean slate.
                unset($editState[$userId]);
                $client->answerCallbackQuery($cqId)->await();
                startAddWizard($client, $db, $userId, $chatId, $chatType, $from, $messageId, $addState);
                return;
            }

            /* ---------- add-task wizard: skip description (button → edit) ---------- */
            if ($data === 'addd:skip') {
                if (!isset($addState[$userId])) {
                    $client->answerCallbackQuery($cqId, 'جلسه منقضی شده. دوباره شروع کن.', true)->await();
                    return;
                }
                $client->answerCallbackQuery($cqId, 'رد شد.')->await();
                $addState[$userId]['description'] = null;
                $addState[$userId]['step']        = 'type';
                addWizardTypePrompt($client, $db, $userId, $chatId, $messageId, $addState, $db->getUserPrefs($userId));
                return;
            }

            /* ---------- add-task wizard: no due, finalize (button → edit) ---------- */
            if ($data === 'addt:0') {
                if (!isset($addState[$userId])) {
                    $client->answerCallbackQuery($cqId, 'جلسه منقضی شده. دوباره شروع کن.', true)->await();
                    return;
                }
                $client->answerCallbackQuery($cqId)->await();
                $addState[$userId]['dueAt']      = null;
                $addState[$userId]['recurrence'] = null;
                finalizeAddWizard($client, $db, $userId, $chatId, $messageId, $addState, $db->getUserPrefs($userId));
                return;
            }

            /* ---------- add-task wizard: with due (button → edit) ---------- */
            if ($data === 'addt:1') {
                if (!isset($addState[$userId])) {
                    $client->answerCallbackQuery($cqId, 'جلسه منقضی شده. دوباره شروع کن.', true)->await();
                    return;
                }
                $client->answerCallbackQuery($cqId)->await();
                $addState[$userId]['step'] = 'due';
                addWizardDuePrompt($client, $db, $userId, $chatId, $messageId, $addState, $db->getUserPrefs($userId));
                return;
            }

            /* ---------- add-task wizard: recurring (button → edit) ---------- */
            if ($data === 'addt:2') {
                if (!isset($addState[$userId])) {
                    $client->answerCallbackQuery($cqId, 'جلسه منقضی شده. دوباره شروع کن.', true)->await();
                    return;
                }
                $client->answerCallbackQuery($cqId)->await();
                $addState[$userId]['step'] = 'recurrence';
                addWizardRecPrompt($client, $db, $userId, $chatId, $messageId, $addState, $db->getUserPrefs($userId));
                return;
            }

            /* ---------- add-task wizard: cancel (button → edit) ---------- */
            if ($data === 'addc') {
                if (!isset($addState[$userId])) {
                    $client->answerCallbackQuery($cqId)->await();
                    return;
                }
                $client->answerCallbackQuery($cqId, 'لغو شد.')->await();
                unset($addState[$userId]);
                $kb = new KeyboardBuilder();
                $kb->inlineRow(['➕ افزودن کار' => 'add']);
                editMessageCached($client, $chatId, $messageId, '❌ افزودن کار لغو شد.', withNav($kb->build()));
                return;
            }

            /* ---------- display-only index label (no-op) ---------- */
            if (preg_match('/^n:(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                // Quietly acknowledge the tap; the index button is a display-only
                // label and carries no action.
                $client->answerCallbackQuery($cqId)->await();
                return;
            }

            /* ---------- quick cycle (reminder button, no list re-render) ---------- */
            if (preg_match('/^b:(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $updated = $db->cycleTodo($todoId, $userId);
                if ($updated === null) {
                    $client->answerCallbackQuery($cqId, 'خطا در بروزرسانی.')->await();
                    return;
                }

                $client->answerCallbackQuery($cqId, statusLabel((string) $updated['status']))->await();
                return;
            }

            /* ---------- cycle state ---------- */
            if (preg_match('/^t:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $updated = $db->cycleTodo($todoId, $userId);
                if ($updated === null) {
                    $client->answerCallbackQuery($cqId, 'خطا در بروزرسانی.')->await();
                    return;
                }

                $client->answerCallbackQuery($cqId, statusLabel((string) $updated['status']))->await();
                handleList($client, $db, $userId, $chatId, $filter, $page, $messageId, $chatType, $from);
                return;
            }

            /* ---------- toggle personal ---------- */
            if (preg_match('/^p:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $updated = $db->togglePersonal($todoId, $userId);
                if ($updated === null) {
                    $client->answerCallbackQuery($cqId, 'خطا در بروزرسانی.')->await();
                    return;
                }

                $label = ((int) ($updated['personal'] ?? 0)) === 1 ? '🔒 شخصی شد' : '🔓 عمومی شد';
                $client->answerCallbackQuery($cqId, $label)->await();
                handleList($client, $db, $userId, $chatId, $filter, $page, $messageId, $chatType, $from);
                return;
            }

            /* ---------- show edit menu (name / description) ---------- */
            if (preg_match('/^e:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $indexMap = $db->getUserTodoIndexMap($userId);
                $index    = $indexMap[$todoId] ?? '?';

                $kb = new KeyboardBuilder();
                $kb->inlineRow([
                    '✏️ تغییر نام'    => "en:{$todoId}:{$filter}:{$page}",
                    '📝 تغییر توضیحات' => "ed:{$todoId}:{$filter}:{$page}",
                ]);
                $kb->inlineRow(['❌ انصراف' => "l:{$filter}:{$page}"]);

                $text     = "✏️ ویرایش کار #{$index}\n\n"
                    . "چه چیزی را می‌خواهید تغییر دهید؟";
                $keyboard = withNav($kb->build());

                $client->answerCallbackQuery($cqId)->await();

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- start editing the name ---------- */
            if (preg_match('/^en:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                // Abandon any active add-task wizard — the user is editing now.
                unset($addState[$userId]);

                $editState[$userId] = [
                    'mode'            => 'text',
                    'todoId'          => $todoId,
                    'filter'          => $filter,
                    'page'            => $page,
                    'chatType'        => $chatType,
                    'listMessageId'   => $messageId,
                    'promptMessageId' => null,
                ];

                $client->answerCallbackQuery($cqId, 'نام جدید را ارسال کنید.')->await();

                $indexMap = $db->getUserTodoIndexMap($userId);
                $index    = $indexMap[$todoId] ?? '?';

                $kb = new KeyboardBuilder();
                $kb->inlineRow(['❌ لغو' => "c:{$filter}:{$page}"]);

                $result = sendAuto(
                    $client,
                    $db,
                    $chatId,
                    $chatType,
                    "✏️ ویرایش نام کار #{$index}\n\n"
                    . "نام فعلی:\n" . (string) $todo['text'] . "\n\n"
                    . "نام جدید را ارسال کنید یا /cancel بزنید.",
                    $kb->build()
                );

                $promptId = (int) ($result['result']['message_id'] ?? 0);
                if ($promptId > 0) {
                    $editState[$userId]['promptMessageId'] = $promptId;
                }
                return;
            }

            /* ---------- start editing the description ---------- */
            if (preg_match('/^ed:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                // Abandon any active add-task wizard — the user is editing now.
                unset($addState[$userId]);

                $editState[$userId] = [
                    'mode'            => 'description',
                    'todoId'          => $todoId,
                    'filter'          => $filter,
                    'page'            => $page,
                    'chatType'        => $chatType,
                    'listMessageId'   => $messageId,
                    'promptMessageId' => null,
                ];

                $client->answerCallbackQuery($cqId, 'توضیحات جدید را ارسال کنید.')->await();

                $indexMap = $db->getUserTodoIndexMap($userId);
                $index    = $indexMap[$todoId] ?? '?';

                $currentDesc = trim((string) ($todo['description'] ?? ''));
                $current     = $currentDesc !== '' ? "توضیحات فعلی:\n{$currentDesc}" : 'در حال حاضر توضیحاتی ثبت نشده.';

                $kb = new KeyboardBuilder();
                $kb->inlineRow(['❌ لغو' => "c:{$filter}:{$page}"]);

                $result = sendAuto(
                    $client,
                    $db,
                    $chatId,
                    $chatType,
                    "📝 ویرایش توضیحات کار #{$index}\n\n"
                    . $current . "\n\n"
                    . "توضیحات جدید را ارسال کنید.\n"
                    . "برای حذف توضیحات، یک پیام خالی (فقط فاصله یا -) بفرستید یا /cancel بزنید.",
                    $kb->build()
                );

                $promptId = (int) ($result['result']['message_id'] ?? 0);
                if ($promptId > 0) {
                    $editState[$userId]['promptMessageId'] = $promptId;
                }
                return;
            }

            /* ---------- start due-time FSM ---------- */
            if (preg_match('/^u:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                // Abandon any active add-task wizard — the user is editing now.
                unset($addState[$userId]);

                $editState[$userId] = [
                    'mode'            => 'due',
                    'todoId'          => $todoId,
                    'filter'          => $filter,
                    'page'            => $page,
                    'chatType'        => $chatType,
                    'listMessageId'   => $messageId,
                    'promptMessageId' => null,
                ];

                $client->answerCallbackQuery($cqId, 'زمان را ارسال کنید.')->await();

                $indexMap = $db->getUserTodoIndexMap($userId);
                $index    = $indexMap[$todoId] ?? '?';

                $prefs = $db->getUserPrefs($userId);

                $current = $todo['due_at'] !== null
                    ? 'زمان فعلی: ' . formatDueAt((int) $todo['due_at'], $prefs)
                    : 'در حال حاضر زمانی تنظیم نشده.';

                $kb = new KeyboardBuilder();
                $kb->inlineRow(['❌ لغو' => "c:{$filter}:{$page}"]);

                $result = sendAuto(
                    $client,
                    $db,
                    $chatId,
                    $chatType,
                    "⏰ تنظیم زمان برای کار #{$index}\n\n"
                    . $current . "\n\n"
                    . "فرمت‌های مجاز:\n"
                    . "• نسبی: 10m, 1h, 2d, 1w, \"30 دقیقه\"\n"
                    . "• میلادی: 2026-09-20 14:30 یا 14:30\n"
                    . "• شمسی: 1405-06-29 14:30\n"
                    . "• حذف: - \n\n"
                    . "یا /cancel بزنید.",
                    $kb->build()
                );

                $promptId = (int) ($result['result']['message_id'] ?? 0);
                if ($promptId > 0) {
                    $editState[$userId]['promptMessageId'] = $promptId;
                }
                return;
            }

            /* ---------- cancel edit FSM ---------- */
            if (preg_match('/^c:([a-z]+):(\d+)$/', $data, $m)) {
                $filter = in_array($m[1], ['all', 'active', 'pending', 'done'], true) ? $m[1] : 'all';
                $page   = max(1, (int) $m[2]);

                unset($editState[$userId]);
                $client->answerCallbackQuery($cqId, 'لغو شد.')->await();
                handleList($client, $db, $userId, $chatId, $filter, $page, $messageId, $chatType, $from);
                return;
            }

            /* ---------- ask for delete confirmation (only confirm / cancel) ---------- */
            if (preg_match('/^x:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $indexMap = $db->getUserTodoIndexMap($userId);
                $index    = $indexMap[$todoId] ?? '?';

                $kb = new KeyboardBuilder();
                $kb->inlineRow([
                    '✅ بله، حذف کن' => "xa:{$todoId}:{$filter}:{$page}",
                    '❌ انصراف'      => "l:{$filter}:{$page}",
                ]);

                $text = "🗑 حذف کار #{$index}\n\n"
                    . (string) $todo['text'] . "\n\n"
                    . "آیا از حذف این کار مطمئن هستید؟";

                // Only confirm / cancel buttons — no extra navigation rows here.
                $keyboard = $kb->build();

                $client->answerCallbackQuery($cqId)->await();

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, null, true);
                }
                return;
            }

            /* ---------- confirm delete ---------- */
            if (preg_match('/^xa:(\d+):([a-z]+):(\d+)$/', $data, $m)) {
                $todoId = (int) $m[1];
                $filter = in_array($m[2], ['all', 'active', 'pending', 'done'], true) ? $m[2] : 'all';
                $page   = max(1, (int) $m[3]);

                $todo = requireOwnedTodo($client, $db, $todoId, $userId, $cqId);
                if ($todo === null) {
                    return;
                }

                $db->deleteTodo($todoId, $userId);
                $client->answerCallbackQuery($cqId, 'حذف شد 🗑')->await();
                handleList($client, $db, $userId, $chatId, $filter, $page, $messageId, $chatType, $from);
                return;
            }

            /* ---------- reminder settings ---------- */
            if (preg_match('/^s:(\d+)$/', $data, $m)) {
                $hours = (int) $m[1];
                $db->setReminderHours($userId, $hours);

                $label = $hours === 0 ? 'خاموش شد' : "{$hours} ساعت";
                $client->answerCallbackQuery($cqId, "✅ {$label}")->await();

                $prefs    = $db->getUserPrefs($userId);
                $text     = settingsText($prefs);
                $keyboard = withNav(settingsKeyboard($prefs)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- set display calendar ---------- */
            if (preg_match('/^cal:(gregorian|jalali)$/', $data, $m)) {
                $calendar = $m[1];
                $db->setUserCalendar($userId, $calendar);

                $label = $calendar === 'jalali' ? 'شمسی' : 'میلادی';
                $client->answerCallbackQuery($cqId, "✅ تقویم: {$label}")->await();

                $prefs    = $db->getUserPrefs($userId);
                $text     = settingsText($prefs);
                $keyboard = withNav(settingsKeyboard($prefs)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- open timezone selection menu ---------- */
            if (preg_match('/^tzm:(\d+)$/', $data, $m)) {
                $page  = max(0, (int) $m[1]);
                $prefs = $db->getUserPrefs($userId);

                $client->answerCallbackQuery($cqId)->await();

                $text = "🕐 انتخاب منطقه زمانی\n\n"
                    . "منطقه فعلی: " . $prefs['timezone'] . "\n\n"
                    . "یک گزینه را انتخاب کنید:";
                $keyboard = withNav(timezoneKeyboard((string) $prefs['timezone'], $page)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- set timezone ---------- */
            if (preg_match('/^tz:(.+)$/', $data, $m)) {
                $tz = $m[1];

                if (!isValidTimezone($tz)) {
                    $client->answerCallbackQuery($cqId, '❌ منطقه زمانی نامعتبر.', true)->await();
                    return;
                }

                $db->setUserTimezone($userId, $tz);
                $client->answerCallbackQuery($cqId, "✅ {$tz}")->await();

                $prefs    = $db->getUserPrefs($userId);
                $text     = settingsText($prefs);
                $keyboard = withNav(settingsKeyboard($prefs)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- open / back to settings menu ---------- */
            if ($data === 'st') {
                $client->answerCallbackQuery($cqId)->await();

                $prefs    = $db->getUserPrefs($userId);
                $text     = settingsText($prefs);
                $keyboard = withNav(settingsKeyboard($prefs)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- open group settings (admin only) ---------- */
            if ($data === 'gst') {
                if (!isGroupChat($chatType)) {
                    $client->answerCallbackQuery($cqId, '❌ فقط در گروه.', true)->await();
                    return;
                }

                if (!isGroupAdmin($client, $chatId, $userId)) {
                    $client->answerCallbackQuery($cqId, '❌ فقط ادمین‌های گروه.', true)->await();
                    return;
                }

                $client->answerCallbackQuery($cqId)->await();

                $current  = $db->getGroupAutoDelete($chatId);
                $text     = groupSettingsText($current);
                $keyboard = withNav(groupSettingsKeyboard($current)->build());

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
                }
                return;
            }

            /* ---------- open main menu ---------- */
            if ($data === 'pnl') {
                $client->answerCallbackQuery($cqId)->await();

                $text     = panelText();
                // Menu is the root screen: no back row.
                $keyboard = panelKeyboard(isGroupChat($chatType));

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, null, true);
                }
                return;
            }

            /* ---------- open quick help ---------- */
            if ($data === 'hlp') {
                $client->answerCallbackQuery($cqId)->await();

                $text     = helpText();
                $keyboard = withNav(null);

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- legacy support screen (kept for old messages) ---------- */
            if ($data === 'sup') {
                $client->answerCallbackQuery($cqId)->await();

                $text     = "💬 پشتیبانی\n\n"
                    . "برای ارتباط با پشتیبانی به آیدی زیر پیام دهید:\n\n"
                    . SUPPORT_URL;
                $keyboard = withNav(null);

                if (!editMessageCached($client, $chatId, $messageId, $text, $keyboard)) {
                    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
                }
                return;
            }

            /* ---------- (re)render list ---------- */
            if (preg_match('/^l:([a-z]+):(\d+)$/', $data, $m)) {
                $filter = in_array($m[1], ['all', 'active', 'pending', 'done'], true) ? $m[1] : 'all';
                $page   = max(1, (int) $m[2]);

                $client->answerCallbackQuery($cqId)->await();
                handleList($client, $db, $userId, $chatId, $filter, $page, $messageId, $chatType, $from);
                return;
            }

            $client->answerCallbackQuery($cqId, 'Callback نامعتبر.')->await();
        } catch (\Throwable $e) {
            $logger->error('onCallbackQuery error: ' . $e->getMessage());
        }
    });
}


@@@FILE: commands.php@@@

<?php

declare(strict_types=1);

use Neili\Client;
use Neili\KeyboardBuilder;

function settingsText(array $prefs): string
{
    $hours    = (int) ($prefs['reminder_hours'] ?? Database::DEFAULT_REMINDER_HOURS);
    $calendar = (string) ($prefs['calendar'] ?? Database::DEFAULT_CALENDAR);
    $timezone = (string) ($prefs['timezone'] ?? Database::DEFAULT_TIMEZONE);

    $hoursLabel = $hours === 0 ? 'خاموش' : "{$hours} ساعت";
    $calLabel   = $calendar === 'jalali' ? 'شمسی' : 'میلادی';

    return "⚙️ تنظیمات\n\n"
        . "🔔 بازه یادآور: {$hoursLabel}\n"
        . "📅 تقویم نمایش: {$calLabel}\n"
        . "🕐 منطقه زمانی: {$timezone}\n\n"
        . "از دکمه‌های زیر برای تغییر تنظیمات استفاده کنید.";
}

function settingsKeyboard(array $prefs): KeyboardBuilder
{
    $kb = new KeyboardBuilder();
    $kb->inlineRow(['۶ ساعت'   => 's:6',  '۱۲ ساعت'  => 's:12']);
    $kb->inlineRow(['۲۴ ساعت'  => 's:24', '۴۸ ساعت'  => 's:48']);
    $kb->inlineRow(['۷۲ ساعت'  => 's:72', '🔕 خاموش' => 's:0']);

    $calendar = (string) ($prefs['calendar'] ?? Database::DEFAULT_CALENDAR);
    if ($calendar === 'jalali') {
        $kb->inlineRow(['📅 تبدیل به میلادی' => 'cal:gregorian']);
    } else {
        $kb->inlineRow(['📅 تبدیل به شمسی'  => 'cal:jalali']);
    }

    $kb->inlineRow(['🕐 تغییر منطقه زمانی' => 'tzm:0']);

    return $kb;
}

/**
 * Build the paginated timezone-selection inline keyboard.
 * Current timezone is marked with a ✅ prefix.
 */
function timezoneKeyboard(string $current, int $page): KeyboardBuilder
{
    $perPage = 6;
    $tzs     = TIMEZONE_PRESETS;
    $total   = count($tzs);
    $pages   = max(1, (int) ceil($total / $perPage));
    $page    = max(0, min($page, $pages - 1));

    $slice = array_slice($tzs, $page * $perPage, $perPage, true);

    $kb  = new KeyboardBuilder();
    $row = [];
    foreach ($slice as $tz => $label) {
        $mark = $tz === $current ? '✅ ' : '';
        $row[$mark . $label] = "tz:{$tz}";
        if (count($row) === 2) {
            $kb->inlineRow($row);
            $row = [];
        }
    }
    if (!empty($row)) {
        $kb->inlineRow($row);
    }

    if ($pages > 1) {
        $prev = max(0, $page - 1);
        $next = min($pages - 1, $page + 1);
        $kb->inlineRow([
            '⬅️'                                => "tzm:{$prev}",
            'صفحه ' . ($page + 1) . '/' . $pages => "tzm:{$page}",
            '➡️'                                => "tzm:{$next}",
        ]);
    }

    $kb->inlineRow(['🔙 بازگشت' => 'st']);

    return $kb;
}

/* -------------------------------------------------------------------------
 | Group settings (admin only)
 * ---------------------------------------------------------------------- */

function groupSettingsText(int $seconds): string
{
    $label = formatSeconds($seconds);

    return "⚙️ تنظیمات گروه\n\n"
        . "⏱ حذف خودکار پیام‌های ربات: {$label}\n\n"
        . "پیام‌هایی که ربات در این گروه می‌فرستد بعد از مدت انتخاب‌شده خودکار حذف می‌شن.\n"
        . "برای تغییر یکی از گزینه‌های زیر را انتخاب کنید:";
}

function groupSettingsKeyboard(int $current): KeyboardBuilder
{
    $kb = new KeyboardBuilder();

    $options = [
        60   => '۱ دقیقه',
        120  => '۲ دقیقه',
        300  => '۵ دقیقه',
        600  => '۱۰ دقیقه',
        1800 => '۳۰ دقیقه',
    ];

    $row = [];
    foreach ($options as $sec => $label) {
        $mark = $sec === $current ? '✅ ' : '';
        $row[$mark . $label] = "g:{$sec}";
        if (count($row) === 3) {
            $kb->inlineRow($row);
            $row = [];
        }
    }
    if (!empty($row)) {
        $kb->inlineRow($row);
    }

    $mark = $current === 0 ? '✅ ' : '';
    $kb->inlineRow([$mark . '🔕 خاموش' => 'g:0']);

    return $kb;
}

/* -------------------------------------------------------------------------
 | Main menu — the single entry point that replaces most text commands.
 |
 | The support button is a URL button (links to the support account) and
 | therefore the raw reply_markup array is returned instead of using the
 | callback-only KeyboardBuilder shortcut.
 * ---------------------------------------------------------------------- */

function panelText(): string
{
    return "🧭 منو\n\nاز دکمه‌های زیر برای دسترسی سریع استفاده کن.";
}

/**
 * @return array<string,mixed> Raw Telegram reply_markup array.
 */
function panelKeyboard(bool $isGroup): array
{
    $rows = [
        [['text' => '➕ افزودن کار',   'callback_data' => 'add']],
        [['text' => '📋 لیست تودوها',   'callback_data' => 'l:all:1']],
        [['text' => '❓ راهنما',        'callback_data' => 'hlp']],
        [['text' => '💬 پشتیبانی',      'url'           => SUPPORT_URL]],
        [['text' => '⚙️ تنظیمات',       'callback_data' => 'st']],
    ];

    if ($isGroup) {
        $rows[] = [['text' => '👥 تنظیمات گروه', 'callback_data' => 'gst']];
    }

    return ['inline_keyboard' => $rows];
}

function sendPanel(Client $client, Database $db, int $chatId, string $chatType): void
{
    $keyboard = panelKeyboard(isGroupChat($chatType));
    // The main menu is the root screen; no back row is appended.
    sendAuto($client, $db, $chatId, $chatType, panelText(), $keyboard, null, true);
}

/* -------------------------------------------------------------------------
 | Quick help — short reference, deliberately distinct from /start.
 * ---------------------------------------------------------------------- */

function helpText(): string
{
    return implode("\n", [
        '❓ راهنمای کامل ToBeDo',
        '',
        '📌 هر تودو از دو بخش تشکیل شده:',
        '   • نام (الزامی)',
        '   • توضیحات (اختیاری)',
        '',
        '➕ افزودن تودو:',
        '   /todo نام کار',
        '   /todo نام کار | توضیحات',
        '   مثال: /todo خرید | شیر و نان',
        '',
        '🎯 یا بدون کامند: دکمه «➕ افزودن کار» توی منو.',
        '   خودش مرحله‌به‌مرحله ازت نام، توضیحات و نوع کار رو می‌پرسه.',
        '',
        '⏰ افزودن تودو با موعد (-t):',
        '',
        '   نسبی (انگلیسی):',
        '      /todo -t 10m کار   → ۱۰ دقیقه دیگر',
        '      /todo -t 2h کار    → ۲ ساعت دیگر',
        '      /todo -t 3d کار    → ۳ روز دیگر',
        '      /todo -t 1w کار    → ۱ هفته دیگر',
        '',
        '   نسبی (فارسی):',
        '      /todo -t "30 دقیقه" کار',
        '      /todo -t "2 ساعت" کار',
        '      /todo -t "3 روز" کار',
        '      /todo -t "1 هفته" کار',
        '',
        '   تاریخ میلادی:',
        '      /todo -t "2026-09-20 14:30" کار',
        '      /todo -t "2026/09/20" کار',
        '',
        '   تاریخ شمسی (جلالی):',
        '      /todo -t "1405-06-29 14:30" کار',
        '',
        '   فقط ساعت (امروز یا فردا):',
        '      /todo -t "14:30" کار',
        '',
        '🔁 افزودن تودوی تکرارشونده (-r):',
        '',
        '   روزانه (انگلیسی):',
        '      /todo -r daily 14:30 کار',
        '   روزانه (فارسی):',
        '      /todo -r "روزانه 14:30" کار',
        '',
        '   هفتگی (انگلیسی):',
        '      /todo -r weekly mon 09:00 کار',
        '   هفتگی (فارسی):',
        '      /todo -r "هفتگی دوشنبه 09:00" کار',
        '',
        '   روزهای هفته:',
        '      mon / دوشنبه        tue / سه شنبه',
        '      wed / چهارشنبه      thu / پنج شنبه',
        '      fri / جمعه          sat / شنبه',
        '      sun / یک شنبه',
        '',
        '🎤 تودوی صوتی:',
        '   • در پیوی: فقط یک ویس بفرست.',
        '   • در گروه: روی یک ویس ریپلای کن و /todo بنویس.',
        '',
        '📋 سایر دستورات:',
        '   /todos   — نمایش لیست کارها',
        '   /panel   — منو و تنظیمات',
        '   /cancel  — لغو ویرایش در حال انجام',
        '',
        '👆 ویرایش، حذف، موعد، وضعیت و تنظیمات همه دکمه‌ای هستن.',
    ]);
}

function sendHelp(Client $client, Database $db, int $chatId, string $chatType): void
{
    // No keyboard here; withNav() adds a single back button.
    sendAuto($client, $db, $chatId, $chatType, helpText());
}

/* -------------------------------------------------------------------------
 | Start — a short introduction with the same buttons as the main menu.
 * ---------------------------------------------------------------------- */

function sendStart(Client $client, Database $db, int $chatId, string $chatType): void
{
    $isGroup = isGroupChat($chatType);

    $lines   = [];
    $lines[] = '👋 سلام! من ToBeDo هستم.';
    $lines[] = 'دستیار مدیریت کارها — با موعد، تکرار، یادآور و تودوی صوتی.';
    $lines[] = '';

    if ($isGroup) {
        $lines[] = 'برای شروع همینجا بنویس:';
        $lines[] = '/todo نام کار';
        $lines[] = '';
        $lines[] = 'یا کلمه «توبیدو» را بفرست تا منو برایت باز شود.';
        $lines[] = 'بقیه‌ی کارها — لیست، ویرایش، حذف، تنظیمات گروه — همه دکمه‌ای هستن.';
    } else {
        $lines[] = 'برای شروع یه کار اضافه کن:';
        $lines[] = '/todo نام کار';
        $lines[] = '';
        $lines[] = 'یا فقط یه پیام صوتی بفرست — خودش تبدیل به تودو می‌شه.';
        $lines[] = '';
        $lines[] = 'بقیه‌ی کارها — لیست، ویرایش، موعد، تنظیمات — دکمه‌ای هستن.';
    }

    // Root message: same buttons as the panel; no back row.
    $keyboard = panelKeyboard($isGroup);
    sendAuto($client, $db, $chatId, $chatType, implode("\n", $lines), $keyboard, null, true);
}

function handleAdd(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $args,
    string $chatType,
    array $from
): void {
    $isGroup = isGroupChat($chatType);
    $raw     = trim($args);
    $prefs   = $db->getUserPrefs($userId);

    // In private chats every new todo is personal by default.
    // In groups, new todos are public (members can mark them private later).
    $personal = !$isGroup;

    if ($raw === '') {
        sendAuto(
            $client,
            $db,
            $chatId,
            $chatType,
            "استفاده صحیح:\n\n"
            . "/todo نام کار\n"
            . "/todo نام کار | توضیحات\n"
            . "/todo -t 1h نام کار (با موعد)\n"
            . "/todo -r daily 14:30 نام کار (تکرارشونده روزانه)\n"
            . "/todo -r weekly mon 09:00 نام کار (تکرارشونده هفتگی)\n\n"
            . "فرمت زمان: 10m، 1h، 2d، 1w، \"30 دقیقه\"،\n"
            . "2026-09-20 14:30 (میلادی) یا 1405-06-29 14:30 (شمسی)\n\n"
            . "یا از دکمه «➕ افزودن کار» توی منو استفاده کن.\n\n"
            . "مثال:\n/todo بررسی وضعیت سرور"
        );
        return;
    }

    [$text, $description, $dueAt, $recurrence, $error] = parseTodoArgs($raw, $prefs['timezone']);

    if ($error !== null) {
        sendAuto($client, $db, $chatId, $chatType, "❌ {$error}");
        return;
    }

    if ($text === '') {
        sendAuto($client, $db, $chatId, $chatType, 'متن کار خالی است.');
        return;
    }

    if (mb_strlen($text) > 200) {
        sendAuto($client, $db, $chatId, $chatType, 'متن کار خیلی طولانیه. حداکثر ۲۰۰ کاراکتر.');
        return;
    }

    // Recurring todos derive their due time from the recurrence pattern.
    if ($recurrence !== null) {
        $recArr = json_decode($recurrence, true);
        if (is_array($recArr)) {
            $dueAt = computeNextOccurrence($recArr, time(), $prefs['timezone']);
        }
    }

    $todoId = $db->createTodo($userId, $chatId, $text, $personal, $dueAt, $recurrence, $description);
    $active = $db->countActiveTodos($userId);

    $indexMap = $db->getUserTodoIndexMap($userId);
    $index    = $indexMap[$todoId] ?? '?';

    $dueLine  = $dueAt !== null ? "\n⏰ موعد: " . formatDueAt($dueAt, $prefs) : '';
    $recText  = formatRecurrence($recurrence);
    $recLine  = $recText !== null ? "\n🔁 " . $recText : '';
    $descLine = $description !== null ? "\n📝 " . $description : '';

    if ($isGroup) {
        $head = '✅ ' . displayName($from) . " کار جدید اضافه کرد:\n\n#{$index} ⏳ {$text}" . $descLine . $dueLine . $recLine;
        sendAuto(
            $client,
            $db,
            $chatId,
            $chatType,
            $head . "\n\nکارهای فعال: {$active}"
        );
        return;
    }

    $kb = new KeyboardBuilder();
    $kb->inlineRow([
        $personal ? '🔒 شخصی' : '🔓 عمومی' => "p:{$todoId}:all:1",
    ]);

    sendAuto(
        $client,
        $db,
        $chatId,
        $chatType,
        "✅ کار جدید اضافه شد:\n\n"
        . "#{$index} " . ($personal ? '🔒' : '⏳') . " {$text}" . $descLine . $dueLine . $recLine . "\n\n"
        . "کارهای فعال: {$active}",
        $kb->build()
    );
}

/**
 * Convert a Telegram voice message (sent directly, in private chat) into a
 * personal todo.
 *
 * The todo is created with a default name (`VOICE_TODO_DEFAULT_NAME`) and
 * the voice metadata as its description. The Telegram file_id is stored so
 * the user can replay the voice later via the 🎤 button. The user can
 * rename the todo via the standard edit FSM.
 */
function handleVoice(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $chatType,
    array $voice
): void {
    if (isGroupChat($chatType)) {
        return;
    }

    $duration = (int) ($voice['duration'] ?? 0);
    $fileId   = (string) ($voice['file_id'] ?? '');

    $description = $duration > 0
        ? "پیام صوتی ({$duration} ثانیه)"
        : 'پیام صوتی';

    $todoId = $db->createTodo(
        $userId,
        $chatId,
        VOICE_TODO_DEFAULT_NAME,
        true,
        null,
        null,
        $description,
        $fileId !== '' ? $fileId : null
    );
    $active = $db->countActiveTodos($userId);

    $indexMap = $db->getUserTodoIndexMap($userId);
    $index    = $indexMap[$todoId] ?? '?';

    $kb = new KeyboardBuilder();
    if ($fileId !== '') {
        $kb->inlineRow(['🎤 پخش ویس' => "vf:{$todoId}"]);
    }
    $kb->inlineRow(['✏️ ویرایش' => "e:{$todoId}:all:1"]);

    sendAuto(
        $client,
        $db,
        $chatId,
        $chatType,
        "🎤 ویس به تودو اضافه شد:\n\n"
        . "#{$index} 🔒 " . VOICE_TODO_DEFAULT_NAME . "\n"
        . "    📝 {$description}\n\n"
        . "کارهای فعال: {$active}\n"
        . "برای شنیدن دوباره روی 🎤 و برای ویرایش روی ✏️ بزنید.",
        $kb->build()
    );
}

/**
 * Register a voice todo for a group member who replied to a voice message
 * with `/todo` or one of the panel trigger words.
 *
 * The new todo belongs to the replier (the user who ran the command), and
 * the replied voice's file_id is attached so it can be replayed. Because
 * the voice was recorded by the replier and its content is theirs, the
 * todo defaults to personal so that the 🎤 replay button prompts for
 * confirmation before broadcasting the voice back into the group.
 */
function handleVoiceReply(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $chatType,
    array $voice,
    array $from,
    string $args
): void {
    $duration = (int) ($voice['duration'] ?? 0);
    $fileId   = (string) ($voice['file_id'] ?? '');

    $name = trim($args);
    if ($name === '') {
        $name = VOICE_TODO_DEFAULT_NAME;
    }
    if (mb_strlen($name) > 200) {
        $name = mb_substr($name, 0, 200);
    }

    $description = $duration > 0
        ? "پیام صوتی ({$duration} ثانیه)"
        : 'پیام صوتی';

    $todoId = $db->createTodo(
        $userId,
        $chatId,
        $name,
        true,
        null,
        null,
        $description,
        $fileId !== '' ? $fileId : null
    );

    $indexMap = $db->getUserTodoIndexMap($userId);
    $index    = $indexMap[$todoId] ?? '?';
    $active   = $db->countActiveTodos($userId);

    $kb = new KeyboardBuilder();
    if ($fileId !== '') {
        $kb->inlineRow(['🎤 پخش ویس' => "vf:{$todoId}"]);
    }
    $kb->inlineRow([
        '✏️ ویرایش'    => "e:{$todoId}:all:1",
        '🔓 عمومی‌کردن' => "p:{$todoId}:all:1",
    ]);

    $head = '✅ ' . displayName($from) . " تودوی صوتی ثبت کرد:\n\n"
        . "#{$index} 🔒 {$name}\n"
        . "    📝 {$description}\n\n"
        . "کارهای فعال: {$active}";

    sendAuto($client, $db, $chatId, $chatType, $head, $kb->build());
}

/**
 * Commit an FSM session (text edit, description edit, or due-time set).
 *
 * @param array<int,array<string,mixed>> $editState
 */
function handleFsmCommit(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $text,
    array &$editState
): void {
    $state = $editState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }

    $mode     = (string) ($state['mode'] ?? 'text');
    $todoId   = (int) ($state['todoId'] ?? 0);
    $filter   = (string) ($state['filter'] ?? 'all');
    $page     = (int) ($state['page'] ?? 1);
    $chatType = (string) ($state['chatType'] ?? 'private');
    $listId   = $state['listMessageId'] ?? null;
    $promptId = (int) ($state['promptMessageId'] ?? 0);

    $text  = trim($text);
    $prefs = $db->getUserPrefs($userId);

    if ($mode === 'due') {
        $parsed = parseDueTime($text, null, $prefs['timezone']);
        if ($parsed === null) {
            sendAuto(
                $client,
                $db,
                $chatId,
                $chatType,
                "❌ زمان نامعتبر.\n\n"
                . "نمونه‌های مجاز: 10m، 1h، 2d، 1w، \"30 دقیقه\"،\n"
                . "2026-09-20 14:30 (میلادی)،\n"
                . "1405-06-29 14:30 (شمسی) یا 14:30\n"
                . "برای حذف موعد: -"
            );
            return;
        }

        unset($editState[$userId]);

        $dueAt   = $parsed > 0 ? $parsed : null;
        $updated = $db->setTodoDue($todoId, $userId, $dueAt);

        if ($promptId > 0) {
            try {
                $client->deleteMessage($chatId, $promptId)->await();
            } catch (\Throwable $e) {
                // Prompt may already be gone.
            }
        }

        if ($updated === null) {
            sendAuto($client, $db, $chatId, $chatType, 'خطا در تنظیم زمان.');
            return;
        }

        $msg = $dueAt !== null
            ? '✅ موعد تنظیم شد: ' . formatDueAt($dueAt, $prefs)
            : '✅ موعد حذف شد.';

        sendAuto($client, $db, $chatId, $chatType, $msg);

        handleList(
            $client,
            $db,
            $userId,
            $chatId,
            $filter,
            $page,
            is_int($listId) ? $listId : null,
            $chatType,
            null
        );
        return;
    }

    if ($mode === 'description') {
        $description = $text === '' ? null : $text;
        if ($description !== null && mb_strlen($description) > 500) {
            sendAuto($client, $db, $chatId, $chatType, 'توضیحات طولانی است. حداکثر ۵۰۰ کاراکتر.');
            return;
        }

        unset($editState[$userId]);

        $updated = $db->updateTodoDescription($todoId, $userId, $description);
        if ($updated === null) {
            sendAuto($client, $db, $chatId, $chatType, 'خطا در ویرایش توضیحات.');
            return;
        }

        if ($promptId > 0) {
            try {
                $client->deleteMessage($chatId, $promptId)->await();
            } catch (\Throwable $e) {
                // Prompt may already be gone.
            }
        }

        $msg = $description === null
            ? '✅ توضیحات حذف شد.'
            : '✅ توضیحات به‌روزرسانی شد.';
        sendAuto($client, $db, $chatId, $chatType, $msg);

        handleList(
            $client,
            $db,
            $userId,
            $chatId,
            $filter,
            $page,
            is_int($listId) ? $listId : null,
            $chatType,
            null
        );
        return;
    }

    // Default: text (name) edit mode.
    if ($text === '') {
        sendAuto($client, $db, $chatId, $chatType, 'نام خالی است. نام معتبر بفرستید یا /cancel بزنید.');
        return;
    }
    if (mb_strlen($text) > 200) {
        sendAuto($client, $db, $chatId, $chatType, 'نام طولانی است. حداکثر ۲۰۰ کاراکتر.');
        return;
    }

    unset($editState[$userId]);

    $updated = $db->updateTodoText($todoId, $userId, $text);
    if ($updated === null) {
        sendAuto($client, $db, $chatId, $chatType, 'خطا در ویرایش کار.');
        return;
    }

    if ($promptId > 0) {
        try {
            $client->deleteMessage($chatId, $promptId)->await();
        } catch (\Throwable $e) {
            // Prompt may already be gone.
        }
    }

    sendAuto($client, $db, $chatId, $chatType, '✅ نام کار به‌روزرسانی شد.');

    handleList(
        $client,
        $db,
        $userId,
        $chatId,
        $filter,
        $page,
        is_int($listId) ? $listId : null,
        $chatType,
        null
    );
}

/* -------------------------------------------------------------------------
 | Button-driven "add task" wizard
 |
 | A guided multi-step flow for users who don't know the /todo flags.
 | Steps:
 |   1. name (required)
 |   2. description (optional / skippable)
 |   3. type: no due | with due | recurring
 |   4. if due       → prompt for time
 |      if recurring → prompt for pattern
 | Then the todo is created and a confirmation is shown.
 |
 | Interaction rule (per project convention):
 |   - When the user presses an inline button, the SAME message is edited
 |     in place so the wizard always lives in a single message.
 |   - When the user sends a text message, the bot replies with a NEW
 |     message (never edits the previous one). The wizard's tracked
 |     prompt id is moved to the new message so subsequent button presses
 |     keep editing the latest prompt.
 |
 | State is held in $addState (in main.php), keyed by user id.
 * ---------------------------------------------------------------------- */

/**
 * Start (or restart) the wizard and ask for the task name.
 *
 * @param array<int,array<string,mixed>> $addState
 */
function startAddWizard(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $chatType,
    ?array $from,
    ?int $editMessageId,
    array &$addState
): void {
    $isGroup = isGroupChat($chatType);

    $addState[$userId] = [
        'step'        => 'name',
        'name'        => '',
        'description' => null,
        'dueAt'       => null,
        'recurrence'  => null,
        'chatType'    => $chatType,
        'personal'    => !$isGroup,
        'from'        => $from,
        'promptId'    => $editMessageId ?? 0,
    ];

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['❌ لغو' => 'addc']);
    $keyboard = withNav($kb->build());

    $text = "➕ افزودن کار جدید\n\n"
        . "مرحله ۱ — 📝 نام کار\n\n"
        . "یک نام کوتاه بفرست. همون چیزیه که توی لیست نمایش داده میشه.\n\n"
        . "مثال: خرید نان";

    // Button-triggered: edit the source message in place.
    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        return;
    }

    // Wizard prompts are not auto-deleted: they are edited at each step and
    // are needed throughout the whole flow.
    $result   = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
    $promptId = (int) ($result['result']['message_id'] ?? 0);
    if ($promptId > 0) {
        $addState[$userId]['promptId'] = $promptId;
    }
}

/**
 * Step 2 — ask for an optional description.
 *
 * @param array<int,array<string,mixed>> $addState
 */
function addWizardDescPrompt(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    ?int $editMessageId,
    array &$addState,
    array $prefs
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }
    $chatType = (string) $state['chatType'];
    $name     = (string) $state['name'];

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['⏭ بدون توضیحات' => 'addd:skip']);
    $kb->inlineRow(['❌ لغو'          => 'addc']);
    $keyboard = withNav($kb->build());

    $text = "➕ افزودن کار جدید\n\n"
        . "مرحله ۲ — 📝 توضیحات (اختیاری)\n\n"
        . "نام کار: {$name}\n\n"
        . "اگر توضیح بیشتری داری بفرست، وگرنه «بدون توضیحات» رو بزن.";

    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        $addState[$userId]['promptId'] = $editMessageId;
        return;
    }

    $result = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
    $newId  = (int) ($result['result']['message_id'] ?? 0);
    if ($newId > 0) {
        $addState[$userId]['promptId'] = $newId;
    }
}

/**
 * Step 3 — ask for the task type (no due / with due / recurring).
 *
 * @param array<int,array<string,mixed>> $addState
 */
function addWizardTypePrompt(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    ?int $editMessageId,
    array &$addState,
    array $prefs
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }
    $chatType = (string) $state['chatType'];
    $name     = (string) $state['name'];

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['⏱ بدون موعد'   => 'addt:0']);
    $kb->inlineRow(['⏰ با موعد'     => 'addt:1']);
    $kb->inlineRow(['🔁 تکرارشونده' => 'addt:2']);
    $kb->inlineRow(['❌ لغو'         => 'addc']);
    $keyboard = withNav($kb->build());

    $text = "➕ افزودن کار جدید\n\n"
        . "مرحله ۳ — 🎯 نوع کار\n\n"
        . "نام کار: {$name}\n\n"
        . "این کار چطور باشه؟\n"
        . "• ⏱ بدون موعد: فقط یه کار ساده\n"
        . "• ⏰ با موعد: یه زمان مشخص براش تعیین کن\n"
        . "• 🔁 تکرارشونده: مثلاً هر روز یا هر هفته";

    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        $addState[$userId]['promptId'] = $editMessageId;
        return;
    }

    $result = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
    $newId  = (int) ($result['result']['message_id'] ?? 0);
    if ($newId > 0) {
        $addState[$userId]['promptId'] = $newId;
    }
}

/**
 * Step 4a — ask for the due time (text input).
 *
 * @param array<int,array<string,mixed>> $addState
 */
function addWizardDuePrompt(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    ?int $editMessageId,
    array &$addState,
    array $prefs
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }
    $chatType = (string) $state['chatType'];

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['❌ لغو' => 'addc']);
    $keyboard = withNav($kb->build());

    $text = "➕ افزودن کار جدید\n\n"
        . "مرحله ۴ — ⏰ موعد مقرر\n\n"
        . "زمان رو بفرست:\n\n"
        . "• نسبی: 10m یا 1h یا 2d یا 1w\n"
        . "• فارسی: 30 دقیقه یا 2 ساعت یا 3 روز\n"
        . "• تاریخ میلادی: 2026-09-20 14:30\n"
        . "• تاریخ شمسی: 1405-06-29 14:30\n"
        . "• فقط ساعت: 14:30 (امروز یا فردا)";

    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        $addState[$userId]['promptId'] = $editMessageId;
        return;
    }

    $result = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
    $newId  = (int) ($result['result']['message_id'] ?? 0);
    if ($newId > 0) {
        $addState[$userId]['promptId'] = $newId;
    }
}

/**
 * Step 4b — ask for the recurrence pattern (text input).
 *
 * @param array<int,array<string,mixed>> $addState
 */
function addWizardRecPrompt(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    ?int $editMessageId,
    array &$addState,
    array $prefs
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }
    $chatType = (string) $state['chatType'];

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['❌ لغو' => 'addc']);
    $keyboard = withNav($kb->build());

    $text = "➕ افزودن کار جدید\n\n"
        . "مرحله ۴ — 🔁 تکرارشونده\n\n"
        . "الگوی تکرار رو بفرست:\n\n"
        . "• روزانه: روزانه 14:30 یا daily 09:00\n"
        . "• هفتگی: هفتگی دوشنبه 09:00 یا weekly mon 09:00\n\n"
        . "روزهای هفته: دوشنبه، سه شنبه، چهارشنبه، پنج شنبه، جمعه، شنبه، یک شنبه";

    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        $addState[$userId]['promptId'] = $editMessageId;
        return;
    }

    $result = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard, 0);
    $newId  = (int) ($result['result']['message_id'] ?? 0);
    if ($newId > 0) {
        $addState[$userId]['promptId'] = $newId;
    }
}

/**
 * Advance the wizard with a text value received from the user (name,
 * description, due time, or recurrence pattern depending on the step).
 *
 * Because the user sent a TEXT message, every follow-up prompt or final
 * confirmation is delivered as a NEW message — never as an edit of the
 * previous prompt.
 *
 * @param array<int,array<string,mixed>> $addState
 */
function handleAddWizardText(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $text,
    array &$addState
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }

    $chatType = (string) ($state['chatType'] ?? 'private');
    $step     = (string) ($state['step'] ?? 'name');
    $text     = trim($text);
    $prefs    = $db->getUserPrefs($userId);

    if ($text === '') {
        sendAuto($client, $db, $chatId, $chatType, 'متن خالیه. یه مقدار معتبر بفرست یا /cancel بزن.');
        return;
    }

    if ($step === 'name') {
        if (mb_strlen($text) > 200) {
            sendAuto($client, $db, $chatId, $chatType, 'نام طولانیه. حداکثر ۲۰۰ کاراکتر.');
            return;
        }
        $addState[$userId]['name'] = $text;
        $addState[$userId]['step'] = 'description';
        // Text input → reply with a NEW prompt (editMessageId = null).
        addWizardDescPrompt($client, $db, $userId, $chatId, null, $addState, $prefs);
        return;
    }

    if ($step === 'description') {
        // Accept a few "skip" markers as shortcuts alongside the skip button.
        $skipTokens = ['-', 'skip', 'رد', 'نه', 'بدون'];
        if (in_array(mb_strtolower($text, 'UTF-8'), $skipTokens, true)) {
            $addState[$userId]['description'] = null;
        } else {
            if (mb_strlen($text) > 500) {
                sendAuto($client, $db, $chatId, $chatType, 'توضیحات طولانیه. حداکثر ۵۰۰ کاراکتر.');
                return;
            }
            $addState[$userId]['description'] = $text;
        }
        $addState[$userId]['step'] = 'type';
        // Text input → new message.
        addWizardTypePrompt($client, $db, $userId, $chatId, null, $addState, $prefs);
        return;
    }

    if ($step === 'due') {
        $parsed = parseDueTime($text, null, $prefs['timezone']);
        if ($parsed === null || $parsed === 0) {
            sendAuto(
                $client,
                $db,
                $chatId,
                $chatType,
                "❌ زمان نامعتبر.\n\n"
                . "نمونه‌های مجاز:\n"
                . "• 10m یا 1h یا 2d یا 1w\n"
                . "• 30 دقیقه یا 2 ساعت\n"
                . "• 2026-09-20 14:30\n"
                . "• 1405-06-29 14:30\n"
                . "• 14:30"
            );
            return;
        }
        $addState[$userId]['dueAt'] = $parsed;
        // Text input → new final confirmation.
        finalizeAddWizard($client, $db, $userId, $chatId, null, $addState, $prefs);
        return;
    }

    if ($step === 'recurrence') {
        $pattern = parseRecurrencePattern($text);
        if ($pattern === null) {
            sendAuto(
                $client,
                $db,
                $chatId,
                $chatType,
                "❌ الگوی تکرار نامعتبر.\n\n"
                . "نمونه‌های مجاز:\n"
                . "• روزانه 14:30 یا daily 09:00\n"
                . "• هفتگی دوشنبه 09:00 یا weekly mon 09:00"
            );
            return;
        }
        $addState[$userId]['recurrence'] = json_encode($pattern, JSON_UNESCAPED_UNICODE);
        // Text input → new final confirmation.
        finalizeAddWizard($client, $db, $userId, $chatId, null, $addState, $prefs);
        return;
    }
}

/**
 * Create the todo from the collected wizard state and show a confirmation.
 *
 * When $editMessageId is non-null the confirmation is rendered by editing
 * that message (button-triggered path); otherwise a NEW message is sent
 * (text-triggered path).
 *
 * @param array<int,array<string,mixed>> $addState
 */
function finalizeAddWizard(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    ?int $editMessageId,
    array &$addState,
    array $prefs
): void {
    $state = $addState[$userId] ?? null;
    if (!is_array($state)) {
        return;
    }

    $chatType    = (string) ($state['chatType'] ?? 'private');
    $name        = (string) ($state['name'] ?? '');
    $description = $state['description'] ?? null;
    $dueAt       = $state['dueAt'] ?? null;
    $recurrence  = $state['recurrence'] ?? null;
    $personal    = (bool) ($state['personal'] ?? true);
    $from        = $state['from'] ?? null;

    if ($name === '') {
        unset($addState[$userId]);
        sendAuto($client, $db, $chatId, $chatType, 'خطا: نام کار خالی است.');
        return;
    }

    // Recurring todos derive their due time from the pattern.
    if ($recurrence !== null) {
        $recArr = json_decode($recurrence, true);
        if (is_array($recArr)) {
            $dueAt = computeNextOccurrence($recArr, time(), $prefs['timezone']);
        }
    }

    $todoId = $db->createTodo(
        $userId,
        $chatId,
        $name,
        $personal,
        $dueAt,
        $recurrence,
        $description
    );

    $active   = $db->countActiveTodos($userId);
    $indexMap = $db->getUserTodoIndexMap($userId);
    $index    = $indexMap[$todoId] ?? '?';

    unset($addState[$userId]);

    $descLine = $description !== null && $description !== '' ? "\n📝 " . $description : '';
    $dueLine  = $dueAt !== null ? "\n⏰ موعد: " . formatDueAt($dueAt, $prefs) : '';
    $recText  = formatRecurrence($recurrence);
    $recLine  = $recText !== null ? "\n🔁 " . $recText : '';

    $isGroup = isGroupChat($chatType);

    if ($isGroup && is_array($from)) {
        $text = '✅ ' . displayName($from) . " کار جدید اضافه کرد:\n\n"
            . "#{$index} ⏳ {$name}" . $descLine . $dueLine . $recLine . "\n\n"
            . "کارهای فعال: {$active}";
    } else {
        $text = "✅ کار جدید اضافه شد:\n\n"
            . "#{$index} " . ($personal ? '🔒' : '⏳') . " {$name}" . $descLine . $dueLine . $recLine . "\n\n"
            . "کارهای فعال: {$active}";
    }

    $kb = new KeyboardBuilder();
    $kb->inlineRow(['📋 دیدن لیست' => 'l:all:1']);
    $keyboard = withNav($kb->build());

    if ($editMessageId !== null && editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
        return;
    }

    sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
}

/**
 * Render (or re-render) the paginated, filterable todo list.
 *
 * Uses the shared MessageContentCache (see helpers.php) to skip
 * editMessageText calls whose content is byte-identical to what was last
 * written to that message — Telegram rejects them as "message is not
 * modified" and the Neili HTTP client logs that error before user code can
 * intercept it. Sharing the cache with every other edit path (settings,
 * panel, help, confirmations) ensures a message edited elsewhere does not
 * leave a stale entry that would make a legitimate re-render a no-op.
 */
function handleList(
    Client $client,
    Database $db,
    int $userId,
    int $chatId,
    string $filter,
    int $page,
    ?int $editMessageId,
    string $chatType,
    ?array $from = null
): void {
    $isGroup = isGroupChat($chatType);
    $perPage = 5;
    $filter  = in_array($filter, ['all', 'active', 'pending', 'done'], true) ? $filter : 'all';

    $allTodos = $db->getUserTodos($userId, $filter);
    $total    = count($allTodos);
    $pages    = max(1, (int) ceil($total / $perPage));
    $page     = max(1, min($page, $pages));
    $offset   = ($page - 1) * $perPage;
    $items    = array_slice($allTodos, $offset, $perPage);

    $indexMap = $db->getUserTodoIndexMap($userId);
    $fLabel   = filterLabel($filter);
    $prefs    = $db->getUserPrefs($userId);

    /* ---------- message body ---------- */
    $header   = [];
    $header[] = $isGroup && $from !== null
        ? '📋 لیست ' . displayName($from)
        : '📋 کارهای شما';
    $header[] = "فیلتر: {$fLabel} • صفحه {$page}/{$pages}";
    $header[] = "مجموع: {$total} مورد";

    $body = [];
    if (empty($items)) {
        $body[] = '';
        $body[] = 'موردی برای نمایش نیست.';
    } else {
        $body[] = '';
        foreach ($items as $todo) {
            $index      = $indexMap[(int) $todo['id']] ?? '?';
            $isPersonal = ((int) ($todo['personal'] ?? 0)) === 1;
            $statusIcon = statusEmoji((string) $todo['status']);
            $content    = (string) $todo['text'];
            $desc       = trim((string) ($todo['description'] ?? ''));

            $hideForPrivacy = $isPersonal && $isGroup;

            if ($hideForPrivacy) {
                $content = '🔒 شخصی';
                $desc    = '';
            } elseif ($isPersonal) {
                $content = '🔒 ' . $content;
            }

            $line = '#' . $index . ' ' . $statusIcon . ' ' . $content;

            if ($desc !== '') {
                $line .= "\n    📝 " . $desc;
            }

            if ($todo['due_at'] !== null) {
                $line .= "\n    ⏰ " . formatDueAt((int) $todo['due_at'], $prefs);
            }

            $recText = formatRecurrence($todo['recurrence'] ?? null);
            if ($recText !== null) {
                $line .= "\n    🔁 " . $recText;
            }

            if (!empty($todo['started_at'])) {
                $line .= "\n    ▶️ شروع: " . formatStamp((int) $todo['started_at'], $prefs);
            }

            if (!empty($todo['completed_at'])) {
                $line .= "\n    ✔️ پایان: " . formatStamp((int) $todo['completed_at'], $prefs);
            }

            $body[] = $line;
        }
    }

    $text = implode("\n", array_merge($header, $body));

    /* ---------- inline keyboard ---------- */
    $kb = new KeyboardBuilder();

    foreach ($items as $todo) {
        $index      = $indexMap[(int) $todo['id']] ?? '?';
        $isPersonal = ((int) ($todo['personal'] ?? 0)) === 1;
        $status     = (string) $todo['status'];

        $statusIcon = statusEmoji($status);
        $lockIcon   = $isPersonal ? '🔒' : '🔓';
        $todoId     = (int) $todo['id'];

        // Row layout: index | status | lock | due | edit | delete (| voice).
        // The index button is a display-only label (no-op callback) and is
        // kept separate from the status-toggle button.
        $row = [
            '#' . $index => "n:{$todoId}",
            $statusIcon  => "t:{$todoId}:{$filter}:{$page}",
            $lockIcon    => "p:{$todoId}:{$filter}:{$page}",
            '⏰'         => "u:{$todoId}:{$filter}:{$page}",
            '✏️'         => "e:{$todoId}:{$filter}:{$page}",
            '🗑'         => "x:{$todoId}:{$filter}:{$page}",
        ];

        if (!empty($todo['voice_file_id'])) {
            $row['🎤'] = "vf:{$todoId}";
        }

        $kb->inlineRow($row);
    }

    // Pagination row (only when more than one page).
    if ($pages > 1) {
        $prev = max(1, $page - 1);
        $next = min($pages, $page + 1);
        $kb->inlineRow([
            '⬅️'                    => "l:{$filter}:{$prev}",
            "صفحه {$page}/{$pages}" => "l:{$filter}:{$page}",
            '➡️'                    => "l:{$filter}:{$next}",
        ]);
    }

    // Filter row.
    $kb->inlineRow([
        'همه'       => 'l:all:1',
        'در انتظار' => 'l:pending:1',
        'در جریان'  => 'l:active:1',
        'انجام شده' => 'l:done:1',
    ]);

    // Quick actions: add a new task + open settings.
    $kb->inlineRow([
        '➕ افزودن کار' => 'add',
        '⚙️ تنظیمات'    => 'st',
    ]);

    // List navigation: back + help.
    $keyboard = withListNav($kb->build());

    if ($editMessageId !== null) {
        if (editMessageCached($client, $chatId, $editMessageId, $text, $keyboard)) {
            return;
        }
        // Edit failed for a reason other than "not modified" — fall through
        // and send a fresh list message (typical cause: the original message
        // was auto-deleted in a group).
    }

    $result   = sendAuto($client, $db, $chatId, $chatType, $text, $keyboard);
    $newMsgId = (int) ($result['result']['message_id'] ?? 0);

    if ($newMsgId > 0) {
        MessageContentCache::put(
            $chatId,
            $newMsgId,
            MessageContentCache::hash($text, $keyboard)
        );
    }
}


@@@FILE: Archive.zip@@@

[binary file omitted]


