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

View File

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

View File

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

150
plugins/Btcpay/Plugin.php Normal file
View File

@@ -0,0 +1,150 @@
<?php
namespace Plugin\Btcpay;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$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接口所在网址',
'type' => 'string',
'required' => true,
'description' => '包含最后的斜杠例如https://your-btcpay.com/'
],
'btcpay_storeId' => [
'label' => 'Store ID',
'type' => 'string',
'required' => true,
'description' => 'BTCPay商店标识符'
],
'btcpay_api_key' => [
'label' => 'API KEY',
'type' => 'string',
'required' => true,
'description' => '个人设置中的API KEY(非商店设置中的)'
],
'btcpay_webhook_key' => [
'label' => 'WEBHOOK KEY',
'type' => 'string',
'required' => true,
'description' => 'Webhook通知密钥'
],
];
}
public function pay($order): array
{
$params = [
'jsonResponse' => true,
'amount' => sprintf('%.2f', $order['total_amount'] / 100),
'currency' => 'CNY',
'metadata' => [
'orderId' => $order['trade_no']
]
];
$params_string = @json_encode($params);
$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,
'data' => $ret['checkoutLink'],
];
}
public function notify($params): array|bool
{
$payload = trim(request()->getContent());
$headers = getallheaders();
$headerName = 'Btcpay-Sig';
$signraturHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$json_param = json_decode($payload, true);
$computedSignature = "sha256=" . \hash_hmac('sha256', $payload, $this->getConfig('btcpay_webhook_key'));
if (!$this->hashEqual($signraturHeader, $computedSignature)) {
throw new ApiException('HMAC signature does not match', 400);
}
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'header' => "Authorization:" . "token " . $this->getConfig('btcpay_api_key') . "\r\n"
)
));
$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)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
array('Authorization:' . 'token ' . $this->getConfig('btcpay_api_key'), 'Content-Type: application/json')
);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
private function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
}
if (strlen($str1) != strlen($str2)) {
return false;
} else {
$res = $str1 ^ $str2;
$ret = 0;
for ($i = strlen($res) - 1; $i >= 0; $i--) {
$ret |= ord($res[$i]);
}
return !$ret;
}
}
}

View File

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

View File

@@ -0,0 +1,112 @@
<?php
namespace Plugin\CoinPayments;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$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
{
return [
'coinpayments_merchant_id' => [
'label' => 'Merchant ID',
'type' => 'string',
'required' => true,
'description' => '商户 ID填写您在 Account Settings 中得到的 ID'
],
'coinpayments_ipn_secret' => [
'label' => 'IPN Secret',
'type' => 'string',
'required' => true,
'description' => '通知密钥,填写您在 Merchant Settings 中自行设置的值'
],
'coinpayments_currency' => [
'label' => '货币代码',
'type' => 'string',
'required' => true,
'description' => '填写您的货币代码(大写),建议与 Merchant Settings 中的值相同'
]
];
}
public function pay($order): array
{
$parseUrl = parse_url($order['return_url']);
$port = isset($parseUrl['port']) ? ":{$parseUrl['port']}" : '';
$successUrl = "{$parseUrl['scheme']}://{$parseUrl['host']}{$port}";
$params = [
'cmd' => '_pay_simple',
'reset' => 1,
'merchant' => $this->getConfig('coinpayments_merchant_id'),
'item_name' => $order['trade_no'],
'item_number' => $order['trade_no'],
'want_shipping' => 0,
'currency' => $this->getConfig('coinpayments_currency'),
'amountf' => sprintf('%.2f', $order['total_amount'] / 100),
'success_url' => $successUrl,
'cancel_url' => $order['return_url'],
'ipn_url' => $order['notify_url']
];
$params_string = http_build_query($params);
return [
'type' => 1,
'data' => 'https://www.coinpayments.net/index.php?' . $params_string
];
}
public function notify($params): array|string
{
if (!isset($params['merchant']) || $params['merchant'] != trim($this->getConfig('coinpayments_merchant_id'))) {
throw new ApiException('No or incorrect Merchant ID passed');
}
$headers = getallheaders();
ksort($params);
reset($params);
$request = stripslashes(http_build_query($params));
$headerName = 'Hmac';
$signHeader = isset($headers[$headerName]) ? $headers[$headerName] : '';
$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);
}
$status = $params['status'];
if ($status >= 100 || $status == 2) {
return [
'trade_no' => $params['item_number'],
'callback_no' => $params['txn_id'],
'custom_result' => 'IPN OK'
];
} else if ($status < 0) {
throw new ApiException('Payment Timed Out or Error');
} else {
return 'IPN OK: pending';
}
}
}

View File

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

138
plugins/Coinbase/Plugin.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Plugin\Coinbase;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
class Plugin extends AbstractPlugin implements PaymentInterface
{
public function boot(): void
{
$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
{
return [
'coinbase_url' => [
'label' => '接口地址',
'type' => 'string',
'required' => true,
'description' => 'Coinbase Commerce API地址'
],
'coinbase_api_key' => [
'label' => 'API KEY',
'type' => 'string',
'required' => true,
'description' => 'Coinbase Commerce API密钥'
],
'coinbase_webhook_key' => [
'label' => 'WEBHOOK KEY',
'type' => 'string',
'required' => true,
'description' => 'Webhook签名验证密钥'
],
];
}
public function pay($order): array
{
$params = [
'name' => '订阅套餐',
'description' => '订单号 ' . $order['trade_no'],
'pricing_type' => 'fixed_price',
'local_price' => [
'amount' => sprintf('%.2f', $order['total_amount'] / 100),
'currency' => 'CNY'
],
'metadata' => [
"outTradeNo" => $order['trade_no'],
],
];
$params_string = http_build_query($params);
$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'],
];
}
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->getConfig('coinbase_webhook_key'));
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)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
curl_setopt(
$ch,
CURLOPT_HTTPHEADER,
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;
}
private function hashEqual($str1, $str2)
{
if (function_exists('hash_equals')) {
return \hash_equals($str1, $str2);
}
if (strlen($str1) != strlen($str2)) {
return false;
} else {
$res = $str1 ^ $str2;
$ret = 0;
for ($i = strlen($res) - 1; $i >= 0; $i--) {
$ret |= ord($res[$i]);
}
return !$ret;
}
}
}

View File

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

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

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

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

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

124
plugins/Mgate/Plugin.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
namespace Plugin\Mgate;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Exceptions\ApiException;
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['MGate'] = [
'name' => $this->getConfig('display_name', 'MGate'),
'icon' => $this->getConfig('icon', '🏛️'),
'plugin_code' => $this->getPluginCode(),
'type' => 'plugin'
];
}
return $methods;
});
}
public function form(): array
{
return [
'mgate_url' => [
'label' => 'API地址',
'type' => 'string',
'required' => true,
'description' => 'MGate支付网关API地址'
],
'mgate_app_id' => [
'label' => 'APP ID',
'type' => 'string',
'required' => true,
'description' => 'MGate应用标识符'
],
'mgate_app_secret' => [
'label' => 'App Secret',
'type' => 'string',
'required' => true,
'description' => 'MGate应用密钥'
],
'mgate_source_currency' => [
'label' => '源货币',
'type' => 'string',
'description' => '默认CNY源货币类型'
]
];
}
public function pay($order): array
{
$params = [
'out_trade_no' => $order['trade_no'],
'total_amount' => $order['total_amount'],
'notify_url' => $order['notify_url'],
'return_url' => $order['return_url']
];
if ($this->getConfig('mgate_source_currency')) {
$params['source_currency'] = $this->getConfig('mgate_source_currency');
}
$params['app_id'] = $this->getConfig('mgate_app_id');
ksort($params);
$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->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;
throw new ApiException($errors[array_keys($errors)[0]][0]);
}
if (isset($result->message)) {
throw new ApiException($result->message);
}
throw new ApiException('未知错误');
}
$curl->close();
if (!isset($result->data->trade_no)) {
throw new ApiException('接口请求失败');
}
return [
'type' => 1,
'data' => $result->data->pay_url
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign']);
ksort($params);
reset($params);
$str = http_build_query($params) . $this->getConfig('mgate_app_secret');
if ($sign !== md5($str)) {
return false;
}
return [
'trade_no' => $params['out_trade_no'],
'callback_no' => $params['trade_no']
];
}
}

View File

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

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

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

View File

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

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

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

View File

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

View File

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