添加插件管理前端

This commit is contained in:
John Smith 2024-03-07 21:25:02 +08:00
parent e1d0473627
commit 29ff61748b
12 changed files with 303 additions and 25 deletions

View File

@ -24,4 +24,7 @@ data/emoticons/*
!data/custom_public/ !data/custom_public/
data/custom_public/* data/custom_public/*
!data/custom_public/README.txt !data/custom_public/README.txt
!data/plugins/
data/plugins/*
!data/plugins/.gitkeep
log/* log/*

View File

@ -24,6 +24,8 @@ class _AdminHandlerBase(api.base.ApiHandler):
if not cfg.enable_admin_plugins: if not cfg.enable_admin_plugins:
raise tornado.web.HTTPError(403) raise tornado.web.HTTPError(403)
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
super().prepare() super().prepare()
def _get_plugin(self): def _get_plugin(self):
@ -60,16 +62,21 @@ class EnableHandler(_AdminHandlerBase):
enabled = bool(self.json_args.get('enabled', False)) enabled = bool(self.json_args.get('enabled', False))
plugin = self._get_plugin() plugin = self._get_plugin()
old_enabled = plugin.enabled
is_switch_success = True
msg = '' msg = ''
try: try:
plugin.enabled = enabled plugin.enabled = enabled
except services.plugin.StartTooFrequently as e: except services.plugin.SwitchTooFrequently as e:
is_switch_success = False
msg = str(e) msg = str(e)
plugin.enabled = False plugin.enabled = old_enabled
except services.plugin.StartPluginError as e: except services.plugin.SwitchPluginError as e:
is_switch_success = False
msg = str(e) msg = str(e)
self.write({ self.write({
'enabled': plugin.enabled, 'enabled': plugin.enabled,
'isSwitchSuccess': is_switch_success,
'msg': msg 'msg': msg
}) })

View File

@ -0,0 +1,16 @@
import axios from 'axios'
export async function getPlugins() {
return (await axios.get('/api/plugin/plugins')).data
}
export async function setEnabled(pluginId, enabled) {
return (await axios.post('/api/plugin/enable_plugin', {
pluginId,
enabled
})).data
}
export async function openAdminUi(pluginId) {
return (await axios.post('/api/plugin/open_admin_ui', { pluginId })).data
}

View File

@ -3,6 +3,7 @@ export default {
home: 'Home', home: 'Home',
stylegen: 'Style Generator', stylegen: 'Style Generator',
help: 'Help', help: 'Help',
plugins: 'Plugins',
links: 'Links', links: 'Links',
projectAddress: 'Project Address', projectAddress: 'Project Address',
discussion: 'Discussions', discussion: 'Discussions',
@ -169,5 +170,25 @@ export default {
sendGift: 'Sent {giftName}x{num}', sendGift: 'Sent {giftName}x{num}',
membershipTitle: 'New member', membershipTitle: 'New member',
tickerMembership: 'Member' tickerMembership: 'Member'
} },
plugins: {
plugins: 'Plugins',
help: 'Help',
helpContent: `\
<p>Plugins can add more functionality to blivechat, such as message logging, text to speech, song requests, etc. Plugins may
be developed by third-party authors, and the security and quality are the responsibility of the plugin author. You can
find some published plugins in <a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
>GitHub Discussions</a></p>
<p>Plugin installation method: Put the extracted plugin directory into the "data/plugins" directory, then restart blivechat</p>
<p>Notes: Most plugins require enabling the "Relay messages by the server" option and connecting to the room
in order to receive messages</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">
Plugin development documentation</a></p>
`,
author: 'Author: ',
disabledByServer: 'Administration for plugins is disabled by the server',
admin: 'Admin',
connected: 'Connected',
unconnected: 'Unconnected',
},
} }

View File

@ -3,6 +3,7 @@ export default {
home: 'トップページ', home: 'トップページ',
stylegen: 'スタイルジェネレータ', stylegen: 'スタイルジェネレータ',
help: 'ヘルプ', help: 'ヘルプ',
plugins: 'プラグイン',
links: 'リンク', links: 'リンク',
projectAddress: 'プロジェクトアドレス', projectAddress: 'プロジェクトアドレス',
discussion: '議論', discussion: '議論',
@ -169,5 +170,25 @@ export default {
sendGift: '{giftName}x{num} を贈りました', sendGift: '{giftName}x{num} を贈りました',
membershipTitle: '新規メンバー', membershipTitle: '新規メンバー',
tickerMembership: 'メンバー' tickerMembership: 'メンバー'
} },
plugins: {
plugins: 'プラグイン',
help: 'ヘルプ',
helpContent: `\
<p>プラグインはメッセージの記録テキスト読み上げ曲リクエストなどblivechatにさらなる機能を追加できます
プラグインはサードパーティの作者によって開発される場合がありセキュリティと品質はプラグイン作者の責任です
いくつかの公開されたプラグインは<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
>GitHub Discussions</a></p>
<p>プラグインのインストール方法抽出されたプラグインディレクトリをdata/pluginsディレクトリに配置しblivechatを再起動します</p>
<p>注意ほとんどのプラグインはサーバを介してメッセージを転送するオプションを有効にしメッセージを受信するために
ルームに接続する必要があります</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F"
>プラグイン開発ドキュメント</a></p>
`,
author: '作者:',
disabledByServer: 'プラグインの管理は、サーバーによって無効にされています',
admin: '管理',
connected: '接続済み',
unconnected: '未接続',
},
} }

View File

@ -3,6 +3,7 @@ export default {
home: '首页', home: '首页',
stylegen: '样式生成器', stylegen: '样式生成器',
help: '帮助', help: '帮助',
plugins: '插件',
links: '常用链接', links: '常用链接',
projectAddress: '项目地址', projectAddress: '项目地址',
discussion: '反馈 / 交流', discussion: '反馈 / 交流',
@ -169,5 +170,22 @@ export default {
sendGift: '赠送 {giftName}x{num}', sendGift: '赠送 {giftName}x{num}',
membershipTitle: '新会员', membershipTitle: '新会员',
tickerMembership: '会员' tickerMembership: '会员'
} },
plugins: {
plugins: '插件',
help: '帮助',
helpContent: `\
<p>插件可以给blivechat添加更多功能比如消息日志语音播报点歌等插件可能由第三方作者开发其安全性和质量由插件作者负责
你可以在<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
>GitHub Discussions</a></p>
<p>插件安装方法把解压后的插件目录放到data/plugins目录然后重启blivechat</p>
<p>注意事项大部分插件需要开启通过服务器转发消息并且连接到房间才能接收消息</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">插件开发文档</a></p>
`,
author: '作者:',
disabledByServer: '已被服务器禁用',
admin: '管理',
connected: '已连接',
unconnected: '未连接',
},
} }

View File

@ -16,6 +16,9 @@
<el-menu-item :index="$router.resolve({ name: 'help' }).href"> <el-menu-item :index="$router.resolve({ name: 'help' }).href">
<i class="el-icon-question"></i>{{ $t('sidebar.help') }} <i class="el-icon-question"></i>{{ $t('sidebar.help') }}
</el-menu-item> </el-menu-item>
<el-menu-item :index="$router.resolve({ name: 'plugins' }).href">
<i class="el-icon-magic-stick"></i>{{ $t('sidebar.plugins') }}
</el-menu-item>
<el-submenu index="1"> <el-submenu index="1">
<template slot="title"> <template slot="title">
<i class="el-icon-link"></i>{{ $t('sidebar.links') }} <i class="el-icon-link"></i>{{ $t('sidebar.links') }}

View File

@ -21,7 +21,8 @@ const router = new VueRouter({
children: [ children: [
{ path: '', name: 'home', component: () => import('./views/Home') }, { path: '', name: 'home', component: () => import('./views/Home') },
{ path: 'stylegen', name: 'stylegen', component: () => import('./views/StyleGenerator') }, { path: 'stylegen', name: 'stylegen', component: () => import('./views/StyleGenerator') },
{ path: 'help', name: 'help', component: () => import('./views/Help') } { path: 'help', name: 'help', component: () => import('./views/Help') },
{ path: 'plugins', name: 'plugins', component: () => import('./views/Plugins') },
] ]
}, },
{ {

View File

@ -0,0 +1,183 @@
<template>
<div>
<div style="display: flex; align-items: center;">
<h1 style="display: inline;">{{ $t('plugins.plugins') }}</h1>
<el-button round icon="el-icon-question" class="push-inline-end" @click="isHelpVisible = true">{{
$t('plugins.help')
}}</el-button>
<el-dialog :title="$t('plugins.help')" :visible.sync="isHelpVisible">
<div style="word-break: initial;" v-html="$t('plugins.helpContent')"></div>
</el-dialog>
</div>
<el-empty v-if="plugins.length === 0" description=""></el-empty>
<el-row v-else :gutter="16">
<el-col v-for="plugin in plugins" :key="plugin.id" :sm="24" :md="12">
<el-card class="card">
<div class="card-body">
<div class="title-line">
<h3 class="name">{{ plugin.name }}</h3>
<span class="version">{{ plugin.version }}</span>
<span class="author push-inline-end">{{ $t('plugins.author') }}{{ plugin.author }}</span>
</div>
<div class="description">{{ plugin.description }}</div>
<div class="operations">
<el-tooltip :content="$t('plugins.disabledByServer')" :disabled="serverConfig.enableAdminPlugins">
<el-button type="primary" :disabled="!serverConfig.enableAdminPlugins || !plugin.isConnected"
:loading="getPluginCtx(plugin.id).isOperating" @click="adminPlugin(plugin)"
>{{ $t('plugins.admin') }}</el-button>
</el-tooltip>
<el-tag v-if="plugin.isConnected" type="success" class="status push-inline-end">{{ $t('plugins.connected') }}</el-tag>
<el-tag v-else type="danger" class="status push-inline-end">{{ $t('plugins.unconnected') }}</el-tag>
<el-switch :value="plugin.enabled" :disabled="!serverConfig.enableAdminPlugins || getPluginCtx(plugin.id).isOperating"
@change="enabled => setPluginEnabled(plugin, enabled)"
></el-switch>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import * as mainApi from '@/api/main'
import * as pluginsApi from '@/api/plugins'
export default {
name: 'Plugins',
data() {
return {
serverConfig: {
enableAdminPlugins: true
},
isHelpVisible: false,
plugins: [],
pluginCtxMap: {},
}
},
mounted() {
this.updateServerConfig()
this.updatePlugins()
},
methods: {
getPluginCtx(pluginId) {
let ctx = this.pluginCtxMap[pluginId]
if (ctx === undefined) {
ctx = {
isOperating: false,
}
this.$set(this.pluginCtxMap, pluginId, ctx)
}
return ctx
},
async updateServerConfig() {
try {
this.serverConfig = (await mainApi.getServerInfo()).config
} catch (e) {
this.$message.error(`Failed to fetch server information: ${e}`)
throw e
}
},
async updatePlugins() {
try {
this.plugins = (await pluginsApi.getPlugins()).plugins
} catch (e) {
this.$message.error(`Failed to fetch plugins: ${e}`)
throw e
}
},
async adminPlugin(plugin) {
let ctx = this.getPluginCtx(plugin.id)
ctx.isOperating = true
try {
await pluginsApi.openAdminUi(plugin.id)
} catch (e) {
this.$message.error(`Request failed: ${e}`)
throw e
} finally {
ctx.isOperating = false
}
},
async setPluginEnabled(plugin, enabled) {
let ctx = this.getPluginCtx(plugin.id)
ctx.isOperating = true
try {
let res
try {
res = await pluginsApi.setEnabled(plugin.id, enabled)
} catch (e) {
this.$message.error(`Request failed: ${e}`)
throw e
}
plugin.enabled = res.enabled
if (!res.isSwitchSuccess) {
this.$message.error(`Failed to switch the plugin: ${res.msg}`)
}
await new Promise(resolve => window.setTimeout(resolve, 3000))
this.updatePlugins()
} finally {
ctx.isOperating = false
}
},
}
}
</script>
<style scoped>
.push-inline-end {
margin-inline-start: auto;
}
.card {
margin-block-end: 16px;
}
.card-body {
height: 200px;
display: flex;
flex-flow: column nowrap;
}
.title-line {
margin-block-end: 1em;
flex: none;
display: flex;
flex-flow: row nowrap;
align-items: baseline;
text-wrap: nowrap;
}
.name {
margin-block: 0 0;
margin-inline-end: 8px;
display: inline;
}
.version {
color: #909399;
}
.author {
color: #606266;
}
.description {
margin-block-end: 1em;
flex: auto;
overflow-y: auto;
}
.operations {
flex: none;
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.status {
margin-inline-end: 8px;
}
</style>

View File

@ -2,7 +2,7 @@
"name": "消息日志", "name": "消息日志",
"version": "1.0.0", "version": "1.0.0",
"author": "xfgryujk", "author": "xfgryujk",
"description": "把收到的消息记录到文件中", "description": "把收到的消息记录到文件中。点击管理打开日志目录。日志不会自动清理,记得定期清理",
"run": "msg-logging.exe", "run": "msg-logging.exe",
"enabled": true "enabled": true
} }

View File

@ -2,7 +2,7 @@
"name": "文字转语音", "name": "文字转语音",
"version": "1.0.0", "version": "1.0.0",
"author": "xfgryujk", "author": "xfgryujk",
"description": "用语音读出收到的消息", "description": "用语音读出收到的消息。点击管理打开配置文件,编辑保存后重启插件生效",
"run": "text-to-speech.exe", "run": "text-to-speech.exe",
"enabled": true "enabled": true
} }

View File

@ -37,7 +37,7 @@ def init():
if plugin.enabled: if plugin.enabled:
try: try:
plugin.start() plugin.start()
except StartPluginError: except SwitchPluginError:
pass pass
@ -130,21 +130,21 @@ class PluginConfig:
cfg['version'] = self.version cfg['version'] = self.version
cfg['author'] = self.author cfg['author'] = self.author
cfg['description'] = self.description cfg['description'] = self.description
cfg['run_cmd'] = self.run_cmd cfg['run'] = self.run_cmd
cfg['enabled'] = self.enabled cfg['enabled'] = self.enabled
tmp_path = path + '.tmp' tmp_path = path + '.tmp'
with open(tmp_path, encoding='utf-8') as f: with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2) json.dump(cfg, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, path) os.replace(tmp_path, path)
class StartPluginError(Exception): class SwitchPluginError(Exception):
"""启动插件时错误""" """开关插件时错误"""
class StartTooFrequently(StartPluginError): class SwitchTooFrequently(SwitchPluginError):
"""启动插件太频繁""" """开关插件太频繁"""
class Plugin: class Plugin:
@ -152,7 +152,7 @@ class Plugin:
self._id = plugin_id self._id = plugin_id
self._config = cfg self._config = cfg
self._last_start_time = datetime.datetime.fromtimestamp(0) self._last_switch_time = datetime.datetime.fromtimestamp(0)
self._token = '' self._token = ''
self._client: Optional['api.plugin.PluginWsHandler'] = None self._client: Optional['api.plugin.PluginWsHandler'] = None
@ -204,11 +204,7 @@ class Plugin:
def start(self): def start(self):
if self.is_started: if self.is_started:
return return
self._refresh_last_switch_time()
cur_time = datetime.datetime.now()
if cur_time - self._last_start_time < datetime.timedelta(seconds=3):
raise StartTooFrequently(f'plugin={self._id} starts too frequently')
self._last_start_time = cur_time
token = ''.join(random.choice(string.hexdigits) for _ in range(32)) token = ''.join(random.choice(string.hexdigits) for _ in range(32))
self._set_token(token) self._set_token(token)
@ -228,10 +224,19 @@ class Plugin:
) )
except OSError as e: except OSError as e:
logger.exception('plugin=%s failed to start', self._id) logger.exception('plugin=%s failed to start', self._id)
raise StartPluginError(str(e)) raise SwitchPluginError(str(e))
def _refresh_last_switch_time(self):
cur_time = datetime.datetime.now()
if cur_time - self._last_switch_time < datetime.timedelta(seconds=3):
raise SwitchTooFrequently(f'plugin={self._id} switches too frequently')
self._last_switch_time = cur_time
def stop(self): def stop(self):
if self.is_started: if not self.is_started:
return
self._refresh_last_switch_time()
self._set_token('') self._set_token('')
def _set_token(self, token): def _set_token(self, token):