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