feat: enhance plugin management

- Add command support for plugin management
- Optimize plugin management page layout
- Add email copy functionality for users
- Convert payment methods and Telegram Bot to plugin system
This commit is contained in:
xboard 2025-07-26 18:49:58 +08:00
parent dc5a6326a4
commit 5b8cf681f9
56 changed files with 3677 additions and 1329 deletions

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Services\Plugin\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use App\Models\User;
@ -15,6 +16,8 @@ use function Laravel\Prompts\confirm;
use function Laravel\Prompts\text;
use function Laravel\Prompts\note;
use function Laravel\Prompts\select;
use App\Models\Plugin;
use Illuminate\Support\Str;
class XboardInstall extends Command
{
@ -157,6 +160,11 @@ class XboardInstall extends Command
if (!self::registerAdmin($email, $password)) {
abort(500, '管理员账号注册失败,请重试');
}
self::restoreProtectedPlugins($this);
$this->info('正在安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件安装完成');
$this->info('🎉:一切就绪');
$this->info("管理员邮箱:{$email}");
$this->info("管理员密码:{$password}");
@ -356,4 +364,64 @@ class XboardInstall extends Command
}
}
}
/**
* 还原内置受保护插件(可在安装和更新时调用)
*/
public static function restoreProtectedPlugins(Command $console = null)
{
exec("git config core.filemode false", $output, $returnVar);
$cmd = "git status --porcelain plugins/ 2>/dev/null";
exec($cmd, $output, $returnVar);
if (!empty($output)) {
$hasNonNewFiles = false;
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
if ($status !== 'A') {
$hasNonNewFiles = true;
break;
}
}
if ($hasNonNewFiles) {
if ($console)
$console->info("检测到 plugins 目录有变更,正在还原...");
foreach ($output as $line) {
$status = trim(substr($line, 0, 2));
$filePath = trim(substr($line, 3));
if (strpos($filePath, 'plugins/') === 0 && $status !== 'A') {
$relativePath = substr($filePath, 8);
if ($console) {
$action = match ($status) {
'M' => '修改',
'D' => '删除',
'R' => '重命名',
'C' => '复制',
default => '变更'
};
$console->info("还原插件文件 [{$relativePath}] ({$action})");
}
$cmd = "git checkout HEAD -- {$filePath}";
exec($cmd, $gitOutput, $gitReturnVar);
if ($gitReturnVar === 0) {
if ($console)
$console->info("插件文件 [{$relativePath}] 已还原。");
} else {
if ($console)
$console->error("插件文件 [{$relativePath}] 还原失败。");
}
}
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
}
} else {
if ($console)
$console->info("plugins 目录状态正常,无需还原。");
}
}
}

View File

@ -6,7 +6,10 @@ use App\Services\ThemeService;
use App\Services\UpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\PluginManager;
use App\Models\Plugin;
use Illuminate\Support\Str;
use App\Console\Commands\XboardInstall;
class XboardUpdate extends Command
{
@ -44,12 +47,17 @@ class XboardUpdate extends Command
$this->info('正在导入数据库请稍等...');
Artisan::call("migrate");
$this->info(Artisan::output());
Artisan::call('horizon:terminate');
$this->info('正在检查内置插件文件...');
XboardInstall::restoreProtectedPlugins($this);
$this->info('正在检查并安装默认插件...');
PluginManager::installDefaultPlugins();
$this->info('默认插件检查完成');
Artisan::call('reset:traffic', ['--fix-null' => true]);
$updateService = new UpdateService();
$updateService->updateVersionCache();
$themeService = app(ThemeService::class);
$themeService->refreshCurrentTheme();
Artisan::call('horizon:terminate');
$this->info('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}

View File

@ -64,6 +64,10 @@ class Kernel extends ConsoleKernel
{
$this->load(__DIR__ . '/Commands');
try {
app(PluginManager::class)->initializeEnabledPlugins();
} catch (\Exception $e) {
}
require base_path('routes/console.php');
}
}

View File

@ -11,13 +11,16 @@ use App\Traits\HasPluginConfig;
*/
abstract class PluginController extends Controller
{
use HasPluginConfig;
use HasPluginConfig;
/**
* 执行插件操作前的检查
*/
protected function beforePluginAction(): ?array
{
return null;
}
/**
* 执行插件操作前的检查
*/
protected function beforePluginAction(): ?array
{
if (!$this->isPluginEnabled()) {
return [400, '插件未启用'];
}
return null;
}
}

View File

@ -2,14 +2,12 @@
namespace App\Http\Controllers\V1\Guest;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\Payment;
use App\Services\OrderService;
use App\Services\PaymentService;
use App\Services\TelegramService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Services\Plugin\HookManager;
class PaymentController extends Controller
@ -30,7 +28,7 @@ class PaymentController extends Controller
}
return (isset($verify['custom_result']) ? $verify['custom_result'] : 'success');
} catch (\Exception $e) {
\Log::error($e);
Log::error($e);
return $this->fail([500, 'fail']);
}
}
@ -48,22 +46,7 @@ class PaymentController extends Controller
return false;
}
$payment = Payment::where('id', $order->payment_id)->first();
$telegramService = new TelegramService();
$message = sprintf(
"💰成功收款%s元\n" .
"———————————————\n" .
"支付接口:%s\n" .
"支付渠道:%s\n" .
"本站订单:`%s`"
,
$order->total_amount / 100,
$payment->payment,
$payment->name,
$order->trade_no
);
$telegramService->sendMessageWithAdmin($message);
HookManager::call('payment.notify.success', $order);
return true;
}
}

View File

@ -4,121 +4,123 @@ namespace App\Http\Controllers\V1\Guest;
use App\Exceptions\ApiException;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Plugin\HookManager;
use App\Services\TelegramService;
use App\Services\UserService;
use Illuminate\Http\Request;
class TelegramController extends Controller
{
protected $msg;
protected $commands = [];
protected $telegramService;
protected ?object $msg = null;
protected TelegramService $telegramService;
protected UserService $userService;
public function __construct(TelegramService $telegramService)
public function __construct(TelegramService $telegramService, UserService $userService)
{
$this->telegramService = $telegramService;
$this->userService = $userService;
}
public function webhook(Request $request)
public function webhook(Request $request): void
{
if ($request->input('access_token') !== md5(admin_setting('telegram_bot_token'))) {
$expectedToken = md5(admin_setting('telegram_bot_token'));
if ($request->input('access_token') !== $expectedToken) {
throw new ApiException('access_token is error', 401);
}
$data = json_decode(request()->getContent(),true);
$data = $request->json()->all();
$this->formatMessage($data);
$this->formatChatJoinRequest($data);
$this->handle();
}
private function handle()
private function handle(): void
{
if (!$this->msg) return;
if (!$this->msg)
return;
$msg = $this->msg;
$commandName = explode('@', $msg->command);
// To reduce request, only commands contains @ will get the bot name
if (count($commandName) == 2) {
$botName = $this->getBotName();
if ($commandName[1] === $botName){
$msg->command = $commandName[0];
}
}
$this->processBotName($msg);
try {
foreach (glob(base_path('app//Plugins//Telegram//Commands') . '/*.php') as $file) {
$command = basename($file, '.php');
$class = '\\App\\Plugins\\Telegram\\Commands\\' . $command;
if (!class_exists($class)) continue;
$instance = new $class();
if ($msg->message_type === 'message') {
if (!isset($instance->command)) continue;
if ($msg->command !== $instance->command) continue;
$instance->handle($msg);
return;
}
if ($msg->message_type === 'reply_message') {
if (!isset($instance->regex)) continue;
if (!preg_match($instance->regex, $msg->reply_text, $match)) continue;
$instance->handle($msg, $match);
return;
}
HookManager::call('telegram.message.before', [$msg]);
$handled = HookManager::filter('telegram.message.handle', false, [$msg]);
if (!$handled) {
HookManager::call('telegram.message.unhandled', [$msg]);
}
HookManager::call('telegram.message.after', [$msg]);
} catch (\Exception $e) {
HookManager::call('telegram.message.error', [$msg, $e]);
$this->telegramService->sendMessage($msg->chat_id, $e->getMessage());
}
}
private function getBotName()
private function processBotName(object $msg): void
{
$commandParts = explode('@', $msg->command);
if (count($commandParts) === 2) {
$botName = $this->getBotName();
if ($commandParts[1] === $botName) {
$msg->command = $commandParts[0];
}
}
}
private function getBotName(): string
{
$response = $this->telegramService->getMe();
return $response->result->username;
}
private function formatMessage(array $data)
private function formatMessage(array $data): void
{
if (!isset($data['message'])) return;
if (!isset($data['message']['text'])) return;
$obj = new \StdClass();
$text = explode(' ', $data['message']['text']);
$obj->command = $text[0];
$obj->args = array_slice($text, 1);
$obj->chat_id = $data['message']['chat']['id'];
$obj->message_id = $data['message']['message_id'];
$obj->message_type = 'message';
$obj->text = $data['message']['text'];
$obj->is_private = $data['message']['chat']['type'] === 'private';
if (isset($data['message']['reply_to_message']['text'])) {
$obj->message_type = 'reply_message';
$obj->reply_text = $data['message']['reply_to_message']['text'];
if (!isset($data['message']['text']))
return;
$message = $data['message'];
$text = explode(' ', $message['text']);
$this->msg = (object) [
'command' => $text[0],
'args' => array_slice($text, 1),
'chat_id' => $message['chat']['id'],
'message_id' => $message['message_id'],
'message_type' => 'message',
'text' => $message['text'],
'is_private' => $message['chat']['type'] === 'private',
];
if (isset($message['reply_to_message']['text'])) {
$this->msg->message_type = 'reply_message';
$this->msg->reply_text = $message['reply_to_message']['text'];
}
$this->msg = $obj;
}
private function formatChatJoinRequest(array $data)
private function formatChatJoinRequest(array $data): void
{
if (!isset($data['chat_join_request'])) return;
if (!isset($data['chat_join_request']['from']['id'])) return;
if (!isset($data['chat_join_request']['chat']['id'])) return;
$user = \App\Models\User::where('telegram_id', $data['chat_join_request']['from']['id'])
->first();
$joinRequest = $data['chat_join_request'] ?? null;
if (!$joinRequest)
return;
$chatId = $joinRequest['chat']['id'] ?? null;
$userId = $joinRequest['from']['id'] ?? null;
if (!$chatId || !$userId)
return;
$user = User::where('telegram_id', $userId)->first();
if (!$user) {
$this->telegramService->declineChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
$this->telegramService->declineChatJoinRequest($chatId, $userId);
return;
}
$userService = new \App\Services\UserService();
if (!$userService->isAvailable($user)) {
$this->telegramService->declineChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
if (!$this->userService->isAvailable($user)) {
$this->telegramService->declineChatJoinRequest($chatId, $userId);
return;
}
$userService = new \App\Services\UserService();
$this->telegramService->approveChatJoinRequest(
$data['chat_join_request']['chat']['id'],
$data['chat_join_request']['from']['id']
);
$this->telegramService->approveChatJoinRequest($chatId, $userId);
}
}

View File

@ -9,12 +9,11 @@ use App\Http\Resources\TicketResource;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\TelegramService;
use App\Services\TicketService;
use App\Utils\Dict;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\HookManager;
use Illuminate\Support\Facades\Log;
class TicketController extends Controller
{
@ -42,39 +41,20 @@ class TicketController extends Controller
public function save(TicketSave $request)
{
try{
DB::beginTransaction();
if (Ticket::where('status', 0)->where('user_id', $request->user()->id)->lockForUpdate()->first()) {
DB::rollBack();
return $this->fail([400, '存在未关闭的工单']);
}
$ticket = Ticket::create(array_merge($request->only([
'subject',
'level'
]), [
'user_id' => $request->user()->id
]));
if (!$ticket) {
throw new \Exception(__('There are other unresolved tickets'));
}
$ticketMessage = TicketMessage::create([
'user_id' => $request->user()->id,
'ticket_id' => $ticket->id,
'message' => $request->input('message')
]);
if (!$ticketMessage) {
throw new \Exception(__('Failed to open ticket'));
}
DB::commit();
try {
$ticketService = new TicketService();
$ticket = $ticketService->createTicket(
$request->user()->id,
$request->input('subject'),
$request->input('level'),
$request->input('message')
);
HookManager::call('ticket.create.after', $ticket);
$this->sendNotify($ticket, $request->input('message'), $request->user()->id);
return $this->success(true);
}catch(\Exception $e){
DB::rollBack();
\Log::error($e);
} catch (\Exception $e) {
Log::error($e);
return $this->fail([400, $e->getMessage()]);
}
}
public function reply(Request $request)
@ -95,18 +75,19 @@ class TicketController extends Controller
return $this->fail([400, __('The ticket is closed and cannot be replied')]);
}
if ($request->user()->id == $this->getLastMessage($ticket->id)->user_id) {
return $this->fail([400, __('Please wait for the technical enginneer to reply')]);
return $this->fail(codeResponse: [400, __('Please wait for the technical enginneer to reply')]);
}
$ticketService = new TicketService();
if (!$ticketService->reply(
$ticket,
$request->input('message'),
$request->user()->id
)) {
if (
!$ticketService->reply(
$ticket,
$request->input('message'),
$request->user()->id
)
) {
return $this->fail([400, __('Ticket reply failed')]);
}
HookManager::call('ticket.reply.user.after', [$ticket, $this->getLastMessage($ticket->id)]);
$this->sendNotify($ticket, $request->input('message'), $request->user()->id);
return $this->success(true);
}
@ -138,13 +119,15 @@ class TicketController extends Controller
public function withdraw(TicketWithdraw $request)
{
if ((int)admin_setting('withdraw_close_enable', 0)) {
if ((int) admin_setting('withdraw_close_enable', 0)) {
return $this->fail([400, 'Unsupported withdraw']);
}
if (!in_array(
$request->input('withdraw_method'),
admin_setting('commission_withdraw_method',Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT)
)) {
if (
!in_array(
$request->input('withdraw_method'),
admin_setting('commission_withdraw_method', Dict::WITHDRAW_METHOD_WHITELIST_DEFAULT)
)
) {
return $this->fail([422, __('Unsupported withdrawal method')]);
}
$user = User::find($request->user()->id);
@ -152,77 +135,24 @@ class TicketController extends Controller
if ($limit > ($user->commission_balance / 100)) {
return $this->fail([422, __('The current required minimum withdrawal commission is :limit', ['limit' => $limit])]);
}
try{
DB::beginTransaction();
try {
$ticketService = new TicketService();
$subject = __('[Commission Withdrawal Request] This ticket is opened by the system');
$ticket = Ticket::create([
'subject' => $subject,
'level' => 2,
'user_id' => $request->user()->id
]);
if (!$ticket) {
return $this->fail([400, __('Failed to open ticket')]);
}
$message = sprintf("%s\r\n%s",
$message = sprintf(
"%s\r\n%s",
__('Withdrawal method') . ":" . $request->input('withdraw_method'),
__('Withdrawal account') . ":" . $request->input('withdraw_account')
);
$ticketMessage = TicketMessage::create([
'user_id' => $request->user()->id,
'ticket_id' => $ticket->id,
'message' => $message
]);
if (!$ticketMessage) {
DB::rollBack();
return $this->fail([400, __('Failed to open ticket')]);
}
DB::commit();
}catch(\Exception $e){
DB::rollBack();
$ticket = $ticketService->createTicket(
$request->user()->id,
$subject,
2,
$message
);
} catch (\Exception $e) {
throw $e;
}
$this->sendNotify($ticket, $message, $request->user()->id);
HookManager::call('ticket.create.after', $ticket);
return $this->success(true);
}
private function sendNotify(Ticket $ticket, string $message, $user_id)
{
$user = User::find($user_id)->load('plan');
$transfer_enable = $this->getFlowData($user->transfer_enable); // 总流量
$remaining_traffic = $this->getFlowData($user->transfer_enable - $user->u - $user->d); // 剩余流量
$u = $this->getFlowData($user->u); // 上传
$d = $this->getFlowData($user->d); // 下载
$expired_at = date("Y-m-d h:m:s", $user->expired_at); // 到期时间
$money = $user->balance / 100;
$affmoney = $user->commission_balance / 100;
$plan = $user->plan;
$ip = request()->ip();
$region = filter_var($ip,FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? (new \Ip2Region())->simple($ip) : "NULL";
$TGmessage = "📮工单提醒 #{$ticket->id}\n———————————————\n";
$TGmessage .= "邮箱: `{$user->email}`\n";
$TGmessage .= "用户位置: \n`{$region}`\n";
if($user->plan){
$TGmessage .= "套餐与流量: \n`{$plan->name} {$transfer_enable}/{$remaining_traffic}`\n";
$TGmessage .= "上传/下载: \n`{$u}/{$d}`\n";
$TGmessage .= "到期时间: \n`{$expired_at}`\n";
}else{
$TGmessage .= "套餐与流量: \n`未订购任何套餐`\n";
}
$TGmessage .= "余额/佣金余额: \n`{$money}/{$affmoney}`\n";
$TGmessage .= "主题:\n`{$ticket->subject}`\n内容:\n`{$message}`\n";
$telegramService = new TelegramService();
$telegramService->sendMessageWithAdmin($TGmessage, true);
}
private function getFlowData($b)
{
$m = $b / (1024 * 1024);
if ($m >= 1024) {
$g = $m / 1024;
$text = round($g, 2) . "GB";
} else {
$text = round($m, 2) . "MB";
}
return $text;
}
}

View File

@ -71,7 +71,6 @@ class ConfigController extends Controller
public function setTelegramWebhook(Request $request)
{
// 判断站点网址
$app_url = admin_setting('app_url');
if (blank($app_url))
return $this->fail([422, '请先设置站点网址']);
@ -81,17 +80,14 @@ class ConfigController extends Controller
$telegramService = new TelegramService($request->input('telegram_bot_token'));
$telegramService->getMe();
$telegramService->setWebhook($hookUrl);
$telegramService->registerBotCommands();
return $this->success(true);
}
public function fetch(Request $request)
{
$key = $request->input('key');
// 构建配置数据映射
$configMappings = $this->getConfigMappings();
// 如果请求特定分组,直接返回
if ($key && isset($configMappings[$key])) {
return $this->success([$key => $configMappings[$key]]);
}

View File

@ -9,16 +9,18 @@ use App\Services\PaymentService;
use App\Utils\Helper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function getPaymentMethods()
{
$methods = [];
foreach (glob(base_path('app//Payments') . '/*.php') as $file) {
array_push($methods, pathinfo($file)['filename']);
}
return $this->success($methods);
$pluginMethods = PaymentService::getAllPaymentMethodNames();
$methods = array_merge($methods, $pluginMethods);
return $this->success(array_unique($methods));
}
public function fetch()
@ -37,23 +39,29 @@ class PaymentController extends Controller
public function getPaymentForm(Request $request)
{
$paymentService = new PaymentService($request->input('payment'), $request->input('id'));
return $this->success(collect($paymentService->form())->values());
try {
$paymentService = new PaymentService($request->input('payment'), $request->input('id'));
return $this->success(collect($paymentService->form()));
} catch (\Exception $e) {
return $this->fail([400, '支付方式不存在或未启用']);
}
}
public function show(Request $request)
{
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
$payment->enable = !$payment->enable;
if (!$payment->save()) return $this->fail([500 ,'保存失败']);
if (!$payment->save())
return $this->fail([500, '保存失败']);
return $this->success(true);
}
public function save(Request $request)
{
if (!admin_setting('app_url')) {
return $this->fail([400 ,'请在站点配置中配置站点地址']);
return $this->fail([400, '请在站点配置中配置站点地址']);
}
$params = $request->validate([
'name' => 'required',
@ -73,18 +81,19 @@ class PaymentController extends Controller
]);
if ($request->input('id')) {
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
try {
$payment->update($params);
} catch (\Exception $e) {
\Log::error($e);
return $this->fail([500 ,'保存失败']);
Log::error($e);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
$params['uuid'] = Helper::randomChar(8);
if (!Payment::create($params)) {
return $this->fail([500 ,'保存失败']);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
@ -92,7 +101,8 @@ class PaymentController extends Controller
public function drop(Request $request)
{
$payment = Payment::find($request->input('id'));
if (!$payment) return $this->fail([400202 ,'支付方式不存在']);
if (!$payment)
return $this->fail([400202, '支付方式不存在']);
return $this->success($payment->delete());
}
@ -105,7 +115,7 @@ class PaymentController extends Controller
'ids.required' => '参数有误',
'ids.array' => '参数有误'
]);
try{
try {
DB::beginTransaction();
foreach ($request->input('ids') as $k => $v) {
if (!Payment::find($v)->update(['sort' => $k + 1])) {
@ -113,11 +123,11 @@ class PaymentController extends Controller
}
}
DB::commit();
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollBack();
return $this->fail([500 ,'保存失败']);
return $this->fail([500, '保存失败']);
}
return $this->success(true);
}
}

View File

@ -23,14 +23,43 @@ class PluginController extends Controller
$this->configService = $configService;
}
/**
* 获取所有插件类型
*/
public function types()
{
return response()->json([
'data' => [
[
'value' => Plugin::TYPE_FEATURE,
'label' => '功能',
'description' => '提供功能扩展的插件,如Telegram登录、邮件通知等',
'icon' => '🔧'
],
[
'value' => Plugin::TYPE_PAYMENT,
'label' => '支付方式',
'description' => '提供支付接口的插件,如支付宝、微信支付等',
'icon' => '💳'
]
]
]);
}
/**
* 获取插件列表
*/
public function index()
public function index(Request $request)
{
$installedPlugins = Plugin::get()
$type = $request->query('type');
$installedPlugins = Plugin::when($type, function($query) use ($type) {
return $query->byType($type);
})
->get()
->keyBy('code')
->toArray();
$pluginPath = base_path('plugins');
$plugins = [];
@ -42,8 +71,14 @@ class PluginController extends Controller
if (File::exists($configFile)) {
$config = json_decode(File::get($configFile), true);
$code = $config['code'];
$pluginType = $config['type'] ?? Plugin::TYPE_FEATURE;
// 如果指定了类型,过滤插件
if ($type && $pluginType !== $type) {
continue;
}
$installed = isset($installedPlugins[$code]);
// 使用配置服务获取配置
$pluginConfig = $installed ? $this->configService->getConfig($code) : ($config['config'] ?? []);
$readmeFile = collect(['README.md', 'readme.md'])
->map(fn($f) => $directory . '/' . $f)
@ -56,8 +91,11 @@ class PluginController extends Controller
'version' => $config['version'],
'description' => $config['description'],
'author' => $config['author'],
'type' => $pluginType,
'is_installed' => $installed,
'is_enabled' => $installed ? $installedPlugins[$code]['is_enabled'] : false,
'is_protected' => in_array($code, Plugin::PROTECTED_PLUGINS),
'can_be_deleted' => !in_array($code, Plugin::PROTECTED_PLUGINS),
'config' => $pluginConfig,
'readme' => $readmeContent,
];
@ -236,8 +274,17 @@ class PluginController extends Controller
'code' => 'required|string'
]);
$code = $request->input('code');
// 检查是否为受保护的插件
if (in_array($code, Plugin::PROTECTED_PLUGINS)) {
return response()->json([
'message' => '该插件为系统默认插件,不允许删除'
], 403);
}
try {
$this->pluginManager->delete($request->input('code'));
$this->pluginManager->delete($code);
return response()->json([
'message' => '插件删除成功'
]);

View File

@ -164,7 +164,6 @@ class UserController extends Controller
$users = $userModel->orderBy('id', 'desc')
->paginate($pageSize, ['*'], 'page', $current);
/** @phpstan-ignore-next-line */
$users->getCollection()->transform(function ($user): array {
return self::transformUserData($user);
});

View File

@ -247,6 +247,7 @@ class AdminRoute
$router->group([
'prefix' => 'plugin'
], function ($router) {
$router->get('/types', [\App\Http\Controllers\V2\Admin\PluginController::class, 'types']);
$router->get('/getPlugins', [\App\Http\Controllers\V2\Admin\PluginController::class, 'index']);
$router->post('/upload', [\App\Http\Controllers\V2\Admin\PluginController::class, 'upload']);
$router->post('/delete', [\App\Http\Controllers\V2\Admin\PluginController::class, 'delete']);

View File

@ -3,6 +3,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/**
* @property int $id
@ -16,11 +18,27 @@ use Illuminate\Database\Eloquent\Model;
* @property string $license
* @property string $requires
* @property string $config
* @property string $type
*/
class Plugin extends Model
{
protected $table = 'v2_plugins';
const TYPE_FEATURE = 'feature';
const TYPE_PAYMENT = 'payment';
// 默认不可删除的插件列表
const PROTECTED_PLUGINS = [
'epay', // EPay
'alipay_f2f', // Alipay F2F
'btcpay', // BTCPay
'coinbase', // Coinbase
'coin_payments', // CoinPayments
'mgate', // MGate
'smogate', // Smogate
'telegram', // Telegram
];
protected $guarded = [
'id',
'created_at',
@ -28,6 +46,32 @@ class Plugin extends Model
];
protected $casts = [
'is_enabled' => 'boolean'
'is_enabled' => 'boolean',
];
public function scopeByType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
public function isFeaturePlugin(): bool
{
return $this->type === self::TYPE_FEATURE;
}
public function isPaymentPlugin(): bool
{
return $this->type === self::TYPE_PAYMENT;
}
public function isProtected(): bool
{
return in_array($this->code, self::PROTECTED_PLUGINS);
}
public function canBeDeleted(): bool
{
return !$this->isProtected();
}
}

View File

@ -40,6 +40,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property int|null $is_admin 是否管理员
* @property int|null $next_reset_at 下次流量重置时间
* @property int|null $last_reset_at 上次流量重置时间
* @property int|null $telegram_id Telegram ID
* @property int $reset_count 流量重置次数
* @property int $created_at
* @property int $updated_at

View File

@ -1,9 +0,0 @@
*
!.gitignore
!AlipayF2F.php
!BTCPay.php
!Coinbase.php
!CoinPayments.php
!EPay.php
!MGate.php

View File

@ -1,99 +0,0 @@
<?php
/**
* 自己写别抄,抄NMB抄
*/
namespace App\Payments;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class AlipayF2F implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'app_id' => [
'label' => '支付宝APPID',
'description' => '',
'type' => 'input',
],
'private_key' => [
'label' => '支付宝私钥',
'description' => '',
'type' => 'input',
],
'public_key' => [
'label' => '支付宝公钥',
'description' => '',
'type' => 'input',
],
'product_name' => [
'label' => '自定义商品名称',
'description' => '将会体现在支付宝账单中',
'type' => 'input'
]
];
}
public function pay($order): array
{
try {
$gateway = new \Library\AlipayF2F();
$gateway->setMethod('alipay.trade.precreate');
$gateway->setAppId($this->config['app_id']);
$gateway->setPrivateKey($this->config['private_key']); // 可以是路径,也可以是密钥内容
$gateway->setAlipayPublicKey($this->config['public_key']); // 可以是路径,也可以是密钥内容
$gateway->setNotifyUrl($order['notify_url']);
$gateway->setBizContent([
'subject' => $this->config['product_name'] ?? (admin_setting('app_name', 'XBoard') . ' - 订阅'),
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'] / 100
]);
$gateway->send();
return [
'type' => 0, // 0:qrcode 1:url
'data' => $gateway->getQrCodeUrl()
];
} catch (\Exception $e) {
\Log::error($e);
throw new ApiException($e->getMessage());
}
}
public function notify($params)
{
if ($params['trade_status'] !== 'TRADE_SUCCESS')
return false;
$gateway = new \Library\AlipayF2F();
$gateway->setAppId($this->config['app_id']);
$gateway->setPrivateKey($this->config['private_key']); // 可以是路径,也可以是密钥内容
$gateway->setAlipayPublicKey($this->config['public_key']); // 可以是路径,也可以是密钥内容
try {
if ($gateway->verify($params)) {
/**
* Payment is successful
*/
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
} else {
/**
* Payment is not successful
*/
return false;
}
} catch (\Exception $e) {
/**
* Payment is not successful
*/
return false;
}
}
}

View File

@ -1,81 +0,0 @@
<?php
namespace App\Payments;
use App\Contracts\PaymentInterface;
class EPay implements PaymentInterface
{
protected $config;
public function __construct($config)
{
$this->config = $config;
}
public function form(): array
{
return [
'url' => [
'label' => 'URL',
'description' => '',
'type' => 'input',
],
'pid' => [
'label' => 'PID',
'description' => '',
'type' => 'input',
],
'key' => [
'label' => 'KEY',
'description' => '',
'type' => 'input',
],
'type' => [
'label' => 'TYPE',
'description' => 'alipay / qqpay / wxpay',
'type' => 'input',
],
];
}
public function pay($order): array
{
$params = [
'money' => $order['total_amount'] / 100,
'name' => $order['trade_no'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url'],
'out_trade_no' => $order['trade_no'],
'pid' => $this->config['pid']
];
if (optional($this->config)['type']) {
$params['type'] = $this->config['type'];
}
ksort($params);
reset($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->config['key'];
$params['sign'] = md5($str);
$params['sign_type'] = 'MD5';
return [
'type' => 1, // 0:qrcode 1:url
'data' => $this->config['url'] . '/submit.php?' . http_build_query($params)
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign']);
unset($params['sign_type']);
ksort($params);
reset($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->config['key'];
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class Bind extends Telegram {
public $command = '/bind';
public $description = '将Telegram账号绑定到网站';
public function handle($message, $match = []) {
if (!$message->is_private) return;
if (!isset($message->args[0])) {
throw new ApiException('参数有误,请携带订阅地址发送', 422);
}
$subscribeUrl = $message->args[0];
$subscribeUrl = parse_url($subscribeUrl);
// 首先尝试从查询参数获取token
$token = null;
if (isset($subscribeUrl['query'])) {
parse_str($subscribeUrl['query'], $query);
$token = $query['token'] ?? null;
}
if (!$token && isset($subscribeUrl['path'])) {
$pathParts = explode('/', trim($subscribeUrl['path'], '/'));
$token = end($pathParts);
}
if (!$token) {
throw new ApiException('订阅地址无效');
}
$user = User::where('token', $token)->first();
if (!$user) {
throw new ApiException('用户不存在');
}
if ($user->telegram_id) {
throw new ApiException('该账号已经绑定了Telegram账号');
}
$user->telegram_id = $message->chat_id;
if (!$user->save()) {
throw new ApiException('设置失败');
}
$telegramService = $this->telegramService;
$telegramService->sendMessage($message->chat_id, '绑定成功');
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class GetLatestUrl extends Telegram {
public $command = '/getlatesturl';
public $description = '将Telegram账号绑定到网站';
public function handle($message, $match = []) {
$telegramService = $this->telegramService;
$text = sprintf(
"%s的最新网址是:%s",
admin_setting('app_name', 'XBoard'),
admin_setting('app_url')
);
$telegramService->sendMessage($message->chat_id, $text, 'markdown');
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
use App\Services\TicketService;
class ReplyTicket extends Telegram {
public $regex = '/[#](.*)/';
public $description = '快速工单回复';
public function handle($message, $match = []) {
if (!$message->is_private) return;
$this->replayTicket($message, $match[1]);
}
private function replayTicket($msg, $ticketId)
{
$user = User::where('telegram_id', $msg->chat_id)->first();
if (!$user) {
throw new ApiException('用户不存在');
}
if (!$msg->text) return;
if (!($user->is_admin || $user->is_staff)) return;
$ticketService = new TicketService();
$ticketService->replyByAdmin(
$ticketId,
$msg->text,
$user->id
);
$telegramService = $this->telegramService;
$telegramService->sendMessage($msg->chat_id, "#`{$ticketId}` 的工单已回复成功", 'markdown');
$telegramService->sendMessageWithAdmin("#`{$ticketId}` 的工单已由 {$user->email} 进行回复", true);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
use App\Utils\Helper;
class Traffic extends Telegram {
public $command = '/traffic';
public $description = '查询流量信息';
public function handle($message, $match = []) {
$telegramService = $this->telegramService;
if (!$message->is_private) return;
$user = User::where('telegram_id', $message->chat_id)->first();
if (!$user) {
$telegramService->sendMessage($message->chat_id, '没有查询到您的用户信息,请先绑定账号', 'markdown');
return;
}
$transferEnable = Helper::trafficConvert($user->transfer_enable);
$up = Helper::trafficConvert($user->u);
$down = Helper::trafficConvert($user->d);
$remaining = Helper::trafficConvert($user->transfer_enable - ($user->u + $user->d));
$text = "🚥流量查询\n———————————————\n计划流量:`{$transferEnable}`\n已用上行:`{$up}`\n已用下行:`{$down}`\n剩余流量:`{$remaining}`";
$telegramService->sendMessage($message->chat_id, $text, 'markdown');
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Plugins\Telegram\Commands;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Plugins\Telegram\Telegram;
class UnBind extends Telegram {
public $command = '/unbind';
public $description = '将Telegram账号从网站解绑';
public function handle($message, $match = []) {
if (!$message->is_private) return;
$user = User::where('telegram_id', $message->chat_id)->first();
$telegramService = $this->telegramService;
if (!$user) {
$telegramService->sendMessage($message->chat_id, '没有查询到您的用户信息,请先绑定账号', 'markdown');
return;
}
$user->telegram_id = NULL;
if (!$user->save()) {
throw new ApiException('解绑失败');
}
$telegramService->sendMessage($message->chat_id, '解绑成功', 'markdown');
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Plugins\Telegram;
use App\Services\TelegramService;
abstract class Telegram {
abstract protected function handle($message, $match);
public $telegramService;
public function __construct()
{
$this->telegramService = new TelegramService();
}
}

View File

@ -2,36 +2,57 @@
namespace App\Services;
use App\Exceptions\ApiException;
use App\Models\Payment;
use App\Services\Plugin\PluginManager;
use App\Services\Plugin\HookManager;
class PaymentService
{
public $method;
protected $class;
protected $config;
protected $payment;
protected $pluginManager;
protected $class;
public function __construct($method, $id = NULL, $uuid = NULL)
{
$this->method = $method;
$this->class = '\\App\\Payments\\' . $this->method;
if (!class_exists($this->class))
throw new ApiException('gate is not found');
if ($id)
$this->pluginManager = app(PluginManager::class);
if ($method === 'temp') {
return;
}
if ($id) {
$payment = Payment::find($id)->toArray();
if ($uuid)
}
if ($uuid) {
$payment = Payment::where('uuid', $uuid)->first()->toArray();
}
$this->config = [];
if (isset($payment)) {
$this->config = $payment['config'];
$this->config = is_string($payment['config']) ? json_decode($payment['config'], true) : $payment['config'];
$this->config['enable'] = $payment['enable'];
$this->config['id'] = $payment['id'];
$this->config['uuid'] = $payment['uuid'];
$this->config['notify_domain'] = $payment['notify_domain'];
$this->config['notify_domain'] = $payment['notify_domain'] ?? '';
}
;
$paymentMethods = $this->getAvailablePaymentMethods();
if (isset($paymentMethods[$this->method])) {
$pluginCode = $paymentMethods[$this->method]['plugin_code'];
$paymentPlugins = $this->pluginManager->getEnabledPaymentPlugins();
foreach ($paymentPlugins as $plugin) {
if ($plugin->getPluginCode() === $pluginCode) {
$plugin->setConfig($this->config);
$this->payment = $plugin;
return;
}
}
}
$this->payment = new $this->class($this->config);
}
@ -64,18 +85,43 @@ class PaymentService
public function form()
{
$form = $this->payment->form();
$keys = array_keys($form);
$result = [];
foreach ($form as $key => $field) {
$form[$key] = [
'label' => $field['label'],
'field_name' => $key,
'field_type' => $field['type'],
$result[$key] = [
'type' => $field['type'],
'label' => $field['label'] ?? '',
'placeholder' => $field['placeholder'] ?? '',
'value' => $this->config[$key] ?? '',
'select_options' => $field['select_options'] ?? [],
'description' => $field['description'] ?? '',
'value' => $this->config[$key] ?? $field['default'] ?? '',
'options' => $field['select_options'] ?? $field['options'] ?? []
];
}
return $form;
return $result;
}
/**
* 获取所有可用的支付方式
*/
public function getAvailablePaymentMethods(): array
{
$methods = [];
$methods = HookManager::filter('available_payment_methods', $methods);
return $methods;
}
/**
* 获取所有支付方式名称列表(用于管理后台)
*/
public static function getAllPaymentMethodNames(): array
{
$pluginManager = app(PluginManager::class);
$pluginManager->initializeEnabledPlugins();
$instance = new self('temp');
$methods = $instance->getAvailablePaymentMethods();
return array_keys($methods);
}
}

View File

@ -99,6 +99,35 @@ abstract class AbstractPlugin
HookManager::remove($hook);
}
/**
* 注册 Artisan 命令
*/
protected function registerCommand(string $commandClass): void
{
if (class_exists($commandClass)) {
app('Illuminate\Contracts\Console\Kernel')->registerCommand(new $commandClass());
}
}
/**
* 注册插件命令目录
*/
public function registerCommands(): void
{
$commandsPath = $this->basePath . '/Commands';
if (File::exists($commandsPath)) {
$files = File::glob($commandsPath . '/*.php');
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$commandClass = $this->namespace . '\\Commands\\' . $className;
if (class_exists($commandClass)) {
$this->registerCommand($commandClass);
}
}
}
}
/**
* 中断当前请求并返回新的响应
*

View File

@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
class PluginManager
{
@ -114,13 +113,25 @@ class PluginManager
*/
protected function loadViews(string $pluginCode): void
{
$viewsPath = $this->getPluginPath($pluginCode) . '/resources/views';
$viewsPath = $this->getPluginPath($pluginCode) . '/views';
if (File::exists($viewsPath)) {
View::addNamespace(Str::studly($pluginCode), $viewsPath);
}
}
/**
* 注册插件命令
*/
protected function registerPluginCommands(string $pluginCode, AbstractPlugin $pluginInstance): void
{
try {
// 调用插件的命令注册方法
$pluginInstance->registerCommands();
} catch (\Exception $e) {
Log::error("Failed to register commands for plugin '{$pluginCode}': " . $e->getMessage());
}
}
/**
* 安装插件
*/
@ -163,6 +174,7 @@ class PluginManager
'code' => $pluginCode,
'name' => $config['name'],
'version' => $config['version'],
'type' => $config['type'] ?? Plugin::TYPE_FEATURE,
'is_enabled' => false,
'config' => json_encode($defaultValues),
'installed_at' => now(),
@ -259,6 +271,14 @@ class PluginManager
return false;
}
// 验证插件类型
if (isset($config['type'])) {
$validTypes = ['feature', 'payment'];
if (!in_array($config['type'], $validTypes)) {
return false;
}
}
return true;
}
@ -478,6 +498,7 @@ class PluginManager
$this->registerServiceProvider($pluginCode);
$this->loadRoutes($pluginCode);
$this->loadViews($pluginCode);
$this->registerPluginCommands($pluginCode, $pluginInstance);
$pluginInstance->boot();
@ -535,4 +556,42 @@ class PluginManager
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
}
/**
* Get enabled plugins by type
*/
public function getEnabledPluginsByType(string $type): array
{
$this->initializeEnabledPlugins();
$enabledPluginCodes = Plugin::where('is_enabled', true)
->byType($type)
->pluck('code')
->all();
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
}
/**
* Get enabled payment plugins
*/
public function getEnabledPaymentPlugins(): array
{
return $this->getEnabledPluginsByType('payment');
}
/**
* install default plugins
*/
public static function installDefaultPlugins(): void
{
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
if (!Plugin::where('code', $pluginCode)->exists()) {
$pluginManager = app(self::class);
$pluginManager->install($pluginCode);
$pluginManager->enable($pluginCode);
Log::info("Installed and enabled default plugin: {$pluginCode}");
}
}
}
}

View File

@ -1,85 +1,160 @@
<?php
namespace App\Services;
use App\Exceptions\ApiException;
use App\Jobs\SendTelegramJob;
use App\Models\User;
use \Curl\Curl;
use App\Services\Plugin\HookManager;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramService {
protected $api;
class TelegramService
{
protected PendingRequest $http;
protected string $apiUrl;
public function __construct($token = '')
public function __construct(?string $token = null)
{
$this->api = 'https://api.telegram.org/bot' . admin_setting('telegram_bot_token', $token) . '/';
$botToken = admin_setting('telegram_bot_token', $token);
$this->apiUrl = "https://api.telegram.org/bot{$botToken}/";
$this->http = Http::timeout(30)
->retry(3, 1000)
->withHeaders([
'Accept' => 'application/json',
]);
}
public function sendMessage(int $chatId, string $text, string $parseMode = '')
public function sendMessage(int $chatId, string $text, string $parseMode = ''): void
{
if ($parseMode === 'markdown') {
$text = str_replace('_', '\_', $text);
}
$text = $parseMode === 'markdown' ? str_replace('_', '\_', $text) : $text;
$this->request('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => $parseMode
'parse_mode' => $parseMode ?: null,
]);
}
public function approveChatJoinRequest(int $chatId, int $userId)
public function approveChatJoinRequest(int $chatId, int $userId): void
{
$this->request('approveChatJoinRequest', [
'chat_id' => $chatId,
'user_id' => $userId
'user_id' => $userId,
]);
}
public function declineChatJoinRequest(int $chatId, int $userId)
public function declineChatJoinRequest(int $chatId, int $userId): void
{
$this->request('declineChatJoinRequest', [
'chat_id' => $chatId,
'user_id' => $userId
'user_id' => $userId,
]);
}
public function getMe()
public function getMe(): object
{
return $this->request('getMe');
}
public function setWebhook(string $url)
public function setWebhook(string $url): object
{
return $this->request('setWebhook', [
'url' => $url
]);
$result = $this->request('setWebhook', ['url' => $url]);
return $result;
}
private function request(string $method, array $params = [])
/**
* 注册 Bot 命令列表
*/
public function registerBotCommands(): void
{
$curl = new Curl();
$curl->get($this->api . $method . '?' . http_build_query($params));
$response = $curl->response;
$curl->close();
if (!isset($response->ok)) throw new ApiException('请求失败');
if (!$response->ok) {
throw new ApiException('来自TG的错误:' . $response->description);
}
return $response;
}
try {
$commands = HookManager::filter('telegram.bot.commands', []);
public function sendMessageWithAdmin($message, $isStaff = false)
{
if (!admin_setting('telegram_bot_enable', 0)) return;
$users = User::where(function ($query) use ($isStaff) {
$query->where('is_admin', 1);
if ($isStaff) {
$query->orWhere('is_staff', 1);
if (empty($commands)) {
Log::warning('没有找到任何 Telegram Bot 命令');
return;
}
})
->where('telegram_id', '!=', NULL)
->get();
$this->request('setMyCommands', [
'commands' => json_encode($commands),
'scope' => json_encode(['type' => 'default'])
]);
Log::info('Telegram Bot 命令注册成功', [
'commands_count' => count($commands),
'commands' => $commands
]);
} catch (\Exception $e) {
Log::error('Telegram Bot 命令注册失败', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
}
/**
* 获取当前注册的命令列表
*/
public function getMyCommands(): object
{
return $this->request('getMyCommands');
}
/**
* 删除所有命令
*/
public function deleteMyCommands(): object
{
return $this->request('deleteMyCommands');
}
public function sendMessageWithAdmin(string $message, bool $isStaff = false): void
{
$query = User::where('telegram_id', '!=', null);
$query->where(
fn($q) => $q->where('is_admin', 1)
->when($isStaff, fn($q) => $q->orWhere('is_staff', 1))
);
$users = $query->get();
foreach ($users as $user) {
SendTelegramJob::dispatch($user->telegram_id, $message);
}
}
protected function request(string $method, array $params = []): object
{
try {
$response = $this->http->get($this->apiUrl . $method, $params);
if (!$response->successful()) {
throw new ApiException("HTTP 请求失败: {$response->status()}");
}
$data = $response->object();
if (!isset($data->ok)) {
throw new ApiException('无效的 Telegram API 响应');
}
if (!$data->ok) {
$description = $data->description ?? '未知错误';
throw new ApiException("Telegram API 错误: {$description}");
}
return $data;
} catch (\Exception $e) {
Log::error('Telegram API 请求失败', [
'method' => $method,
'params' => $params,
'error' => $e->getMessage(),
]);
throw new ApiException("Telegram 服务错误: {$e->getMessage()}");
}
}
}

View File

@ -11,10 +11,11 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use App\Services\Plugin\HookManager;
class TicketService {
class TicketService
{
public function reply($ticket, $message, $userId)
{
try{
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
@ -31,13 +32,13 @@ class TicketService {
}
DB::commit();
return $ticketMessage;
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollback();
return false;
}
}
public function replyByAdmin($ticketId, $message, $userId):void
public function replyByAdmin($ticketId, $message, $userId): void
{
$ticket = Ticket::where('id', $ticketId)
->first();
@ -45,7 +46,7 @@ class TicketService {
throw new ApiException('工单不存在');
}
$ticket->status = Ticket::STATUS_OPENING;
try{
try {
DB::beginTransaction();
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
@ -62,13 +63,46 @@ class TicketService {
}
DB::commit();
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
}catch(\Exception $e){
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
$this->sendEmailNotify($ticket, $ticketMessage);
}
public function createTicket($userId, $subject, $level, $message)
{
try {
DB::beginTransaction();
if (Ticket::where('status', 0)->where('user_id', $userId)->lockForUpdate()->first()) {
DB::rollBack();
throw new ApiException('存在未关闭的工单');
}
$ticket = Ticket::create([
'user_id' => $userId,
'subject' => $subject,
'level' => $level
]);
if (!$ticket) {
throw new ApiException('工单创建失败');
}
$ticketMessage = TicketMessage::create([
'user_id' => $userId,
'ticket_id' => $ticket->id,
'message' => $message
]);
if (!$ticketMessage) {
DB::rollBack();
throw new ApiException('工单消息创建失败');
}
DB::commit();
return $ticket;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
// 半小时内不再重复通知
private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage)
{

View File

@ -134,9 +134,7 @@ class Setting
*/
private function flush(): void
{
// 清除共享的Redis缓存
$this->cache->forget(self::CACHE_KEY);
// 清除当前请求的实例内存缓存
$this->loadedSettings = null;
}
}

View File

@ -7,8 +7,6 @@ use Illuminate\Support\Arr;
class Helper
{
private static $subscribeUrlCache = null;
public static function uuidToBase64($uuid, $length)
{
return base64_encode(substr($uuid, 0, $length));

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('v2_plugins', function (Blueprint $table) {
$table->string('type', 20)->default('feature')->after('code')->comment('插件类型:feature功能性,payment支付型');
$table->index(['type', 'is_enabled']);
});
}
public function down(): void
{
Schema::table('v2_plugins', function (Blueprint $table) {
$table->dropIndex(['type', 'is_enabled']);
$table->dropColumn('type');
});
}
};

View File

@ -1,164 +0,0 @@
<?php
namespace Library;
use Illuminate\Support\Facades\Http;
class AlipayF2F {
private $appId;
private $privateKey;
private $alipayPublicKey;
private $signType = 'RSA2';
public $bizContent;
public $method;
public $notifyUrl;
public $response;
public function __construct()
{
}
public function verify($data): bool
{
if (is_string($data)) {
parse_str($data, $data);
}
$sign = $data['sign'];
unset($data['sign']);
unset($data['sign_type']);
ksort($data);
$data = $this->buildQuery($data);
$res = "-----BEGIN PUBLIC KEY-----\n" .
wordwrap($this->alipayPublicKey, 64, "\n", true) .
"\n-----END PUBLIC KEY-----";
if ("RSA2" == $this->signType) {
$result = (openssl_verify($data, base64_decode($sign), $res, OPENSSL_ALGO_SHA256) === 1);
} else {
$result = (openssl_verify($data, base64_decode($sign), $res) === 1);
}
openssl_free_key(openssl_get_publickey($res));
return $result;
}
public function setBizContent($bizContent = [])
{
$this->bizContent = json_encode($bizContent);
}
public function setMethod($method)
{
$this->method = $method;
}
public function setAppId($appId)
{
$this->appId = $appId;
}
public function setPrivateKey($privateKey)
{
$this->privateKey = $privateKey;
}
public function setAlipayPublicKey($alipayPublicKey)
{
$this->alipayPublicKey = $alipayPublicKey;
}
public function setNotifyUrl($url)
{
$this->notifyUrl = $url;
}
public function send()
{
$response = Http::get('https://openapi.alipay.com/gateway.do', $this->buildParam())->json();
$resKey = str_replace('.', '_', $this->method) . '_response';
if (!isset($response[$resKey])) throw new \Exception('从支付宝请求失败');
$response = $response[$resKey];
if ($response['msg'] !== 'Success') throw new \Exception($response['sub_msg']);
$this->response = $response;
}
public function getQrCodeUrl()
{
$response = $this->response;
if (!isset($response['qr_code'])) throw new \Exception('获取付款二维码失败');
return $response['qr_code'];
}
public function getResponse()
{
return $this->response;
}
public function buildParam(): array
{
$params = [
'app_id' => $this->appId,
'method' => $this->method,
'charset' => 'UTF-8',
'sign_type' => $this->signType,
'timestamp' => date('Y-m-d H:m:s'),
'biz_content' => $this->bizContent,
'version' => '1.0',
'_input_charset' => 'UTF-8'
];
if ($this->notifyUrl) $params['notify_url'] = $this->notifyUrl;
ksort($params);
$params['sign'] = $this->buildSign($this->buildQuery($params));
return $params;
}
public function buildQuery($query)
{
if (!$query) {
throw new \Exception('参数构造错误');
}
//将要 参数 排序
ksort($query);
//重新组装参数
$params = array();
foreach ($query as $key => $value) {
$params[] = $key . '=' . $value;
}
$data = implode('&', $params);
return $data;
}
private function buildSign(string $signData): string
{
$privateKey = $this->privateKey;
$p_key = array();
//如果私钥是 1行
if (!stripos($privateKey, "\n")) {
$i = 0;
while ($key_str = substr($privateKey, $i * 64, 64)) {
$p_key[] = $key_str;
$i++;
}
}
$privateKey = "-----BEGIN RSA PRIVATE KEY-----\n" . implode("\n", $p_key);
$privateKey = $privateKey . "\n-----END RSA PRIVATE KEY-----";
//私钥
$privateId = openssl_pkey_get_private($privateKey, '');
// 签名
$signature = '';
if ("RSA2" == $this->signType) {
openssl_sign($signData, $signature, $privateId, OPENSSL_ALGO_SHA256);
} else {
openssl_sign($signData, $signature, $privateId, OPENSSL_ALGO_SHA1);
}
openssl_free_key($privateId);
//加密后的内容通常含有特殊字符,需要编码转换下
$signature = base64_encode($signature);
return $signature;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Plugin\AlipayF2f;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
use Illuminate\Support\Facades\Log;
use Plugin\AlipayF2f\library\AlipayF2F;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$this->filter('available_payment_methods', function ($methods) {
if ($this->getConfig('enabled', true)) {
$methods['AlipayF2F'] = [
'name' => $this->getConfig('display_name', '支付宝当面付'),
'icon' => $this->getConfig('icon', '💙'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
{
return [
'app_id' => [
'label' => '支付宝APPID',
'type' => 'string',
'required' => true,
'description' => '支付宝开放平台应用的APPID'
],
'private_key' => [
'label' => '支付宝私钥',
'type' => 'textarea',
'required' => true,
'description' => '应用私钥,用于签名'
],
'public_key' => [
'label' => '支付宝公钥',
'type' => 'textarea',
'required' => true,
'description' => '支付宝公钥,用于验签'
],
'product_name' => [
'label' => '自定义商品名称',
'type' => 'string',
'description' => '将会体现在支付宝账单中'
]
];
}
public function pay($order): array
{
try {
$gateway = new AlipayF2F();
$gateway->setMethod('alipay.trade.precreate');
$gateway->setAppId($this->getConfig('app_id'));
$gateway->setPrivateKey($this->getConfig('private_key'));
$gateway->setAlipayPublicKey($this->getConfig('public_key'));
$gateway->setNotifyUrl($order['notify_url']);
$gateway->setBizContent([
'subject' => $this->getConfig('product_name') ?? (admin_setting('app_name', 'XBoard') . ' - 订阅'),
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'] / 100
]);
$gateway->send();
return [
'type' => 0,
'data' => $gateway->getQrCodeUrl()
];
} catch (\Exception $e) {
Log::error($e);
throw new ApiException($e->getMessage());
}
}
public function notify($params): array|bool
{
if ($params['trade_status'] !== 'TRADE_SUCCESS')
return false;
$gateway = new AlipayF2F();
$gateway->setAppId($this->getConfig('app_id'));
$gateway->setPrivateKey($this->getConfig('private_key'));
$gateway->setAlipayPublicKey($this->getConfig('public_key'));
try {
if ($gateway->verify($params)) {
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
} else {
return false;
}
} catch (\Exception $e) {
return false;
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "AlipayF2F",
"code": "alipay_f2f",
"type": "payment",
"version": "1.0.0",
"description": "AlipayF2F payment plugin",
"author": "XBoard Team"
}

View File

@ -1,47 +1,60 @@
<?php
namespace App\Payments;
namespace Plugin\Btcpay;
use App\Exceptions\ApiException;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class BTCPay implements PaymentInterface
class Plugin extends AbstractPlugin implements PaymentInterface
{
protected $config;
public function __construct($config)
public function boot(): void
{
$this->config = $config;
$this->filter('available_payment_methods', function($methods) {
if ($this->getConfig('enabled', true)) {
$methods['BTCPay'] = [
'name' => $this->getConfig('display_name', 'BTCPay'),
'icon' => $this->getConfig('icon', '₿'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
{
return [
'btcpay_url' => [
'label' => 'API接口所在网址(包含最后的斜杠)',
'description' => '',
'type' => 'input',
'label' => 'API接口所在网址',
'type' => 'string',
'required' => true,
'description' => '包含最后的斜杠,例如:https://your-btcpay.com/'
],
'btcpay_storeId' => [
'label' => 'storeId',
'description' => '',
'type' => 'input',
'label' => 'Store ID',
'type' => 'string',
'required' => true,
'description' => 'BTCPay商店标识符'
],
'btcpay_api_key' => [
'label' => 'API KEY',
'description' => '个人设置中的API KEY(非商店设置中的)',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => '个人设置中的API KEY(非商店设置中的)'
],
'btcpay_webhook_key' => [
'label' => 'WEBHOOK KEY',
'description' => '',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => 'Webhook通知密钥'
],
];
}
public function pay($order): array
{
$params = [
'jsonResponse' => true,
'amount' => sprintf('%.2f', $order['total_amount'] / 100),
@ -52,16 +65,15 @@ class BTCPay implements PaymentInterface
];
$params_string = @json_encode($params);
$ret_raw = self::_curlPost($this->config['btcpay_url'] . 'api/v1/stores/' . $this->config['btcpay_storeId'] . '/invoices', $params_string);
$ret_raw = $this->curlPost($this->getConfig('btcpay_url') . 'api/v1/stores/' . $this->getConfig('btcpay_storeId') . '/invoices', $params_string);
$ret = @json_decode($ret_raw, true);
if (empty($ret['checkoutLink'])) {
throw new ApiException("error!");
}
return [
'type' => 1, // Redirect to url
'type' => 1,
'data' => $ret['checkoutLink'],
];
}
@ -69,46 +81,38 @@ class BTCPay implements PaymentInterface
public function notify($params): array|bool
{
$payload = trim(request()->getContent());
$headers = getallheaders();
//IS Btcpay-Sig
//NOT BTCPay-Sig
//API doc is WRONG!
$headerName = 'Btcpay-Sig';
$signraturHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$json_param = json_decode($payload, true);
$computedSignature = "sha256=" . \hash_hmac('sha256', $payload, $this->config['btcpay_webhook_key']);
$computedSignature = "sha256=" . \hash_hmac('sha256', $payload, $this->getConfig('btcpay_webhook_key'));
if (!self::hashEqual($signraturHeader, $computedSignature)) {
if (!$this->hashEqual($signraturHeader, $computedSignature)) {
throw new ApiException('HMAC signature does not match', 400);
}
//get order id store in metadata
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'header' => "Authorization:" . "token " . $this->config['btcpay_api_key'] . "\r\n"
'header' => "Authorization:" . "token " . $this->getConfig('btcpay_api_key') . "\r\n"
)
));
$invoiceDetail = file_get_contents($this->config['btcpay_url'] . 'api/v1/stores/' . $this->config['btcpay_storeId'] . '/invoices/' . $json_param['invoiceId'], false, $context);
$invoiceDetail = file_get_contents($this->getConfig('btcpay_url') . 'api/v1/stores/' . $this->getConfig('btcpay_storeId') . '/invoices/' . $json_param['invoiceId'], false, $context);
$invoiceDetail = json_decode($invoiceDetail, true);
$out_trade_no = $invoiceDetail['metadata']["orderId"];
$pay_trade_no = $json_param['invoiceId'];
return [
'trade_no' => $out_trade_no,
'callback_no' => $pay_trade_no
];
}
private function _curlPost($url, $params = false)
private function curlPost($url, $params = false)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
@ -118,22 +122,15 @@ class BTCPay implements PaymentInterface
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('Authorization:' . 'token ' . $this->config['btcpay_api_key'], 'Content-Type: application/json')
array('Authorization:' . 'token ' . $this->getConfig('btcpay_api_key'), 'Content-Type: application/json')
);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* @param string $str1
* @param string $str2
* @return bool
*/
private function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
}
@ -150,4 +147,4 @@ class BTCPay implements PaymentInterface
return !$ret;
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "BTCPay",
"code": "btcpay",
"type": "payment",
"version": "1.0.0",
"description": "BTCPay payment plugin",
"author": "XBoard Team"
}

View File

@ -1,16 +1,26 @@
<?php
namespace App\Payments;
namespace Plugin\CoinPayments;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class CoinPayments implements PaymentInterface
class Plugin extends AbstractPlugin implements PaymentInterface
{
protected $config;
public function __construct($config)
public function boot(): void
{
$this->config = $config;
$this->filter('available_payment_methods', function($methods) {
if ($this->getConfig('enabled', true)) {
$methods['CoinPayments'] = [
'name' => $this->getConfig('display_name', 'CoinPayments'),
'icon' => $this->getConfig('icon', '💰'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
@ -18,26 +28,27 @@ class CoinPayments implements PaymentInterface
return [
'coinpayments_merchant_id' => [
'label' => 'Merchant ID',
'description' => '商户 ID,填写您在 Account Settings 中得到的 ID',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => '商户 ID,填写您在 Account Settings 中得到的 ID'
],
'coinpayments_ipn_secret' => [
'label' => 'IPN Secret',
'description' => '通知密钥,填写您在 Merchant Settings 中自行设置的值',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => '通知密钥,填写您在 Merchant Settings 中自行设置的值'
],
'coinpayments_currency' => [
'label' => '货币代码',
'description' => '填写您的货币代码(大写),建议与 Merchant Settings 中的值相同',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => '填写您的货币代码(大写),建议与 Merchant Settings 中的值相同'
]
];
}
public function pay($order): array
{
// IPN notifications are slow, when the transaction is successful, we should return to the user center to avoid user confusion
$parseUrl = parse_url($order['return_url']);
$port = isset($parseUrl['port']) ? ":{$parseUrl['port']}" : '';
$successUrl = "{$parseUrl['scheme']}://{$parseUrl['host']}{$port}";
@ -45,11 +56,11 @@ class CoinPayments implements PaymentInterface
$params = [
'cmd' => '_pay_simple',
'reset' => 1,
'merchant' => $this->config['coinpayments_merchant_id'],
'merchant' => $this->getConfig('coinpayments_merchant_id'),
'item_name' => $order['trade_no'],
'item_number' => $order['trade_no'],
'want_shipping' => 0,
'currency' => $this->config['coinpayments_currency'],
'currency' => $this->getConfig('coinpayments_currency'),
'amountf' => sprintf('%.2f', $order['total_amount'] / 100),
'success_url' => $successUrl,
'cancel_url' => $order['return_url'],
@ -59,15 +70,14 @@ class CoinPayments implements PaymentInterface
$params_string = http_build_query($params);
return [
'type' => 1, // Redirect to url
'type' => 1,
'data' => 'https://www.coinpayments.net/index.php?' . $params_string
];
}
public function notify($params)
public function notify($params): array|string
{
if (!isset($params['merchant']) || $params['merchant'] != trim($this->config['coinpayments_merchant_id'])) {
if (!isset($params['merchant']) || $params['merchant'] != trim($this->getConfig('coinpayments_merchant_id'))) {
throw new ApiException('No or incorrect Merchant ID passed');
}
@ -80,31 +90,23 @@ class CoinPayments implements PaymentInterface
$headerName = 'Hmac';
$signHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$hmac = hash_hmac("sha512", $request, trim($this->config['coinpayments_ipn_secret']));
// if ($hmac != $signHeader) { <-- Use this if you are running a version of PHP below 5.6.0 without the hash_equals function
// throw new ApiException(400, 'HMAC signature does not match');
// }
$hmac = hash_hmac("sha512", $request, trim($this->getConfig('coinpayments_ipn_secret')));
if (!hash_equals($hmac, $signHeader)) {
throw new ApiException('HMAC signature does not match', 400);
}
// HMAC Signature verified at this point, load some variables.
$status = $params['status'];
if ($status >= 100 || $status == 2) {
// payment is complete or queued for nightly payout, success
return [
'trade_no' => $params['item_number'],
'callback_no' => $params['txn_id'],
'custom_result' => 'IPN OK'
];
} else if ($status < 0) {
//payment error, this is usually final but payments will sometimes be reopened if there was no exchange rate conversion or with seller consent
throw new ApiException('Payment Timed Out or Error');
} else {
//payment is pending, you can optionally add a note to the order page
return ('IPN OK: pending');
return 'IPN OK: pending';
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "CoinPayments",
"code": "coin_payments",
"type": "payment",
"version": "1.0.0",
"description": "CoinPayments payment plugin",
"author": "XBoard Team"
}

View File

@ -1,16 +1,26 @@
<?php
namespace App\Payments;
namespace Plugin\Coinbase;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class Coinbase implements PaymentInterface
class Plugin extends AbstractPlugin implements PaymentInterface
{
protected $config;
public function __construct($config)
public function boot(): void
{
$this->config = $config;
$this->filter('available_payment_methods', function($methods) {
if ($this->getConfig('enabled', true)) {
$methods['Coinbase'] = [
'name' => $this->getConfig('display_name', 'Coinbase'),
'icon' => $this->getConfig('icon', '🪙'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
@ -18,25 +28,27 @@ class Coinbase implements PaymentInterface
return [
'coinbase_url' => [
'label' => '接口地址',
'description' => '',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => 'Coinbase Commerce API地址'
],
'coinbase_api_key' => [
'label' => 'API KEY',
'description' => '',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => 'Coinbase Commerce API密钥'
],
'coinbase_webhook_key' => [
'label' => 'WEBHOOK KEY',
'description' => '',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => 'Webhook签名验证密钥'
],
];
}
public function pay($order): array
{
$params = [
'name' => '订阅套餐',
'description' => '订单号 ' . $order['trade_no'],
@ -51,14 +63,13 @@ class Coinbase implements PaymentInterface
];
$params_string = http_build_query($params);
$ret_raw = self::_curlPost($this->config['coinbase_url'], $params_string);
$ret_raw = $this->curlPost($this->getConfig('coinbase_url'), $params_string);
$ret = @json_decode($ret_raw, true);
if (empty($ret['data']['hosted_url'])) {
throw new ApiException("error!");
}
return [
'type' => 1,
'data' => $ret['data']['hosted_url'],
@ -67,32 +78,29 @@ class Coinbase implements PaymentInterface
public function notify($params): array
{
$payload = trim(request()->getContent());
$json_param = json_decode($payload, true);
$headerName = 'X-Cc-Webhook-Signature';
$headers = getallheaders();
$signatureHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$computedSignature = \hash_hmac('sha256', $payload, $this->config['coinbase_webhook_key']);
$computedSignature = \hash_hmac('sha256', $payload, $this->getConfig('coinbase_webhook_key'));
if (!self::hashEqual($signatureHeader, $computedSignature)) {
if (!$this->hashEqual($signatureHeader, $computedSignature)) {
throw new ApiException('HMAC signature does not match', 400);
}
$out_trade_no = $json_param['event']['data']['metadata']['outTradeNo'];
$pay_trade_no = $json_param['event']['id'];
return [
'trade_no' => $out_trade_no,
'callback_no' => $pay_trade_no
];
}
private function _curlPost($url, $params = false)
private function curlPost($url, $params = false)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
@ -102,20 +110,14 @@ class Coinbase implements PaymentInterface
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('X-CC-Api-Key:' . $this->config['coinbase_api_key'], 'X-CC-Version: 2018-03-22')
array('X-CC-Api-Key:' . $this->getConfig('coinbase_api_key'), 'X-CC-Version: 2018-03-22')
);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* @param string $str1
* @param string $str2
* @return bool
*/
public function hashEqual($str1, $str2)
private function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
@ -133,4 +135,4 @@ class Coinbase implements PaymentInterface
return !$ret;
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "Coinbase",
"code": "coinbase",
"type": "payment",
"version": "1.0.0",
"description": "Coinbase payment plugin",
"author": "XBoard Team"
}

100
plugins/Epay/Plugin.php Normal file
View File

@ -0,0 +1,100 @@
<?php
namespace Plugin\Epay;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$this->filter('available_payment_methods', function ($methods) {
if ($this->getConfig('enabled', true)) {
$methods['EPay'] = [
'name' => $this->getConfig('display_name', '易支付'),
'icon' => $this->getConfig('icon', '💳'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
{
return [
'url' => [
'label' => '支付网关地址',
'type' => 'string',
'required' => true,
'description' => '请填写完整的支付网关地址,包括协议(http或https)'
],
'pid' => [
'label' => '商户ID',
'type' => 'string',
'description' => '请填写商户ID',
'required' => true
],
'key' => [
'label' => '通信密钥',
'type' => 'string',
'required' => true,
'description' => '请填写通信密钥'
],
'type' => [
'label' => '支付类型',
'type' => 'select',
'options' => [
['value' => 'alipay', 'label' => '支付宝'],
['value' => 'wxpay', 'label' => '微信支付'],
['value' => 'qqpay', 'label' => 'QQ钱包']
]
],
];
}
public function pay($order): array
{
$params = [
'money' => $order['total_amount'] / 100,
'name' => $order['trade_no'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url'],
'out_trade_no' => $order['trade_no'],
'pid' => $this->getConfig('pid')
];
if ($paymentType = $this->getConfig('type')) {
$params['type'] = $paymentType;
}
ksort($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->getConfig('key');
$params['sign'] = md5($str);
$params['sign_type'] = 'MD5';
return [
'type' => 1,
'data' => $this->getConfig('url') . '/submit.php?' . http_build_query($params)
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
ksort($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->getConfig('key');
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}

8
plugins/Epay/config.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "EPay",
"code": "epay",
"type": "payment",
"version": "1.0.0",
"description": "EPay payment plugin",
"author": "XBoard Team"
}

View File

@ -1,20 +1,27 @@
<?php
/**
* 自己写别抄,抄NMB抄
*/
namespace App\Payments;
namespace Plugin\Mgate;
use App\Exceptions\ApiException;
use \Curl\Curl;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
class MGate implements PaymentInterface
{
private $config;
use App\Exceptions\ApiException;
use Curl\Curl;
public function __construct($config)
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$this->config = $config;
$this->filter('available_payment_methods', function ($methods) {
if ($this->getConfig('enabled', true)) {
$methods['MGate'] = [
'name' => $this->getConfig('display_name', 'MGate'),
'icon' => $this->getConfig('icon', '🏛️'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
@ -22,23 +29,26 @@ class MGate implements PaymentInterface
return [
'mgate_url' => [
'label' => 'API地址',
'description' => '',
'type' => 'input',
'type' => 'string',
'required' => true,
'description' => 'MGate支付网关API地址'
],
'mgate_app_id' => [
'label' => 'APPID',
'description' => '',
'type' => 'input',
'label' => 'APP ID',
'type' => 'string',
'required' => true,
'description' => 'MGate应用标识符'
],
'mgate_app_secret' => [
'label' => 'AppSecret',
'description' => '',
'type' => 'input',
'label' => 'App Secret',
'type' => 'string',
'required' => true,
'description' => 'MGate应用密钥'
],
'mgate_source_currency' => [
'label' => '源货币',
'description' => '默认CNY',
'type' => 'input'
'type' => 'string',
'description' => '默认CNY,源货币类型'
]
];
}
@ -51,21 +61,26 @@ class MGate implements PaymentInterface
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url']
];
if (isset($this->config['mgate_source_currency'])) {
$params['source_currency'] = $this->config['mgate_source_currency'];
if ($this->getConfig('mgate_source_currency')) {
$params['source_currency'] = $this->getConfig('mgate_source_currency');
}
$params['app_id'] = $this->config['mgate_app_id'];
$params['app_id'] = $this->getConfig('mgate_app_id');
ksort($params);
$str = http_build_query($params) . $this->config['mgate_app_secret'];
$str = http_build_query($params) . $this->getConfig('mgate_app_secret');
$params['sign'] = md5($str);
$curl = new Curl();
$curl->setUserAgent('MGate');
$curl->setOpt(CURLOPT_SSL_VERIFYPEER, 0);
$curl->post($this->config['mgate_url'] . '/v1/gateway/fetch', http_build_query($params));
$curl->post($this->getConfig('mgate_url') . '/v1/gateway/fetch', http_build_query($params));
$result = $curl->response;
if (!$result) {
throw new ApiException('网络异常');
}
if ($curl->error) {
if (isset($result->errors)) {
$errors = (array) $result->errors;
@ -76,12 +91,15 @@ class MGate implements PaymentInterface
}
throw new ApiException('未知错误');
}
$curl->close();
if (!isset($result->data->trade_no)) {
throw new ApiException('接口请求失败');
}
return [
'type' => 1, // 0:qrcode 1:url
'type' => 1,
'data' => $result->data->pay_url
];
}
@ -92,13 +110,15 @@ class MGate implements PaymentInterface
unset($params['sign']);
ksort($params);
reset($params);
$str = http_build_query($params) . $this->config['mgate_app_secret'];
$str = http_build_query($params) . $this->getConfig('mgate_app_secret');
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "MGate",
"code": "mgate",
"type": "payment",
"version": "1.0.0",
"description": "MGate payment plugin",
"author": "XBoard Team"
}

128
plugins/Smogate/Plugin.php Normal file
View File

@ -0,0 +1,128 @@
<?php
namespace Plugin\Smogate;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use Curl\Curl;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$this->filter('available_payment_methods', function($methods) {
if ($this->getConfig('enabled', true)) {
$methods['Smogate'] = [
'name' => $this->getConfig('display_name', 'Smogate'),
'icon' => $this->getConfig('icon', '🔥'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
{
return [
'smogate_app_id' => [
'label' => 'APP ID',
'type' => 'string',
'required' => true,
'description' => 'Smogate -> 接入文档和密钥 -> 查看APPID和密钥'
],
'smogate_app_secret' => [
'label' => 'APP Secret',
'type' => 'string',
'required' => true,
'description' => 'Smogate -> 接入文档和密钥 -> 查看APPID和密钥'
],
'smogate_source_currency' => [
'label' => '源货币',
'type' => 'string',
'description' => '默认CNY,源货币类型'
],
'smogate_method' => [
'label' => '支付方式',
'type' => 'string',
'required' => true,
'description' => 'Smogate支付方式标识'
]
];
}
public function pay($order): array
{
$params = [
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'],
'notify_url' => $order['notify_url'],
'method' => $this->getConfig('smogate_method')
];
if ($this->getConfig('smogate_source_currency')) {
$params['source_currency'] = strtolower($this->getConfig('smogate_source_currency'));
}
$params['app_id'] = $this->getConfig('smogate_app_id');
ksort($params);
$str = http_build_query($params) . $this->getConfig('smogate_app_secret');
$params['sign'] = md5($str);
$curl = new Curl();
$curl->setUserAgent("Smogate {$this->getConfig('smogate_app_id')}");
$curl->setOpt(CURLOPT_SSL_VERIFYPEER, 0);
$curl->post("https://{$this->getConfig('smogate_app_id')}.vless.org/v1/gateway/pay", http_build_query($params));
$result = $curl->response;
if (!$result) {
abort(500, '网络异常');
}
if ($curl->error) {
if (isset($result->errors)) {
$errors = (array)$result->errors;
abort(500, $errors[array_keys($errors)[0]][0]);
}
if (isset($result->message)) {
abort(500, $result->message);
}
abort(500, '未知错误');
}
$curl->close();
if (!isset($result->data)) {
abort(500, '请求失败');
}
return [
'type' => $this->isMobile() ? 1 : 0,
'data' => $result->data
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign']);
ksort($params);
reset($params);
$str = http_build_query($params) . $this->getConfig('smogate_app_secret');
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
private function isMobile(): bool
{
return strpos(strtolower($_SERVER['HTTP_USER_AGENT']), 'mobile') !== false;
}
}

View File

@ -0,0 +1,8 @@
{
"name": "Smogate",
"code": "smogate",
"type": "payment",
"version": "1.0.0",
"description": "Smogate payment plugin",
"author": "XBoard Team"
}

425
plugins/Telegram/Plugin.php Normal file
View File

@ -0,0 +1,425 @@
<?php
namespace Plugin\Telegram;
use App\Models\Order;
use App\Models\Ticket;
use App\Models\User;
use App\Services\Plugin\AbstractPlugin;
use App\Services\Plugin\HookManager;
use App\Services\TelegramService;
use App\Services\TicketService;
use App\Utils\Helper;
use Illuminate\Support\Facades\Log;
class Plugin extends AbstractPlugin
{
protected array $commands = [];
protected TelegramService $telegramService;
protected array $commandConfigs = [
'/start' => ['description' => '开始使用', 'handler' => 'handleStartCommand'],
'/bind' => ['description' => '绑定账号', 'handler' => 'handleBindCommand'],
'/traffic' => ['description' => '查看流量', 'handler' => 'handleTrafficCommand'],
'/getlatesturl' => ['description' => '获取订阅链接', 'handler' => 'handleGetLatestUrlCommand'],
'/unbind' => ['description' => '解绑账号', 'handler' => 'handleUnbindCommand'],
];
public function boot(): void
{
$this->telegramService = new TelegramService();
$this->registerDefaultCommands();
$this->filter('telegram.message.handle', [$this, 'handleMessage'], 10);
$this->listen('telegram.message.unhandled', [$this, 'handleUnknownCommand'], 10);
$this->listen('telegram.message.error', [$this, 'handleError'], 10);
$this->filter('telegram.bot.commands', [$this, 'addBotCommands'], 10);
$this->listen('ticket.create.after', [$this, 'sendTicketNotify'], 10);
$this->listen('ticket.reply.user.after', [$this, 'sendTicketNotify'], 10);
$this->listen('payment.notify.success', [$this, 'sendPaymentNotify'], 10);
}
public function sendPaymentNotify(Order $order): void
{
if (!$this->getConfig('enable_payment_notify', true)) {
return;
}
$payment = $order->payment;
if (!$payment) {
Log::warning('支付通知失败:订单关联的支付方式不存在', ['order_id' => $order->id]);
return;
}
$message = sprintf(
"💰成功收款%s元\n" .
"———————————————\n" .
"支付接口:%s\n" .
"支付渠道:%s\n" .
"本站订单:`%s`",
$order->total_amount / 100,
$payment->payment,
$payment->name,
$order->trade_no
);
$this->telegramService->sendMessageWithAdmin($message, true);
}
public function sendTicketNotify(array $data): void
{
if (!$this->getConfig('enable_ticket_notify', true)) {
return;
}
[$ticket, $message] = $data;
$user = User::find($ticket->user_id);
if (!$user)
return;
$user->load('plan');
$transfer_enable = Helper::transferToGB($user->transfer_enable);
$remaining_traffic = Helper::transferToGB($user->transfer_enable - $user->u - $user->d);
$u = Helper::transferToGB($user->u);
$d = Helper::transferToGB($user->d);
$expired_at = $user->expired_at ? date('Y-m-d H:i:s', $user->expired_at) : '';
$money = $user->balance / 100;
$affmoney = $user->commission_balance / 100;
$plan = $user->plan;
$ip = request()?->ip() ?? '';
$region = $ip ? (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? (new \Ip2Region())->simple($ip) : 'NULL') : '';
$TGmessage = "📮工单提醒 #{$ticket->id}\n———————————————\n";
$TGmessage .= "邮箱: `{$user->email}`\n";
$TGmessage .= "用户位置: \n`{$region}`\n";
if ($plan) {
$TGmessage .= "套餐与流量: \n`{$plan->name} {$transfer_enable}/{$remaining_traffic}`\n";
$TGmessage .= "上传/下载: \n`{$u}/{$d}`\n";
$TGmessage .= "到期时间: \n`{$expired_at}`\n";
} else {
$TGmessage .= "套餐与流量: \n`未订购任何套餐`\n";
}
$TGmessage .= "余额/佣金余额: \n`{$money}/{$affmoney}`\n";
$TGmessage .= "主题:\n`{$ticket->subject}`\n内容:\n`{$message->message}`\n";
$this->telegramService->sendMessageWithAdmin($TGmessage, true);
}
protected function registerDefaultCommands(): void
{
foreach ($this->commandConfigs as $command => $config) {
$this->registerTelegramCommand($command, [$this, $config['handler']]);
}
$this->registerReplyHandler('/(工单提醒 #?|工单ID: ?)(\\d+)/', [$this, 'handleTicketReply']);
}
public function registerTelegramCommand(string $command, callable $handler): void
{
$this->commands['commands'][$command] = $handler;
}
public function registerReplyHandler(string $regex, callable $handler): void
{
$this->commands['replies'][$regex] = $handler;
}
/**
* 发送消息给用户
*/
protected function sendMessage(object $msg, string $message): void
{
$this->telegramService->sendMessage($msg->chat_id, $message, 'markdown');
}
/**
* 检查是否为私聊
*/
protected function checkPrivateChat(object $msg): bool
{
if (!$msg->is_private) {
$this->sendMessage($msg, '请在私聊中使用此命令');
return false;
}
return true;
}
/**
* 获取绑定的用户
*/
protected function getBoundUser(object $msg): ?User
{
$user = User::where('telegram_id', $msg->chat_id)->first();
if (!$user) {
$this->sendMessage($msg, '请先绑定账号');
return null;
}
return $user;
}
public function handleStartCommand(object $msg): void
{
$welcomeTitle = $this->getConfig('start_welcome_title', '🎉 欢迎使用 XBoard Telegram Bot!');
$botDescription = $this->getConfig('start_bot_description', '🤖 我是您的专属助手,可以帮助您:\\n• 绑定您的 XBoard 账号\\n• 查看流量使用情况\\n• 获取最新订阅链接\\n• 管理账号绑定状态');
$footer = $this->getConfig('start_footer', '💡 提示:所有命令都需要在私聊中使用');
$welcomeText = $welcomeTitle . "\n\n" . $botDescription . "\n\n";
$user = User::where('telegram_id', $msg->chat_id)->first();
if ($user) {
$welcomeText .= "✅ 您已绑定账号:{$user->email}\n\n";
$welcomeText .= $this->getConfig('start_unbind_guide', '📋 可用命令:\\n/traffic - 查看流量使用情况\\n/getlatesturl - 获取订阅链接\\n/unbind - 解绑账号');
} else {
$welcomeText .= $this->getConfig('start_bind_guide', '🔗 请先绑定您的 XBoard 账号:\\n1. 登录您的 XBoard 账户\\n2. 复制您的订阅链接\\n3. 发送 /bind + 订阅链接') . "\n\n";
$welcomeText .= $this->getConfig('start_bind_commands', '📋 可用命令:\\n/bind [订阅链接] - 绑定账号');
}
$welcomeText .= "\n\n" . $footer;
$welcomeText = str_replace('\\n', "\n", $welcomeText);
$this->sendMessage($msg, $welcomeText);
}
public function handleMessage(bool $handled, array $data): bool
{
list($msg) = $data;
if ($handled)
return $handled;
try {
return match ($msg->message_type) {
'message' => $this->handleCommandMessage($msg),
'reply_message' => $this->handleReplyMessage($msg),
default => false
};
} catch (\Exception $e) {
Log::error('Telegram 命令处理意外错误', [
'command' => $msg->command ?? 'unknown',
'chat_id' => $msg->chat_id ?? 'unknown',
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
if (isset($msg->chat_id)) {
$this->telegramService->sendMessage($msg->chat_id, '系统繁忙,请稍后重试');
}
return true;
}
}
protected function handleCommandMessage(object $msg): bool
{
if (!isset($this->commands['commands'][$msg->command])) {
return false;
}
call_user_func($this->commands['commands'][$msg->command], $msg);
return true;
}
protected function handleReplyMessage(object $msg): bool
{
if (!isset($this->commands['replies'])) {
return false;
}
foreach ($this->commands['replies'] as $regex => $handler) {
if (preg_match($regex, $msg->reply_text, $matches)) {
call_user_func($handler, $msg, $matches);
return true;
}
}
return false;
}
public function handleUnknownCommand(array $data): void
{
list($msg) = $data;
if (!$msg->is_private || $msg->message_type !== 'message')
return;
$helpText = $this->getConfig('help_text', '未知命令,请查看帮助');
$this->telegramService->sendMessage($msg->chat_id, $helpText);
}
public function handleError(array $data): void
{
list($msg, $e) = $data;
Log::error('Telegram 消息处理错误', [
'chat_id' => $msg->chat_id ?? 'unknown',
'command' => $msg->command ?? 'unknown',
'message_type' => $msg->message_type ?? 'unknown',
'error' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]);
}
public function handleBindCommand(object $msg): void
{
if (!$this->checkPrivateChat($msg)) {
return;
}
$subscribeUrl = $msg->args[0] ?? null;
if (!$subscribeUrl) {
$this->sendMessage($msg, '参数有误,请携带订阅地址发送');
return;
}
$token = $this->extractTokenFromUrl($subscribeUrl);
if (!$token) {
$this->sendMessage($msg, '订阅地址无效');
return;
}
$user = User::where('token', $token)->first();
if (!$user) {
$this->sendMessage($msg, '用户不存在');
return;
}
if ($user->telegram_id) {
$this->sendMessage($msg, '该账号已经绑定了Telegram账号');
return;
}
$user->telegram_id = $msg->chat_id;
if (!$user->save()) {
$this->sendMessage($msg, '设置失败');
return;
}
HookManager::call('user.telegram.bind.after', [$user]);
$this->sendMessage($msg, '绑定成功');
}
protected function extractTokenFromUrl(string $url): ?string
{
$parsedUrl = parse_url($url);
if (isset($parsedUrl['query'])) {
parse_str($parsedUrl['query'], $query);
if (isset($query['token'])) {
return $query['token'];
}
}
if (isset($parsedUrl['path'])) {
$pathParts = explode('/', trim($parsedUrl['path'], '/'));
$lastPart = end($pathParts);
return $lastPart ?: null;
}
return null;
}
public function handleTrafficCommand(object $msg): void
{
if (!$this->checkPrivateChat($msg)) {
return;
}
$user = $this->getBoundUser($msg);
if (!$user) {
return;
}
$transferUsed = $user->u + $user->d;
$transferTotal = $user->transfer_enable;
$transferRemaining = $transferTotal - $transferUsed;
$usagePercentage = $transferTotal > 0 ? ($transferUsed / $transferTotal) * 100 : 0;
$text = sprintf(
"📊 流量使用情况\n\n已用流量:%s\n总流量:%s\n剩余流量:%s\n使用率:%.2f%%",
Helper::transferToGB($transferUsed),
Helper::transferToGB($transferTotal),
Helper::transferToGB($transferRemaining),
$usagePercentage
);
$this->sendMessage($msg, $text);
}
public function handleGetLatestUrlCommand(object $msg): void
{
if (!$this->checkPrivateChat($msg)) {
return;
}
$user = $this->getBoundUser($msg);
if (!$user) {
return;
}
$subscribeUrl = Helper::getSubscribeUrl($user->token);
$text = sprintf("🔗 您的订阅链接:\n\n%s", $subscribeUrl);
$this->sendMessage($msg, $text);
}
public function handleUnbindCommand(object $msg): void
{
if (!$this->checkPrivateChat($msg)) {
return;
}
$user = $this->getBoundUser($msg);
if (!$user) {
return;
}
$user->telegram_id = null;
if (!$user->save()) {
$this->sendMessage($msg, '解绑失败');
return;
}
$this->sendMessage($msg, '解绑成功');
}
public function handleTicketReply(object $msg, array $matches): void
{
$user = $this->getBoundUser($msg);
if (!$user) {
return;
}
if (!isset($matches[2]) || !is_numeric($matches[2])) {
Log::warning('Telegram 工单回复正则未匹配到工单ID', ['matches' => $matches, 'msg' => $msg]);
$this->sendMessage($msg, '未能识别工单ID,请直接回复工单提醒消息。');
return;
}
$ticketId = (int) $matches[2];
$ticket = Ticket::where('id', $ticketId)->first();
if (!$ticket) {
$this->sendMessage($msg, '工单不存在');
return;
}
$ticketService = new TicketService();
$ticketService->replyByAdmin(
$ticketId,
$msg->text,
$user->id
);
$this->sendMessage($msg, "工单 #{$ticketId} 回复成功");
}
/**
* 添加 Bot 命令到命令列表
*/
public function addBotCommands(array $commands): array
{
foreach ($this->commandConfigs as $command => $config) {
$commands[] = [
'command' => $command,
'description' => $config['description']
];
}
return $commands;
}
}

View File

@ -0,0 +1,84 @@
# Telegram 插件
XBoard 的 Telegram Bot 插件,提供用户账号绑定、流量查询、订阅链接获取等功能。
## 功能特性
- ✅ 工单通知功能(可配置开关)
- ✅ 支付通知功能(可配置开关)
- ✅ 用户账号绑定/解绑
- ✅ 流量使用情况查询
- ✅ 订阅链接获取
- ✅ 工单回复支持
## 可用命令
### `/start` - 开始使用
欢迎新用户并显示帮助信息,支持动态配置。
### `/bind` - 绑定账号
绑定用户的 XBoard 账号到 Telegram。
```
/bind [订阅链接]
```
### `/traffic` - 查看流量
查看当前绑定账号的流量使用情况。
### `/getlatesturl` - 获取订阅链接
获取最新的订阅链接。
### `/unbind` - 解绑账号
解绑当前 Telegram 账号与 XBoard 账号的关联。
## 配置选项
### 基础配置
| 配置项 | 类型 | 默认值 | 说明 |
| ------------ | ------- | ------------------------------------------------------------------------------------------ | -------------------- |
| `auto_reply` | boolean | true | 是否自动回复未知命令 |
| `help_text` | text | '请使用以下命令:\\n/bind - 绑定账号\\n/traffic - 查看流量\\n/getlatesturl - 获取最新链接' | 未知命令的回复文本 |
### `/start` 命令动态配置
| 配置项 | 类型 | 说明 |
| ----------------------- | ---- | ------------------------ |
| `start_welcome_title` | text | 欢迎标题 |
| `start_bot_description` | text | 机器人功能介绍 |
| `start_bind_guide` | text | 未绑定用户的绑定指导 |
| `start_unbind_guide` | text | 已绑定用户显示的命令列表 |
| `start_bind_commands` | text | 未绑定用户显示的命令列表 |
| `start_footer` | text | 底部提示信息 |
### 工单通知配置
| 配置项 | 类型 | 默认值 | 说明 |
| ---------------------- | ------- | ------ | -------------------- |
| `enable_ticket_notify` | boolean | true | 是否开启工单通知功能 |
### 支付通知配置
| 配置项 | 类型 | 默认值 | 说明 |
| ----------------------- | ------- | ------ | -------------------- |
| `enable_payment_notify` | boolean | true | 是否开启支付通知功能 |
## 使用流程
### 新用户使用流程
1. 用户首次使用 Bot,发送 `/start`
2. 根据提示绑定账号:`/bind [订阅链接]`
3. 绑定成功后即可使用其他功能
### 日常使用流程
1. 查看流量:`/traffic`
2. 获取订阅链接:`/getlatesturl`
3. 管理绑定:`/unbind`

View File

@ -0,0 +1,66 @@
{
"name": "Telegram Bot 集成",
"code": "telegram",
"version": "1.0.0",
"description": "Telegram Bot 消息处理和命令系统",
"author": "XBoard Team",
"require": {
"xboard": ">=1.0.0"
},
"config": {
"enable_ticket_notify": {
"type": "boolean",
"default": true,
"label": "开启工单通知",
"description": "是否开启工单创建和回复的 Telegram 通知功能"
},
"enable_payment_notify": {
"type": "boolean",
"default": true,
"label": "开启支付通知",
"description": "是否开启支付成功的 Telegram 通知功能"
},
"start_welcome_title": {
"type": "string",
"default": "🎉 欢迎使用 XBoard Telegram Bot!",
"label": "欢迎标题",
"description": "/start 命令显示的欢迎标题"
},
"start_bot_description": {
"type": "text",
"default": "🤖 我是您的专属助手,可以帮助您:\\n• 绑定您的 XBoard 账号\\n• 查看流量使用情况\\n• 获取最新订阅链接\\n• 管理账号绑定状态",
"label": "机器人描述",
"description": "/start 命令显示的机器人功能介绍"
},
"start_bind_guide": {
"type": "text",
"default": "🔗 请先绑定您的 XBoard 账号:\\n1. 登录您的 XBoard 账户\\n2. 复制您的订阅链接\\n3. 发送 /bind + 订阅链接",
"label": "绑定指导",
"description": "未绑定用户显示的绑定指导文本"
},
"start_unbind_guide": {
"type": "text",
"default": "📋 可用命令:\\n/traffic - 查看流量使用情况\\n/getlatesturl - 获取订阅链接\\n/unbind - 解绑账号",
"label": "已绑定用户命令列表",
"description": "已绑定用户显示的命令列表"
},
"start_bind_commands": {
"type": "text",
"default": "📋 可用命令:\\n/bind [订阅链接] - 绑定账号",
"label": "未绑定用户命令列表",
"description": "未绑定用户显示的命令列表"
},
"start_footer": {
"type": "text",
"default": "💡 提示:所有命令都需要在私聊中使用",
"label": "底部提示",
"description": "/start 命令底部的提示信息"
},
"help_text": {
"type": "text",
"default": "请使用以下命令:\\n/bind - 绑定账号\\n/traffic - 查看流量\\n/getlatesturl - 获取最新链接",
"label": "帮助文本",
"description": "未知命令时显示的帮助文本"
}
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -85,6 +85,9 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"cancel": "Cancel",
"submit": "Submit"
},
"sections": {
"payment_config": "Payment Configuration"
},
"messages": {
"success": "Saved successfully"
}
@ -172,10 +175,9 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"search": {
"placeholder": "Search plugin name or description..."
},
"category": {
"placeholder": "Select Category",
"all": "All",
"other": "Other"
"type": {
"placeholder": "Select Plugin Type",
"all": "All Types"
},
"tabs": {
"all": "All Plugins",
@ -184,14 +186,21 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
"disabled": "Disabled",
"not_installed": "Not Installed",
"protected": "Protected",
"filter_placeholder": "Install Status",
"all": "All Status",
"installed": "Installed",
"available": "Available"
},
"button": {
"install": "Install",
"config": "Configure",
"enable": "Enable",
"disable": "Disable",
"uninstall": "Uninstall"
"uninstall": "Uninstall",
"readme": "View Documentation"
},
"upload": {
"button": "Upload Plugin",
@ -221,6 +230,9 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"save": "Save",
"cancel": "Cancel"
},
"readme": {
"title": "Plugin Documentation"
},
"author": "Author",
"messages": {
"installSuccess": "Plugin installed successfully",
@ -767,8 +779,8 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"success": "Webhook set successfully"
},
"bot_enable": {
"title": "Enable Bot Notifications",
"description": "When enabled, the bot will send basic notifications to administrators and users who have bound their Telegram accounts."
"title": "Enable Telegram Binding Guide",
"description": "When enabled, a Telegram binding guide will be displayed on the user side to help users bind their Telegram accounts for notifications."
},
"discuss_link": {
"title": "Group Link",

View File

@ -85,6 +85,9 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"cancel": "取消",
"submit": "提交"
},
"sections": {
"payment_config": "支付配置"
},
"messages": {
"success": "保存成功"
}
@ -172,10 +175,9 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"search": {
"placeholder": "搜索插件名称或描述..."
},
"category": {
"placeholder": "选择分类",
"all": "全部",
"other": "其他"
"type": {
"placeholder": "选择插件类型",
"all": "全部类型"
},
"tabs": {
"all": "所有插件",
@ -184,14 +186,21 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
},
"status": {
"enabled": "已启用",
"disabled": "已禁用"
"disabled": "已禁用",
"not_installed": "未安装",
"protected": "受保护",
"filter_placeholder": "安装状态",
"all": "全部状态",
"installed": "已安装",
"available": "可安装"
},
"button": {
"install": "安装",
"config": "配置",
"enable": "启用",
"disable": "禁用",
"uninstall": "卸载"
"uninstall": "卸载",
"readme": "查看文档"
},
"upload": {
"button": "上传插件",
@ -221,6 +230,9 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"save": "保存",
"cancel": "取消"
},
"readme": {
"title": "插件文档"
},
"author": "作者",
"messages": {
"installSuccess": "插件安装成功",
@ -687,8 +699,8 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"success": "Webhook 设置成功"
},
"bot_enable": {
"title": "启用机器人通知",
"description": "开启后机器人将向管理员和已绑定Telegram的用户发送基础通知。"
"title": "启用Telegram绑定引导",
"description": "开启后将在用户端显示Telegram绑定引导,帮助用户绑定Telegram账户以接收通知。"
},
"discuss_link": {
"title": "群组链接",