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 02d853d46a
commit 58868268dd
56 changed files with 3677 additions and 1329 deletions
+68
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 目录状态正常,无需还原。");
}
}
}
+10 -2
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('更新完毕,队列服务已重启,你无需进行任何操作。');
}
}
+4
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');
}
}
+11 -8
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;
}
}
@@ -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;
}
}
@@ -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);
}
}
+38 -108
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;
}
}
@@ -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]]);
}
@@ -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);
}
}
@@ -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' => '插件删除成功'
]);
@@ -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);
});
+1
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']);
+45 -1
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();
}
}
+1
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
-9
View File
@@ -1,9 +0,0 @@
*
!.gitignore
!AlipayF2F.php
!BTCPay.php
!Coinbase.php
!CoinPayments.php
!EPay.php
!MGate.php
-99
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;
}
}
}
-81
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']
];
}
}
-50
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, '绑定成功');
}
}
@@ -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');
}
}
@@ -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);
}
}
-28
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');
}
}
-27
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');
}
}
-15
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();
}
}
+64 -18
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);
}
}
+29
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);
}
}
}
}
/**
* 中断当前请求并返回新的响应
*
+62 -3
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}");
}
}
}
}
+115 -40
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()}");
}
}
}
+40 -6
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)
{
-2
View File
@@ -134,9 +134,7 @@ class Setting
*/
private function flush(): void
{
// 清除共享的Redis缓存
$this->cache->forget(self::CACHE_KEY);
// 清除当前请求的实例内存缓存
$this->loadedSettings = null;
}
}
-2
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));
@@ -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');
});
}
};
-164
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;
}
}
+105
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;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "AlipayF2F",
"code": "alipay_f2f",
"type": "payment",
"version": "1.0.0",
"description": "AlipayF2F payment plugin",
"author": "XBoard Team"
}
@@ -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;
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "BTCPay",
"code": "btcpay",
"type": "payment",
"version": "1.0.0",
"description": "BTCPay payment plugin",
"author": "XBoard Team"
}
@@ -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';
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "CoinPayments",
"code": "coin_payments",
"type": "payment",
"version": "1.0.0",
"description": "CoinPayments payment plugin",
"author": "XBoard Team"
}
@@ -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;
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Coinbase",
"code": "coinbase",
"type": "payment",
"version": "1.0.0",
"description": "Coinbase payment plugin",
"author": "XBoard Team"
}
+100
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
View File
@@ -0,0 +1,8 @@
{
"name": "EPay",
"code": "epay",
"type": "payment",
"version": "1.0.0",
"description": "EPay payment plugin",
"author": "XBoard Team"
}
@@ -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']
];
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "MGate",
"code": "mgate",
"type": "payment",
"version": "1.0.0",
"description": "MGate payment plugin",
"author": "XBoard Team"
}
+128
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;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Smogate",
"code": "smogate",
"type": "payment",
"version": "1.0.0",
"description": "Smogate payment plugin",
"author": "XBoard Team"
}
+425
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;
}
}
+84
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`
+66
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
+16 -16
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1829 -317
View File
File diff suppressed because one or more lines are too long
+20 -8
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",
+20 -8
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": "群组链接",