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@@@ =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@@@ tests calendar.php time_parser.php @@@FILE: calendar.php@@@ 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@@@ */ 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@@@ 'تهران', '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@@@ > /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@@@ 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> $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> $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@@@ — 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> */ 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 \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 |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@@@ 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 */ 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 */ 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}> */ 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 $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@@@ $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@@@ 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