Files
pk10/App/Controllers/Api/BotController.php
T
li 4a94fe36cf feat: 重构代码架构 + 新增报表/跟单/输赢统计功能
- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController
- 新增 Service 层: TransactionService, ReportService, FollowPlanService
- pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll)
- 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan)
- 后台新增跟单计划管理 + 用户输赢明细统计
- 封盘状态显示倒计时 (x:xx)
- 音效仅在开奖弹窗打开时播放
- 路由按模块分组整理
- autoload 支持 App\Services 命名空间
2026-03-27 18:41:06 +08:00

1352 lines
56 KiB
PHP

<?php
namespace App\Controllers\Api;
use App\Core\BotApiBaseController;
use App\Core\GameFactory;
class BotController extends BotApiBaseController
{
private function getRuntimeSecret(): string
{
$envSecret = trim((string)($_ENV['BOT_RUNTIME_SECRET'] ?? getenv('BOT_RUNTIME_SECRET') ?: ''));
if ($envSecret !== '') {
return $envSecret;
}
$botEnvPath = dirname(__DIR__, 3) . '/bot-runtime/.env';
if (!is_file($botEnvPath) || !is_readable($botEnvPath)) {
return '';
}
$lines = @file($botEnvPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$trimmed = trim((string)$line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
if (!str_starts_with($trimmed, 'BOT_RUNTIME_SECRET=')) {
continue;
}
return trim((string)substr($trimmed, strlen('BOT_RUNTIME_SECRET=')));
}
return '';
}
public function getRuntimeInstances(): void
{
$runtimeSecret = trim((string)($_SERVER['HTTP_X_RUNTIME_SECRET'] ?? ''));
$expectedSecret = $this->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);
}
}
}