feat: add unified webhook for wecom

This commit is contained in:
wangcham
2025-11-05 14:23:10 +00:00
parent a0dec39905
commit ceb38d91b4
13 changed files with 339 additions and 87 deletions
+38 -14
View File
@@ -21,6 +21,7 @@ class WecomClient:
EncodingAESKey: str, EncodingAESKey: str,
contacts_secret: str, contacts_secret: str,
logger: None, logger: None,
unified_mode: bool = False,
): ):
self.corpid = corpid self.corpid = corpid
self.secret = secret self.secret = secret
@@ -31,13 +32,18 @@ class WecomClient:
self.access_token = '' self.access_token = ''
self.secret_for_contacts = contacts_secret self.secret_for_contacts = contacts_secret
self.logger = logger self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__) self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', # 只有在非统一模式下才注册独立路由
'handle_callback', if not self.unified_mode:
self.handle_callback_request, self.app.add_url_rule(
methods=['GET', 'POST'], '/callback/command',
) 'handle_callback',
self.handle_callback_request,
methods=['GET', 'POST'],
)
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -168,25 +174,43 @@ class WecomClient:
raise Exception('Failed to send message: ' + str(data)) raise Exception('Failed to send message: ' + str(data))
async def handle_callback_request(self): async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request)
async def handle_unified_webhook(self, req):
"""处理回调请求(统一 webhook 模式,显式传递 request)。
Args:
req: Quart Request 对象
Returns:
响应数据
""" """
处理回调请求,包括 GET 验证和 POST 消息接收。 return await self._handle_callback_internal(req)
async def _handle_callback_internal(self, req):
"""
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
Args:
req: Quart Request 对象
""" """
try: try:
msg_signature = request.args.get('msg_signature') msg_signature = req.args.get('msg_signature')
timestamp = request.args.get('timestamp') timestamp = req.args.get('timestamp')
nonce = request.args.get('nonce') nonce = req.args.get('nonce')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
if request.method == 'GET': if req.method == 'GET':
echostr = request.args.get('echostr') echostr = req.args.get('echostr')
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0: if ret != 0:
await self.logger.error('验证失败') await self.logger.error('验证失败')
raise Exception(f'验证失败,错误码: {ret}') raise Exception(f'验证失败,错误码: {ret}')
return reply_echo_str return reply_echo_str
elif request.method == 'POST': elif req.method == 'POST':
encrypt_msg = await request.data encrypt_msg = await req.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0: if ret != 0:
await self.logger.error('消息解密失败') await self.logger.error('消息解密失败')
@@ -18,7 +18,8 @@ class BotsRouterGroup(group.RouterGroup):
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE']) @self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'])
async def _(bot_uuid: str) -> str: async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
bot = await self.ap.bot_service.get_bot(bot_uuid) # 返回运行时信息,包括webhook地址等
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
if bot is None: if bot is None:
return self.http_status(404, -1, 'bot not found') return self.http_status(404, -1, 'bot not found')
return self.success(data={'bot': bot}) return self.success(data={'bot': bot})
@@ -0,0 +1,69 @@
"""统一 Webhook 路由组
处理所有外部平台的回调请求,统一在一个端口上通过 bot_uuid 路由到对应的适配器。
路由格式:
- /bots/{bot_uuid} - 处理 bot 的 webhook 回调
- /bots/{bot_uuid}/{path} - 处理带子路径的 webhook 回调
Example:
http://your-server.com:5300/bots/550e8400-e29b-41d4-a716-446655440000
"""
from __future__ import annotations
import quart
import traceback
from .. import group
@group.group_class('webhooks', '/bots')
class WebhookRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook(bot_uuid: str):
"""处理 bot webhook 回调(无子路径)"""
return await self._dispatch_webhook(bot_uuid, '')
@self.route('/<bot_uuid>/<path:path>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def handle_webhook_with_path(bot_uuid: str, path: str):
"""处理 bot webhook 回调(带子路径)"""
return await self._dispatch_webhook(bot_uuid, path)
async def _dispatch_webhook(self, bot_uuid: str, path: str):
"""分发 webhook 请求到对应的 bot adapter
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
Returns:
适配器返回的响应
"""
try:
# 通过 UUID 获取运行时 bot
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if not runtime_bot:
return quart.jsonify({'error': 'Bot not found'}), 404
if not runtime_bot.enable:
return quart.jsonify({'error': 'Bot is disabled'}), 403
# 检查 adapter 是否支持统一 webhook
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
# 调用 adapter 的 handle_unified_webhook 方法,显式传递 request 对象
response = await runtime_bot.adapter.handle_unified_webhook(
bot_uuid=bot_uuid,
path=path,
request=quart.request,
)
return response
except Exception as e:
self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
return quart.jsonify({'error': str(e)}), 500
+11
View File
@@ -58,6 +58,17 @@ class BotService:
if runtime_bot is not None: if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# 为支持统一 webhook 的适配器生成 webhook URL
# 目前只有 wecom 支持
if persistence_bot['adapter'] == 'wecom':
api_port = self.ap.instance_config.data['api']['port']
webhook_url = f"/bots/{bot_uuid}"
adapter_runtime_values['webhook_url'] = webhook_url
adapter_runtime_values['webhook_full_url'] = f"http://<Your-Server-IP>:{api_port}{webhook_url}"
else:
adapter_runtime_values['webhook_url'] = None
adapter_runtime_values['webhook_full_url'] = None
persistence_bot['adapter_runtime_values'] = adapter_runtime_values persistence_bot['adapter_runtime_values'] = adapter_runtime_values
return persistence_bot return persistence_bot
+4
View File
@@ -232,6 +232,10 @@ class PlatformManager:
logger, logger,
) )
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook
if hasattr(adapter_inst, 'set_bot_uuid'):
adapter_inst.set_bot_uuid(bot_entity.uuid)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize() await runtime_bot.initialize()
+39 -8
View File
@@ -132,6 +132,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message_converter: WecomMessageConverter = WecomMessageConverter() message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter() event_converter: WecomEventConverter = WecomEventConverter()
config: dict config: dict
bot_uuid: str = None
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
# 校验必填项 # 校验必填项
@@ -142,11 +143,12 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'EncodingAESKey', 'EncodingAESKey',
'contacts_secret', 'contacts_secret',
] ]
missing_keys = [key for key in required_keys if key not in config] missing_keys = [key for key in required_keys if key not in config]
if missing_keys: if missing_keys:
raise Exception(f'Wecom 缺少配置项: {missing_keys}') raise Exception(f'Wecom 缺少配置项: {missing_keys}')
# 创建运行时 bot 对象 # 创建运行时 bot 对象,始终使用统一 webhook 模式
bot = WecomClient( bot = WecomClient(
corpid=config['corpid'], corpid=config['corpid'],
secret=config['secret'], secret=config['secret'],
@@ -154,6 +156,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'], EncodingAESKey=config['EncodingAESKey'],
contacts_secret=config['contacts_secret'], contacts_secret=config['contacts_secret'],
logger=logger, logger=logger,
unified_mode=True,
) )
@@ -164,6 +167,9 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id="", bot_account_id="",
) )
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
self.bot_uuid = bot_uuid
async def reply_message( async def reply_message(
self, self,
@@ -217,16 +223,41 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif event_type == platform_events.GroupMessage: elif event_type == platform_events.GroupMessage:
pass pass
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
Args:
bot_uuid: Bot 的 UUID
path: 子路径(如果有的话)
request: Quart Request 对象
Returns:
响应数据
"""
return await self.bot.handle_unified_webhook(request)
async def run_async(self): async def run_async(self):
async def shutdown_trigger_placeholder(): # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"企业微信 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在企业微信后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await keep_alive()
await self.bot.run_task(
host=self.config['host'],
port=self.config['port'],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool: async def kill(self) -> bool:
return False return False
-17
View File
@@ -11,23 +11,6 @@ metadata:
icon: wecom.png icon: wecom.png
spec: spec:
config: config:
- name: host
label:
en_US: Host
zh_Hans: 监听主机
description:
en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0
zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: "0.0.0.0"
- name: port
label:
en_US: Port
zh_Hans: 监听端口
type: integer
required: true
default: 2290
- name: corpid - name: corpid
label: label:
en_US: Corpid en_US: Corpid
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
IChooseAdapterEntity, IChooseAdapterEntity,
IPipelineEntity, IPipelineEntity,
@@ -112,12 +112,77 @@ export default function BotForm({
IDynamicFormItemSchema[] IDynamicFormItemSchema[]
>([]); >([]);
const [, setIsLoading] = useState<boolean>(false); const [, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
const copyToClipboard = () => {
console.log('[Copy] Attempting to copy from input element');
const inputElement = webhookInputRef.current;
if (!inputElement) {
console.error('[Copy] Input element not found');
toast.error(t('common.copyFailed'));
return;
}
try {
// 确保input元素可见且未被禁用
inputElement.disabled = false;
inputElement.readOnly = false;
// 聚焦并选中所有文本
inputElement.focus();
inputElement.select();
// 尝试使用现代API
if (navigator.clipboard && navigator.clipboard.writeText) {
console.log('[Copy] Using Clipboard API with input value:', inputElement.value);
navigator.clipboard.writeText(inputElement.value)
.then(() => {
console.log('[Copy] Clipboard API success');
inputElement.blur(); // 取消选中
inputElement.readOnly = true;
toast.success(t('bots.webhookUrlCopied'));
})
.catch((err) => {
console.error('[Copy] Clipboard API failed, trying execCommand:', err);
// 降级到execCommand
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
});
} else {
// 直接使用execCommand
console.log('[Copy] Using execCommand with input value:', inputElement.value);
const successful = document.execCommand('copy');
console.log('[Copy] execCommand result:', successful);
inputElement.blur();
inputElement.readOnly = true;
if (successful) {
toast.success(t('bots.webhookUrlCopied'));
} else {
toast.error(t('common.copyFailed'));
}
}
} catch (err) {
console.error('[Copy] Copy failed:', err);
inputElement.readOnly = true;
toast.error(t('common.copyFailed'));
}
};
function setBotFormValues() { function setBotFormValues() {
initBotFormComponent().then(() => { initBotFormComponent().then(() => {
// 拉取初始化表单信息 // 拉取初始化表单信息
@@ -133,12 +198,20 @@ export default function BotForm({
console.log('form', form.getValues()); console.log('form', form.getValues());
handleAdapterSelect(val.adapter); handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config); // dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.getBotConfigError') + err.message); toast.error(t('bots.getBotConfigError') + err.message);
}); });
} else { } else {
form.reset(); form.reset();
setWebhookUrl('');
} }
}); });
} }
@@ -213,7 +286,7 @@ export default function BotForm({
async function getBotConfig( async function getBotConfig(
botId: string, botId: string,
): Promise<z.infer<typeof formSchema>> { ): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
httpClient httpClient
.getBot(botId) .getBot(botId)
@@ -226,6 +299,9 @@ export default function BotForm({
adapter_config: bot.adapter_config, adapter_config: bot.adapter_config,
enable: bot.enable ?? true, enable: bot.enable ?? true,
use_pipeline_uuid: bot.use_pipeline_uuid ?? '', use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
webhook_full_url: bot.adapter_runtime_values
? (bot.adapter_runtime_values as any).webhook_full_url
: undefined,
}); });
}) })
.catch((err) => { .catch((err) => {
@@ -369,51 +445,83 @@ export default function BotForm({
<div className="space-y-4"> <div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */} {/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && ( {initBotId && (
<div className="flex items-center gap-6"> <>
<FormField <div className="flex items-center gap-6">
control={form.control} <FormField
name="enable" control={form.control}
render={({ field }) => ( name="enable"
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]"> render={({ field }) => (
<FormLabel>{t('common.enable')}</FormLabel> <FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormControl> <FormLabel>{t('common.enable')}</FormLabel>
<Switch <FormControl>
checked={field.value} <Switch
onCheckedChange={field.onChange} checked={field.value}
/> onCheckedChange={field.onChange}
</FormControl> />
</FormItem> </FormControl>
)} </FormItem>
/> )}
/>
<FormField <FormField
control={form.control} control={form.control}
name="use_pipeline_uuid" name="use_pipeline_uuid"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]"> <FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel> <FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} {...field}> <Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue <SelectValue
placeholder={t('bots.selectPipeline')} placeholder={t('bots.selectPipeline')}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent className="fixed z-[1000]"> <SelectContent className="fixed z-[1000]">
<SelectGroup> <SelectGroup>
{pipelineNameList.map((item) => ( {pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} {item.label}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
{/* Webhook 地址显示(仅企业微信) */}
{webhookUrl && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
ref={webhookInputRef}
value={webhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
// 点击输入框时自动全选
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={copyToClipboard}
>
{t('common.copy')}
</Button>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
)} )}
<FormField <FormField
+1
View File
@@ -141,6 +141,7 @@ export interface Bot {
use_pipeline_uuid?: string; use_pipeline_uuid?: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
adapter_runtime_values?: object;
} }
export interface ApiRespKnowledgeBases { export interface ApiRespKnowledgeBases {
+5
View File
@@ -39,7 +39,9 @@ const enUS = {
deleteSuccess: 'Deleted successfully', deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ', deleteError: 'Delete failed: ',
addRound: 'Add Round', addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully', copySuccess: 'Copy Successfully',
copyFailed: 'Copy Failed',
test: 'Test', test: 'Test',
forgotPassword: 'Forgot Password?', forgotPassword: 'Forgot Password?',
loading: 'Loading...', loading: 'Loading...',
@@ -149,6 +151,9 @@ const enUS = {
log: 'Log', log: 'Log',
configuration: 'Configuration', configuration: 'Configuration',
logs: 'Logs', logs: 'Logs',
webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint: 'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
}, },
plugins: { plugins: {
title: 'Plugins', title: 'Plugins',
+5
View File
@@ -40,7 +40,9 @@ const jaJP = {
deleteSuccess: '削除に成功しました', deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:', deleteError: '削除に失敗しました:',
addRound: 'ラウンドを追加', addRound: 'ラウンドを追加',
copy: 'コピー',
copySuccess: 'コピーに成功しました', copySuccess: 'コピーに成功しました',
copyFailed: 'コピーに失敗しました',
test: 'テスト', test: 'テスト',
forgotPassword: 'パスワードを忘れた?', forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...', loading: '読み込み中...',
@@ -151,6 +153,9 @@ const jaJP = {
log: 'ログ', log: 'ログ',
configuration: '設定', configuration: '設定',
logs: 'ログ', logs: 'ログ',
webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint: '入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
}, },
plugins: { plugins: {
title: 'プラグイン', title: 'プラグイン',
+5
View File
@@ -39,7 +39,9 @@ const zhHans = {
deleteSuccess: '删除成功', deleteSuccess: '删除成功',
deleteError: '删除失败:', deleteError: '删除失败:',
addRound: '添加回合', addRound: '添加回合',
copy: '复制',
copySuccess: '复制成功', copySuccess: '复制成功',
copyFailed: '复制失败',
test: '测试', test: '测试',
forgotPassword: '忘记密码?', forgotPassword: '忘记密码?',
loading: '加载中...', loading: '加载中...',
@@ -146,6 +148,9 @@ const zhHans = {
log: '日志', log: '日志',
configuration: '配置', configuration: '配置',
logs: '日志', logs: '日志',
webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint: '点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
}, },
plugins: { plugins: {
title: '插件管理', title: '插件管理',
+5
View File
@@ -39,7 +39,9 @@ const zhHant = {
deleteSuccess: '刪除成功', deleteSuccess: '刪除成功',
deleteError: '刪除失敗:', deleteError: '刪除失敗:',
addRound: '新增回合', addRound: '新增回合',
copy: '複製',
copySuccess: '複製成功', copySuccess: '複製成功',
copyFailed: '複製失敗',
test: '測試', test: '測試',
forgotPassword: '忘記密碼?', forgotPassword: '忘記密碼?',
loading: '載入中...', loading: '載入中...',
@@ -146,6 +148,9 @@ const zhHant = {
log: '日誌', log: '日誌',
configuration: '設定', configuration: '設定',
logs: '日誌', logs: '日誌',
webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint: '點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
}, },
plugins: { plugins: {
title: '外掛管理', title: '外掛管理',