getRuntimeSecret(); if ($expectedSecret === '' || !hash_equals($expectedSecret, $runtimeSecret)) { $this->jsonResponse(false, 'Runtime auth failed', [], 401); } try { $bots = $this->db->select('bot_instances', '*', [ 'status' => 1, 'ORDER' => ['id' => 'ASC'], ]) ?: []; $instances = []; foreach ($bots as $bot) { $botId = (int)($bot['id'] ?? 0); if ($botId <= 0 || empty($bot['bot_token']) || empty($bot['bot_key']) || empty($bot['bot_secret'])) { continue; } $groups = $this->db->select('bot_groups', [ 'id', 'tg_group_id', 'group_name', 'status', 'bet_enabled', ], [ 'bot_id' => $botId, 'status' => 1, 'ORDER' => ['id' => 'ASC'], ]) ?: []; if (empty($groups)) { continue; } $instances[] = [ 'id' => $botId, 'name' => (string)($bot['name'] ?? ''), 'bot_token' => (string)$bot['bot_token'], 'bot_username' => (string)($bot['bot_username'] ?? ''), 'bot_key' => (string)$bot['bot_key'], 'bot_secret' => (string)$bot['bot_secret'], 'run_mode' => (string)($bot['run_mode'] ?? 'polling'), 'webhook_url' => (string)($bot['webhook_url'] ?? ''), 'groups' => array_map(static function (array $group): array { return [ 'id' => (int)($group['id'] ?? 0), 'tg_group_id' => (string)($group['tg_group_id'] ?? ''), 'group_name' => (string)($group['group_name'] ?? ''), 'status' => (int)($group['status'] ?? 0), 'bet_enabled' => (int)($group['bet_enabled'] ?? 0) === 1, ]; }, $groups), ]; } $this->jsonResponse(true, 'ok', [ 'instances' => $instances, 'count' => count($instances), 'server_time' => date('Y-m-d H:i:s'), ], 200); } catch (\Throwable $e) { $this->jsonResponse(false, 'Failed to load runtime instances', [], 500); } } private function buildPeriodSnapshot(array $group): array { $gameId = (int)$group['game_id']; $game = $this->db->get('games', ['id', 'type', 'period_duration', 'lock_before_end'], ['id' => $gameId]); if (!$game) { throw new \RuntimeException('Game not found'); } $period = $this->db->get('periods', '*', [ 'game_id' => $gameId, 'ORDER' => ['id' => 'DESC'] ]); $now = time(); $countdown = 0; $lockCountdown = 0; if ($period && in_array($period['status'], ['pending', 'locked'], true)) { $start = strtotime($period['start_time'] ?? $period['created_at']); $countdown = max(0, !empty($period['end_time']) ? strtotime($period['end_time']) - $now : ((int)($game['period_duration'] ?? 300) - ($now - $start))); $periodDuration = (int)($game['period_duration'] ?? 300); $lockBefore = (int)($game['lock_before_end'] ?? 30); $lockCountdown = max(0, ($periodDuration - $lockBefore) - ($now - $start)); } return [ 'game_id' => $gameId, 'game' => $game, 'period' => $period, 'countdown' => $countdown, 'lock_countdown' => $lockCountdown, 'server_time' => date('Y-m-d H:i:s'), ]; } private function getResultPayload(array $game, ?array $periodRow): ?array { if (!$periodRow) { return null; } $algoClass = GameFactory::getAlgorithm($game['type'] ?? 'pk10'); $resultTable = $algoClass::getResultTable(); if ($resultTable) { $result = $this->db->get($resultTable, '*', ['period_id' => (int)$periodRow['id']]); if (!$result) { return null; } $result['period_number'] = $periodRow['period_number']; $result['status'] = $periodRow['status']; return $result; } $result = $periodRow; $result['period_number'] = $periodRow['period_number']; return $result; } private function applyFundChange(array $group, array $wallet, string $requestType): void { $idempotencyKey = $this->buildIdempotencyKey($requestType); $amount = (float)($this->requestJson['amount'] ?? 0); $reason = trim((string)($this->requestJson['reason'] ?? '')); $tgUserId = (string)($this->requestJson['tg_user_id'] ?? ''); $operator = trim((string)($this->requestJson['operator'] ?? 'bot')); $platformUserId = (int)($this->requestJson['platform_user_id'] ?? $wallet['platform_user_id']); if ($amount <= 0) { $this->auditAndRespond(false, 'Amount must be greater than 0', [], 422, (int)$group['id'], $idempotencyKey); } $existing = $this->db->get('bot_fund_requests', '*', ['idempotency_key' => $idempotencyKey]); if ($existing) { $this->auditAndRespond(true, 'Duplicate request ignored', [ 'fund_request_id' => (int)$existing['id'], 'transaction_id' => (int)($existing['transaction_id'] ?? 0), 'status' => $existing['status'], 'duplicate' => true, ], 200, (int)$group['id'], $idempotencyKey); } try { $this->db->medoo->pdo->beginTransaction(); $stmt = $this->db->medoo->pdo->prepare('SELECT balance, is_virtual FROM users WHERE id = :id FOR UPDATE'); $stmt->execute([':id' => $platformUserId]); $user = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$user) { throw new \RuntimeException('Platform wallet user not found'); } $balanceBefore = (float)$user['balance']; $delta = $requestType === 'credit' ? $amount : -$amount; if ($requestType === 'debit' && $balanceBefore < $amount) { $this->db->medoo->pdo->rollBack(); $this->auditAndRespond(false, 'Insufficient balance', [], 422, (int)$group['id'], $idempotencyKey); } $balanceAfter = $balanceBefore + $delta; $this->db->update('users', [ 'balance' => $balanceAfter, 'updated_at' => date('Y-m-d H:i:s') ], ['id' => $platformUserId]); $transactionId = $this->db->insert('transactions', [ 'user_id' => $platformUserId, 'type' => $requestType, 'amount' => $delta, 'balance_before' => $balanceBefore, 'balance_after' => $balanceAfter, 'description' => $reason !== '' ? $reason : ('TG Bot ' . $requestType), 'is_virtual' => (int)($user['is_virtual'] ?? 0), 'source' => 'bot', 'source_ref' => $idempotencyKey, 'operator_type' => 'system', 'created_at' => date('Y-m-d H:i:s') ]); $fundRequestId = $this->db->insert('bot_fund_requests', [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId !== '' ? $tgUserId : null, 'platform_user_id' => $platformUserId, 'request_type' => $requestType, 'amount' => $amount, 'reason' => $reason !== '' ? $reason : ('TG Bot ' . $requestType), 'idempotency_key' => $idempotencyKey, 'transaction_id' => $transactionId, 'status' => 'approved', 'operator_type' => 'system', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); $this->db->insert('bot_push_logs', [ 'group_id' => (int)$group['id'], 'push_type' => $requestType, 'payload_json' => json_encode([ 'operator' => $operator, 'platform_user_id' => $platformUserId, 'amount' => $amount, 'balance_after' => $balanceAfter, ], JSON_UNESCAPED_UNICODE), 'status' => 'success', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); $this->db->medoo->pdo->commit(); $this->auditAndRespond(true, ucfirst($requestType) . ' success', [ 'fund_request_id' => (int)$fundRequestId, 'transaction_id' => (int)$transactionId, 'platform_user_id' => $platformUserId, 'balance_before' => $balanceBefore, 'balance_after' => $balanceAfter, 'amount' => $amount, ], 200, (int)$group['id'], $idempotencyKey); } catch (\Throwable $e) { if ($this->db->medoo && $this->db->medoo->pdo->inTransaction()) { $this->db->medoo->pdo->rollBack(); } try { $this->db->insert('bot_fund_requests', [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId !== '' ? $tgUserId : null, 'platform_user_id' => $platformUserId, 'request_type' => $requestType, 'amount' => $amount, 'reason' => $reason !== '' ? $reason : ('TG Bot ' . $requestType), 'idempotency_key' => $idempotencyKey, 'status' => 'failed', 'operator_type' => 'system', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); } catch (\Throwable $ignore) { } $this->auditAndRespond(false, 'System error', [], 500, (int)$group['id'], $idempotencyKey); } } public function ping(): void { $this->authenticateBotRequest(); $this->auditAndRespond(true, 'pong', [ 'bot_id' => (int)$this->botInstance['id'], 'bot_name' => $this->botInstance['name'], 'server_time' => date('Y-m-d H:i:s'), ], 200, null, null); } public function getGroupConfig(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $wallet = $this->getGroupWallet((int)$group['id']); $rule = null; if (!empty($group['bet_format_rule_id'])) { $rule = $this->db->get('bot_bet_format_rules', '*', ['id' => (int)$group['bet_format_rule_id']]); } $members = $this->db->count('bot_group_members', ['group_id' => (int)$group['id']]); $this->auditAndRespond(true, 'ok', [ 'group_id' => (int)$group['id'], 'tg_group_id' => $group['tg_group_id'], 'group_name' => $group['group_name'], 'game_id' => (int)$group['game_id'], 'bet_enabled' => (int)$group['bet_enabled'] === 1, 'wallet_mode' => $wallet['wallet_mode'], 'master_platform_user_id' => (int)$wallet['platform_user_id'], 'reminders' => [ 'bet_success' => (int)$group['remind_bet_success'] === 1, 'draw_result' => (int)$group['remind_draw_result'] === 1, 'close_countdown' => (int)$group['remind_close_countdown'] === 1, ], 'countdown_config' => $group['countdown_config'] ? json_decode($group['countdown_config'], true) : [], 'animation_enabled' => (int)$group['animation_enabled'] === 1, 'member_count' => (int)$members, 'bet_format_rule' => $rule ? [ 'id' => (int)$rule['id'], 'rule_code' => $rule['rule_code'], 'name' => $rule['name'], 'parser_type' => $rule['parser_type'], 'rule_config' => $rule['rule_config'] ? json_decode($rule['rule_config'], true) : null, 'example_text' => $rule['example_text'], ] : null, ], 200, (int)$group['id']); } private function updateGroupSwitches(array $group, array $allowedFields): void { $this->authenticateBotRequest(); $payload = $this->requestJson; $actorTgUserId = trim((string)($payload['actor_tg_user_id'] ?? '')); $field = trim((string)($payload['field'] ?? '')); $enabledRaw = $payload['enabled'] ?? null; $idempotencyKey = $this->buildIdempotencyKey('group-action'); if ($actorTgUserId === '' || $field === '' || !array_key_exists($field, $allowedFields) || !in_array($enabledRaw, [0, 1, '0', '1', true, false], true)) { $this->auditAndRespond(false, 'Invalid group action payload', [], 422, (int)$group['id'], $idempotencyKey); } $member = $this->db->get('bot_group_members', ['id', 'role'], [ 'group_id' => (int)$group['id'], 'tg_user_id' => $actorTgUserId, ]); $isConfiguredAdmin = $member && in_array((string)($member['role'] ?? ''), ['admin', 'owner'], true); $isRuntimeAdmin = !empty($payload['is_runtime_admin']); if (!$isConfiguredAdmin && !$isRuntimeAdmin) { $this->auditAndRespond(false, 'Permission denied', [], 403, (int)$group['id'], $idempotencyKey); } $enabled = in_array($enabledRaw, [1, '1', true], true) ? 1 : 0; $dbField = $allowedFields[$field]; try { $this->db->update('bot_groups', [ $dbField => $enabled, 'updated_at' => date('Y-m-d H:i:s'), ], ['id' => (int)$group['id']]); $group[$dbField] = $enabled; $this->auditAndRespond(true, 'Group action updated', [ 'field' => $field, 'db_field' => $dbField, 'enabled' => $enabled === 1, 'group_id' => (int)$group['id'], 'tg_group_id' => (string)$group['tg_group_id'], ], 200, (int)$group['id'], $idempotencyKey); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to update group action', [], 500, (int)$group['id'], $idempotencyKey); } } public function getDrawLatest(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $snapshot = $this->buildPeriodSnapshot($group); $game = $snapshot['game']; $lastDrawn = $this->db->get('periods', '*', [ 'game_id' => (int)$snapshot['game_id'], 'status' => ['drawn', 'settled'], 'ORDER' => ['id' => 'DESC'] ]); $currentLocked = null; if (!empty($snapshot['period']) && ($snapshot['period']['status'] ?? '') === 'locked' && !empty($snapshot['period']['result'])) { $currentLocked = $this->getResultPayload($game, $snapshot['period']); } $latest = $this->getResultPayload($game, $lastDrawn); $this->auditAndRespond(true, 'ok', [ 'latest_result' => $latest, 'current_locked_result' => $currentLocked, 'server_time' => $snapshot['server_time'], ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load latest draw', [], 500, (int)$group['id']); } } public function toggleGroupSetting(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $this->updateGroupSwitches($group, [ 'bet_enabled' => 'bet_enabled', 'bet_success' => 'remind_bet_success', 'draw_result' => 'remind_draw_result', 'close_countdown' => 'remind_close_countdown', 'animation' => 'animation_enabled', ]); } public function getOdds(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $gameId = (int)$group['game_id']; $game = $this->db->get('games', ['id', 'name', 'type'], ['id' => $gameId]); if (!$game) { $this->auditAndRespond(false, 'Game not found', [], 404, (int)$group['id']); } $oddsRows = $this->db->select('game_odds', '*', ['game_id' => $gameId]); $grouped = []; foreach ($oddsRows as $row) { $type = (string)($row['type'] ?? ''); if (!isset($grouped[$type])) { $grouped[$type] = []; } $grouped[$type][] = [ 'target' => (string)($row['target'] ?? ''), 'odds' => (float)($row['odds'] ?? 0), ]; } $this->auditAndRespond(true, 'ok', [ 'game_id' => $gameId, 'game_name' => $game['name'], 'game_type' => $game['type'], 'odds' => $grouped, 'total' => count($oddsRows), ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load odds', [], 500, (int)$group['id']); } } public function getBetFormatRules(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $rule = null; if (!empty($group['bet_format_rule_id'])) { $rule = $this->db->get('bot_bet_format_rules', '*', [ 'id' => (int)$group['bet_format_rule_id'], 'status' => 1, ]); } if (!$rule) { $rules = $this->db->select('bot_bet_format_rules', '*', [ 'game_id' => (int)$group['game_id'], 'status' => 1, 'ORDER' => ['id' => 'ASC'], 'LIMIT' => 10, ]); $this->auditAndRespond(true, 'ok', [ 'active_rule' => null, 'available_rules' => array_map(function ($r) { return [ 'id' => (int)$r['id'], 'rule_code' => $r['rule_code'], 'name' => $r['name'], 'parser_type' => $r['parser_type'], 'rule_config' => $r['rule_config'] ? json_decode($r['rule_config'], true) : null, 'example_text' => $r['example_text'], ]; }, $rules ?: []), ], 200, (int)$group['id']); return; } $this->auditAndRespond(true, 'ok', [ 'active_rule' => [ 'id' => (int)$rule['id'], 'rule_code' => $rule['rule_code'], 'name' => $rule['name'], 'parser_type' => $rule['parser_type'], 'rule_config' => $rule['rule_config'] ? json_decode($rule['rule_config'], true) : null, 'example_text' => $rule['example_text'], ], 'available_rules' => [], ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load bet format rules', [], 500, (int)$group['id']); } } public function getHistory(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $snapshot = $this->buildPeriodSnapshot($group); $game = $snapshot['game']; $limit = (int)($_GET['limit'] ?? 10); if ($limit <= 0) { $limit = 10; } $limit = min($limit, 50); $periods = $this->db->select('periods', '*', [ 'game_id' => (int)$snapshot['game_id'], 'status' => ['drawn', 'settled'], 'ORDER' => ['id' => 'DESC'], 'LIMIT' => $limit, ]); $history = []; foreach ($periods as $period) { $payload = $this->getResultPayload($game, $period); if ($payload) { $history[] = $payload; } } $this->auditAndRespond(true, 'ok', [ 'items' => $history, 'count' => count($history), 'server_time' => $snapshot['server_time'], ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load history', [], 500, (int)$group['id']); } } public function touchMember(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $tgUserId = trim((string)($this->requestJson['tg_user_id'] ?? '')); $tgUsername = trim((string)($this->requestJson['tg_username'] ?? '')); $tgNickname = trim((string)($this->requestJson['tg_nickname'] ?? '')); if ($tgUserId === '') { $this->auditAndRespond(false, 'Missing tg_user_id', [], 422, (int)$group['id']); } try { $existing = $this->db->get('bot_group_members', ['id', 'tg_username', 'tg_nickname'], [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId, ]); $now = date('Y-m-d H:i:s'); if ($existing) { $update = ['last_seen_at' => $now, 'updated_at' => $now]; if ($tgUsername !== '' && $tgUsername !== ($existing['tg_username'] ?? '')) { $update['tg_username'] = $tgUsername; } if ($tgNickname !== '' && $tgNickname !== ($existing['tg_nickname'] ?? '')) { $update['tg_nickname'] = $tgNickname; } $this->db->update('bot_group_members', $update, ['id' => (int)$existing['id']]); $this->auditAndRespond(true, 'ok', ['action' => 'updated', 'member_id' => (int)$existing['id']], 200, (int)$group['id']); } $this->db->insert('bot_group_members', [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId, 'tg_username' => $tgUsername ?: null, 'tg_nickname' => $tgNickname ?: null, 'role' => 'member', 'bet_enabled' => 1, 'last_seen_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); $memberId = (int)$this->db->id(); $this->auditAndRespond(true, 'ok', ['action' => 'created', 'member_id' => $memberId], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(true, 'ok', ['action' => 'skipped'], 200, (int)$group['id']); } } public function bindMember(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $tgUserId = trim((string)($this->requestJson['tg_user_id'] ?? '')); $platformUsername = trim((string)($this->requestJson['platform_username'] ?? '')); if ($tgUserId === '' || $platformUsername === '') { $this->auditAndRespond(false, 'Missing tg_user_id or platform_username', [], 422, (int)$group['id']); } try { $user = $this->db->get('users', ['id', 'username', 'balance', 'status'], [ 'username' => $platformUsername, ]); if (!$user) { $this->auditAndRespond(false, 'Platform user not found', [], 404, (int)$group['id']); } $existing = $this->db->get('bot_group_members', ['id'], [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId, ]); $now = date('Y-m-d H:i:s'); if ($existing) { $this->db->update('bot_group_members', [ 'platform_user_id' => (int)$user['id'], 'updated_at' => $now, ], ['id' => (int)$existing['id']]); } else { $this->db->insert('bot_group_members', [ 'group_id' => (int)$group['id'], 'tg_user_id' => $tgUserId, 'tg_username' => trim((string)($this->requestJson['tg_username'] ?? '')) ?: null, 'tg_nickname' => trim((string)($this->requestJson['tg_nickname'] ?? '')) ?: null, 'platform_user_id' => (int)$user['id'], 'role' => 'member', 'bet_enabled' => 1, 'last_seen_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); } $this->auditAndRespond(true, 'Bind success', [ 'platform_user_id' => (int)$user['id'], 'platform_username' => $user['username'], 'platform_balance' => (float)$user['balance'], ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Bind failed', [], 500, (int)$group['id']); } } public function getGroupMembers(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $members = $this->db->select('bot_group_members', [ '[>]users' => ['platform_user_id' => 'id'], ], [ 'bot_group_members.id', 'bot_group_members.tg_user_id', 'bot_group_members.tg_username', 'bot_group_members.tg_nickname', 'bot_group_members.platform_user_id', 'bot_group_members.role', 'bot_group_members.bet_enabled', 'bot_group_members.last_seen_at', 'bot_group_members.created_at', 'bot_group_members.updated_at', 'users.username(platform_username)', 'users.balance(platform_balance)', 'users.status(platform_status)', ], [ 'bot_group_members.group_id' => (int)$group['id'], 'ORDER' => ['bot_group_members.id' => 'ASC'], ]); $shillUserIds = $this->db->select('bot_shills', ['tg_user_id'], [ 'group_id' => (int)$group['id'], 'enabled' => 1, ]); $shillMap = array_fill_keys($shillUserIds ?: [], true); $items = []; foreach ($members as $member) { $tgUserId = (string)$member['tg_user_id']; $items[] = [ 'id' => (int)$member['id'], 'tg_user_id' => $tgUserId, 'tg_username' => $member['tg_username'], 'tg_nickname' => $member['tg_nickname'], 'platform_user_id' => $member['platform_user_id'] !== null ? (int)$member['platform_user_id'] : null, 'platform_username' => $member['platform_username'] ?? null, 'platform_balance' => $member['platform_balance'] !== null ? (float)$member['platform_balance'] : null, 'platform_status' => $member['platform_status'] !== null ? (int)$member['platform_status'] : null, 'role' => $member['role'], 'bet_enabled' => (int)$member['bet_enabled'] === 1, 'is_shill' => isset($shillMap[$tgUserId]) || $member['role'] === 'shill', 'last_seen_at' => $member['last_seen_at'], 'created_at' => $member['created_at'], 'updated_at' => $member['updated_at'], ]; } $this->auditAndRespond(true, 'ok', [ 'group_id' => (int)$group['id'], 'items' => $items, 'count' => count($items), ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load group members', [], 500, (int)$group['id']); } } public function getMemberStats(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $from = trim((string)($_GET['date'] ?? date('Y-m-d'))); $startAt = $from . ' 00:00:00'; $endAt = $from . ' 23:59:59'; $orders = $this->db->select('bot_bet_orders', [ 'tg_user_id', 'tg_username', 'bet_amount_total', 'accepted_bet_count', 'is_shill', ], [ 'group_id' => (int)$group['id'], 'sync_status' => 'success', 'created_at[<>]' => [$startAt, $endAt], 'ORDER' => ['id' => 'ASC'], ]); $memberMap = []; foreach ($orders as $order) { $tgUserId = (string)($order['tg_user_id'] ?? ''); if ($tgUserId === '') { continue; } if (!isset($memberMap[$tgUserId])) { $memberMap[$tgUserId] = [ 'tg_user_id' => $tgUserId, 'tg_username' => (string)($order['tg_username'] ?? ''), 'bet_order_count' => 0, 'bet_count' => 0, 'total_bet' => 0.0, 'is_shill' => (int)($order['is_shill'] ?? 0) === 1, ]; } $memberMap[$tgUserId]['bet_order_count']++; $memberMap[$tgUserId]['bet_count'] += (int)($order['accepted_bet_count'] ?? 0); $memberMap[$tgUserId]['total_bet'] += (float)($order['bet_amount_total'] ?? 0); if ($memberMap[$tgUserId]['tg_username'] === '' && !empty($order['tg_username'])) { $memberMap[$tgUserId]['tg_username'] = (string)$order['tg_username']; } if ((int)($order['is_shill'] ?? 0) === 1) { $memberMap[$tgUserId]['is_shill'] = true; } } $members = $this->db->select('bot_group_members', [ 'tg_user_id', 'tg_username', 'tg_nickname', 'role', 'bet_enabled', 'last_seen_at', ], [ 'group_id' => (int)$group['id'], ]); foreach ($members as $member) { $tgUserId = (string)$member['tg_user_id']; if (!isset($memberMap[$tgUserId])) { $memberMap[$tgUserId] = [ 'tg_user_id' => $tgUserId, 'tg_username' => (string)($member['tg_username'] ?? ''), 'bet_order_count' => 0, 'bet_count' => 0, 'total_bet' => 0.0, 'is_shill' => $member['role'] === 'shill', ]; } $memberMap[$tgUserId]['tg_nickname'] = $member['tg_nickname']; $memberMap[$tgUserId]['role'] = $member['role']; $memberMap[$tgUserId]['bet_enabled'] = (int)$member['bet_enabled'] === 1; $memberMap[$tgUserId]['last_seen_at'] = $member['last_seen_at']; if ($memberMap[$tgUserId]['tg_username'] === '' && !empty($member['tg_username'])) { $memberMap[$tgUserId]['tg_username'] = (string)$member['tg_username']; } if ($member['role'] === 'shill') { $memberMap[$tgUserId]['is_shill'] = true; } } $items = array_values($memberMap); usort($items, function (array $left, array $right): int { $betCompare = $right['total_bet'] <=> $left['total_bet']; if ($betCompare !== 0) { return $betCompare; } return $right['bet_order_count'] <=> $left['bet_order_count']; }); $this->auditAndRespond(true, 'ok', [ 'date' => $from, 'group_id' => (int)$group['id'], 'items' => $items, 'count' => count($items), ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load member stats', [], 500, (int)$group['id']); } } public function getDailyStats(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $wallet = $this->getGroupWallet((int)$group['id']); $platformUserId = (int)$wallet['platform_user_id']; $from = trim((string)($_GET['date'] ?? date('Y-m-d'))); $startAt = $from . ' 00:00:00'; $endAt = $from . ' 23:59:59'; $betWhere = [ 'user_id' => $platformUserId, 'source' => 'bot', 'created_at[<>]' => [$startAt, $endAt], ]; $txWhere = [ 'user_id' => $platformUserId, 'source' => 'bot', 'created_at[<>]' => [$startAt, $endAt], ]; $totalBet = (float)($this->db->sum('bets', 'amount', $betWhere) ?: 0); $totalWin = (float)($this->db->sum('bets', 'win_amount', array_merge($betWhere, ['status' => 'win'])) ?: 0); $betCount = (int)($this->db->count('bets', $betWhere) ?: 0); $deposit = (float)($this->db->sum('transactions', 'amount', array_merge($txWhere, ['type' => 'credit', 'amount[>]' => 0])) ?: 0); $withdraw = abs((float)($this->db->sum('transactions', 'amount', array_merge($txWhere, ['type' => 'debit', 'amount[<]' => 0])) ?: 0)); $orderCount = (int)($this->db->count('bot_bet_orders', [ 'group_id' => (int)$group['id'], 'created_at[<>]' => [$startAt, $endAt], 'sync_status' => 'success', ]) ?: 0); $shillOrderCount = (int)($this->db->count('bot_bet_orders', [ 'group_id' => (int)$group['id'], 'created_at[<>]' => [$startAt, $endAt], 'sync_status' => 'success', 'is_shill' => 1, ]) ?: 0); $this->auditAndRespond(true, 'ok', [ 'date' => $from, 'group_id' => (int)$group['id'], 'platform_user_id' => $platformUserId, 'bet_order_count' => $orderCount, 'shill_order_count' => $shillOrderCount, 'effective_order_count' => max(0, $orderCount - $shillOrderCount), 'bet_count' => $betCount, 'total_bet' => $totalBet, 'total_win' => $totalWin, 'profit_loss' => $totalWin - $totalBet, 'credit_total' => $deposit, 'debit_total' => $withdraw, ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load daily stats', [], 500, (int)$group['id']); } } public function getCurrentPeriod(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); try { $snapshot = $this->buildPeriodSnapshot($group); $period = $snapshot['period']; $this->auditAndRespond(true, 'ok', [ 'id' => (int)($period['id'] ?? 0), 'game_id' => (int)$snapshot['game_id'], 'period_number' => $period['period_number'] ?? '', 'status' => $period['status'] ?? 'none', 'remaining_seconds' => (int)$snapshot['countdown'], 'lock_countdown' => (int)$snapshot['lock_countdown'], 'auto_generated' => (int)($period['auto_generated'] ?? 0), 'server_time' => $snapshot['server_time'], ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load current period', [], 500, (int)$group['id']); } } public function credit(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $wallet = $this->getGroupWallet((int)$group['id']); $this->applyFundChange($group, $wallet, 'credit'); } public function debit(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $wallet = $this->getGroupWallet((int)$group['id']); $this->applyFundChange($group, $wallet, 'debit'); } public function placeGroupBet(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $wallet = $this->getGroupWallet((int)$group['id']); if ((int)$group['bet_enabled'] !== 1) { $this->auditAndRespond(false, 'Bet sync disabled for this group', [], 403, (int)$group['id']); } $gameId = (int)($this->requestJson['game_id'] ?? $group['game_id'] ?? 0); $periodNumber = trim((string)($this->requestJson['period_number'] ?? '')); $bets = $this->requestJson['bets'] ?? []; $tgUser = $this->requestJson['tg_user'] ?? []; $rawText = trim((string)($this->requestJson['bet_context']['raw_text'] ?? $this->requestJson['raw_text'] ?? '')); $idempotencyKey = $this->buildIdempotencyKey('tg'); if ($gameId <= 0 || $periodNumber === '' || !is_array($bets) || empty($bets)) { $this->auditAndRespond(false, 'Missing required bet fields', [], 422, (int)$group['id'], $idempotencyKey); } $exists = $this->db->get('bot_bet_orders', '*', ['idempotency_key' => $idempotencyKey]); if ($exists) { $data = [ 'platform_order_ref' => $exists['platform_order_ref'], 'sync_status' => $exists['sync_status'], 'bet_amount_total' => (float)$exists['bet_amount_total'], 'accepted_bet_count' => (int)$exists['accepted_bet_count'], 'duplicate' => true, ]; $this->auditAndRespond(true, 'Duplicate request ignored', $data, 200, (int)$group['id'], $idempotencyKey); } $period = $this->db->get('periods', '*', [ 'period_number' => $periodNumber, 'game_id' => $gameId, ]); if (!$period || $period['status'] !== 'pending') { $this->auditAndRespond(false, 'Period is not open for betting', [], 422, (int)$group['id'], $idempotencyKey); } $oddsRows = $this->db->select('game_odds', '*', ['game_id' => $gameId]); $oddsMap = []; foreach ($oddsRows as $row) { $oddsMap[$row['type'] . '_' . $row['target']] = (float)$row['odds']; } $limitsRaw = $this->db->select('bet_limits', '*', ['game_id' => $gameId]); $limits = []; foreach ($limitsRaw as $row) { $limits[$row['bet_type']] = $row; } $totalAmount = 0.0; $validBets = []; foreach ($bets as $bet) { $type = trim((string)($bet['type'] ?? '')); $target = trim((string)($bet['target'] ?? $bet['value'] ?? '')); $amount = (float)($bet['amount'] ?? 0); if ($type === '' || $target === '' || $amount <= 0) { continue; } $key = $type . '_' . $target; $odds = $oddsMap[$key] ?? null; if ($odds === null) { $this->auditAndRespond(false, 'Invalid bet: ' . $key, [], 422, (int)$group['id'], $idempotencyKey); } if (isset($limits[$type])) { if ($amount < (float)$limits[$type]['min_amount']) { $this->auditAndRespond(false, 'Min bet for ' . $type . ': ' . $limits[$type]['min_amount'], [], 422, (int)$group['id'], $idempotencyKey); } if ($amount > (float)$limits[$type]['max_amount']) { $this->auditAndRespond(false, 'Max bet for ' . $type . ': ' . $limits[$type]['max_amount'], [], 422, (int)$group['id'], $idempotencyKey); } } $totalAmount += $amount; $validBets[] = [ 'type' => $type, 'target' => $target, 'amount' => $amount, 'odds' => $odds, ]; } if (empty($validBets)) { $this->auditAndRespond(false, 'No valid bets', [], 422, (int)$group['id'], $idempotencyKey); } $platformUserId = (int)$wallet['platform_user_id']; $platformOrderRef = 'BOT' . date('YmdHis') . substr(hash('sha256', $idempotencyKey), 0, 10); $isShill = $this->db->has('bot_shills', [ 'group_id' => (int)$group['id'], 'tg_user_id' => (string)($tgUser['id'] ?? ''), 'enabled' => 1, ]) ? 1 : 0; try { $this->db->medoo->pdo->beginTransaction(); $stmt = $this->db->medoo->pdo->prepare('SELECT balance, is_virtual, agent_id FROM users WHERE id = :id FOR UPDATE'); $stmt->execute([':id' => $platformUserId]); $user = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$user) { throw new \RuntimeException('Platform wallet user not found'); } $currentBalance = (float)$user['balance']; if ($currentBalance < $totalAmount) { $this->db->medoo->pdo->rollBack(); $this->auditAndRespond(false, 'Insufficient balance', [], 422, (int)$group['id'], $idempotencyKey); } $newBalance = $currentBalance - $totalAmount; $this->db->update('users', ['balance' => $newBalance], ['id' => $platformUserId]); $this->db->insert('transactions', [ 'user_id' => $platformUserId, 'type' => 'bet', 'amount' => -$totalAmount, 'balance_before' => $currentBalance, 'balance_after' => $newBalance, 'related_id' => (int)$period['id'], 'description' => 'TG Bot Bet - ' . $periodNumber, 'is_virtual' => (int)($user['is_virtual'] ?? 0), 'source' => 'bot', 'source_ref' => $idempotencyKey, 'created_at' => date('Y-m-d H:i:s'), ]); foreach ($validBets as $vb) { $this->db->insert('bets', [ 'user_id' => $platformUserId, 'game_id' => $gameId, 'period_id' => (int)$period['id'], 'period_number' => $periodNumber, 'bet_type' => $vb['type'], 'bet_value' => $vb['target'], 'amount' => $vb['amount'], 'odds' => $vb['odds'], 'status' => 'pending', 'is_virtual' => (int)($user['is_virtual'] ?? 0), 'agent_id' => $user['agent_id'] ?? null, 'source' => 'bot', 'source_ref' => $idempotencyKey, 'created_at' => date('Y-m-d H:i:s'), ]); } $this->db->insert('bot_bet_orders', [ 'group_id' => (int)$group['id'], 'tg_chat_id' => $tgGroupId, 'tg_message_id' => (int)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? 0), 'tg_user_id' => (string)($tgUser['id'] ?? ''), 'tg_username' => (string)($tgUser['username'] ?? ''), 'platform_user_id' => $platformUserId, 'game_id' => $gameId, 'period_number' => $periodNumber, 'raw_text' => $rawText, 'parsed_payload_json' => json_encode($validBets, JSON_UNESCAPED_UNICODE), 'bet_amount_total' => $totalAmount, 'accepted_bet_count' => count($validBets), 'platform_order_ref' => $platformOrderRef, 'idempotency_key' => $idempotencyKey, 'sync_status' => 'success', 'is_shill' => $isShill, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); if ((int)$group['remind_bet_success'] === 1) { $this->db->insert('bot_push_logs', [ 'group_id' => (int)$group['id'], 'period_number' => $periodNumber, 'push_type' => 'bet_success', 'payload_json' => json_encode([ 'event' => 'bet_success', 'period_number' => $periodNumber, 'platform_order_ref' => $platformOrderRef, 'tg_user_id' => (string)($tgUser['id'] ?? ''), 'tg_username' => (string)($tgUser['username'] ?? ''), 'accepted_bets' => count($validBets), 'total_amount' => $totalAmount, 'balance_after' => $newBalance, 'raw_text' => $rawText, ], JSON_UNESCAPED_UNICODE), 'status' => 'pending', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } $this->db->medoo->pdo->commit(); $data = [ 'platform_order_ref' => $platformOrderRef, 'new_balance' => $newBalance, 'accepted_bets' => count($validBets), 'total_amount' => $totalAmount, 'is_shill' => $isShill === 1, ]; $this->auditAndRespond(true, 'Bet placed successfully', $data, 200, (int)$group['id'], $idempotencyKey); } catch (\Throwable $e) { if ($this->db->medoo && $this->db->medoo->pdo->inTransaction()) { $this->db->medoo->pdo->rollBack(); } try { $this->db->insert('bot_bet_orders', [ 'group_id' => (int)$group['id'], 'tg_chat_id' => $tgGroupId, 'tg_message_id' => (int)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? 0), 'tg_user_id' => (string)($tgUser['id'] ?? ''), 'tg_username' => (string)($tgUser['username'] ?? ''), 'platform_user_id' => $platformUserId, 'game_id' => $gameId, 'period_number' => $periodNumber, 'raw_text' => $rawText, 'parsed_payload_json' => json_encode($bets, JSON_UNESCAPED_UNICODE), 'bet_amount_total' => $totalAmount, 'accepted_bet_count' => 0, 'platform_order_ref' => $platformOrderRef, 'idempotency_key' => $idempotencyKey, 'sync_status' => 'failed', 'sync_error' => mb_substr($e->getMessage(), 0, 255), 'is_shill' => $isShill, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]); } catch (\Throwable $ignore) { } $this->auditAndRespond(false, 'System error', [], 500, (int)$group['id'], $idempotencyKey); } } public function getPendingPushes(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $limit = (int)($this->requestJson['limit'] ?? $_GET['limit'] ?? 20); if ($limit <= 0) { $limit = 20; } $limit = min($limit, 100); $workerToken = trim((string)($this->requestJson['worker_token'] ?? $_GET['worker_token'] ?? '')); if ($workerToken === '') { $workerToken = 'worker:' . substr(hash('sha256', uniqid('', true) . '|' . microtime(true)), 0, 24); } try { $reclaimBefore = date('Y-m-d H:i:s', time() - 120); $this->db->update('bot_push_logs', [ 'claim_token' => null, 'claimed_at' => null, 'updated_at' => date('Y-m-d H:i:s'), ], [ 'group_id' => (int)$group['id'], 'status' => 'pending', 'claim_token[!]' => null, 'claimed_at[<]' => $reclaimBefore, ]); $rows = $this->db->select('bot_push_logs', '*', [ 'group_id' => (int)$group['id'], 'status' => 'pending', 'claim_token' => null, 'ORDER' => ['id' => 'ASC'], 'LIMIT' => 200, ]) ?: []; $pushes = []; $nowTs = time(); $claimedAt = date('Y-m-d H:i:s'); foreach ($rows as $row) { $payload = []; if (!empty($row['payload_json'])) { $decoded = json_decode((string)$row['payload_json'], true); if (is_array($decoded)) { $payload = $decoded; } } $dispatchAtTs = !empty($payload['dispatch_at']) ? strtotime((string)$payload['dispatch_at']) : 0; if ($dispatchAtTs > 0 && $dispatchAtTs > $nowTs) { continue; } $updated = $this->db->update('bot_push_logs', [ 'claim_token' => $workerToken, 'claimed_at' => $claimedAt, 'updated_at' => $claimedAt, ], [ 'id' => (int)$row['id'], 'group_id' => (int)$group['id'], 'status' => 'pending', 'claim_token' => null, ]); if (($updated->rowCount() ?? 0) <= 0) { continue; } $pushes[] = [ 'id' => (int)$row['id'], 'group_id' => (int)$row['group_id'], 'period_number' => $row['period_number'], 'push_type' => $row['push_type'], 'payload' => $payload, 'claim_token' => $workerToken, 'claimed_at' => $claimedAt, 'created_at' => $row['created_at'], 'updated_at' => $claimedAt, ]; if (count($pushes) >= $limit) { break; } } $this->auditAndRespond(true, 'ok', [ 'items' => $pushes, 'count' => count($pushes), 'limit' => $limit, 'worker_token' => $workerToken, ], 200, (int)$group['id']); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to load pending pushes', [], 500, (int)$group['id']); } } public function ackPushes(string $tgGroupId): void { $this->authenticateBotRequest(); $group = $this->requireGroupByTelegramId($tgGroupId); $idempotencyKey = $this->buildIdempotencyKey('push-ack'); $pushLogIds = $this->requestJson['push_log_ids'] ?? []; $status = trim((string)($this->requestJson['status'] ?? '')); $errorMessage = trim((string)($this->requestJson['error_message'] ?? '')); $tgMessageId = (int)($this->requestJson['tg_message_id'] ?? 0); $claimToken = trim((string)($this->requestJson['claim_token'] ?? '')); if (!is_array($pushLogIds) || empty($pushLogIds)) { $this->auditAndRespond(false, 'push_log_ids is required', [], 422, (int)$group['id'], $idempotencyKey); } $allowedStatus = ['success', 'failed', 'skipped']; if (!in_array($status, $allowedStatus, true)) { $this->auditAndRespond(false, 'Invalid status', [], 422, (int)$group['id'], $idempotencyKey); } if ($claimToken === '') { $this->auditAndRespond(false, 'claim_token is required', [], 422, (int)$group['id'], $idempotencyKey); } $pushLogIds = array_values(array_unique(array_filter(array_map('intval', $pushLogIds), static function ($id) { return $id > 0; }))); if (empty($pushLogIds)) { $this->auditAndRespond(false, 'No valid push_log_ids', [], 422, (int)$group['id'], $idempotencyKey); } try { $rows = $this->db->select('bot_push_logs', ['id', 'status', 'claim_token'], [ 'id' => $pushLogIds, 'group_id' => (int)$group['id'], 'claim_token' => $claimToken, ]) ?: []; if (empty($rows)) { $this->auditAndRespond(false, 'Push logs not found or not claimed by this worker', [], 404, (int)$group['id'], $idempotencyKey); } $foundIds = array_map(static function (array $row): int { return (int)$row['id']; }, $rows); sort($foundIds); $expectedIds = $pushLogIds; sort($expectedIds); if ($foundIds !== $expectedIds) { $this->auditAndRespond(false, 'Some push logs are missing or owned by another worker', [ 'expected_ids' => $expectedIds, 'found_ids' => $foundIds, ], 409, (int)$group['id'], $idempotencyKey); } $updateData = [ 'status' => $status, 'updated_at' => date('Y-m-d H:i:s'), 'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 255) : null, 'claim_token' => null, 'claimed_at' => null, ]; if ($tgMessageId > 0) { $updateData['tg_message_id'] = $tgMessageId; } $this->db->update('bot_push_logs', $updateData, [ 'id' => $foundIds, 'group_id' => (int)$group['id'], 'claim_token' => $claimToken, ]); $this->auditAndRespond(true, 'Push ack updated', [ 'updated_count' => count($foundIds), 'updated_ids' => $foundIds, 'status' => $status, 'tg_message_id' => $tgMessageId > 0 ? $tgMessageId : null, 'claim_token' => $claimToken, ], 200, (int)$group['id'], $idempotencyKey); } catch (\Throwable $e) { $this->auditAndRespond(false, 'Failed to ack pushes', [], 500, (int)$group['id'], $idempotencyKey); } } }