Compare commits

..

116 Commits

Author SHA1 Message Date
John Smith
824e073ba2 更新版本号v1.9.3 2024-11-16 12:00:01 +08:00
John Smith
de3712a7ca 添加用于测速的接口 2024-11-12 20:09:42 +08:00
John Smith
fb9e0418c7 更新blivedm 2024-11-10 16:47:45 +08:00
John Smith
5a54f0cdaf 前端修复样式生成器中第一次加载房间后CSS不生效的问题 2024-11-10 16:45:31 +08:00
John Smith
cefca66d76 前端动态获取后端地址、支持故障转移 2024-11-08 00:18:16 +08:00
John Smith
99d3b567da 后端动态获取公共服务器地址、支持故障转移 2024-11-08 00:14:52 +08:00
John Smith
132d2a9f71 添加服务发现接口、支持跨域 2024-11-05 22:16:33 +08:00
John Smith
baa64f3dc5 支持信号重新加载配置 2024-11-05 22:16:33 +08:00
John Smith
48154b2be9 公共服务器前端不生成source map 2024-11-05 22:16:33 +08:00
John Smith
06e5481574 前端CDN改成Zstatic,添加开关 2024-11-05 22:16:33 +08:00
John Smith
8b91fa2b98 压缩前端的图片 2024-11-05 22:16:33 +08:00
John Smith
a0d1bf4742 添加Vercel配置 2024-11-05 22:16:25 +08:00
John Smith
75938141f0 docker build时NPM使用镜像 2024-07-30 00:16:36 +08:00
John Smith
187d45e6d9 更新版本号v1.9.2 2024-07-29 23:22:16 +08:00
John Smith
6d7c7b78d9 加载器改成本地文件 2024-07-29 23:21:02 +08:00
John Smith
b00ae596ef 移除腾讯翻译白嫖版 2024-07-29 22:41:38 +08:00
John Smith
e6299a03e8 前端暂时不使用CDN,因为BootCDN被uBlock Origin屏蔽了 2024-07-29 22:41:38 +08:00
dependabot[bot]
e77c26c792 Bump tornado from 6.3.3 to 6.4.1 (#207)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.3.3 to 6.4.1.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.3.3...v6.4.1)

---
updated-dependencies:
- dependency-name: tornado
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-07 09:57:37 +08:00
John Smith
a426adde65 补充注意事项链接 2024-05-26 13:14:26 +08:00
John Smith
df29586b7d 连接数限制的错误信息添加解决方法链接 2024-05-23 01:30:33 +08:00
John Smith
d5cd7f9f7e 抓一下刷请求的人 2024-05-21 20:35:46 +08:00
John Smith
7a0423699c 更新版本号v1.9.1 2024-04-18 19:39:10 +08:00
John Smith
06bd43a173 移除不常用的网络字体 2024-04-14 15:08:50 +08:00
Sodako
47f47f2344 字体选择器增强 (#171)
* 字体选择器增强

* 优化字体选择器

---------

Co-authored-by: John Smith <xfgryujk@126.com>
2024-04-14 14:28:23 +08:00
John Smith
68fd680ec4 同一个房间连接数超过上限时报致命错误 2024-04-13 18:05:06 +08:00
John Smith
5f744c8214 添加插件介绍视频链接 2024-04-08 23:51:06 +08:00
John Smith
6cfe332e78 UID=0的默认头像URL改为由前端计算 2024-04-08 23:45:12 +08:00
John Smith
690f6aa35c 仿微信样式生成器添加显示大表情背景选项 2024-04-06 15:56:23 +08:00
John Smith
64f77c5df9 优化前端两个按钮 2024-04-06 15:18:33 +08:00
John Smith
4874fe464b 完善前端调试消息,添加开关 2024-04-06 14:34:10 +08:00
John Smith
f52a6f4ef9 回退项目心跳策略 2024-04-06 13:12:56 +08:00
John Smith
3ee3d4335f 前端直连开放平台接口使用更保险的项目心跳策略 2024-04-03 22:00:16 +08:00
John Smith
4530bc83c7 前端房间页面添加调试消息 2024-03-31 17:26:08 +08:00
John Smith
e4c599ced6 更新版本号v1.9.0 2024-03-21 19:45:08 +08:00
John Smith
409e61f8ef 前端直连时认证包加上key 2024-03-21 00:02:58 +08:00
John Smith
891566222e 前端修复Chrome抓WS二进制包时显示空包的问题 2024-03-19 22:44:21 +08:00
John Smith
ae248d0d70 更新README 2024-03-18 20:44:00 +08:00
John Smith
1412446a23 修改前端版本号 2024-03-18 20:12:50 +08:00
John Smith
36eec8f98b docker不打包插件源码(和数据) 2024-03-18 20:11:57 +08:00
John Smith
b2fc08def3 插件上舰消息添加价格 2024-03-17 22:37:06 +08:00
John Smith
da3e27c8cb 兼容开放平台接口升级,UID改成Open ID 2024-03-17 22:11:19 +08:00
dependabot[bot]
1cbdd4a2bf Bump pycryptodome from 3.10.1 to 3.19.1 (#169)
Bumps [pycryptodome](https://github.com/Legrandin/pycryptodome) from 3.10.1 to 3.19.1.
- [Release notes](https://github.com/Legrandin/pycryptodome/releases)
- [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst)
- [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.10.1...v3.19.1)

---
updated-dependencies:
- dependency-name: pycryptodome
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 18:12:44 +08:00
dependabot[bot]
bf108b7d6e Bump tornado from 6.3.2 to 6.3.3 (#168)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.3.2 to 6.3.3.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.3.2...v6.3.3)

---
updated-dependencies:
- dependency-name: tornado
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 18:12:30 +08:00
John Smith
a35e0f3607 GUI插件打包WebView2Loader.dll 2024-03-17 17:49:35 +08:00
John Smith
175ecdd5a7 GUI插件添加打包脚本 2024-03-17 17:21:49 +08:00
John Smith
0b39380f70 GUI插件网络消息移到线程处理 2024-03-17 12:19:40 +08:00
John Smith
644e36b4eb GUI插件完成托盘图标 2024-03-17 09:54:55 +08:00
John Smith
c285ba20cc GUI插件完成设置窗口 2024-03-16 22:58:06 +08:00
John Smith
abbbbb1154 GUI插件添加导出Excel 2024-03-15 22:37:13 +08:00
John Smith
5cac2bcb59 插件心跳间隔时间改为30秒 2024-03-12 23:52:17 +08:00
John Smith
87ec69a08d 修复插件的金额格式化 2024-03-11 22:44:09 +08:00
John Smith
67a935866c GUI插件完成房间窗口 2024-03-11 22:17:55 +08:00
John Smith
8277c56224 GUI插件添加消息处理 2024-03-09 22:37:11 +08:00
John Smith
d6030aba9c 完善UI设计 2024-03-09 14:18:48 +08:00
John Smith
62b12d64ab 添加GUI插件框架 2024-03-08 23:24:59 +08:00
John Smith
9c13dce45b 插件说明添加发布地址 2024-03-07 21:46:32 +08:00
John Smith
29ff61748b 添加插件管理前端 2024-03-07 21:25:02 +08:00
John Smith
e1d0473627 添加插件管理后端 2024-03-03 11:18:14 +08:00
John Smith
58afbbf888 前端补充不推荐房间ID的提示 2024-03-03 09:56:11 +08:00
John Smith
bc89552701 优化需要翻译的条件 2024-03-03 09:40:05 +08:00
John Smith
b2b60955e0 添加插件打包脚本 2024-03-02 08:59:08 +08:00
John Smith
c9b713fa85 TTS插件添加配置文件 2024-03-01 21:58:18 +08:00
John Smith
7a158bf185 TTS插件自动合并礼物消息 2024-02-29 23:03:04 +08:00
John Smith
5761ae44f2 添加TTS插件 2024-02-27 23:18:46 +08:00
John Smith
263d4d8e65 优化Gemini提示词变量格式 2024-02-27 22:21:09 +08:00
John Smith
0d7f847dc8 优化Gemini提示词 2024-02-26 00:03:09 +08:00
John Smith
8c3d62d08d 优化Gemini翻译 2024-02-25 18:22:13 +08:00
HalfMAI
21a06e68b1 Add gemini (#157)
* add ginimi

* 更改Prompt

* 1. 更改Prompt
2. 删除打包bat
3. 添加Proxy 和重试
4. 当有额外错误时提供基本的FinishedReason输出
5. 添加Model参数
2024-02-23 20:58:04 +08:00
John Smith
8134313f48 刚开始时消息与底端对齐 2024-02-22 00:23:11 +08:00
John Smith
12aaddb891 修复插件判断版本兼容的方法 2024-02-18 17:22:44 +08:00
John Smith
6ad2e145b2 命令行参数合并到配置里 2024-02-18 17:04:20 +08:00
John Smith
f622144809 优化搜索引擎显示的结果、免费礼物文字颜色 2024-02-18 16:16:07 +08:00
John Smith
26f1dfe54c 侧边栏添加常用链接 2024-02-08 15:45:54 +08:00
John Smith
d7738d0e64 优化样式 2024-02-08 01:58:29 +08:00
John Smith
261abd9bbf git添加plugins目录 2024-02-06 00:16:03 +08:00
John Smith
f091047cc5 Revert "临时屏蔽心跳请求重复请求的报错"
This reverts commit 165b4bf90a.
2024-02-05 23:46:28 +08:00
John Smith
c657a368ae Merge branch 'batch_heartbeat' into dev
# Conflicts:
#	main.py
#	services/avatar.py
#	services/chat.py
#	services/translate.py
#	update.py
2024-02-05 23:34:03 +08:00
John Smith
7343cc0a86 优化查找礼物配置的算法 2024-02-05 23:26:58 +08:00
John Smith
17915faa07 显示免费礼物、最低显示打赏价格默认值改为0.1 2024-02-05 01:24:56 +08:00
John Smith
104f0da94e 修复aiohttp的BUG导致的协程被异常取消 2024-01-30 23:58:27 +08:00
John Smith
9663a77338 当页面不可见时暂时不重连,防止重连后又心跳超时 2024-01-26 02:24:32 +08:00
John Smith
ee3b11e001 防止协程执行完之前就被GC 2024-01-25 20:11:42 +08:00
John Smith
263bf80546 完善插件消息 2024-01-15 01:15:31 +08:00
John Smith
1772caebe3 添加防止无限重连的保险措施、开放平台心跳添加随机延迟 2024-01-06 18:34:24 +08:00
John Smith
5441cb7cd5 修改前端版本号 2023-12-05 00:23:53 +08:00
John Smith
165b4bf90a 临时屏蔽心跳请求重复请求的报错 2023-12-05 00:20:55 +08:00
John Smith
d79738441c 开放平台签名使用更难碰撞的随机数 2023-11-28 22:30:31 +08:00
John Smith
33d3303ff1 修复报错、优化限流参数 2023-11-27 21:47:22 +08:00
John Smith
d4d300b376 采用更保守的重连时间策略,防止雪崩 2023-11-26 17:00:14 +08:00
John Smith
9c3538f17b 限制请求开放平台的频率 2023-11-26 15:50:12 +08:00
John Smith
65a9efa37e 开放平台项目心跳改成批量心跳 2023-11-26 12:18:14 +08:00
John Smith
dbf4de5fa5 升级blivedm 2023-11-25 22:54:53 +08:00
John Smith
be5b49fae4 添加更多插件接口 2023-11-08 00:21:17 +08:00
John Smith
0f9fcddde8 发送房间消息给插件 2023-11-05 21:42:43 +08:00
John Smith
b0b38bf0f0 完成插件和blivechat连接 2023-11-05 16:29:11 +08:00
John Smith
f886506c39 添加插件框架 2023-11-04 16:52:07 +08:00
John Smith
5d98bb5d14 修复表情弹幕可能会漏合并消息的问题 2023-10-15 21:10:31 +08:00
John Smith
fafb3bf107 样式生成器添加字体选择提示 2023-10-15 19:29:03 +08:00
John Smith
bb23e2e5dc 修复合并消息可能导致滚动卡住的问题 2023-10-15 10:57:56 +08:00
John Smith
9874cf4291 加载图片尺寸补上await 2023-10-15 10:35:53 +08:00
John Smith
c5ec713b4d 样式生成器添加单独设置大表情尺寸 2023-10-14 18:04:05 +08:00
John Smith
83ff877f54 添加大表情类 2023-10-14 17:48:53 +08:00
John Smith
1809fefd65 消息元素添加舰队等级、金额等级属性 2023-10-14 16:28:29 +08:00
John Smith
c8bb109079 测试消息添加文本表情和大表情 2023-10-14 00:03:04 +08:00
John Smith
a703c5a90c 预设CSS不进版本库 2023-10-12 21:05:38 +08:00
John Smith
66daa5b79d 添加custom_public目录,用来对前端暴露一些文件;添加导入服务器预设CSS选项 2023-10-11 22:52:06 +08:00
John Smith
5773673bdf 更新官方打赏记录链接 2023-10-11 20:37:05 +08:00
John Smith
4a5d59016e 屏蔽用户改用Set 2023-10-10 23:47:56 +08:00
John Smith
26afe6c2c4 前端封装消息模型,防止以后加新的字段时漏改 2023-10-10 23:11:11 +08:00
John Smith
fd1518db1d 样式生成器预览放到iframe,防止影响到外面网页 2023-10-09 22:43:57 +08:00
John Smith
0f17dfdec7 优化前端打包文件大小 2023-10-08 22:55:12 +08:00
John Smith
05ce486f46 修复手癌 2023-10-08 20:14:46 +08:00
John Smith
8ff3c80f6f 添加wiki链接 2023-09-29 10:36:19 +08:00
John Smith
5be00a8332 前端添加一些提示 2023-09-27 00:35:12 +08:00
reito
c8ff34c998 [Fix] author_id -> uid (#141)
* [Fix] author_id -> uid

* [Fix] author_id -> uid
2023-09-25 22:14:55 +08:00
reito
09a8538406 Relay 方式传递 author_id (uid) (#138)
* Relay 方式传递各种 author_id (uid)

* Rename author_id to uid
2023-09-24 18:36:38 +08:00
122 changed files with 9528 additions and 1300 deletions

View File

@@ -1,24 +1,31 @@
# compiled output
**/__pycache__/
build/
**/build/
**/dist/
# dependencies
frontend/package-lock.json
**/node_modules/
# IDEs and editors
.idea/
**/.idea/
# misc
**/.git*
*.spec
**/*.spec
screenshots/
README.md
plugins/
# runtime data
data/*
!data/config.example.ini
!data/loader.html
!data/custom_public/
data/custom_public/*
!data/custom_public/README.txt
!data/emoticons/
data/emoticons/*
!data/emoticons/.gitkeep
!data/plugins/
data/plugins/*
log/*

9
.gitignore vendored
View File

@@ -105,9 +105,6 @@ venv.bak/
.idea/
data/*
!data/config.example.ini
!data/emoticons/
data/emoticons/*
!data/emoticons/.gitkeep
log/*
data/
log/
.vercel

View File

@@ -8,7 +8,7 @@ WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i
RUN npm i --registry=https://registry.npmmirror.com
# 编译前端
COPY frontend ./

108
README.md
View File

@@ -11,23 +11,25 @@
## 特性
* 兼容YouTube直播评论栏的样式
* 付费礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、合并相似弹幕等设置
* 高亮舰队、 ~~房管~~ 、主播的用户名
* 自带两种样式生成器经典YouTube风格和仿微信风格
* 支持屏蔽弹幕、合并礼物等设置
* 支持前端直连B站服务器或者通过后端转发
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
* 支持标注打赏用户名的读音,可选拼音或日文假名
* 支持配置自定义表情不需要开通B站官方表情
* 支持插件开发
## 使用方法
以下几种方式任选一种即可
以下几种方式任选一种即可。**正式使用之前记得看[注意事项](https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98)**
推荐的方式:如果你需要使用插件、翻译等高级特性,则在本地使用;否则推荐直接通过公共服务器在线使用。因为本地使用时不会自动升级版本,有时候出了问题不能及时解决;但公共服务器会禁用部分高级特性,如果你有需要,只能本地使用了
### 一、本地使用
1. 下载[](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
1. 下载[本地分发版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版。也可以在[B站商店](https://play-live.bilibili.com/details/1694397161340)下载
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```sh
blivechat.exe --host 127.0.0.1 --port 12450
@@ -39,17 +41,9 @@
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
### 二、公共服务器
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
* [公共服务器](http://chat.bilisc.com/)
直接用浏览器打开[公共服务器](http://chat.bilisc.com/),剩下的步骤和本地使用时是一样的
### 三、源代码版自建服务器或在Windows以外平台
@@ -98,88 +92,14 @@
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
## 自建服务器相关补充
### 服务器配置
## 服务器配置
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
**自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
### 参考nginx配置
## 常用链接
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
```nginx
upstream blivechat {
keepalive 8;
# blivechat地址
server 127.0.0.1:12450;
}
# 强制HTTPS
server {
listen 80;
listen [::]:80;
server_name YOUR.DOMAIN.NAME;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name YOUR.DOMAIN.NAME;
# SSL
ssl_certificate /PATH/TO/CERT.crt;
ssl_certificate_key /PATH/TO/CERT_KEY.key;
set $blivechat_path /PATH/TO/BLIVECHAT;
client_body_buffer_size 256k;
client_max_body_size 1.1m;
# 代理header
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 静态文件
location / {
root $blivechat_path/frontend/dist;
try_files $uri $uri/ @index;
}
# 不存在的文件请求转发到index.html交给前端路由
location @index {
rewrite ^ /index.html last;
}
location = /index.html {
root $blivechat_path/frontend/dist;
# index.html不缓存防止更新后前端还是旧版
add_header Cache-Control no-cache;
}
location /emoticons {
alias $blivechat_path/data/emoticons;
}
# 动态API
location /api {
proxy_pass http://blivechat;
}
# websocket
location = /api/chat {
proxy_pass http://blivechat;
# 代理websocket必须设置
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# 由于这个块有proxy_set_header这些不会自动继承
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
* [文档](https://github.com/xfgryujk/blivechat/wiki)
* [交流社区](https://github.com/xfgryujk/blivechat/discussions)
* [B站商店](https://play-live.bilibili.com/details/1694397161340)

View File

@@ -4,18 +4,37 @@ from typing import *
import tornado.web
import config
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args: Optional[dict] = None
def prepare(self):
def set_default_headers(self):
self.set_header('Cache-Control', 'no-cache')
self.add_header('Vary', 'Origin')
origin = self.request.headers.get('Origin', None)
if origin is None:
return
cfg = config.get_config()
if not cfg.is_allowed_cors_origin(origin):
return
self.set_header('Access-Control-Allow-Origin', origin)
self.set_header('Access-Control-Allow-Methods', '*')
self.set_header('Access-Control-Allow-Headers', '*')
self.set_header('Access-Control-Max-Age', '3600')
def prepare(self):
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
return
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
pass
async def options(self, *_args, **_kwargs):
self.set_status(204)

View File

@@ -10,6 +10,7 @@ from typing import *
import aiohttp
import tornado.websocket
import yarl
import api.base
import blivedm.blivedm.clients.web as dm_web_cli
@@ -17,6 +18,7 @@ import config
import services.avatar
import services.chat
import services.translate
import utils.async_io
import utils.request
logger = logging.getLogger(__name__)
@@ -41,6 +43,8 @@ class ContentType(enum.IntEnum):
class FatalErrorType(enum.IntEnum):
AUTH_CODE_ERROR = 1
TOO_MANY_RETRIES = 2
TOO_MANY_CONNECTIONS = 3
def make_message_body(cmd, data):
@@ -68,6 +72,8 @@ def make_text_message_data(
translation: str = '',
content_type: int = ContentType.TEXT,
content_type_params: list = None,
uid: str = '',
medal_name: str = '',
):
# 为了节省带宽用list而不是dict
return [
@@ -103,6 +109,10 @@ def make_text_message_data(
content_type_params if content_type_params is not None else [],
# 15: textEmoticons
[], # 已废弃,保留
# 16: uid
uid,
# 17: medalName
medal_name,
]
@@ -196,21 +206,10 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
room_key_dict = data.get('roomKey', None)
if room_key_dict is not None:
room_key_type = services.chat.RoomKeyType(room_key_dict['type'])
room_key_value = room_key_dict['value']
if room_key_type == services.chat.RoomKeyType.ROOM_ID:
if not isinstance(room_key_value, int):
raise TypeError(f'Room key value type error, value={room_key_value}')
elif room_key_type == services.chat.RoomKeyType.AUTH_CODE:
if not isinstance(room_key_value, str):
raise TypeError(f'Room key value type error, value={room_key_value}')
else:
raise ValueError(f'Unknown RoomKeyType={room_key_type}')
self.room_key = services.chat.RoomKey.from_dict(room_key_dict)
else:
# 兼容旧版客户端 TODO 过几个版本可以移除
room_key_type = services.chat.RoomKeyType.ROOM_ID
room_key_value = int(data['roomId'])
self.room_key = services.chat.RoomKey(room_key_type, room_key_value)
self.room_key = services.chat.RoomKey(services.chat.RoomKeyType.ROOM_ID, int(data['roomId']))
logger.info('client=%s joining room %s', self.request.remote_ip, self.room_key)
try:
@@ -220,15 +219,17 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
pass
services.chat.client_room_manager.add_client(self.room_key, self)
asyncio.create_task(self._on_joined_room())
utils.async_io.create_task_with_ref(self._on_joined_room())
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
cfg = config.get_config()
return (
cfg.debug # 开发时前端localhost直连
or cfg.is_allowed_cors_origin(origin)
or super().check_origin(origin) # 和Host相同
)
@property
def has_joined_room(self):
@@ -244,24 +245,24 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
self.close()
async def _on_joined_room(self):
if self.settings['debug']:
cfg = config.get_config()
if cfg.debug:
await self._send_test_message()
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
if (
cfg.allow_translate_rooms
# 身份码就不管了吧,反正配置正确的情况下不会看到这个提示
and self.room_key.type == services.chat.RoomKeyType.ROOM_ID
and self.room_key.value not in cfg.allow_translate_rooms
):
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
content='Translation is not allowed in this room. Please download to use translation',
author_level=60,
))
if (
self.auto_translate
and cfg.allow_translate_rooms
# 身份码就不管了吧,反正配置正确的情况下不会看到这个提示
and self.room_key.type == services.chat.RoomKeyType.ROOM_ID
and self.room_key.value not in cfg.allow_translate_rooms
):
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
content='Translation is not allowed in this room. Please download to use translation',
author_level=60,
))
# 测试用
async def _send_test_message(self):
@@ -317,29 +318,38 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
gift_data['totalCoin'] = 1245000
gift_data['giftName'] = '小电视飞船'
self.send_cmd_data(Command.ADD_GIFT, gift_data)
gift_data['id'] = uuid.uuid4().hex
gift_data['totalCoin'] = 0
gift_data['totalFreeCoin'] = 1000
gift_data['giftName'] = '辣条'
gift_data['num'] = 10
self.send_cmd_data(Command.ADD_GIFT, gift_data)
class RoomInfoHandler(api.base.ApiHandler):
async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)
room_id, owner_uid = await self._get_room_info(room_id)
# 连接其他host必须要key
host_server_list = dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST
if owner_uid == 0:
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1
self.set_header('Cache-Control', 'private, max-age=86400')
(room_id, owner_uid), (host_server_list, host_server_token), buvid = await asyncio.gather(
self._get_room_info(room_id),
self._get_server_host_list_and_token(room_id),
self._get_buvid()
)
# 缓存1分钟
self.set_header('Cache-Control', 'private, max-age=60')
self.write({
'roomId': room_id,
'ownerUid': owner_uid,
'hostServerList': host_server_list
'hostServerList': host_server_list,
'hostServerToken': host_server_token,
# 虽然没什么用但还是加上比较保险
'buvid': buvid,
})
@staticmethod
async def _get_room_info(room_id):
async def _get_room_info(room_id) -> Tuple[int, int]:
try:
async with utils.request.http_session.get(
dm_web_cli.ROOM_INIT_URL,
@@ -368,19 +378,74 @@ class RoomInfoHandler(api.base.ApiHandler):
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional[str]]:
try:
async with utils.request.http_session.get(
dm_web_cli.DANMAKU_SERVER_CONF_URL,
headers={
# token会对UA签名要使用和客户端一样的UA
'User-Agent': self.request.headers.get('User-Agent', '')
},
params={
'id': room_id,
'type': 0
}
) as res:
if res.status != 200:
logger.warning('room %d _get_server_host_list failed: %d %s', room_id,
res.status, res.reason)
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None
data = await res.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('room %d _get_server_host_list failed', room_id)
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None
if data['code'] != 0:
logger.warning('room %d _get_server_host_list failed: %s', room_id, data['message'])
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None
data = data['data']
host_server_list = data['host_list']
if not host_server_list:
logger.warning('room %d _get_server_host_list failed: host_server_list is empty')
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None
host_server_token = data.get('token', None)
return host_server_list, host_server_token
async def _get_buvid(self):
buvid = self._do_get_buvid()
if buvid != '':
return buvid
try:
async with utils.request.http_session.get(dm_web_cli.BUVID_INIT_URL):
pass
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
pass
return self._do_get_buvid()
@staticmethod
def _do_get_buvid():
cookies = utils.request.http_session.cookie_jar.filter_cookies(yarl.URL(dm_web_cli.BUVID_INIT_URL))
buvid_cookie = cookies.get('buvid3', None)
if buvid_cookie is None:
return ''
return buvid_cookie.value
class AvatarHandler(api.base.ApiHandler):
async def get(self):
# uid基本是0了现在都是前端计算默认头像URL这个接口留着只是为了兼容旧版
uid = int(self.get_query_argument('uid'))
username = self.get_query_argument('username', '')
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
if avatar_url is None:
avatar_url = services.avatar.get_default_avatar_url(uid, username)
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
cache_time = 86400 if uid == 0 else 180
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
cache_time = 86400
self.set_header('Cache-Control', f'private, max-age={cache_time}')
self.write({'avatarUrl': avatar_url})

View File

@@ -14,9 +14,10 @@ logger = logging.getLogger(__name__)
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public')
class MainHandler(tornado.web.StaticFileHandler):
class StaticHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
@@ -45,11 +46,25 @@ class ServerInfoHandler(api.base.ApiHandler):
'config': {
'enableTranslate': cfg.enable_translate,
'enableUploadFile': cfg.enable_upload_file,
'loaderUrl': cfg.loader_url
'loaderUrl': cfg.loader_url,
'enableAdminPlugins': cfg.enable_admin_plugins,
}
})
class ServiceDiscoveryHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
'endpoints': cfg.registered_endpoints,
})
class PingHandler(api.base.ApiHandler):
async def get(self):
self.set_status(204)
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
@@ -85,12 +100,21 @@ class UploadEmoticonHandler(api.base.ApiHandler):
return f'{EMOTICON_BASE_URL}/{filename}'
class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control', 'no-cache')
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/endpoints', ServiceDiscoveryHandler),
(r'/api/ping', PingHandler),
(r'/api/emoticon', UploadEmoticonHandler),
]
# 通配的放在最后
LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
# 这个目录不保证文件内容不会变,还是不用缓存了
(r'/custom_public/(.*)', NoCacheStaticFileHandler, {'path': CUSTOM_PUBLIC_PATH}),
(r'/(.*)', StaticHandler, {'path': config.WEB_ROOT}),
]

View File

@@ -5,8 +5,8 @@ import hashlib
import hmac
import json
import logging
import random
import re
import uuid
from typing import *
import aiohttp
@@ -15,20 +15,27 @@ import tornado.web
import api.base
import config
import services.open_live
import utils.rate_limit
import utils.request
logger = logging.getLogger(__name__)
START_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/start'
END_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
OPEN_LIVE_BASE_URL = 'https://live-open.biliapi.com'
START_GAME_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/start'
END_GAME_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/heartbeat'
GAME_BATCH_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/batchHeartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
START_GAME_COMMON_SERVER_URL = '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = '/api/internal/open_live/game_heartbeat'
_error_auth_code_cache = cachetools.LRUCache(256)
# 应B站要求抓一下刷请求的人不会用于其他用途
auth_code_room_id_cache = cachetools.LRUCache(256)
# 用于限制请求开放平台的频率
_open_live_rate_limiter = utils.rate_limit.TokenBucket(8, 8)
class TransportError(Exception):
@@ -46,24 +53,15 @@ class BusinessError(Exception):
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict, **kwargs) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await _request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
return await request_open_live(open_live_url, body, **kwargs)
return await request_common_server(common_server_url, body, **kwargs)
async def _request_open_live(url, body: dict) -> dict:
async def request_open_live(url, body: dict, *, ignore_rate_limit=False, **kwargs) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
@@ -74,12 +72,16 @@ async def _request_open_live(url, body: dict) -> dict:
else:
auth_code = ''
# 频率限制防止触发B站风控被下架
if not _open_live_rate_limiter.try_decrease_token() and not ignore_rate_limit:
raise BusinessError({'code': 4009, 'message': 'BLC接口访问限制', 'request_id': '0', 'data': None})
body_bytes = json.dumps(body).encode('utf-8')
headers = {
'x-bili-accesskeyid': cfg.open_live_access_key_id,
'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
'x-bili-signature-method': 'HMAC-SHA256',
'x-bili-signature-nonce': str(random.randint(0, 999999999)),
'x-bili-signature-nonce': uuid.uuid4().hex,
'x-bili-signature-version': '1.0',
'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
}
@@ -97,25 +99,56 @@ async def _request_open_live(url, body: dict) -> dict:
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes, **kwargs)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
msg = str(e)
if e.code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:30] + '...'
logger.warning('Request open live failed: %s', msg)
if e.code == 7007:
_error_auth_code_cache[auth_code] = True
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
async def request_common_server(rel_url, body: dict, **kwargs) -> dict:
base_url, breaker = utils.request.get_common_server_base_url_and_circuit_breaker()
if base_url is None:
logger.error('No available common server endpoint')
raise TransportError('No available common server endpoint')
url = base_url + rel_url
with breaker:
try:
req_ctx_mgr = utils.request.http_session.post(url, json=body, **kwargs)
return await _read_response(req_ctx_mgr, is_common_server=True)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse], is_common_server=False) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
if code == 7010 and not is_common_server:
data['message'] += (
' 解决方法https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5'
'%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98#%E6%8A%A5%E9%94%997010-%E8%B6%85%E8%BF%87%E4%B8%8'
'A%E9%99%90%E5%90%8C%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8%E5%8D%95%E4%B8%AA%E7%9B%B4%E6%92%AD%'
'E9%97%B4%E6%9C%80%E5%A4%9A%E5%90%8C%E6%97%B6%E6%89%93%E5%BC%805%E4%B8%AA'
)
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
@@ -130,19 +163,24 @@ def _validate_auth_code(auth_code):
):
raise BusinessError({
'code': 7007,
'message': 'oioioi!你的身份码错误了!别再重试了!!!!!!!!!!',
'message': 'CNM!你的身份码错误了!别再重试了!!!!!!!!!!',
'request_id': '0',
'data': None
})
class _OpenLiveHandlerBase(api.base.ApiHandler):
_LOG_REQUEST = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.res: Optional[dict] = None
def prepare(self):
super().prepare()
if self.request.method == 'OPTIONS':
return
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
@@ -150,7 +188,8 @@ class _OpenLiveHandlerBase(api.base.ApiHandler):
cfg = config.get_config()
self.json_args['app_id'] = cfg.open_live_app_id
logger.info('client=%s requesting open live, cls=%s', self.request.remote_ip, type(self).__name__)
if self._LOG_REQUEST:
logger.info('client=%s requesting open live, cls=%s', self.request.remote_ip, type(self).__name__)
class _PublicHandlerBase(_OpenLiveHandlerBase):
@@ -180,7 +219,7 @@ class _PrivateHandlerBase(_OpenLiveHandlerBase):
raise tornado.web.HTTPError(501)
try:
self.res = await _request_open_live(self._OPEN_LIVE_URL, self.json_args)
self.res = await request_open_live(self._OPEN_LIVE_URL, self.json_args)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
@@ -197,16 +236,31 @@ class _StartGameMixin(_OpenLiveHandlerBase):
if self.res is None:
return
auth_code = self.json_args.get('code', None)
try:
room_id = self.res['data']['anchor_info']['room_id']
except (TypeError, KeyError):
room_id = None
room_id = auth_code_room_id_cache.get(auth_code, None)
else:
auth_code_room_id_cache[auth_code] = room_id
try:
game_id = self.res['data']['game_info']['game_id']
except (TypeError, KeyError):
game_id = None
code = self.res['code']
logger.info('room_id=%s start game res: %s %s', room_id, code, self.res['message'])
msg = self.res['message']
if code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:10] + '...'
logger.info(
'client=%s room_id=%s start game res: %s %s, game_id=%s', self.request.remote_ip, room_id,
code, msg, game_id
)
if code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info('Auth code error! auth_code=%s', self.json_args.get('code', None))
logger.info('client=%s auth code error! auth_code=%s', self.request.remote_ip, auth_code)
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
@@ -226,13 +280,63 @@ class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
class GameHeartbeatPublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
_COMMON_SERVER_URL = GAME_HEARTBEAT_COMMON_SERVER_URL
class GameHeartbeatPublicHandler(_OpenLiveHandlerBase):
_LOG_REQUEST = False
async def post(self):
game_id = self.json_args.get('game_id', None)
if not isinstance(game_id, str) or game_id == '':
raise tornado.web.MissingArgumentError('game_id')
try:
self.res = await send_game_heartbeat_by_service_or_common_server(game_id)
except TransportError as e:
logger.error(
'client=%s game heartbeat failed, game_id=%s, error: %s', self.request.remote_ip, game_id, e
)
raise tornado.web.HTTPError(500)
except BusinessError as e:
logger.info(
'client=%s game heartbeat failed, game_id=%s, error: %s', self.request.remote_ip, game_id, e
)
self.res = e.data
self.write(self.res)
class GameHeartbeatPrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
async def send_game_heartbeat_by_service_or_common_server(game_id):
cfg = config.get_config()
if cfg.is_open_live_configured:
return await services.open_live.send_game_heartbeat(game_id)
return await request_common_server(
GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}, timeout=aiohttp.ClientTimeout(total=15)
)
class GameHeartbeatPrivateHandler(_OpenLiveHandlerBase):
_LOG_REQUEST = False
async def post(self):
cfg = config.get_config()
if not cfg.is_open_live_configured:
raise tornado.web.HTTPError(501)
game_id = self.json_args.get('game_id', None)
if not isinstance(game_id, str) or game_id == '':
raise tornado.web.MissingArgumentError('game_id')
try:
self.res = await services.open_live.send_game_heartbeat(game_id)
except TransportError as e:
logger.error(
'client=%s game heartbeat failed, game_id=%s, error: %s', self.request.remote_ip, game_id, e
)
raise tornado.web.HTTPError(500)
except BusinessError as e:
logger.info(
'client=%s game heartbeat failed, game_id=%s, error: %s', self.request.remote_ip, game_id, e
)
self.res = e.data
self.write(self.res)
ROUTES = [

255
api/plugin.py Normal file
View File

@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
import asyncio
import json
import logging
from typing import *
import tornado.web
import tornado.websocket
import api.base
import api.chat
import blcsdk.models as models
import config
import services.avatar
import services.chat
import services.plugin
logger = logging.getLogger(__name__)
class _AdminHandlerBase(api.base.ApiHandler):
def prepare(self):
cfg = config.get_config()
if not cfg.enable_admin_plugins:
raise tornado.web.HTTPError(403)
if self.request.method != 'OPTIONS':
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
super().prepare()
def _get_plugin(self):
plugin_id = self.json_args.get('pluginId', None)
if not isinstance(plugin_id, str) or plugin_id == '':
raise tornado.web.MissingArgumentError('pluginId')
plugin = services.plugin.get_plugin(plugin_id)
if plugin is None:
raise tornado.web.HTTPError(404, 'no plugin, plugin_id=%s', plugin_id)
return plugin
# 不继承_AdminHandlerBase为了忽略enable_admin_plugins
class PluginsHandler(api.base.ApiHandler):
async def get(self):
plugin_dicts = []
for plugin in services.plugin.iter_plugins():
plugin_cfg = plugin.config
plugin_dicts.append({
'id': plugin.id,
'name': plugin_cfg.name,
'version': plugin_cfg.version,
'author': plugin_cfg.author,
'description': plugin_cfg.description,
'enabled': plugin.enabled,
'isStarted': plugin.is_started,
'isConnected': plugin.is_connected,
})
self.write({'plugins': plugin_dicts})
class EnableHandler(_AdminHandlerBase):
async def post(self):
enabled = bool(self.json_args.get('enabled', False))
plugin = self._get_plugin()
old_enabled = plugin.enabled
is_switch_success = True
msg = ''
try:
plugin.enabled = enabled
except services.plugin.SwitchTooFrequently as e:
is_switch_success = False
msg = str(e)
plugin.enabled = old_enabled
except services.plugin.SwitchPluginError as e:
is_switch_success = False
msg = str(e)
self.write({
'enabled': plugin.enabled,
'isSwitchSuccess': is_switch_success,
'msg': msg
})
class OpenAdminUiHandler(_AdminHandlerBase):
async def post(self):
plugin = self._get_plugin()
plugin.send_cmd_data(models.Command.OPEN_PLUGIN_ADMIN_UI, {})
self.write({})
class _PluginApiHandlerBase(api.base.ApiHandler):
"""给插件用的接口"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plugin: Optional[services.plugin.Plugin] = None
def prepare(self):
try:
auth = self.request.headers['Authorization']
if not auth.startswith('Bearer '):
raise ValueError(f'Bad authorization: {auth}')
token = auth[7:]
self.plugin = services.plugin.get_plugin_by_token(token)
if self.plugin is None:
raise ValueError(f'Token error: {token}')
except (KeyError, ValueError) as e:
logger.warning('client=%s failed to find plugin: %r', self.request.remote_ip, e)
raise tornado.web.HTTPError(403)
super().prepare()
def make_message_body(cmd, data, extra: Optional[dict] = None):
body = {'cmd': cmd, 'data': data}
if extra:
body['extra'] = extra
return json.dumps(body).encode('utf-8')
class PluginWsHandler(_PluginApiHandlerBase, tornado.websocket.WebSocketHandler):
HEARTBEAT_INTERVAL = 30
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
def open(self):
logger.info('plugin=%s connected, client=%s', self.plugin.id, self.request.remote_ip)
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
self.plugin.on_client_connect(self)
def _on_send_heartbeat(self):
self.send_cmd_data(models.Command.HEARTBEAT, {})
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
def _refresh_receive_timeout_timer(self):
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = asyncio.get_running_loop().call_later(
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
def _on_receive_timeout(self):
logger.info('plugin=%s timed out', self.plugin.id)
self._receive_timeout_timer_handle = None
self.close()
def on_close(self):
logger.info('plugin=%s disconnected', self.plugin.id)
self.plugin.on_client_close(self)
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = None
def on_message(self, message):
try:
body = json.loads(message)
cmd = int(body['cmd'])
data = body['data']
if cmd == models.Command.HEARTBEAT:
self._refresh_receive_timeout_timer()
elif cmd == models.Command.LOG_REQ:
logger.log(int(data['level']), '[%s] %s', self.plugin.id, data['msg'])
elif cmd == models.Command.ADD_TEXT_REQ:
self._on_add_text_req(data)
else:
logger.warning('plugin=%s unknown cmd=%d, body=%s', self.plugin.id, cmd, body)
except Exception: # noqa
logger.exception('plugin=%s on_message error, message=%s', self.plugin.id, message)
def _on_add_text_req(self, data: dict):
room_key_dict = data['roomKey']
if room_key_dict is not None:
room_key = services.chat.RoomKey.from_dict(room_key_dict)
room = services.chat.client_room_manager.get_room(room_key)
if room is not None:
rooms = [room]
else:
rooms = []
else:
rooms = list(services.chat.client_room_manager.iter_rooms())
if not rooms:
return
author_name = str(data['authorName'])
if author_name == '':
author_name = self.plugin.id
uid = str(data['uid'])
avatar_url = str(data['avatarUrl'])
if avatar_url == '':
avatar_url = services.avatar.get_default_avatar_url(username=author_name)
data_to_send = api.chat.make_text_message_data(
content=str(data['content']),
author_name=author_name,
uid=uid,
avatar_url=avatar_url,
author_type=int(data['authorType']),
privilege_type=int(data['guardLevel']),
medal_level=int(data['medalLevel']),
translation=str(data['translation']),
)
body_for_room = api.chat.make_message_body(api.chat.Command.ADD_TEXT, data_to_send)
for room in rooms:
room.send_body_no_raise(body_for_room)
extra = services.chat.make_plugin_msg_extra_from_client_room(room)
extra['isFromPlugin'] = True
services.plugin.broadcast_cmd_data(models.Command.ADD_TEXT, data_to_send, extra)
def send_cmd_data(self, cmd, data, extra: Optional[dict] = None):
self.send_body_no_raise(make_message_body(cmd, data, extra))
def send_body_no_raise(self, body: Union[bytes, str, Dict[str, Any]]):
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
self.close()
class RoomsHandler(_PluginApiHandlerBase):
async def get(self):
rooms = [
{
'roomId': live_client.room_id,
'roomKey': live_client.room_key.to_dict(),
}
for live_client in services.chat.iter_live_clients()
]
self.write({'rooms': rooms})
ROUTES = [
(r'/api/plugin/plugins', PluginsHandler),
(r'/api/plugin/enable_plugin', EnableHandler),
(r'/api/plugin/open_admin_ui', OpenAdminUiHandler),
(r'/api/plugin/websocket', PluginWsHandler),
(r'/api/plugin/rooms', RoomsHandler),
]

7
blcsdk/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
__version__ = '1.0.0'
from .handlers import *
from .client import *
from .exc import *
from .api import *

302
blcsdk/api.py Normal file
View File

@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
import asyncio
import dataclasses
import logging
import os
import re
from typing import *
import aiohttp
from . import (
__version__,
client as cli,
exc,
handlers,
models,
)
__all__ = (
'init',
'shut_down',
'set_msg_handler',
'is_sdk_version_compatible',
'get_blc_port',
'get_blc_version',
'get_blc_sdk_version',
'get_plugin_id',
'log',
'send_text',
'get_rooms',
)
logger = logging.getLogger('blcsdk')
# 环境变量
_blc_port = 0
"""服务器端口"""
_blc_base_url = ''
"""HTTP API的URL"""
_token = ''
"""插件认证用的token"""
# 初始化消息
_init_future: Optional[asyncio.Future] = None
"""初始化消息的future"""
_init_msg: Optional[dict] = None
"""初始化消息,包含版本等信息"""
# 其他和blivechat通信用的对象
_http_session: Optional[aiohttp.ClientSession] = None
"""插件请求专用的HTTP客户端"""
_plugin_client: Optional[cli.BlcPluginClient] = None
"""插件客户端"""
_msg_handler: Optional[handlers.HandlerInterface] = None
"""插件消息处理器"""
_msg_handler_wrapper: Optional['_HandlerWrapper'] = None
"""用于SDK处理一些消息然后转发给插件消息处理器"""
async def init():
"""
初始化SDK
在调用除了set_msg_handler以外的其他接口之前必须先调用这个。如果抛出任何异常应该退出当前程序
"""
try:
global _blc_port, _blc_base_url, _token, _init_future, _init_msg, _http_session, _plugin_client, \
_msg_handler_wrapper
if _init_future is not None:
raise exc.InitError('Cannot call init() again')
_init_future = asyncio.get_running_loop().create_future()
# 初始化环境变量信息
_blc_port = int(os.environ['BLC_PORT'])
_blc_base_url = f'http://localhost:{_blc_port}'
blc_ws_url = f'ws://localhost:{_blc_port}/api/plugin/websocket'
_token = os.environ['BLC_TOKEN']
_http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=10),
headers={'Authorization': f'Bearer {_token}'},
)
# 连接blivechat
_msg_handler_wrapper = _HandlerWrapper()
_plugin_client = cli.BlcPluginClient(blc_ws_url, session=_http_session)
_plugin_client.set_handler(_msg_handler_wrapper)
_plugin_client.start()
# 等待初始化消息
_init_msg = await _init_future
logger.debug('SDK initialized, _init_msg=%s', _init_msg)
except exc.InitError:
raise
except Exception as e:
raise exc.InitError(f'Error in init(): {e}') from e
async def shut_down():
"""退出程序之前建议调用"""
if _plugin_client is not None:
await _plugin_client.stop_and_close()
if _http_session is not None:
await _http_session.close()
def set_msg_handler(handler: Optional[handlers.HandlerInterface]):
"""
设置消息处理器
注意消息处理器和网络协程运行在同一个协程如果处理消息耗时太长会阻塞接收消息。如果是CPU密集型的任务建议将消息推到线程池处理
如果是IO密集型的任务应该使用async函数并且在handler里使用create_task创建新的协程
:param handler: 消息处理器
"""
global _msg_handler
_msg_handler = handler
class _HandlerWrapper(handlers.HandlerInterface):
"""用于SDK处理一些消息然后转发给插件消息处理器"""
def handle(self, client: cli.BlcPluginClient, command: dict):
if not _init_future.done():
if command['cmd'] == models.Command.BLC_INIT:
_init_future.set_result(command['data'])
if _msg_handler is not None:
_msg_handler.handle(client, command)
def on_client_stopped(self, client: cli.BlcPluginClient, exception: Optional[Exception]):
if not _init_future.done():
if exception is not None:
_init_future.set_exception(exception)
else:
_init_future.set_exception(exc.InitError('Connection closed before init msg'))
if _msg_handler is not None:
_msg_handler.on_client_stopped(client, exception)
def is_sdk_version_compatible():
"""
检查SDK版本和blivechat的版本是否兼容
如果不兼容,建议退出当前程序。如果继续执行有可能不能正常工作
"""
assert _init_msg is not None, 'Please call init() first'
version_pattern = r'(\d+)\.(\d+)\.\d+'
remote_ver = get_blc_sdk_version()
m = re.match(version_pattern, remote_ver)
if m is None:
raise exc.SdkError(f"Bad remote version format: {remote_ver}")
remote_major_ver = m[1]
remote_minor_ver = m[2]
m = re.match(version_pattern, __version__)
if m is None:
raise exc.SdkError(f"Bad local version format: {__version__}")
local_major_ver = m[1]
local_minor_ver = m[2]
res = (
remote_major_ver == local_major_ver
and int(remote_minor_ver) >= int(local_minor_ver)
)
if not res:
logger.warning('SDK version is not compatible, remote=%s, local=%s', remote_ver, __version__)
return res
def get_blc_port():
"""取blivechat服务器监听的端口"""
assert _blc_port != 0, 'Please call init() first'
return _blc_port
def get_blc_version() -> str:
"""取blivechat版本"""
assert _init_msg is not None, 'Please call init() first'
return _init_msg['blcVersion']
def get_blc_sdk_version() -> str:
"""取blivechat用的SDK版本。是主进程用的版本不是这个包的__version__"""
assert _init_msg is not None, 'Please call init() first'
return _init_msg['sdkVersion']
def get_plugin_id() -> str:
"""取当前插件的ID"""
assert _init_msg is not None, 'Please call init() first'
return _init_msg['pluginId']
async def _blc_ws_send_cmd_data(cmd: models.Command, data: dict):
assert _plugin_client is not None, 'Please call init() first'
try:
await _plugin_client.send_cmd_data(cmd, data)
except (ConnectionResetError, aiohttp.ClientError) as e:
raise exc.TransportError(str(e)) from e
def _blc_get(rel_url, *, params=None):
return _blc_http_request('GET', rel_url, params=params)
def _blc_post(rel_url, *, params=None, json=None):
return _blc_http_request('POST', rel_url, params=params, json=json)
async def _blc_http_request(method, rel_url, **kwargs):
assert _http_session is not None, 'Please call init() first'
try:
async with _http_session.request(method, _blc_base_url + rel_url, **kwargs) as r:
if r.ok:
return await r.json()
try:
data = await r.json()
except aiohttp.ContentTypeError:
data = None
raise exc.ResponseError(r.status, r.reason, data)
except aiohttp.ClientError as e:
raise exc.TransportError(str(e)) from e
async def log(msg, level=logging.INFO):
"""
输出日志到blivechat
blivechat不会分离插件的控制台所以直接输出到标准输出流、标准错误流也能看到日志但是通过这个接口输出的还会写到blivechat的日志文件
"""
await _blc_ws_send_cmd_data(models.Command.LOG_REQ, {
'msg': str(msg),
'level': level,
})
async def send_text(
content: str,
author_name: str = '',
*,
uid: str = '',
avatar_url: str = '',
author_type: int = models.AuthorType.NORMAL.value,
guard_level: int = models.GuardLevel.NONE.value,
medal_level: int = 0,
translation: str = '',
room_key: Optional[models.RoomKey] = None,
):
"""
发送文字消息
当前插件也会收到这条消息,注意避免死循环
:param content: 内容
:param author_name: 用户名默认为当前插件ID
:param uid: 用户Open ID或ID
:param avatar_url: 用户头像URL默认自动生成
:param author_type: 用户类型见AuthorType
:param guard_level: 舰队等级见GuardLevel
:param medal_level: 勋章等级
:param translation: 内容翻译
:param room_key: 发送到哪个房间,默认发送到所有房间
"""
await _blc_ws_send_cmd_data(models.Command.ADD_TEXT_REQ, {
'content': content,
'authorName': author_name,
'uid': uid,
'avatarUrl': avatar_url,
'authorType': author_type,
'guardLevel': guard_level,
'medalLevel': medal_level,
'translation': translation,
'roomKey': room_key.to_dict() if room_key is not None else None,
})
@dataclasses.dataclass
class GetRoomsRes:
room_id: Optional[int]
"""房间ID初始化之前是None"""
room_key: models.RoomKey
"""blivechat用来标识一个房间的key"""
async def get_rooms() -> List[GetRoomsRes]:
"""取当前已创建的房间列表"""
rsp = await _blc_get('/api/plugin/rooms')
rooms = [
GetRoomsRes(
room_id=room_dict['roomId'],
room_key=models.RoomKey.from_dict(room_dict['roomKey']),
)
for room_dict in rsp['rooms']
]
return rooms

223
blcsdk/client.py Normal file
View File

@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
import asyncio
import logging
from typing import *
import aiohttp
from . import handlers
from . import models
__all__ = (
'BlcPluginClient',
)
logger = logging.getLogger('blcsdk')
class BlcPluginClient:
"""
blivechat插件服务的客户端
:param ws_url: blivechat消息转发服务WebSocket地址
:param session: 连接池
:param heartbeat_interval: 发送心跳包的间隔时间(秒)
"""
def __init__(
self,
ws_url: str,
*,
session: Optional[aiohttp.ClientSession] = None,
heartbeat_interval: float = 30,
):
self._ws_url = ws_url
if session is None:
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
self._own_session = True
else:
self._session = session
self._own_session = False
assert self._session.loop is asyncio.get_event_loop() # noqa
self._heartbeat_interval = heartbeat_interval
self._handler: Optional[handlers.HandlerInterface] = None
"""消息处理器"""
# 在运行时初始化的字段
self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None
"""WebSocket连接"""
self._network_future: Optional[asyncio.Future] = None
"""网络协程的future"""
self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
"""发心跳包定时器的handle"""
@property
def is_running(self) -> bool:
"""本客户端正在运行注意调用stop后还没完全停止也算正在运行"""
return self._network_future is not None
def set_handler(self, handler: Optional['handlers.HandlerInterface']):
"""
设置消息处理器
注意消息处理器和网络协程运行在同一个协程如果处理消息耗时太长会阻塞接收消息。如果是CPU密集型的任务建议将消息推到线程池处理
如果是IO密集型的任务应该使用async函数并且在handler里使用create_task创建新的协程
:param handler: 消息处理器
"""
self._handler = handler
def start(self):
"""启动本客户端"""
if self.is_running:
logger.warning('Plugin client is running, cannot start() again')
return
self._network_future = asyncio.create_task(self._network_coroutine_wrapper())
def stop(self):
"""停止本客户端"""
if not self.is_running:
logger.warning('Plugin client is stopped, cannot stop() again')
return
self._network_future.cancel()
async def stop_and_close(self):
"""便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用"""
if self.is_running:
self.stop()
await self.join()
await self.close()
async def join(self):
"""等待本客户端停止"""
if not self.is_running:
logger.warning('Plugin client is stopped, cannot join()')
return
await asyncio.shield(self._network_future)
async def close(self):
"""释放本客户端的资源,调用后本客户端将不可用"""
if self.is_running:
logger.warning('Plugin is calling close(), but client is running')
# 如果session是自己创建的则关闭session
if self._own_session:
await self._session.close()
async def send_cmd_data(self, cmd: models.Command, data: dict):
"""
发送消息给服务器
:param cmd: 消息类型见Command
:param data: 消息体JSON数据
"""
if self._websocket is None or self._websocket.closed:
raise ConnectionResetError('websocket is closed')
body = {'cmd': cmd, 'data': data}
await self._websocket.send_json(body)
async def _network_coroutine_wrapper(self):
"""负责处理网络协程的异常网络协程具体逻辑在_network_coroutine里"""
exc = None
try:
await self._network_coroutine()
except asyncio.CancelledError:
# 正常停止
pass
except Exception as e:
logger.exception('_network_coroutine() finished with exception:')
exc = e
finally:
logger.debug('_network_coroutine() finished')
self._network_future = None
if self._handler is not None:
self._handler.on_client_stopped(self, exc)
async def _network_coroutine(self):
"""网络协程,负责连接服务器、接收消息、解包"""
try:
# 连接
async with self._session.ws_connect(
self._ws_url,
receive_timeout=self._heartbeat_interval + 5,
) as websocket:
self._websocket = websocket
await self._on_ws_connect()
# 处理消息
message: aiohttp.WSMessage
async for message in websocket:
self._on_ws_message(message)
finally:
self._websocket = None
await self._on_ws_close()
# 插件消息都是本地通信的,这里不可能是因为网络问题而掉线,所以不尝试重连
async def _on_ws_connect(self):
"""WebSocket连接成功"""
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self._heartbeat_interval, self._on_send_heartbeat
)
async def _on_ws_close(self):
"""WebSocket连接断开"""
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
def _on_send_heartbeat(self):
"""定时发送心跳包的回调"""
if self._websocket is None or self._websocket.closed:
self._heartbeat_timer_handle = None
return
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self._heartbeat_interval, self._on_send_heartbeat
)
asyncio.create_task(self._send_heartbeat())
async def _send_heartbeat(self):
"""发送心跳包"""
try:
await self.send_cmd_data(models.Command.HEARTBEAT, {})
except (ConnectionResetError, aiohttp.ClientConnectionError) as e:
logger.warning('Plugin client _send_heartbeat() failed: %r', e)
except Exception: # noqa
logger.exception('Plugin client _send_heartbeat() failed:')
def _on_ws_message(self, message: aiohttp.WSMessage):
"""
收到WebSocket消息
:param message: WebSocket消息
"""
if message.type != aiohttp.WSMsgType.TEXT:
logger.warning('Unknown websocket message type=%s, data=%s', message.type, message.data)
return
try:
body = message.json()
self._handle_command(body)
except Exception:
logger.error('body=%s', message.data)
raise
def _handle_command(self, command: dict):
"""
处理业务消息
:param command: 业务消息
"""
if self._handler is not None:
try:
self._handler.handle(self, command)
except Exception as e:
logger.exception('Plugin client _handle_command() failed, command=%s', command, exc_info=e)

30
blcsdk/exc.py Normal file
View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from typing import *
__all__ = (
'SdkError',
'InitError',
'TransportError',
'ResponseError',
)
class SdkError(Exception):
"""SDK错误的基类"""
class InitError(SdkError):
"""初始化失败"""
class TransportError(SdkError):
"""通信错误"""
class ResponseError(SdkError):
"""响应代码错误"""
def __init__(self, code: int, msg: str, data: Optional[dict] = None):
super().__init__(f'code={code}, msg={msg}, data={data}')
self.code = code
self.msg = msg
self.data = data

99
blcsdk/handlers.py Normal file
View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
from typing import *
from . import client as cli
from . import models
__all__ = (
'HandlerInterface',
'BaseHandler',
)
class HandlerInterface:
"""blivechat插件消息处理器接口"""
def handle(self, client: cli.BlcPluginClient, command: dict):
raise NotImplementedError
def on_client_stopped(self, client: cli.BlcPluginClient, exception: Optional[Exception]):
"""
当客户端停止时调用
这种情况说明blivechat已经退出了或者插件被禁用了因此重连基本会失败。这里唯一建议的操作是退出当前程序
"""
def _make_msg_callback(method_name, message_cls):
def callback(self: 'BaseHandler', client: cli.BlcPluginClient, command: dict):
method = getattr(self, method_name)
msg = message_cls.from_command(command['data'])
extra = models.ExtraData.from_dict(command.get('extra', {}))
return method(client, msg, extra)
return callback
class BaseHandler(HandlerInterface):
"""一个简单的消息处理器实现带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器"""
_CMD_CALLBACK_DICT: Dict[
int,
Optional[Callable[
['BaseHandler', cli.BlcPluginClient, dict],
Any
]]
] = {
models.Command.ADD_ROOM: _make_msg_callback('_on_add_room', models.AddRoomMsg),
models.Command.ROOM_INIT: _make_msg_callback('_on_room_init', models.RoomInitMsg),
models.Command.DEL_ROOM: _make_msg_callback('_on_del_room', models.DelRoomMsg),
models.Command.OPEN_PLUGIN_ADMIN_UI: _make_msg_callback(
'_on_open_plugin_admin_ui', models.OpenPluginAdminUiMsg
),
models.Command.ADD_TEXT: _make_msg_callback('_on_add_text', models.AddTextMsg),
models.Command.ADD_GIFT: _make_msg_callback('_on_add_gift', models.AddGiftMsg),
models.Command.ADD_MEMBER: _make_msg_callback('_on_add_member', models.AddMemberMsg),
models.Command.ADD_SUPER_CHAT: _make_msg_callback('_on_add_super_chat', models.AddSuperChatMsg),
models.Command.DEL_SUPER_CHAT: _make_msg_callback('_on_del_super_chat', models.DelSuperChatMsg),
models.Command.UPDATE_TRANSLATION: _make_msg_callback('_on_update_translation', models.UpdateTranslationMsg),
}
"""cmd -> 处理回调"""
def handle(self, client: cli.BlcPluginClient, command: dict):
cmd = command['cmd']
callback = self._CMD_CALLBACK_DICT.get(cmd, None)
if callback is not None:
callback(self, client, command)
def _on_add_room(self, client: cli.BlcPluginClient, message: models.AddRoomMsg, extra: models.ExtraData):
"""添加房间"""
def _on_room_init(self, client: cli.BlcPluginClient, message: models.RoomInitMsg, extra: models.ExtraData):
"""房间初始化"""
def _on_del_room(self, client: cli.BlcPluginClient, message: models.DelRoomMsg, extra: models.ExtraData):
"""删除房间"""
def _on_open_plugin_admin_ui(
self, client: cli.BlcPluginClient, message: models.OpenPluginAdminUiMsg, extra: models.ExtraData
):
"""用户请求打开当前插件的管理界面"""
def _on_add_text(self, client: cli.BlcPluginClient, message: models.AddTextMsg, extra: models.ExtraData):
"""收到弹幕"""
def _on_add_gift(self, client: cli.BlcPluginClient, message: models.AddGiftMsg, extra: models.ExtraData):
"""有人送礼"""
def _on_add_member(self, client: cli.BlcPluginClient, message: models.AddMemberMsg, extra: models.ExtraData):
"""有人上舰"""
def _on_add_super_chat(self, client: cli.BlcPluginClient, message: models.AddSuperChatMsg, extra: models.ExtraData):
"""醒目留言"""
def _on_del_super_chat(self, client: cli.BlcPluginClient, message: models.DelSuperChatMsg, extra: models.ExtraData):
"""删除醒目留言"""
def _on_update_translation(
self, client: cli.BlcPluginClient, message: models.UpdateTranslationMsg, extra: models.ExtraData
):
"""更新翻译"""

422
blcsdk/models.py Normal file
View File

@@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
import dataclasses
import enum
from typing import *
__all__ = (
'RoomKeyType',
'RoomKey',
'Command',
'ExtraData',
'AddRoomMsg',
'RoomInitMsg',
'DelRoomMsg',
'OpenPluginAdminUiMsg',
'AuthorType',
'GuardLevel',
'ContentType',
'AddTextMsg',
'AddGiftMsg',
'AddMemberMsg',
'AddSuperChatMsg',
'DelSuperChatMsg',
'UpdateTranslationMsg',
)
class RoomKeyType(enum.IntEnum):
ROOM_ID = 1
AUTH_CODE = 2
class RoomKey(NamedTuple):
"""用来标识一个房间"""
type: RoomKeyType
value: Union[int, str]
def __str__(self):
res = str(self.value)
if self.type == RoomKeyType.AUTH_CODE:
# 身份码要脱敏
res = '***' + res[-3:]
return res
__repr__ = __str__
@classmethod
def from_dict(cls, data: dict):
type_ = RoomKeyType(data['type'])
value = data['value']
if type_ == RoomKeyType.ROOM_ID:
if not isinstance(value, int):
raise TypeError(f'Type of value is {type(value)}, value={value}')
elif type_ == RoomKeyType.AUTH_CODE:
if not isinstance(value, str):
raise TypeError(f'Type of value is {type(value)}, value={value}')
return cls(type=type_, value=value)
def to_dict(self):
return {'type': self.type, 'value': self.value}
class Command(enum.IntEnum):
HEARTBEAT = 0
BLC_INIT = 1
ADD_ROOM = 2
ROOM_INIT = 3
DEL_ROOM = 4
OPEN_PLUGIN_ADMIN_UI = 5
# 从插件发送到blivechat的请求
LOG_REQ = 30
ADD_TEXT_REQ = 31
# 房间内消息
ADD_TEXT = 50
ADD_GIFT = 51
ADD_MEMBER = 52
ADD_SUPER_CHAT = 53
DEL_SUPER_CHAT = 54
UPDATE_TRANSLATION = 55
@dataclasses.dataclass
class ExtraData:
"""一些消息共用的附加信息"""
room_id: Optional[int] = None
"""房间ID"""
room_key: Optional[RoomKey] = None
"""blivechat用来标识一个房间的key"""
is_from_plugin: bool = False
"""
消息是插件生成的
如果你的插件既要监听消息,又要生成消息,注意判断这个以避免死循环
"""
@classmethod
def from_dict(cls, data: dict):
room_key_dict = data.get('roomKey', None)
if room_key_dict is not None:
room_key = RoomKey.from_dict(room_key_dict)
else:
room_key = None
return cls(
room_id=data.get('roomId', None),
room_key=room_key,
is_from_plugin=data.get('isFromPlugin', False),
)
@dataclasses.dataclass
class _EmptyMsg:
@classmethod
def from_command(cls, _data: dict):
return cls()
@dataclasses.dataclass
class AddRoomMsg(_EmptyMsg):
"""
添加房间消息。房间信息在extra里
此时room_id是None因为还没有初始化
"""
@dataclasses.dataclass
class RoomInitMsg:
"""
房间初始化消息。房间信息在extra里
一个房间创建后可能被多次初始化,处理时注意去重
"""
is_success: bool = False
@classmethod
def from_command(cls, data: dict):
return cls(
is_success=data['isSuccess'],
)
@dataclasses.dataclass
class DelRoomMsg(_EmptyMsg):
"""
删除房间消息。房间信息在extra里
注意此时room_id可能是None
"""
@dataclasses.dataclass
class OpenPluginAdminUiMsg(_EmptyMsg):
"""用户请求打开当前插件的管理界面消息"""
class AuthorType(enum.IntEnum):
NORMAL = 0
GUARD = 1
"""舰队"""
ADMIN = 2
"""房管"""
ROOM_OWNER = 3
"""主播"""
class GuardLevel(enum.IntEnum):
"""舰队等级"""
NONE = 0
LV3 = 1
"""总督"""
LV2 = 2
"""提督"""
LV1 = 3
"""舰长"""
class ContentType(enum.IntEnum):
TEXT = 0
EMOTICON = 1
@dataclasses.dataclass
class AddTextMsg:
"""弹幕消息"""
avatar_url: str = ''
"""用户头像URL"""
timestamp: int = 0
"""时间戳(秒)"""
author_name: str = ''
"""用户名"""
author_type: int = AuthorType.NORMAL.value
"""用户类型见AuthorType"""
content: str = ''
"""弹幕内容"""
privilege_type: int = GuardLevel.NONE.value
"""舰队等级见GuardLevel"""
is_gift_danmaku: bool = False
"""是否礼物弹幕"""
author_level: int = 1
"""用户等级"""
is_newbie: bool = False
"""是否正式会员"""
is_mobile_verified: bool = True
"""是否绑定手机"""
medal_level: int = 0
"""勋章等级如果没戴当前房间勋章则为0"""
id: str = ''
"""消息ID"""
translation: str = ''
"""弹幕内容翻译"""
content_type: int = ContentType.TEXT.value
"""内容类型见ContentType"""
content_type_params: Union[dict, list] = dataclasses.field(default_factory=dict)
"""跟内容类型相关的参数"""
uid: str = ''
"""用户Open ID或ID"""
medal_name: str = ''
"""勋章名"""
@classmethod
def from_command(cls, data: list):
content_type = data[13]
content_type_params = data[14]
if content_type == ContentType.EMOTICON:
content_type_params = {'url': content_type_params[0]}
return cls(
avatar_url=data[0],
timestamp=data[1],
author_name=data[2],
author_type=data[3],
content=data[4],
privilege_type=data[5],
is_gift_danmaku=bool(data[6]),
author_level=data[7],
is_newbie=bool(data[8]),
is_mobile_verified=bool(data[9]),
medal_level=data[10],
id=data[11],
translation=data[12],
content_type=content_type,
content_type_params=content_type_params,
uid=data[16],
medal_name=data[17],
)
@dataclasses.dataclass
class AddGiftMsg:
"""礼物消息"""
id: str = ''
"""消息ID"""
avatar_url: str = ''
"""用户头像URL"""
timestamp: int = 0
"""时间戳(秒)"""
author_name: str = ''
"""用户名"""
total_coin: int = 0
"""总价付费瓜子数1000金瓜子 = 1元"""
total_free_coin: int = 0
"""总价免费瓜子数"""
gift_name: str = ''
"""礼物名"""
num: int = 0
"""数量"""
gift_id: int = 0
"""礼物ID"""
gift_icon_url: str = ''
"""礼物图标URL"""
uid: str = ''
"""用户Open ID或ID"""
privilege_type: int = GuardLevel.NONE.value
"""舰队等级见GuardLevel"""
medal_level: int = 0
"""勋章等级如果没戴当前房间勋章则为0"""
medal_name: str = ''
"""勋章名"""
@classmethod
def from_command(cls, data: dict):
return cls(
id=data['id'],
avatar_url=data['avatarUrl'],
timestamp=data['timestamp'],
author_name=data['authorName'],
total_coin=data['totalCoin'],
total_free_coin=data['totalFreeCoin'],
gift_name=data['giftName'],
num=data['num'],
gift_id=data['giftId'],
gift_icon_url=data['giftIconUrl'],
uid=data['uid'],
privilege_type=data['privilegeType'],
medal_level=data['medalLevel'],
medal_name=data['medalName'],
)
@dataclasses.dataclass
class AddMemberMsg:
"""上舰消息"""
id: str = ''
"""消息ID"""
avatar_url: str = ''
"""用户头像URL"""
timestamp: int = 0
"""时间戳(秒)"""
author_name: str = ''
"""用户名"""
privilege_type: int = GuardLevel.NONE.value
"""舰队等级见GuardLevel"""
num: int = 0
"""数量"""
unit: str = ''
"""单位(月)"""
total_coin: int = 0
"""总价付费瓜子数1000金瓜子 = 1元"""
uid: str = ''
"""用户Open ID或ID"""
medal_level: int = 0
"""勋章等级如果没戴当前房间勋章则为0"""
medal_name: str = ''
"""勋章名"""
@classmethod
def from_command(cls, data: dict):
return cls(
id=data['id'],
avatar_url=data['avatarUrl'],
timestamp=data['timestamp'],
author_name=data['authorName'],
privilege_type=data['privilegeType'],
num=data['num'],
unit=data['unit'],
total_coin=data['total_coin'],
uid=data['uid'],
medal_level=data['medalLevel'],
medal_name=data['medalName'],
)
@dataclasses.dataclass
class AddSuperChatMsg:
"""醒目留言消息"""
id: str = ''
"""消息ID"""
avatar_url: str = ''
"""用户头像URL"""
timestamp: int = 0
"""时间戳(秒)"""
author_name: str = ''
"""用户名"""
price: int = 0
"""价格(元)"""
content: str = ''
"""内容"""
translation: str = ''
"""内容翻译"""
uid: str = ''
"""用户Open ID或ID"""
privilege_type: int = GuardLevel.NONE.value
"""舰队等级见GuardLevel"""
medal_level: int = 0
"""勋章等级如果没戴当前房间勋章则为0"""
medal_name: str = ''
"""勋章名"""
@classmethod
def from_command(cls, data: dict):
return cls(
id=data['id'],
avatar_url=data['avatarUrl'],
timestamp=data['timestamp'],
author_name=data['authorName'],
price=data['price'],
content=data['content'],
translation=data['translation'],
uid=data['uid'],
privilege_type=data['privilegeType'],
medal_level=data['medalLevel'],
medal_name=data['medalName'],
)
@dataclasses.dataclass
class DelSuperChatMsg:
"""删除醒目留言消息"""
ids: List[str] = dataclasses.field(default_factory=list)
"""醒目留言ID数组"""
@classmethod
def from_command(cls, data: dict):
return cls(
ids=data['ids'],
)
@dataclasses.dataclass
class UpdateTranslationMsg:
"""更新内容翻译消息"""
id: str = ''
"""消息ID"""
translation: str = ''
"""内容翻译"""
@classmethod
def from_command(cls, data: list):
return cls(
id=data[0],
translation=data[1],
)

2
blcsdk/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aiohttp~=3.9.0
pyinstaller~=5.13.2

Submodule blivedm updated: 4da27950c8...7f400e9c61

104
config.py
View File

@@ -2,6 +2,7 @@
import configparser
import logging
import os
import re
from typing import *
logger = logging.getLogger(__name__)
@@ -18,15 +19,19 @@ CONFIG_PATH_LIST = [
_config: Optional['AppConfig'] = None
def init():
if reload():
def init(cmd_args):
if reload(cmd_args):
return
logger.warning('Using default config')
config = AppConfig()
config.load_cmd_args(cmd_args)
global _config
_config = AppConfig()
_config = config
def reload():
def reload(cmd_args=None):
config_path = ''
for path in CONFIG_PATH_LIST:
if os.path.exists(path):
@@ -38,6 +43,8 @@ def reload():
config = AppConfig()
if not config.load(config_path):
return False
config.load_cmd_args(cmd_args)
global _config
_config = config
return True
@@ -49,6 +56,7 @@ def get_config():
class AppConfig:
def __init__(self):
self.debug = False
self.host = '127.0.0.1'
self.port = 12450
self.database_url = 'sqlite:///data/database.db'
@@ -56,6 +64,7 @@ class AppConfig:
self.loader_url = ''
self.open_browser_at_startup = True
self.enable_upload_file = True
self.enable_admin_plugins = True
self.fetch_avatar_max_queue_size = 4
self.avatar_cache_size = 10000
@@ -65,12 +74,15 @@ class AppConfig:
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.allow_translate_rooms: Set[int] = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.translator_configs: List[dict] = []
self.text_emoticons = []
self.text_emoticons: List[dict] = []
self.registered_endpoints: List[str] = []
self.cors_origins: List[re.Pattern[str]] = []
@property
def is_open_live_configured(self):
@@ -78,6 +90,15 @@ class AppConfig:
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load_cmd_args(self, args=None):
if args is None:
return
if args.host is not None:
self.host = args.host
if args.port is not None:
self.port = args.port
self.debug = args.debug
def load(self, path):
try:
config = configparser.ConfigParser()
@@ -86,6 +107,8 @@ class AppConfig:
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
self._load_registered_endpoints(config)
self._load_cors_origins(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
@@ -98,8 +121,11 @@ class AppConfig:
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
if self.loader_url == '{local_loader}':
self.loader_url = self._get_local_loader_url()
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.enable_admin_plugins = app_section.getboolean('enable_admin_plugins', self.enable_admin_plugins)
self.fetch_avatar_max_queue_size = app_section.getint(
'fetch_avatar_max_queue_size', self.fetch_avatar_max_queue_size
@@ -117,8 +143,20 @@ class AppConfig:
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
@staticmethod
def _get_local_loader_url():
url = os.path.abspath(os.path.join(DATA_PATH, 'loader.html'))
url = url.replace('\\', '/')
if not url.startswith('/'): # Windows
url = '/' + url
url = 'file://' + url
return url
def _load_translator_configs(self, config: configparser.ConfigParser):
app_section = config['app']
try:
app_section = config['app']
except KeyError:
return
section_names = _str_to_list(app_section.get('translator_configs', ''))
translator_configs = []
for section_name in section_names:
@@ -130,11 +168,13 @@ class AppConfig:
'type': type_,
'query_interval': section.getfloat('query_interval'),
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
elif type_ == 'BilibiliTranslateFree':
pass
if type_ in ('TencentTranslateFree', 'BilibiliTranslateFree'):
doc_url = (
'https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9'
'%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3'
)
logger.warning('%s is deprecated, please see %s', type_, doc_url)
continue
elif type_ == 'TencentTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
@@ -146,6 +186,12 @@ class AppConfig:
translator_config['target_language'] = section['target_language']
translator_config['app_id'] = section['app_id']
translator_config['secret'] = section['secret']
elif type_ == 'GeminiTranslate':
translator_config['proxy'] = section['proxy']
translator_config['api_key'] = section['api_key']
translator_config['model_code'] = section['model_code']
translator_config['prompt'] = section['prompt'].replace('\n', ' ').replace('\\n', '\n')
translator_config['temperature'] = section.getfloat('temperature')
else:
raise ValueError(f'Invalid translator type: {type_}')
except Exception: # noqa
@@ -156,13 +202,41 @@ class AppConfig:
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
try:
mappings_section = config['text_emoticon_mappings']
except KeyError:
return
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _load_registered_endpoints(self, config: configparser.ConfigParser):
try:
registered_endpoints_section = config['registered_endpoints']
except KeyError:
return
registered_endpoints = list(registered_endpoints_section.values())
self.registered_endpoints = registered_endpoints
def _load_cors_origins(self, config: configparser.ConfigParser):
try:
cors_origins_section = config['cors_origins']
except KeyError:
return
cors_origins = [
re.compile(origin, re.IGNORECASE)
for origin in cors_origins_section.values()
]
self.cors_origins = cors_origins
def is_allowed_cors_origin(self, origin):
return any(
pattern.fullmatch(origin) is not None
for pattern in self.cors_origins
)
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()
@@ -171,5 +245,5 @@ def _str_to_list(value, item_type: Type = str, container_type: Type = list):
items = value.split(',')
items = map(lambda item: item.strip(), items)
if item_type is not str:
items = map(lambda item: item_type(item), items)
items = map(item_type, items)
return container_type(items)

View File

@@ -17,8 +17,9 @@ tornado_xheaders = false
# 加载器URL本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空不使用加载器
# **自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
# “{local_loader}”表示使用本地加载器文件URL
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html
loader_url = {local_loader}
# 启动时打开浏览器
# Open browser at startup
@@ -28,6 +29,10 @@ open_browser_at_startup = true
# Enable uploading custom emote file
enable_upload_file = true
# 允许管理插件
# Enable administration for plugins
enable_admin_plugins = true
# 获取头像最大队列长度
# Maximum queue length for fetching avatar
@@ -61,6 +66,7 @@ translation_cache_size = 50000
# **The following is for translation team. Leave it default if you don't know its meaning**
# -------------------------------------------------------------------------------------------------
# 文档https://open-live.bilibili.com/document/
# 在B站直播开放平台申请的开发者密钥如果不填会把请求转发到作者的服务器
open_live_access_key_id =
open_live_access_key_secret =
@@ -71,22 +77,8 @@ open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
translator_configs = tencent_translate_free
[tencent_translate_free]
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = jp
# Example: translator_configs = tencent_translate,baidu_translate
translator_configs =
[tencent_translate]
@@ -146,6 +138,50 @@ app_id =
secret =
[gemini_translate]
# 文档https://ai.google.dev/docs
# 定价https://ai.google.dev/pricing
# * 目前只有免费版最高QPS为1
# * 收费版 输入 $0.000125 / 1000 个字符;输出 $0.000375 / 1000 个字符
# 模型规格https://ai.google.dev/models/gemini
# * Gemini Pro 最高QPS为1
# 类型Gemini翻译
type = GeminiTranslate
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1.1
# 代理地址如果为空不使用代理。注意必须要在指定地区才能使用https://ai.google.dev/available_regions
proxy = http://127.0.0.1:7890
# API密钥
api_key =
# 模型代码
model_code = models/gemini-1.0-pro
# 提示词
prompt = As an advanced translation software, your role is to provide accurate translations that respect
the original content's meaning and tone.
Ensure that texts between square brackets "[]" are kept untranslated.
Aim to deliver the translated text in a polite and gentle tone, as per the preferences of the audience.\n\n
Below are some examples to guide you:\n\n
Input1:\n来拉(莱拉)设置一下吧\nOutput1:\nライラちゃん、設定してみましょうか\n\n
Input2:\n我来拉一下各位进队伍\nOutput2:\n皆さんをチームに入れさせていただきます\n\n
Input3:\n死了w\nOutput3:\n死んじゃったわw\n\n
Input4:\n每天来宝这里打个卡[比心]\nOutput4:\n毎日、ここに来て宝さんにチェックインしま[比心]\n\n
\n
Please keep in mind that accurate translation is not just about linguistic conversion;
it is about conveying the same sentiment and meaning as the original input.
Pay special attention to internet vernaculars and colloquial expressions to maintain the nuances of the
source language.\n\n
Now, proceed with the translation of the following Chinese comment text into Japanese, ensuring accuracy
and preservation of the original message:\n
Input:\n{original_text}\nOutput ONLY:\n
# 温度,用于控制令牌选择的随机性,较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果
temperature = 0.4
# 傻逼B站获取表情都要登录开放平台也不发文本表情的URL我服了
[text_emoticon_mappings]
1 = [dog],http://i0.hdslb.com/bfs/live/4428c84e694fbf4e0ef6c06e958d9352c3582740.png
@@ -230,3 +266,13 @@ secret =
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png
# 用于服务发现返回的后端端点
[registered_endpoints]
1 = http://localhost:12450
# 允许跨域的源,正则表达式
[cors_origins]
1 = http://localhost(:\d+)

View File

@@ -0,0 +1,15 @@
这个目录用来暴露一些自定义文件给前端例如图片、CSS、字体等
你可以在网页路径 “/custom_public/README.txt” 访问到这个文件
警告:不要在这个目录里存放密钥等敏感信息,因为可能会被远程访问
这个目录表下的 “preset.css” 文件是服务器预设CSS文件你可以手动创建它
前端可以通过“导入服务器预设CSS”选项自动导入这个CSS而不需要在OBS里面设置自定义CSS
This directory is used to expose some custom files to the front end, such as images, CSS, fonts, etc.
You can access this file on the web path: "/custom_public/README.txt"
WARNING: DO NOT store sensitive information such as secrets in this directory, because it may be accessed by remote
The "preset.css" file in this directory is the server preset CSS file. You can create it manually
The front end can automatically import this CSS through the "Import the server preset CSS" option, instead of
setting the custom CSS in OBS

39
data/loader.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>blivechat</title>
</head>
<body>
<p>Loading... Please run blivechat</p>
<script>
function main() {
let params = new URLSearchParams(window.location.search)
let url = params.get('url')
if (!url) {
let element = document.createElement('p')
element.innerText = 'No url specified'
document.body.appendChild(element)
return
}
let timerId = null
function poll() {
window.fetch(url, {mode: 'no-cors'}).then(
() => {
window.clearInterval(timerId)
window.location.href = url
},
() => {}
)
}
timerId = window.setInterval(poll, 1000)
poll()
}
main()
</script>
</body>
</html>

0
data/plugins/.gitkeep Normal file
View File

6
frontend/.env Normal file
View File

@@ -0,0 +1,6 @@
# 第三方库用CDN引入
LIB_USE_CDN=false
# production环境生成source map
PROD_SOURCE_MAP=true
# 动态发现后端endpoint
BACKEND_DISCOVERY=false

View File

@@ -0,0 +1,4 @@
NODE_ENV=production
LIB_USE_CDN=true
PROD_SOURCE_MAP=false
BACKEND_DISCOVERY=true

1
frontend/.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
# local env files
.env.local
.env.*.local
!.env
# Log files
npm-debug.log*

View File

@@ -1,13 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
],
plugins: [
[
'component', {
'libraryName': 'element-ui',
'styleLibraryName': 'theme-chalk'
}
]
]
}

View File

@@ -1,18 +1,21 @@
{
"name": "blivechat",
"version": "1.8.2",
"version": "1.9.3",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build_common_server": "vue-cli-service build --mode common_server",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.4.0",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"opossum": "^8.3.0",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
@@ -24,7 +27,6 @@
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^9.16.1"
},

View File

@@ -1,13 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!-- 重要如果带referer则获取头像时会403 -->
<meta name="referrer" content="no-referrer">
<meta name="description" content="用于 OBS 的仿 YouTube 风格的 bilibili 直播评论栏">
<meta name="keywords" content="blivechat,bilibili,直播,弹幕,评论栏,字幕,OBS,B站,哔哩哔哩,B站直播,哔哩哔哩直播,YouTube">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>blivechat</title>
<%
if (process.env.LIB_USE_CDN) {
if (process.env.NODE_ENV === 'production') {
%>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.min.js" integrity="sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.min.js" integrity="sha384-OuPzRFb9+YLr+ulVAQiUKWAYfQdbHQI3IvQ86+ApeLgPgTI2EJ/ySfV9UjvFFhxN" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.min.js" integrity="sha384-/NExOjjk+Jjk5ZvMGKDUq/QxHVEJxhJXBBEb9AVSjc1zeQUYhG9cLvz1pY1/QrQF" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js" integrity="sha384-hcSOWoIpDyRLasgG/oDA+pDX1b7k6FX+8BkVxPvKXZ/w1ntszakmyGEJXgMeuWCU" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" integrity="sha384-I0kZgnELdQIeth/GMZEO2WQ1Po7jSQEkkqnh7bT+ofoJ9FVqFDhxeu7I10K5DyuV" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.min.js" integrity="sha384-Wzk9c33au7LnMJ233iNheTpEpVPeseLtoqnJElShswILwuk4JaiHjpj6fKD2yhoV" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.min.js" integrity="sha384-taEjHL+GUvC8IhXeCaTNUxz3O8ItsajGFLDFj4v/VJvM24HU36qP/6sRpuAGGfeT" crossorigin="anonymous"></script>
<% } else { %>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.js" integrity="sha384-l3ZPesZ3gDMDOrzjEodAMRyQlQnAR6KFZN2hnIr+h8Y80fuKlD1jsjdxJpKr9XgP" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.js" integrity="sha384-7Eh7MYtBZnl23Wa8pdx0AXtu35xsvTaEW6ZXBaFGIND87X3ZvFyqUffgUXa2D+HM" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.js" integrity="sha384-IkMvPbEBTaZXtDP8XKR0vzQv6h12mn1Tsm0YRzlJ5yZQtQ0b0fI2upc3EB5erSWp" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js" integrity="sha384-6JdClPjeS6N/byLmSMRUkfj8qIW6PR8InDu0hwcpUGithNg6/3+h6QpYNqB9LXXD" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" integrity="sha384-i3Zi0wHs7p1SlIuNOWAJupwQHyh8Vg54HE50dXe6SiJwHglCpbLr5Nd9wiswohGS" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.js" integrity="sha384-xJNO3hLLbOGPVKrsu1WIty+KkzNuJKdRr4blB8iY29iXAqkeGJWBtHZ0DGCYi/OE" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.js" integrity="sha384-uHURdoyk6Dqn/gSq38WU6iUc3W0A1rnHIKEp5sjIz8w4yz1eKIM8N7WvNwKMzALi" crossorigin="anonymous"></script>
<%
}
}
%>
</head>
<body>
<noscript>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -9,3 +9,22 @@ export default {
name: 'app'
}
</script>
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE \8F6F \96C5 \9ED1 ", "微软雅黑", Arial, sans-serif;
}
html, body, #app {
height: 100%;
}
body {
margin: 0;
background-color: #f6f8fa;
}
a, a:focus, a:hover {
text-decoration: none;
}
</style>

171
frontend/src/api/base.js Normal file
View File

@@ -0,0 +1,171 @@
import axios from 'axios'
import _ from 'lodash'
import CircuitBreaker from 'opossum'
axios.defaults.timeout = 10 * 1000
export const apiClient = axios.create({
timeout: 10 * 1000,
})
export let init
export let getBaseUrl
if (!process.env.BACKEND_DISCOVERY) {
init = async function() {}
const onRequest = config => {
config.baseURL = getBaseUrl()
return config
}
const onRequestError = e => {
throw e
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
getBaseUrl = function() {
return window.location.origin
}
} else {
init = async function() {
return updateBaseUrls()
}
const onRequest = config => {
let baseUrl = getBaseUrl()
if (baseUrl === null) {
throw new Error('No available endpoint')
}
config.baseURL = baseUrl
return config
}
const onRequestError = e => {
throw e
}
const onResponse = response => {
let promise = Promise.resolve(response)
let baseUrl = response.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
return response
}
const onResponseError = e => {
let promise = Promise.reject(e)
if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
let baseUrl = e.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
}
return promise
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
apiClient.interceptors.response.use(onResponse, onResponseError)
const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
// 只有公共服务器会开BACKEND_DISCOVERY这里可以直接跨域访问
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
] : [
`${window.location.origin}/api/endpoints`,
'http://localhost:12450/api/endpoints',
]
let baseUrls = process.env.NODE_ENV === 'production' ? [
'https://api1.blive.chat',
'https://api2.blive.chat',
] : [
window.location.origin,
'http://localhost:12450',
]
let curBaseUrl = null
let baseUrlToCircuitBreaker = new Map()
const doUpdateBaseUrls = async() => {
async function requestGetUrls(discoveryUrl) {
try {
return (await axios.get(discoveryUrl)).data.endpoints
} catch (e) {
console.warn('Failed to discover server endpoints from one source:', e)
throw e
}
}
let _baseUrls = []
try {
let promises = DISCOVERY_URLS.map(requestGetUrls)
_baseUrls = await Promise.any(promises)
} catch {
}
if (_baseUrls.length === 0) {
console.error('Failed to discover server endpoints from any source')
return
}
// 按响应时间排序
let sortedBaseUrls = []
let errorBaseUrls = []
async function testEndpoint(baseUrl) {
try {
let url = `${baseUrl}/api/ping`
await axios.get(url, { timeout: 3 * 1000 })
sortedBaseUrls.push(baseUrl)
} catch {
errorBaseUrls.push(baseUrl)
}
}
await Promise.all(_baseUrls.map(testEndpoint))
sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
baseUrls = sortedBaseUrls
if (baseUrls.indexOf(curBaseUrl) === -1) {
curBaseUrl = null
}
console.log('Found server endpoints:', baseUrls)
}
const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
getBaseUrl = function() {
updateBaseUrls()
if (curBaseUrl !== null) {
let breaker = getOrAddCircuitBreaker(curBaseUrl)
if (!breaker.opened) {
return curBaseUrl
}
curBaseUrl = null
}
// 找第一个未熔断的
for (let baseUrl of baseUrls) {
let breaker = getOrAddCircuitBreaker(baseUrl)
if (!breaker.opened) {
curBaseUrl = baseUrl
console.log('Switch server endpoint to', curBaseUrl)
return curBaseUrl
}
}
return null
}
const getOrAddCircuitBreaker = baseUrl => {
let breaker = baseUrlToCircuitBreaker.get(baseUrl)
if (breaker === undefined) {
breaker = new CircuitBreaker(promise => promise, {
timeout: false,
rollingCountTimeout: 60 * 1000,
errorThresholdPercentage: 70,
resetTimeout: 60 * 1000,
})
baseUrlToCircuitBreaker.set(baseUrl, breaker)
}
return breaker
}
}

View File

@@ -1,6 +1,6 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
@@ -14,7 +14,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
this.roomOwnerAuthCode = roomOwnerAuthCode
// 调用initRoom后初始化
this.roomOwnerUid = null
this.roomOwnerOpenId = null
this.hostServerUrlList = []
this.authBody = null
this.gameId = null
@@ -23,24 +23,13 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
}
stop() {
super.stop()
if (this.gameHeartbeatTimerId) {
window.clearInterval(this.gameHeartbeatTimerId)
this.gameHeartbeatTimerId = null
}
this.endGame()
super.stop()
}
async initRoom() {
if (!await this.startGame()) {
return false
}
if (this.gameId && this.gameHeartbeatTimerId === null) {
this.gameHeartbeatTimerId = window.setInterval(this.sendGameHeartbeat.bind(this), GAME_HEARTBEAT_INTERVAL)
}
return true
return this.startGame()
}
async startGame() {
@@ -54,13 +43,18 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
let msg = `code=${res.code}, message=${res.message}, request_id=${res.request_id}`
if (res.code === 7007) {
// 身份码错误
throw new chat.ChatClientFatalError(chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR, msg)
throw new chatModels.ChatClientFatalError(chatModels.FATAL_ERROR_TYPE_AUTH_CODE_ERROR, msg)
} else if (res.code === 7010) {
// 同一个房间连接数超过上限
throw new chatModels.ChatClientFatalError(chatModels.FATAL_ERROR_TYPE_TOO_MANY_CONNECTIONS, msg)
}
throw Error(msg)
throw new Error(msg)
}
} catch (e) {
console.error('startGame failed:', e)
if (e instanceof chat.ChatClientFatalError) {
this.addDebugMsg(`Failed to start Open Live session: ${e}`)
if (e instanceof chatModels.ChatClientFatalError) {
throw e
}
return false
@@ -73,34 +67,45 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
this.hostServerUrlList = websocketInfo.wss_link
let anchorInfo = data.anchor_info
// this.roomId = anchorInfo.room_id
this.roomOwnerUid = anchorInfo.uid
this.roomOwnerOpenId = anchorInfo.open_id
return true
}
async endGame() {
this.addDebugMsg('Ending Open Live session')
this.needInitRoom = true
if (!this.gameId) {
return true
}
let gameId = this.gameId
// 直接丢弃将要关闭的gameId
this.gameId = null
try {
let res = (await axios.post('/api/open_live/end_game', {
app_id: 0,
game_id: this.gameId
game_id: gameId
})).data
if (res.code !== 0) {
if (res.code === 7000 || res.code === 7003) {
// 项目已经关闭了也算成功
return true
}
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
// 项目已经关闭了也算成功
if ([0, 7000, 7003].indexOf(res.code) === -1) {
throw new Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('endGame failed:', e)
this.addDebugMsg(`Failed to end Open Live session: ${e}`)
return false
}
return true
}
onSendGameHeartbeat() {
// 加上随机延迟,减少同时请求的概率
let sleepTime = GAME_HEARTBEAT_INTERVAL - (2 * 1000) + (Math.random() * 3 * 1000)
this.gameHeartbeatTimerId = window.setTimeout(this.onSendGameHeartbeat.bind(this), sleepTime)
this.sendGameHeartbeat()
}
async sendGameHeartbeat() {
if (!this.gameId) {
return false
@@ -109,33 +114,36 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
// 保存一下防止await之后gameId改变
let gameId = this.gameId
try {
let res = (await axios.post('/api/open_live/game_heartbeat', {
game_id: this.gameId
})).data
let res = (await axios.post(
'/api/open_live/game_heartbeat',
{ game_id: gameId },
// 服务器有心跳合批,超时时间应该长一点
{ timeout: 15 * 1000 }
)).data
if (res.code !== 0) {
console.error(`sendGameHeartbeat failed: code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
if (res.code === 7003 && this.gameId === gameId) {
// 项目异常关闭,可能是心跳超时,需要重新开启项目
this.gameId = null
this.needInitRoom = true
this.discardWebsocket()
}
return false
throw new Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('sendGameHeartbeat failed:', e)
this.addDebugMsg(`Failed to send Open Live heartbeat: ${e}`)
return false
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
// 重连次数太多则重新initRoom保险
let reinitPeriod = Math.max(3, (this.hostServerUrlList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
await this.endGame()
}
return super.onBeforeWsConnect()
}
@@ -143,18 +151,41 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
return this.hostServerUrlList[this.retryCount % this.hostServerUrlList.length]
}
onWsOpen() {
super.onWsOpen()
if (this.gameId && this.gameHeartbeatTimerId === null) {
this.gameHeartbeatTimerId = window.setTimeout(this.onSendGameHeartbeat.bind(this), GAME_HEARTBEAT_INTERVAL)
}
}
sendAuth() {
this.websocket.send(this.makePacket(this.authBody, base.OP_AUTH))
}
async dmCallback(command) {
if (!this.onAddText) {
return
onWsClose() {
if (this.gameHeartbeatTimerId) {
window.clearTimeout(this.gameHeartbeatTimerId)
this.gameHeartbeatTimerId = null
}
super.onWsClose()
}
delayReconnect() {
if (document.visibilityState !== 'visible') {
// 不知道什么时候才能重连先endGame吧
this.endGame()
}
super.delayReconnect()
}
async dmCallback(command) {
let data = command.data
let authorType
if (data.uid === this.roomOwnerUid) {
if (data.open_id === this.roomOwnerOpenId) {
authorType = 3
} else if (data.guard_level !== 0) {
authorType = 1
@@ -167,7 +198,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
emoticon = data.emoji_img_url
}
data = {
data = new chatModels.AddTextMsg({
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
@@ -175,82 +206,61 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
content: data.msg,
privilegeType: data.guard_level,
isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg),
authorLevel: 1,
isNewbie: false,
isMobileVerified: true,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
translation: '',
emoticon: emoticon,
}
this.onAddText(data)
})
this.msgHandler.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (!data.paid) { // 丢人
return
}
data = {
let totalCoin = data.price * data.gift_num
data = new chatModels.AddGiftMsg({
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price * data.gift_num,
totalCoin: data.paid ? totalCoin : 0,
totalFreeCoin: !data.paid ? totalCoin : 0,
giftName: data.gift_name,
num: data.gift_num
}
this.onAddGift(data)
})
this.msgHandler.onAddGift(data)
}
async guardCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
data = new chatModels.AddMemberMsg({
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level
}
this.onAddMember(data)
})
this.msgHandler.onAddMember(data)
}
superChatCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
data = new chatModels.AddSuperChatMsg({
id: data.message_id.toString(),
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
})
this.msgHandler.onAddSuperChat(data)
}
superChatDelCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.message_ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
let data = new chatModels.DelSuperChatMsg({ ids })
this.msgHandler.onDelSuperChat(data)
}
}

View File

@@ -1,7 +1,6 @@
import axios from 'axios'
import { getUuid4Hex } from '@/utils'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
@@ -16,6 +15,8 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
this.hostServerToken = null
this.buvid = ''
}
async initRoom() {
@@ -32,6 +33,8 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
this.hostServerToken = res.hostServerToken
this.buvid = res.buvid
return true
}
@@ -56,15 +59,15 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
protover: 3,
platform: 'web',
type: 2,
buvid: '',
buvid: this.buvid,
}
if (this.hostServerToken !== null) {
authParams.key = this.hostServerToken
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
@@ -91,7 +94,7 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
let authorName = info[2][1]
let content = info[1]
let data = {
let data = new chatModels.AddTextMsg({
avatarUrl: await chat.getAvatarUrl(uid, authorName),
timestamp: info[0][4] / 1000,
authorName: authorName,
@@ -103,78 +106,57 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null,
}
this.onAddText(data)
})
this.msgHandler.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
let isPaidGift = data.coin_type === 'gold'
data = new chatModels.AddGiftMsg({
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
totalCoin: isPaidGift ? data.total_coin : 0,
totalFreeCoin: !isPaidGift ? data.total_coin : 0,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
})
this.msgHandler.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
data = new chatModels.AddMemberMsg({
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
})
this.msgHandler.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
data = new chatModels.AddSuperChatMsg({
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
})
this.msgHandler.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
let data = new chatModels.DelSuperChatMsg({ ids })
this.msgHandler.onDelSuperChat(data)
}
}

View File

@@ -2,6 +2,7 @@ import { BrotliDecode } from './brotli_decode'
import { inflate } from 'pako'
import * as chat from '..'
import * as chatModels from '../models'
const HEADER_SIZE = 16
@@ -45,18 +46,12 @@ export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.msgHandler = chat.getDefaultMsgHandler()
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.totalRetryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
@@ -74,28 +69,37 @@ export default class ChatClientOfficialBase {
}
async initRoom() {
throw Error('Not implemented')
throw new Error('Not implemented')
}
makePacket(data, operation) {
let body
if (typeof data === 'object') {
body = textEncoder.encode(JSON.stringify(data))
} else { // string
body = textEncoder.encode(data)
data = JSON.stringify(data)
}
let header = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(header)
headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len
headerView.setUint16(4, HEADER_SIZE) // raw_header_size
headerView.setUint16(6, 1) // ver
headerView.setUint32(8, operation) // operation
headerView.setUint32(12, 1) // seq_id
return new Blob([header, body])
let bodyArr = textEncoder.encode(data)
let headerBuf = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(headerBuf)
headerView.setUint32(0, HEADER_SIZE + bodyArr.byteLength) // pack_len
headerView.setUint16(4, HEADER_SIZE) // raw_header_size
headerView.setUint16(6, 1) // ver
headerView.setUint32(8, operation) // operation
headerView.setUint32(12, 1) // seq_id
// 这里如果直接返回 new Blob([headerBuf, bodyArr])在Chrome抓包会显示空包实际上是有数据的为了调试体验最好还是拷贝一遍
let headerArr = new Uint8Array(headerBuf)
let packetArr = new Uint8Array(bodyArr.length + headerArr.length)
packetArr.set(headerArr)
packetArr.set(bodyArr, headerArr.length)
return packetArr
}
sendAuth() {
throw Error('Not implemented')
throw new Error('Not implemented')
}
addDebugMsg(content) {
this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content }))
}
async wsConnect() {
@@ -103,6 +107,7 @@ export default class ChatClientOfficialBase {
return
}
this.addDebugMsg('Connecting')
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
@@ -126,25 +131,30 @@ export default class ChatClientOfficialBase {
} catch (e) {
res = false
console.error('initRoom exception:', e)
if (e instanceof chat.ChatClientFatalError && this.onFatalError) {
this.onFatalError(e)
if (e instanceof chatModels.ChatClientFatalError) {
this.stop()
this.msgHandler.onFatalError(e)
}
}
if (!res) {
this.onWsClose()
throw Error('initRoom failed')
window.setTimeout(() => this.onWsClose(), 0)
throw new Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw Error('Not implemented')
throw new Error('Not implemented')
}
onWsOpen() {
this.addDebugMsg('Connected and authenticating')
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
if (this.heartbeatTimerId === null) {
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
}
this.refreshReceiveTimeoutTimer()
}
@@ -161,6 +171,8 @@ export default class ChatClientOfficialBase {
onReceiveTimeout() {
console.warn('接收消息超时')
this.addDebugMsg('Receiving message timed out')
this.discardWebsocket()
}
@@ -170,13 +182,19 @@ export default class ChatClientOfficialBase {
this.receiveTimeoutTimerId = null
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
if (this.websocket) {
if (this.websocket.onclose) {
window.setTimeout(() => this.onWsClose(), 0)
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
}
}
onWsClose() {
this.addDebugMsg('Disconnected')
this.websocket = null
if (this.heartbeatTimerId) {
window.clearInterval(this.heartbeatTimerId)
@@ -191,15 +209,47 @@ export default class ChatClientOfficialBase {
return
}
this.retryCount++
console.warn('掉线重连中', this.retryCount)
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
this.totalRetryCount++
console.warn(`掉线重连中 retryCount=${this.retryCount}, totalRetryCount=${this.totalRetryCount}`)
// 防止无限重连的保险措施。30次重连大概会断线500秒应该够了
if (this.totalRetryCount > 30) {
this.stop()
let error = new chatModels.ChatClientFatalError(
chatModels.FATAL_ERROR_TYPE_TOO_MANY_RETRIES, 'The connection has lost too many times'
)
this.msgHandler.onFatalError(error)
return
}
this.delayReconnect()
}
delayReconnect() {
this.addDebugMsg(`Scheduling reconnection. The page is ${document.visibilityState === 'visible' ? 'visible' : 'invisible'}`)
if (document.visibilityState === 'visible') {
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
return
}
// 页面不可见就先不重连了,即使重连也会心跳超时
let listener = () => {
if (document.visibilityState !== 'visible') {
return
}
document.removeEventListener('visibilitychange', listener)
this.wsConnect()
}
document.addEventListener('visibilitychange', listener)
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
// 不用retryCount了防止意外的连接成功导致retryCount重置
let interval = Math.min(1000 + ((this.totalRetryCount - 1) * 2000), 20 * 1000)
// 加上随机延迟,防止同时请求导致雪崩
interval += Math.random() * 3000
return interval
}
onWsMessage(event) {

View File

@@ -1,4 +1,6 @@
import { getBaseUrl } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
const COMMAND_HEARTBEAT = 0
const COMMAND_JOIN_ROOM = 1
@@ -20,17 +22,11 @@ export default class ChatClientRelay {
this.roomKey = roomKey
this.autoTranslate = autoTranslate
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.msgHandler = chat.getDefaultMsgHandler()
this.websocket = null
this.retryCount = 0
this.totalRetryCount = 0
this.isDestroying = false
this.receiveTimeoutTimerId = null
}
@@ -46,12 +42,26 @@ export default class ChatClientRelay {
}
}
addDebugMsg(content) {
this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content }))
}
wsConnect() {
if (this.isDestroying) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${protocol}://${window.location.host}/api/chat`
this.addDebugMsg('Connecting')
let baseUrl = getBaseUrl()
if (baseUrl === null) {
this.addDebugMsg('No available endpoint')
window.setTimeout(() => this.onWsClose(), 0)
return
}
let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
url += '/api/chat'
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
@@ -59,6 +69,8 @@ export default class ChatClientRelay {
}
onWsOpen() {
this.addDebugMsg('Connected and authenticating')
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
@@ -79,16 +91,23 @@ export default class ChatClientRelay {
}
onReceiveTimeout() {
console.warn('接收消息超时')
this.receiveTimeoutTimerId = null
console.warn('接收消息超时')
this.addDebugMsg('Receiving message timed out')
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
if (this.websocket) {
if (this.websocket.onclose) {
window.setTimeout(() => this.onWsClose(), 0)
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
}
}
onWsClose() {
this.addDebugMsg('Disconnected')
this.websocket = null
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
@@ -98,15 +117,32 @@ export default class ChatClientRelay {
if (this.isDestroying) {
return
}
console.warn(`掉线重连中${++this.retryCount}`)
this.retryCount++
this.totalRetryCount++
console.warn(`掉线重连中 retryCount=${this.retryCount}, totalRetryCount=${this.totalRetryCount}`)
// 防止无限重连的保险措施。30次重连大概会断线500秒应该够了
if (this.totalRetryCount > 30) {
this.stop()
let error = new chatModels.ChatClientFatalError(
chatModels.FATAL_ERROR_TYPE_TOO_MANY_RETRIES, 'The connection has lost too many times'
)
this.msgHandler.onFatalError(error)
return
}
this.addDebugMsg('Scheduling reconnection')
// 这边不用判断页面是否可见,因为发心跳包不是由定时器触发的,即使是不活动页面也不会心跳超时
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
// 不用retryCount了防止意外的连接成功导致retryCount重置
let interval = Math.min(1000 + ((this.totalRetryCount - 1) * 2000), 20 * 1000)
// 加上随机延迟,防止同时请求导致雪崩
interval += Math.random() * 3000
return interval
}
onWsMessage(event) {
@@ -122,10 +158,6 @@ export default class ChatClientRelay {
break
}
case COMMAND_ADD_TEXT: {
if (!this.onAddText) {
break
}
let emoticon = null
let contentType = data[13]
let contentTypeParams = data[14]
@@ -134,7 +166,7 @@ export default class ChatClientRelay {
}
let content = data[4]
data = {
data = new chatModels.AddTextMsg({
avatarUrl: data[0],
timestamp: data[1],
authorName: data[2],
@@ -148,52 +180,43 @@ export default class ChatClientRelay {
medalLevel: data[10],
id: data[11],
translation: data[12],
emoticon: emoticon,
}
this.onAddText(data)
emoticon: emoticon
})
this.msgHandler.onAddText(data)
break
}
case COMMAND_ADD_GIFT: {
if (this.onAddGift) {
this.onAddGift(data)
}
data = new chatModels.AddGiftMsg(data)
this.msgHandler.onAddGift(data)
break
}
case COMMAND_ADD_MEMBER: {
if (this.onAddMember) {
this.onAddMember(data)
}
data = new chatModels.AddMemberMsg(data)
this.msgHandler.onAddMember(data)
break
}
case COMMAND_ADD_SUPER_CHAT: {
if (this.onAddSuperChat) {
this.onAddSuperChat(data)
}
data = new chatModels.AddSuperChatMsg(data)
this.msgHandler.onAddSuperChat(data)
break
}
case COMMAND_DEL_SUPER_CHAT: {
if (this.onDelSuperChat) {
this.onDelSuperChat(data)
}
data = new chatModels.DelSuperChatMsg(data)
this.msgHandler.onDelSuperChat(data)
break
}
case COMMAND_UPDATE_TRANSLATION: {
if (!this.onUpdateTranslation) {
break
}
data = {
data = new chatModels.UpdateTranslationMsg({
id: data[0],
translation: data[1]
}
this.onUpdateTranslation(data)
})
this.msgHandler.onUpdateTranslation(data)
break
}
case COMMAND_FATAL_ERROR: {
if (!this.onFatalError) {
break
}
let error = new chat.ChatClientFatalError(data.type, data.msg)
this.onFatalError(error)
this.stop()
let error = new chatModels.ChatClientFatalError(data.type, data.msg)
this.msgHandler.onFatalError(error)
break
}
}

View File

@@ -1,6 +1,6 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as chat from '.'
import * as chatModels from './models'
const NAMES = [
'光羊',
@@ -32,6 +32,7 @@ const CONTENTS = [
'我柜子动了,我不玩了',
'老板大气,老板身体健康',
'我醉提酒游寒山,爽滑慢舔',
'[dog]文本[比心]表情[喝彩]',
'無駄無駄無駄無駄無駄無駄無駄無駄',
'欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦',
'所有没好全部康复呀,我的癌也全部康复呀',
@@ -46,6 +47,7 @@ const CONTENTS = [
'知らず知らず隠してた 本当の声を響かせてよほら',
'kksk',
'8888888888',
'text[吃瓜]emoticon',
'Never gonna give you up',
'Never gonna let you down',
'888888888888888888888888888888',
@@ -57,24 +59,26 @@ const CONTENTS = [
]
const EMOTICONS = [
'/static/img/emoticons/233.png',
'/static/img/emoticons/miaoa.png',
'/static/img/emoticons/lipu.png'
]
'233',
'miaoa',
'lipu',
'huangdou_xihuan',
'sakaban_jiayu_yutou',
].map(name => `/static/img/emoticons/${name}.png`)
const AUTHOR_TYPES = [
{ weight: 10, value: constants.AUTHRO_TYPE_NORMAL },
{ weight: 5, value: constants.AUTHRO_TYPE_MEMBER },
{ weight: 2, value: constants.AUTHRO_TYPE_ADMIN },
{ weight: 1, value: constants.AUTHRO_TYPE_OWNER }
{ weight: 10, value: constants.AUTHOR_TYPE_NORMAL },
{ weight: 5, value: constants.AUTHOR_TYPE_MEMBER },
{ weight: 2, value: constants.AUTHOR_TYPE_ADMIN },
{ weight: 1, value: constants.AUTHOR_TYPE_OWNER }
]
function randGuardInfo() {
let authorType = randomChoose(AUTHOR_TYPES)
let privilegeType
if (authorType === constants.AUTHRO_TYPE_MEMBER) {
if (authorType === constants.AUTHOR_TYPE_MEMBER) {
privilegeType = randInt(1, 3)
} else if (authorType === constants.AUTHRO_TYPE_ADMIN) {
} else if (authorType === constants.AUTHOR_TYPE_ADMIN) {
privilegeType = randInt(0, 3)
} else {
privilegeType = 0
@@ -83,6 +87,7 @@ function randGuardInfo() {
}
const GIFT_INFO_LIST = [
{ giftName: '辣条', totalFreeCoin: 1000, num: 10 },
{ giftName: 'B坷垃', totalCoin: 9900 },
{ giftName: '礼花', totalCoin: 28000 },
{ giftName: '花式夸夸', totalCoin: 39000 },
@@ -102,21 +107,16 @@ const MESSAGE_GENERATORS = [
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
message: new chatModels.AddTextMsg({
...randGuardInfo(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
isGiftDanmaku: randInt(1, 10) <= 1,
authorLevel: randInt(0, 60),
authorLevel: randInt(1, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: null,
}
})
}
}
},
@@ -126,21 +126,15 @@ const MESSAGE_GENERATORS = [
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
message: new chatModels.AddTextMsg({
...randGuardInfo(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: '',
isGiftDanmaku: false,
authorLevel: randInt(0, 60),
authorLevel: randInt(1, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: randomChoose(EMOTICONS),
}
})
}
}
},
@@ -150,14 +144,10 @@ const MESSAGE_GENERATORS = [
value() {
return {
type: constants.MESSAGE_TYPE_GIFT,
message: {
message: new chatModels.AddGiftMsg({
...randomChoose(GIFT_INFO_LIST),
id: getUuid4Hex(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
num: 1
}
})
}
}
},
@@ -167,15 +157,11 @@ const MESSAGE_GENERATORS = [
value() {
return {
type: constants.MESSAGE_TYPE_SUPER_CHAT,
message: {
id: getUuid4Hex(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
message: new chatModels.AddSuperChatMsg({
authorName: randomChoose(NAMES),
price: randomChoose(SC_PRICES),
content: randomChoose(CONTENTS),
translation: ''
}
})
}
}
},
@@ -185,13 +171,10 @@ const MESSAGE_GENERATORS = [
value() {
return {
type: constants.MESSAGE_TYPE_MEMBER,
message: {
id: getUuid4Hex(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
message: new chatModels.AddMemberMsg({
authorName: randomChoose(NAMES),
privilegeType: randInt(1, 3)
}
})
}
}
}
@@ -231,14 +214,7 @@ function randInt(min, max) {
export default class ChatClientTest {
constructor() {
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.msgHandler = chat.getDefaultMsgHandler()
this.timerId = null
}
@@ -262,6 +238,9 @@ export default class ChatClientTest {
} else {
sleepTime = randInt(0, 400)
}
if (this.timerId) {
window.clearTimeout(this.timerId)
}
this.timerId = window.setTimeout(this.onTimeout.bind(this), sleepTime)
}
@@ -271,16 +250,16 @@ export default class ChatClientTest {
let { type, message } = randomChoose(MESSAGE_GENERATORS)()
switch (type) {
case constants.MESSAGE_TYPE_TEXT:
this.onAddText(message)
this.msgHandler.onAddText(message)
break
case constants.MESSAGE_TYPE_GIFT:
this.onAddGift(message)
this.msgHandler.onAddGift(message)
break
case constants.MESSAGE_TYPE_MEMBER:
this.onAddMember(message)
this.msgHandler.onAddMember(message)
break
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.onAddSuperChat(message)
this.msgHandler.onAddSuperChat(message)
break
}
}

View File

@@ -1,11 +1,19 @@
import axios from 'axios'
import MD5 from 'crypto-js/md5'
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
import { apiClient as axios } from '@/api/base'
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
export function getDefaultMsgHandler() {
let dummyFunc = () => {}
return {
onAddText: dummyFunc,
onAddGift: dummyFunc,
onAddMember: dummyFunc,
onAddSuperChat: dummyFunc,
onDelSuperChat: dummyFunc,
onUpdateTranslation: dummyFunc,
onFatalError: dummyFunc,
onDebugMsg: dummyFunc,
}
}
@@ -21,6 +29,10 @@ export function processAvatarUrl(avatarUrl) {
}
export async function getAvatarUrl(uid, username) {
if (uid === 0) {
return getDefaultAvatarUrl(uid, username)
}
let res
try {
res = (await axios.get('/api/avatar_url', { params: {
@@ -28,11 +40,24 @@ export async function getAvatarUrl(uid, username) {
username: username
} })).data
} catch {
return DEFAULT_AVATAR_URL
return getDefaultAvatarUrl(uid, username)
}
return res.avatarUrl
}
export function getDefaultAvatarUrl(uid, username) {
let strToHash
if (uid !== 0) {
strToHash = uid.toString()
} else if (username !== '') {
strToHash = username
} else {
return DEFAULT_AVATAR_URL
}
let idHash = MD5(strToHash).toString()
return `//cravatar.cn/avatar/${idHash}?s=256&d=robohash&f=y`
}
export async function getTextEmoticons() {
let res
try {

View File

@@ -0,0 +1,132 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as chat from '.'
export class AddTextMsg {
constructor({
avatarUrl = chat.DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
authorType = constants.AUTHOR_TYPE_NORMAL,
content = '',
privilegeType = 0,
isGiftDanmaku = false,
authorLevel = 1,
isNewbie = false,
isMobileVerified = true,
medalLevel = 0,
id = getUuid4Hex(),
translation = '',
emoticon = null,
} = {}) {
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.authorType = authorType
this.content = content
this.privilegeType = privilegeType
this.isGiftDanmaku = isGiftDanmaku
this.authorLevel = authorLevel
this.isNewbie = isNewbie
this.isMobileVerified = isMobileVerified
this.medalLevel = medalLevel
this.id = id
this.translation = translation
this.emoticon = emoticon
}
}
export class AddGiftMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = chat.DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
totalCoin = 0,
totalFreeCoin = 0,
giftName = '',
num = 1,
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.totalCoin = totalCoin
this.totalFreeCoin = totalFreeCoin
this.giftName = giftName
this.num = num
}
}
export class AddMemberMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = chat.DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
privilegeType = 1,
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.privilegeType = privilegeType
}
}
export class AddSuperChatMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = chat.DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
price = 0,
content = '',
translation = '',
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.price = price
this.content = content
this.translation = translation
}
}
export class DelSuperChatMsg {
constructor({
ids = [],
} = {}) {
this.ids = ids
}
}
export class UpdateTranslationMsg {
constructor({
id = getUuid4Hex(),
translation = '',
} = {}) {
this.id = id
this.translation = translation
}
}
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
export const FATAL_ERROR_TYPE_TOO_MANY_RETRIES = 2
export const FATAL_ERROR_TYPE_TOO_MANY_CONNECTIONS = 3
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
}
}
export class DebugMsg {
constructor({
content = '',
} = {}) {
this.content = content
}
}

View File

@@ -3,7 +3,7 @@ import _ from 'lodash'
import { mergeConfig } from '@/utils'
export const DEFAULT_CONFIG = {
minGiftPrice: 7, // $1
minGiftPrice: 0.1,
showDanmaku: true,
showGift: true,
showGiftName: false,
@@ -19,9 +19,11 @@ export const DEFAULT_CONFIG = {
blockUsers: '',
blockMedalLevel: 0,
showDebugMessages: false,
relayMessagesByServer: false,
autoTranslate: false,
giftUsernamePronunciation: '',
importPresetCss: false,
emoticons: [] // [{ keyword: '', url: '' }, ...]
}
@@ -42,7 +44,10 @@ export function getLocalConfig() {
sanitizeConfig(config)
return config
} catch {
return deepCloneDefaultConfig()
let config = deepCloneDefaultConfig()
// 新用户默认开启调试消息,免得总有人问
config.showDebugMessages = true
return config
}
}

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getServerInfo() {
return (await axios.get('/api/server_info')).data

View File

@@ -0,0 +1,16 @@
import { apiClient as axios } from './base'
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

@@ -12,7 +12,7 @@
:isAdmin="false" :privilegeType="privilegeType"
></author-badge>
<template v-else>
<author-badge v-if="authorType === AUTHRO_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip"
<author-badge v-if="authorType === AUTHOR_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip"
isAdmin :privilegeType="0"
></author-badge>
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip"
@@ -40,7 +40,7 @@ export default {
},
data() {
return {
AUTHRO_TYPE_ADMIN: constants.AUTHRO_TYPE_ADMIN
AUTHOR_TYPE_ADMIN: constants.AUTHOR_TYPE_ADMIN
}
},
computed: {

View File

@@ -1,5 +1,7 @@
<template>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header
:blc-guard-level="privilegeType"
>
<div id="card" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header" class="style-scope yt-live-chat-membership-item-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-membership-item-renderer"

View File

@@ -8,6 +8,7 @@
'--yt-live-chat-paid-message-timestamp-color': color.time,
'--yt-live-chat-paid-message-color': color.content
}"
:blc-price-level="priceConfig.priceLevel"
>
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header" class="style-scope yt-live-chat-paid-message-renderer">
@@ -17,7 +18,7 @@
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ priceText }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ showPriceText }}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
</div>
@@ -43,15 +44,19 @@ export default {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
priceText: String,
time: Date,
content: String
},
computed: {
color() {
return constants.getPriceConfig(this.price).colors
priceConfig() {
return constants.getPriceConfig(this.price)
},
priceText() {
return `CN¥${utils.formatCurrency(this.price)}`
color() {
return this.priceConfig.colors
},
showPriceText() {
return this.priceText || `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)

View File

@@ -1,5 +1,5 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText">
<yt-live-chat-text-message-renderer :author-type="authorTypeText" :blc-guard-level="privilegeType">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-text-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
@@ -11,9 +11,12 @@
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大在图片加载完后布局会变化可能导致滚动卡住没什么好的解决方法 -->
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
:width="content.width" :height="content.height"
:class="{ 'blc-large-emoji': content.height >= 100 }"
>
</template>
<el-badge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"

View File

@@ -1,9 +1,9 @@
import * as i18n from '@/i18n'
export const AUTHRO_TYPE_NORMAL = 0
export const AUTHRO_TYPE_MEMBER = 1
export const AUTHRO_TYPE_ADMIN = 2
export const AUTHRO_TYPE_OWNER = 3
export const AUTHOR_TYPE_NORMAL = 0
export const AUTHOR_TYPE_MEMBER = 1
export const AUTHOR_TYPE_ADMIN = 2
export const AUTHOR_TYPE_OWNER = 3
export const AUTHOR_TYPE_TO_TEXT = [
'',
@@ -39,68 +39,37 @@ export const CONTENT_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
export const PRICE_CONFIGS = [
{ // $100红
price: 100 * EXCHANGE_RATE,
const PRICE_CONFIGS = [
// 0 淡蓝
{
price: 0,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 60
},
{ // $50品红
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 30
},
{ // $20橙
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
header: 'rgba(255,255,255,0.87451)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,0.87451)'
},
pinTime: 10
},
{ // $10黄
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
header: 'rgba(0,0,0,0.87451)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,0.87451)'
},
pinTime: 5
},
{ // $5绿
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
contentBg: 'rgba(153, 236, 255, 1)',
headerBg: 'rgba(153, 236, 255, 1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.541176)',
authorName: 'rgba(0,0,0,0.701961)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 2
pinTime: 0,
priceLevel: 0,
},
{ // $2浅
// ¥0.01
{
price: 0.01,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 0,
priceLevel: 1,
},
// $2 浅蓝
{
price: 2 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(0,229,255,1)',
@@ -110,29 +79,91 @@ export const PRICE_CONFIGS = [
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 0
pinTime: 0,
priceLevel: 2,
},
{ // $1蓝
price: EXCHANGE_RATE,
// $5 绿
{
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 2,
priceLevel: 3,
},
// $10 黄
{
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
header: 'rgba(0,0,0,0.87451)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,0.87451)'
},
pinTime: 5,
priceLevel: 4,
},
// $20 橙
{
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
header: 'rgba(255,255,255,0.87451)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,0.87451)'
},
pinTime: 10,
priceLevel: 5,
},
// $50 品红
{
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 0
}
pinTime: 30,
priceLevel: 6,
},
// $100 红
{
price: 100 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 60,
priceLevel: 7,
},
]
export function getPriceConfig(price) {
for (const config of PRICE_CONFIGS) {
if (price >= config.price) {
return config
let i = 0
// 根据先验知识,从小找到大通常更快结束
for (; i < PRICE_CONFIGS.length - 1; i++) {
let nextConfig = PRICE_CONFIGS[i + 1]
if (price < nextConfig.price) {
return PRICE_CONFIGS[i]
}
}
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
return PRICE_CONFIGS[i]
}
export function getShowContent(message) {
@@ -160,6 +191,10 @@ export function getGiftShowContent(message, showGiftName) {
return i18n.i18n.t('chat.sendGift', { giftName: message.giftName, num: message.num })
}
export function getGiftShowNameAndNum(message) {
return `${message.giftName}x${message.num}`
}
export function getShowAuthorName(message) {
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
return `${message.authorName}(${message.authorNamePronunciation})`

View File

@@ -5,7 +5,7 @@
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName"></ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 0px;">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }"
>
@@ -26,7 +26,8 @@
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getGiftShowContent(message)"
:priceText="message.price <= 0 ? getGiftShowNameAndNum(message) : ''"
:content="message.price <= 0 ? '' : getGiftShowContent(message)"
></paid-message>
<membership-item :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
@@ -53,6 +54,7 @@
</template>
<script>
import _ from 'lodash'
import * as chatConfig from '@/api/chatConfig'
import Ticker from './Ticker'
import TextMessage from './TextMessage'
@@ -60,12 +62,12 @@ import MembershipItem from './MembershipItem'
import PaidMessage from './PaidMessage'
import * as constants from './constants'
// 只有要添加的消息需要平滑
const NEED_SMOOTH_MESSAGE_TYPES = [
// 要添加的消息类型
const ADD_MESSAGE_TYPES = [
constants.MESSAGE_TYPE_TEXT,
constants.MESSAGE_TYPE_GIFT,
constants.MESSAGE_TYPE_MEMBER,
constants.MESSAGE_TYPE_SUPER_CHAT
constants.MESSAGE_TYPE_SUPER_CHAT,
]
// 发送消息时间间隔范围
const MESSAGE_MIN_INTERVAL = 80
@@ -149,6 +151,7 @@ export default {
getGiftShowContent(message) {
return constants.getGiftShowContent(message, this.showGiftName)
},
getGiftShowNameAndNum: constants.getGiftShowNameAndNum,
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowAuthorName: constants.getShowAuthorName,
@@ -159,13 +162,14 @@ export default {
addMessages(messages) {
this.enqueueMessages(messages)
},
// 后悔加这个功能了
mergeSimilarText(content) {
content = content.trim().toLowerCase()
let res = false
this.forEachRecentMessage(5, message => {
for (let message of this.iterRecentMessages(5)) {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
return true
continue
}
let messageContent = message.content.trim().toLowerCase()
let longer, shorter
if (messageContent.length > content.length) {
@@ -175,51 +179,61 @@ export default {
longer = content
shorter = messageContent
}
if (longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
if (
longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
// 其实有小概率导致弹幕卡住
message.repeated++
res = true
return false
}
return true
})
return res
},
mergeSimilarGift(authorName, price, giftName, num) {
let res = false
this.forEachRecentMessage(5, message => {
if (message.type === constants.MESSAGE_TYPE_GIFT
&& message.authorName === authorName
&& message.giftName === giftName
) {
message.price += price
message.num += num
res = true
return false
}
return true
})
return res
},
forEachRecentMessage(num, callback) {
// 从新到老遍历num条消息
for (let i = this.smoothedMessageQueue.length - 1; i >= 0 && num > 0; i--) {
let messageGroup = this.smoothedMessageQueue[i]
for (let j = messageGroup.length - 1; j >= 0 && num-- > 0; j--) {
if (!callback(messageGroup[j])) {
return
}
this.updateMessage(message.id, { $add: {
repeated: 1
} })
return true
}
}
for (let arr of [this.messagesBuffer, this.messages]) {
for (let i = arr.length - 1; i >= 0 && num-- > 0; i--) {
if (!callback(arr[i])) {
return
}
return false
},
mergeSimilarGift(authorName, price, _freePrice, giftName, num) {
for (let message of this.iterRecentMessages(5)) {
if (
message.type === constants.MESSAGE_TYPE_GIFT
&& message.authorName === authorName
&& message.giftName === giftName
) {
this.updateMessage(message.id, { $add: {
price: price,
// freePrice: freePrice, // 暂时没用到
num: num
} })
return true
}
}
return false
},
// 从新到老迭代num条消息注意会迭代smoothedMessageQueue不会迭代paidMessages
*iterRecentMessages(num, onlyCountAddMessages = true) {
if (num <= 0) {
return
}
for (let arr of this.iterMessageArrs()) {
for (let i = arr.length - 1; i >= 0 && num > 0; i--) {
let message = arr[i]
yield message
if (!onlyCountAddMessages || this.isAddMessage(message)) {
num--
}
}
if (num <= 0) {
break
}
}
},
// 从新到老迭代消息的数组
*iterMessageArrs() {
for (let i = this.smoothedMessageQueue.length - 1; i >= 0; i--) {
yield this.smoothedMessageQueue[i]
}
yield this.messagesBuffer
yield this.messages
},
delMessage(id) {
this.delMessages([id])
@@ -283,7 +297,7 @@ export default {
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (this.messageNeedSmooth(message)) {
if (this.isAddMessage(message)) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
@@ -306,8 +320,8 @@ export default {
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
}
},
messageNeedSmooth({ type }) {
return NEED_SMOOTH_MESSAGE_TYPES.indexOf(type) !== -1
isAddMessage({ type }) {
return ADD_MESSAGE_TYPES.indexOf(type) !== -1
},
emitSmoothedMessages() {
this.emitSmoothedMessageTimerId = null
@@ -377,6 +391,7 @@ export default {
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
// 这里处理的类型要和 ADD_MESSAGE_TYPES 一致
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
@@ -393,53 +408,73 @@ export default {
this.$nextTick(this.maybeScrollToBottom)
},
handleAddMessage(message) {
message = {
...message,
addTime: new Date() // 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
}
this.messagesBuffer.push(message)
// 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
message.addTime = new Date()
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
this.paidMessages.unshift(_.cloneDeep(message))
const MAX_PAID_MESSAGE_NUM = 100
if (this.paidMessages.length > MAX_PAID_MESSAGE_NUM) {
this.paidMessages.splice(MAX_PAID_MESSAGE_NUM, this.paidMessages.length - MAX_PAID_MESSAGE_NUM)
}
}
// 不知道cloneDeep拷贝Vue的响应式对象会不会有问题保险起见把这句放在后面
this.messagesBuffer.push(message)
},
handleDelMessage({ id }) {
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
let arrs = [this.messages, this.paidMessages, this.messagesBuffer]
let needResetSmoothScroll = false
for (let arr of arrs) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
arr.splice(i, 1)
this.resetSmoothScroll()
break
if (arr[i].id !== id) {
continue
}
arr.splice(i, 1)
if (arr === this.messages) {
needResetSmoothScroll = true
}
break
}
}
if (needResetSmoothScroll) {
this.resetSmoothScroll()
}
},
handleUpdateMessage({ id, newValuesObj }) {
// 遍历滚动的消息
this.forEachRecentMessage(999999999, message => {
if (message.id !== id) {
return true
let arrs = [this.messages, this.paidMessages, this.messagesBuffer]
let needResetSmoothScroll = false
for (let arr of arrs) {
for (let message of arr) {
if (message.id !== id) {
continue
}
this.doUpdateMessage(message, newValuesObj)
if (arr === this.messages) {
needResetSmoothScroll = true
}
break
}
}
if (needResetSmoothScroll) {
this.resetSmoothScroll()
}
},
doUpdateMessage(message, newValuesObj) {
// +=
let addValuesObj = newValuesObj.$add
if (addValuesObj !== undefined) {
for (let name in addValuesObj) {
message[name] += addValuesObj[name]
}
}
// =
for (let name in newValuesObj) {
if (!name.startsWith('$')) {
message[name] = newValuesObj[name]
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
return false
})
// 遍历固定的消息
for (let message of this.paidMessages) {
if (message.id !== id) {
continue
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
break
}
this.resetSmoothScroll()
},
async flushMessagesBuffer() {
@@ -544,6 +579,7 @@ export default {
maybeResizeScrollContainer() {
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
this.$refs.itemOffset.style.minHeight = `${this.$refs.scroller.clientHeight}px`
this.maybeScrollToBottom()
},
maybeScrollToBottom() {

View File

@@ -6,7 +6,9 @@ import zh from '@/lang/zh'
let lastSetLocale = 'zh'
let loadedLocales = ['zh']
Vue.use(VueI18n)
if (!process.env.LIB_USE_CDN) {
Vue.use(VueI18n)
}
export async function setLocale(locale) {
lastSetLocale = locale

View File

@@ -3,19 +3,27 @@ export default {
home: 'Home',
stylegen: 'Style Generator',
help: 'Help',
plugins: 'Plugins',
links: 'Links',
projectAddress: 'Project Address',
giftRecordOfficial: 'Official Super Chat Record',
discussion: 'Discussions',
documentation: 'Documentation',
mall: 'Mall',
giftRecordOfficial: 'Super Chat Records',
},
home: {
roomIdEmpty: "Room ID can't be empty",
roomIdInteger: 'Room ID must be positive integer',
authCodeEmpty: "Identity code can't be empty",
authCodeFormatError: 'Identity code format error',
useAuthCodeWarning: 'Please prioritize the identity code',
unavailableWhenUsingAuthCode: 'Deprecated. Unavailable when using identity code',
disabledByServer: 'Disabled by the server',
general: 'General',
useAuthCodeWarning: 'Please prioritize the identity code, otherwise the avatars and usernames will not display',
room: 'Room',
roomId: 'Room ID',
roomId: 'Room ID (not recommended)',
authCode: 'Identity code',
howToGetAuthCode: 'How to get identity code',
showDanmaku: 'Show messages',
@@ -37,12 +45,18 @@ export default {
blockMedalLevel: 'Block medal level lower than',
advanced: 'Advanced',
relayMessagesByServer: 'Relay messages by server',
autoTranslate: 'Auto translate messages to Japanese (requires relay messages by server)',
showDebugMessages: 'Show debug messages',
showDebugMessagesTip: 'If the messages cannot be displayed, you can enable this for debugging. Otherwise there is no need to enable it',
relayMessagesByServer: 'Relay messages by the server',
relayMessagesByServerTip: 'Message path when enabled: Bilibili server -> blivechat server -> your browser. Some advanced features require this to be enabled. It is recommended to enable it only when using blivechat locally, and not when using through a remote server',
autoTranslate: 'Auto translate messages to Japanese',
requiresRelayMessagesByServer: 'Requires relay messages by the server',
giftUsernamePronunciation: 'Pronunciation of gift username',
dontShow: 'None',
pinyin: 'Pinyin',
kana: 'Kana',
importPresetCss: 'Import the server preset CSS',
importPresetCssTip: 'Automatically import the server CSS file: data/custom_public/preset.css',
emoticon: 'Custom Emotes',
emoticonKeyword: 'Emote Code',
@@ -52,9 +66,10 @@ export default {
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
urlTooLong: 'The room URL is too long, and will be truncated by Livehime (but not by OBS)',
roomUrlUpdated: 'The room URL is updated. Remember to copy it again',
roomUrl: 'Room URL',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
copyTestRoomUrl: 'Copy test room URL',
exportConfig: 'Export config',
importConfig: 'Import config',
@@ -62,7 +77,7 @@ export default {
},
stylegen: {
legacy: 'Classic',
lineLike: 'Line-like',
lineLike: 'LINE-like',
light: 'light',
dark: 'dark',
@@ -79,6 +94,10 @@ export default {
userNames: 'User Names',
showUserNames: 'Show user names',
font: 'Font',
fontSelectTip: 'You can also input local font name. Fonts ranked first will be used first',
recentFonts: 'Recent fonts',
presetFonts: 'Preset fonts',
networkFonts: 'Network fonts',
fontSize: 'Font size',
lineHeight: 'Line height (0 for default)',
normalColor: 'Normal color',
@@ -88,6 +107,7 @@ export default {
showBadges: 'Show badges',
showColon: 'Show colon after name',
emoticonSize: 'Emoticon size',
largeEmoticonSize: 'Large emoticon size',
messages: 'Messages',
color: 'Color',
@@ -99,6 +119,7 @@ export default {
backgrounds: 'Backgrounds',
bgColor: 'Background color',
useBarsInsteadOfBg: 'Use bars instead of backgrounds',
showLargeEmoticonBg: 'Show large emoticon background',
messageBgColor: 'Message background color',
ownerMessageBgColor: 'Owner background color',
moderatorMessageBgColor: 'Moderator background color',
@@ -133,6 +154,7 @@ export default {
result: 'Result',
copy: 'Copy',
editor: 'Editor',
resetConfig: 'Reset config'
},
help: {
@@ -144,6 +166,9 @@ export default {
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'
},
room: {
fatalErrorOccurred: 'A fatal error has occurred. Please manually refresh the page to reconnect'
},
chat: {
moderator: 'moderator',
guardLevel1: 'governor',
@@ -152,5 +177,26 @@ export default {
sendGift: 'Sent {giftName}x{num}',
membershipTitle: 'New 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://www.bilibili.com/video/BV1nZ42187TX/">Introducing video</a></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,19 +3,27 @@ export default {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
help: 'ヘルプ',
plugins: 'プラグイン',
links: 'リンク',
projectAddress: 'プロジェクトアドレス',
giftRecordOfficial: '公式スーパーチャット記録',
discussion: '議論',
documentation: 'ドキュメンテーション',
mall: 'モール',
giftRecordOfficial: 'スーパーチャット記録',
},
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
roomIdInteger: 'ルームは正の整数でなければなりません',
authCodeEmpty: 'アイデンティティコードを空白にすることはできません',
authCodeFormatError: 'アイデンティティコードの形式エラー',
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください',
unavailableWhenUsingAuthCode: '非推奨です。アイデンティティコードを使用する際に利用できません',
disabledByServer: 'サーバーによって無効にされました',
general: '常規',
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください。そうしないと、アイコンやユーザー名が表示されません',
room: 'ルーム',
roomId: 'ルームID',
roomId: 'ルームID(推奨されません)',
authCode: 'アイデンティティコード',
howToGetAuthCode: 'アイデンティティコードの取得方法',
showDanmaku: 'コメントを表示する',
@@ -37,12 +45,18 @@ export default {
blockMedalLevel: 'ブロック勲章等級がx未満',
advanced: 'アドバンスド',
showDebugMessages: 'デバッグメッセージを表示する',
showDebugMessagesTip: 'メッセージが表示されない場合、デバッグのためにこれを有効にすることができます。それ以外の場合は、有効にする必要はありません',
relayMessagesByServer: 'サーバを介してメッセージを転送する',
autoTranslate: 'コメントを日本語に翻訳する(サーバを介してメッセージを転送する必要)',
relayMessagesByServerTip: '有効になった場合のメッセージパスBilibiliサーバー -> blivechatサーバー -> あなたのブラウザー。一部の高度な機能では、これが有効になっている必要があります。blivechatをローカルで使用する場合にのみ有効にすることを推奨します。リモートサーバを介して使用する場合には、有効にしないようにしてください',
autoTranslate: 'コメントを日本語に翻訳する',
requiresRelayMessagesByServer: 'サーバを介してメッセージを転送する必要',
giftUsernamePronunciation: 'スーパーチャットのユーザー名の発音',
dontShow: '非表示',
pinyin: 'ピンイン',
kana: '仮名',
importPresetCss: 'サーバープリセットのCSSをインポートする',
importPresetCssTip: 'サーバーのCSSファイル「data/custom_public/preset.css」を自動的にインポートする',
emoticon: 'カスタムスタンプ',
emoticonKeyword: '置き換えるキーワード',
@@ -52,9 +66,10 @@ export default {
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
urlTooLong: 'ルームのURLが長すぎて、直播姬によって切り詰められますただし、OBSでは切り詰められません',
roomUrlUpdated: 'ルームのURLが更新されました。再度コピーすることをお忘れなく',
roomUrl: 'ルームのURL',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
copyTestRoomUrl: 'テストルームのURLをコピーする',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
@@ -62,7 +77,7 @@ export default {
},
stylegen: {
legacy: '古典',
lineLike: 'Line風',
lineLike: 'LINE風',
light: '明るい',
dark: '暗い',
@@ -79,6 +94,10 @@ export default {
userNames: 'ユーザー名',
showUserNames: 'ユーザー名を表示する',
font: 'フォント',
fontSelectTip: 'ローカルフォント名も入力することができます。最初にランク付けされたフォントが最初に使用されます',
recentFonts: '最近のフォント',
presetFonts: 'プリセットフォント',
networkFonts: 'ネットワークフォント',
fontSize: 'フォントサイズ',
lineHeight: '行の高さ0はデフォルト',
normalColor: 'ノーマルの色',
@@ -88,6 +107,7 @@ export default {
showBadges: '勲章を見せる',
showColon: 'ユーザー名の後にコロンが表示されます',
emoticonSize: 'スタンプサイズ',
largeEmoticonSize: '大きなスタンプサイズ',
messages: 'コメント',
color: '色',
@@ -99,6 +119,7 @@ export default {
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '背景に代わります',
showLargeEmoticonBg: '大きなスタンプの背景を表示する',
messageBgColor: 'コメント背景色',
ownerMessageBgColor: 'オーナーコメント背景色',
moderatorMessageBgColor: '管理者コメント背景色',
@@ -133,6 +154,7 @@ export default {
result: '結果',
copy: 'コピー',
editor: 'エディタ',
resetConfig: 'デフォルトに戻す'
},
help: {
@@ -144,6 +166,9 @@ export default {
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
},
room: {
fatalErrorOccurred: '致命的なエラーが発生しました。ページを手動で更新して再接続してください'
},
chat: {
moderator: 'モデレーター',
guardLevel1: '総督',
@@ -152,5 +177,26 @@ export default {
sendGift: '{giftName}x{num} を贈りました',
membershipTitle: '新規メンバー',
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://www.bilibili.com/video/BV1nZ42187TX/">紹介動画</a></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,19 +3,27 @@ export default {
home: '首页',
stylegen: '样式生成器',
help: '帮助',
plugins: '插件',
links: '常用链接',
projectAddress: '项目地址',
giftRecordOfficial: '官方打赏记录',
discussion: '反馈 / 交流',
documentation: '文档',
mall: 'B站商店',
giftRecordOfficial: '打赏记录',
},
home: {
roomIdEmpty: '房间ID不能为空',
roomIdInteger: '房间ID必须为正整数',
authCodeEmpty: '身份码不能为空',
authCodeFormatError: '身份码格式错误',
useAuthCodeWarning: '请优先使用身份码',
unavailableWhenUsingAuthCode: '已过时,使用身份码时不可用',
disabledByServer: '已被服务器禁用',
general: '常规',
useAuthCodeWarning: '请优先使用身份码,否则无法显示头像和昵称',
room: '房间',
roomId: '房间ID',
roomId: '房间ID(不推荐)',
authCode: '身份码',
howToGetAuthCode: '如何获取身份码',
showDanmaku: '显示弹幕',
@@ -37,12 +45,18 @@ export default {
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
advanced: '高级',
showDebugMessages: '显示调试消息',
showDebugMessagesTip: '如果消息不能显示,可以开启这个用来调试,否则没必要开启',
relayMessagesByServer: '通过服务器转发消息',
autoTranslate: '自动翻译弹幕到日语(需要通过服务器转发消息)',
relayMessagesByServerTip: '开启时的消息路径B站服务器 -> blivechat服务器 -> 你的浏览器。部分高级功能需要开启这个。推荐只在本地使用blivechat时开启而通过远程服务器使用时不开启',
autoTranslate: '自动翻译弹幕到日语',
requiresRelayMessagesByServer: '需要通过服务器转发消息',
giftUsernamePronunciation: '标注打赏用户名读音',
dontShow: '不显示',
pinyin: '拼音',
kana: '日文假名',
importPresetCss: '导入服务器预设CSS',
importPresetCssTip: '自动导入服务器的CSS文件data/custom_public/preset.css',
emoticon: '自定义表情',
emoticonKeyword: '替换关键词',
@@ -52,9 +66,10 @@ export default {
emoticonFileTooLarge: '文件尺寸太大最大1MB',
urlTooLong: '房间URL太长了会被直播姬截断OBS不会',
roomUrlUpdated: '房间URL已更新记得重新复制',
roomUrl: '房间URL',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
copyTestRoomUrl: '复制测试房间URL',
exportConfig: '导出配置',
importConfig: '导入配置',
@@ -79,6 +94,10 @@ export default {
userNames: '用户名',
showUserNames: '显示用户名',
font: '字体',
fontSelectTip: '你也可以输入本地字体名。前面的字体会被优先使用',
recentFonts: '最近使用的字体',
presetFonts: '预设字体',
networkFonts: '网络字体',
fontSize: '字体尺寸',
lineHeight: '行高0为默认',
normalColor: '普通颜色',
@@ -88,6 +107,7 @@ export default {
showBadges: '显示勋章',
showColon: '用户名后显示冒号',
emoticonSize: '表情大小',
largeEmoticonSize: '大表情大小',
messages: '消息',
color: '颜色',
@@ -99,6 +119,7 @@ export default {
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '用条代替消息背景',
showLargeEmoticonBg: '显示大表情背景',
messageBgColor: '消息背景色',
ownerMessageBgColor: '主播消息背景色',
moderatorMessageBgColor: '房管消息背景色',
@@ -133,6 +154,7 @@ export default {
result: '结果',
copy: '复制',
editor: '编辑器',
resetConfig: '恢复默认设置'
},
help: {
@@ -144,6 +166,9 @@ export default {
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'
},
room: {
fatalErrorOccurred: '发生了一个致命错误,请手动刷新页面以重新连接'
},
chat: {
moderator: '管理员',
guardLevel1: '总督',
@@ -152,5 +177,23 @@ export default {
sendGift: '赠送 {giftName}x{num}',
membershipTitle: '新会员',
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://www.bilibili.com/video/BV1nZ42187TX/">介绍视频</a></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,17 +16,40 @@
<el-menu-item :index="$router.resolve({ name: 'help' }).href">
<i class="el-icon-question"></i>{{ $t('sidebar.help') }}
</el-menu-item>
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{ $t('sidebar.projectAddress') }}
</el-menu-item>
</a>
<a href="http://link.bilibili.com/ctool/vtuber" target="_blank">
<el-menu-item>
<i class="el-icon-link"></i>{{ $t('sidebar.giftRecordOfficial') }}
</el-menu-item>
</a>
<el-submenu index="null">
<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">
<template slot="title">
<i class="el-icon-link"></i>{{ $t('sidebar.links') }}
</template>
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{ $t('sidebar.projectAddress') }}
</el-menu-item>
</a>
<a href="https://github.com/xfgryujk/blivechat/discussions" target="_blank">
<el-menu-item>
<i class="el-icon-s-comment"></i>{{ $t('sidebar.discussion') }}
</el-menu-item>
</a>
<a href="https://github.com/xfgryujk/blivechat/wiki" target="_blank">
<el-menu-item>
<i class="el-icon-document"></i>{{ $t('sidebar.documentation') }}
</el-menu-item>
</a>
<a href="https://play-live.bilibili.com/details/1694397161340" target="_blank">
<el-menu-item>
<i class="el-icon-s-goods"></i>{{ $t('sidebar.mall') }}
</el-menu-item>
</a>
<a href="https://link.bilibili.com/p/center/index#/live-data/gift-list" target="_blank">
<el-menu-item>
<i class="el-icon-s-finance"></i>{{ $t('sidebar.giftRecordOfficial') }}
</el-menu-item>
</a>
</el-submenu>
<el-submenu index="2">
<template slot="title">
<i class="el-icon-chat-line-square"></i>Language
</template>

View File

@@ -52,23 +52,10 @@ export default {
</script>
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE \8F6F \96C5 \9ED1 ", "微软雅黑", Arial, sans-serif;
}
html, body, #app, .app-wrapper, .sidebar-container {
.app-wrapper, .sidebar-container {
height: 100%;
}
body {
margin: 0;
background-color: #f6f8fa;
}
a, a:focus, a:hover {
text-decoration: none;
}
.drawer-bg {
background: #000;
opacity: 0.3;

View File

@@ -1,60 +1,19 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import {
Alert, Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Link, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
import ElementUI from 'element-ui'
if (!process.env.LIB_USE_CDN) {
import('element-ui/lib/theme-chalk/index.css')
}
import * as apiBase from './api/base'
import * as i18n from './i18n'
import App from './App'
import Layout from './layout'
import Home from './views/Home'
import StyleGenerator from './views/StyleGenerator'
import Help from './views/Help'
import Room from './views/Room'
import NotFound from './views/NotFound'
axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
// 初始化element
Vue.use(Alert)
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(ButtonGroup)
Vue.use(Card)
Vue.use(Col)
Vue.use(ColorPicker)
Vue.use(Container)
Vue.use(Divider)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(Image)
Vue.use(Input)
Vue.use(Link)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
Vue.use(Option)
Vue.use(OptionGroup)
Vue.use(Radio)
Vue.use(RadioGroup)
Vue.use(Row)
Vue.use(Select)
Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
Vue.use(Switch)
Vue.use(Table)
Vue.use(TableColumn)
Vue.use(TabPane)
Vue.use(Tabs)
Vue.use(Tooltip)
Vue.prototype.$message = Message
if (!process.env.LIB_USE_CDN) {
Vue.use(VueRouter)
Vue.use(ElementUI)
}
Vue.config.ignoredElements = [
/^yt-/
@@ -65,23 +24,24 @@ const router = new VueRouter({
routes: [
{
path: '/',
component: Layout,
component: () => import('./layout'),
children: [
{ path: '', name: 'home', component: Home },
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
{ path: 'help', name: 'help', component: Help }
{ path: '', name: 'home', component: () => import('./views/Home') },
{ path: 'stylegen', name: 'stylegen', component: () => import('./views/StyleGenerator') },
{ path: 'help', name: 'help', component: () => import('./views/Help') },
{ path: 'plugins', name: 'plugins', component: () => import('./views/Plugins') },
]
},
{
path: '/room/test',
name: 'test_room',
component: Room,
component: () => import('./views/Room'),
props: route => ({ strConfig: route.query })
},
{
path: '/room/:roomKeyValue',
name: 'room',
component: Room,
component: () => import('./views/Room'),
props(route) {
let roomKeyType = parseInt(route.query.roomKeyType) || 1
if (roomKeyType < 1 || roomKeyType > 2) {
@@ -101,6 +61,8 @@ const router = new VueRouter({
]
})
await apiBase.init()
new Vue({
render: h => h(App),
router,

View File

@@ -21,6 +21,14 @@ export function toInt(val, _default) {
return res
}
export function toFloat(val, _default) {
let res = parseFloat(val)
if (isNaN(res)) {
res = _default
}
return res
}
export function formatCurrency(price) {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: price < 100 ? 2 : 0

View File

@@ -2,18 +2,18 @@
<div>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.jpg"></el-image></p>
<p>{{ $t('help.p2') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.jpg"></el-image></p>
<p>{{ $t('help.p3') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.jpg"></el-image></p>
<p>{{ $t('help.p4') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.jpg"></el-image></p>
<p>{{ $t('help.p5') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.jpg"></el-image></p>
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
<p>喜欢的话可以推荐给别人专栏求支持_(:з)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
<p>配置官方翻译接口傻瓜式教程 <a href="https://www.bilibili.com/read/cv14663633" target="_blank">https://www.bilibili.com/read/cv14663633</a>。注意必须下载到本地才能改后台配置,本地使用方法看 <a href="https://github.com/xfgryujk/blivechat#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95" target="_blank">项目地址中的使用方法说明</a></p>
<p>使用前必看<a href="https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" target="_blank">注意事项和常见问题</a></p>
<p>喜欢的话可以推荐给别人 _(:з)_</p>
</div>
</template>

View File

@@ -106,21 +106,27 @@
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
<el-tooltip :content="$t('home.unavailableWhenUsingAuthCode')">
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
</el-tooltip>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
<el-tooltip :content="$t('home.unavailableWhenUsingAuthCode')">
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
</el-tooltip>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
<el-tooltip :content="$t('home.unavailableWhenUsingAuthCode')">
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
</el-tooltip>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockMedalLevel')">
@@ -137,16 +143,33 @@
</el-tab-pane>
<el-tab-pane :label="$t('home.advanced')">
<el-row>
<el-col :xs="24" :sm="8">
<el-tooltip :content="$t('home.showDebugMessagesTip')">
<el-form-item :label="$t('home.showDebugMessages')">
<el-switch v-model="form.showDebugMessages"></el-switch>
</el-form-item>
</el-tooltip>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-tooltip :content="$t('home.relayMessagesByServerTip')">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
</el-tooltip>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-tooltip :content="$t('home.requiresRelayMessagesByServer')">
<el-form-item :label="$t('home.autoTranslate')">
<el-tooltip :content="$t('home.disabledByServer')" placement="top" :disabled="serverConfig.enableTranslate">
<el-switch v-model="form.autoTranslate"
:disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"
></el-switch>
</el-tooltip>
</el-form-item>
</el-tooltip>
</el-col>
</el-row>
<el-form-item :label="$t('home.giftUsernamePronunciation')">
@@ -156,6 +179,15 @@
<el-radio label="kana">{{$t('home.kana')}}</el-radio>
</el-radio-group>
</el-form-item>
<el-row>
<el-col :xs="24" :sm="8">
<el-tooltip :content="$t('home.importPresetCssTip')">
<el-form-item :label="$t('home.importPresetCss')">
<el-switch v-model="form.importPresetCss"></el-switch>
</el-form-item>
</el-tooltip>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane :label="$t('home.emoticon')">
@@ -195,13 +227,15 @@
<p v-if="obsRoomUrl.length > 1024">
<el-alert :title="$t('home.urlTooLong')" type="warning" show-icon :closable="false"></el-alert>
</p>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
</el-form-item>
<el-tooltip :content="$t('home.roomUrlUpdated')" v-model="showRoomUrlUpdatedTip" manual placement="top">
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
</el-form-item>
</el-tooltip>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="copyTestRoomUrl">{{$t('home.copyTestRoomUrl')}}</el-button>
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
@@ -237,6 +271,7 @@ export default {
// 因为$refs.form.validate是异步的所以不能直接用计算属性
// getUnvalidatedRoomUrl -> unvalidatedRoomUrl -> updateRoomUrl -> roomUrl
roomUrl: '',
showRoomUrlUpdatedTip: false,
}
},
computed: {
@@ -264,7 +299,15 @@ export default {
},
watch: {
unvalidatedRoomUrl: 'updateRoomUrl',
roomUrl: _.debounce(function() {
roomUrl: _.debounce(function(val, oldVal) {
// 提示用户URL已更新
// 如果语言不是默认的中文,则刷新页面时也会有一次提示,没办法
if (val !== '' && oldVal !== '') {
this.showRoomUrlUpdatedTip = true
this.delayHideRoomUrlUpdatedTip()
}
// 保存配置
window.localStorage.roomKeyType = this.form.roomKeyType
window.localStorage.roomId = this.form.roomId
window.localStorage.authCode = this.form.authCode
@@ -296,6 +339,9 @@ export default {
// 没有异步的校验规则,应该不需要考虑竞争条件
this.roomUrl = this.unvalidatedRoomUrl
},
delayHideRoomUrlUpdatedTip: _.debounce(function() {
this.showRoomUrlUpdatedTip = false
}, 3000),
addEmoticon() {
this.form.emoticons.push({
@@ -332,8 +378,8 @@ export default {
enterRoom() {
window.open(this.roomUrl, `room ${this.roomKeyValue}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
enterTestRoom() {
window.open(this.getUnvalidatedRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
copyTestRoomUrl() {
window.navigator.clipboard.writeText(this.getUnvalidatedRoomUrl(true))
},
getUnvalidatedRoomUrl(isTestRoom) {
// 重要的字段放在前面,因为如果被截断就连接不了房间了

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

@@ -4,15 +4,12 @@
<script>
import * as i18n from '@/i18n'
import { mergeConfig, toBool, toInt } from '@/utils'
import { mergeConfig, toBool, toInt, toFloat } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import * as chat from '@/api/chat'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirectWeb from '@/api/chat/ChatClientDirectWeb'
import ChatClientDirectOpenLive from '@/api/chat/ChatClientDirectOpenLive'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import * as chatModels from '@/api/chat/models'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -36,11 +33,17 @@ export default {
}
},
data() {
let customStyleElement = document.createElement('style')
document.head.appendChild(customStyleElement)
return {
config: chatConfig.deepCloneDefaultConfig(),
chatClient: null,
textEmoticons: [], // 官方的文本表情(后端配置的)
pronunciationConverter: null,
textEmoticons: [], // 官方的文本表情
customStyleElement, // 仅用于样式生成器中预览样式
presetCssLinkElement: null,
}
},
computed: {
@@ -54,15 +57,10 @@ export default {
}
return res
},
blockUsersTrie() {
blockUsersSet() {
let blockUsers = this.config.blockUsers.split('\n')
let res = new trie.Trie()
for (let user of blockUsers) {
if (user !== '') {
res.set(user, true)
}
}
return res
blockUsers = blockUsers.filter(user => user !== '')
return new Set(blockUsers)
},
emoticonsTrie() {
let res = new trie.Trie()
@@ -78,17 +76,31 @@ export default {
},
mounted() {
if (document.visibilityState === 'visible') {
this.init()
if (this.roomKeyValue === null) {
this.init()
} else {
// 正式房间要随机延迟加载,防止同时请求导致雪崩
window.setTimeout(this.init, Math.random() * 3000)
}
} else {
// 当前窗口不可见延迟到可见时加载防止OBS中一次并发太多请求OBS中浏览器不可见时也会加载网页除非显式设置
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
window.addEventListener('message', this.onWindowMessage)
},
beforeDestroy() {
window.removeEventListener('message', this.onWindowMessage)
document.removeEventListener('visibilitychange', this.onVisibilityChange)
if (this.chatClient) {
this.chatClient.stop()
}
document.head.removeChild(this.customStyleElement)
if (this.presetCssLinkElement) {
document.head.removeChild(this.presetCssLinkElement)
}
},
methods: {
onVisibilityChange() {
@@ -98,20 +110,40 @@ export default {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
this.init()
},
init() {
async init() {
this.initConfig()
this.initChatClient()
let initChatClientPromise = this.initChatClient()
this.initTextEmoticons()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
}
if (this.config.importPresetCss) {
this.presetCssLinkElement = document.createElement('link')
this.presetCssLinkElement.rel = 'stylesheet'
this.presetCssLinkElement.href = '/custom_public/preset.css'
document.head.appendChild(this.presetCssLinkElement)
}
try {
// 其他初始化就不用等了,就算失败了也不会有很大影响
await initChatClientPromise
} catch (e) {
this.$message.error({
message: `Failed to load: ${e}`,
duration: 10 * 1000
})
throw e
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: 500
})
this.sendMessageToStylegen('stylegenExampleRoomLoad')
},
initConfig() {
let locale = this.strConfig.lang
@@ -128,7 +160,7 @@ export default {
}
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
cfg.minGiftPrice = toInt(cfg.minGiftPrice, chatConfig.DEFAULT_CONFIG.minGiftPrice)
cfg.minGiftPrice = toFloat(cfg.minGiftPrice, chatConfig.DEFAULT_CONFIG.minGiftPrice)
cfg.showDanmaku = toBool(cfg.showDanmaku)
cfg.showGift = toBool(cfg.showGift)
cfg.showGiftName = toBool(cfg.showGiftName)
@@ -142,8 +174,11 @@ export default {
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, chatConfig.DEFAULT_CONFIG.blockMedalLevel)
cfg.showDebugMessages = toBool(cfg.showDebugMessages)
cfg.relayMessagesByServer = toBool(cfg.relayMessagesByServer)
cfg.autoTranslate = toBool(cfg.autoTranslate)
cfg.importPresetCss = toBool(cfg.importPresetCss)
cfg.emoticons = this.toObjIfJson(cfg.emoticons)
chatConfig.sanitizeConfig(cfg)
@@ -159,44 +194,77 @@ export default {
return {}
}
},
initChatClient() {
async initChatClient() {
if (this.roomKeyValue === null) {
let ChatClientTest = (await import('@/api/chat/ChatClientTest')).default
this.chatClient = new ChatClientTest()
} else if (this.config.relayMessagesByServer) {
let roomKey = {
type: this.roomKeyType,
value: this.roomKeyValue
}
let ChatClientRelay = (await import('@/api/chat/ChatClientRelay')).default
this.chatClient = new ChatClientRelay(roomKey, this.config.autoTranslate)
} else {
if (this.roomKeyType === 1) {
let ChatClientDirectWeb = (await import('@/api/chat/ChatClientDirectWeb')).default
this.chatClient = new ChatClientDirectWeb(this.roomKeyValue)
} else {
let ChatClientDirectOpenLive = (await import('@/api/chat/ChatClientDirectOpenLive')).default
this.chatClient = new ChatClientDirectOpenLive(this.roomKeyValue)
}
}
this.chatClient.onAddText = this.onAddText
this.chatClient.onAddGift = this.onAddGift
this.chatClient.onAddMember = this.onAddMember
this.chatClient.onAddSuperChat = this.onAddSuperChat
this.chatClient.onDelSuperChat = this.onDelSuperChat
this.chatClient.onUpdateTranslation = this.onUpdateTranslation
this.chatClient.onFatalError = this.onFatalError
this.chatClient.msgHandler = this
this.chatClient.start()
},
async initTextEmoticons() {
this.textEmoticons = await chat.getTextEmoticons()
},
start() {
this.chatClient.start()
sendMessageToStylegen(type, data = null) {
if (window.parent === window) {
return
}
let msg = { type, data }
window.parent.postMessage(msg, window.location.origin)
},
stop() {
this.chatClient.stop()
// 处理样式生成器发送的消息
onWindowMessage(event) {
if (event.source !== window.parent) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return
}
let { type, data } = event.data
switch (type) {
case 'roomSetCustomStyle':
this.customStyleElement.innerText = data.css
break
case 'roomStartClient':
if (this.chatClient) {
this.chatClient.start()
}
break
case 'roomStopClient':
if (this.chatClient) {
this.chatClient.stop()
}
break
}
},
onAddText(data) {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
/** @param {chatModels.AddTextMsg} data */
async onAddText(data) {
if (!this.config.showDanmaku || !this.filterTextMessage(data)) {
return
}
let richContent = await this.getRichContent(data)
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
if (this.mergeSimilarText(data.content)) {
return
}
let message = {
@@ -207,19 +275,20 @@ export default {
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
richContent: this.getRichContent(data),
richContent: richContent,
privilegeType: data.privilegeType,
repeated: 1,
translation: data.translation
}
this.$refs.renderer.addMessage(message)
},
/** @param {chatModels.AddGiftMsg} data */
onAddGift(data) {
if (!this.config.showGift) {
return
}
let price = data.totalCoin / 1000
if (this.mergeSimilarGift(data.authorName, price, data.giftName, data.num)) {
if (this.mergeSimilarGift(data.authorName, price, data.totalFreeCoin, data.giftName, data.num)) {
return
}
if (price < this.config.minGiftPrice) { // 丢人
@@ -233,11 +302,13 @@ export default {
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
price: price,
// freePrice: data.totalFreeCoin, // 暂时没用到
giftName: data.giftName,
num: data.num
}
this.$refs.renderer.addMessage(message)
},
/** @param {chatModels.AddMemberMsg} data */
onAddMember(data) {
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
return
@@ -254,6 +325,7 @@ export default {
}
this.$refs.renderer.addMessage(message)
},
/** @param {chatModels.AddSuperChatMsg} data */
onAddSuperChat(data) {
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
return
@@ -274,27 +346,47 @@ export default {
}
this.$refs.renderer.addMessage(message)
},
/** @param {chatModels.DelSuperChatMsg} data */
onDelSuperChat(data) {
this.$refs.renderer.delMessages(data.ids)
},
/** @param {chatModels.UpdateTranslationMsg} data */
onUpdateTranslation(data) {
if (!this.config.autoTranslate) {
return
}
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
},
/** @param {chatModels.ChatClientFatalError} error */
onFatalError(error) {
this.$message.error({
message: error.toString(),
duration: 10 * 1000
duration: 30 * 1000
})
this.chatClient.stop()
this.onAddText(new chatModels.AddTextMsg({
authorName: 'blivechat',
authorType: constants.AUTHOR_TYPE_ADMIN,
content: this.$t('room.fatalErrorOccurred'),
authorLevel: 60,
}))
if (error.type === chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR) {
if (error.type === chatModels.FATAL_ERROR_TYPE_AUTH_CODE_ERROR) {
// Read The Fucking Manual
this.$router.push({ name: 'help' })
}
},
/** @param {chatModels.DebugMsg} data */
onDebugMsg(data) {
if (!this.config.showDebugMessages) {
return
}
this.onAddText(new chatModels.AddTextMsg({
authorName: 'blivechat',
authorType: constants.AUTHOR_TYPE_ADMIN,
content: data.content,
authorLevel: 60,
}))
},
filterTextMessage(data) {
if (this.config.blockGiftDanmaku && data.isGiftDanmaku) {
@@ -327,7 +419,7 @@ export default {
return true
},
filterByAuthorName(authorName) {
return !this.blockUsersTrie.has(authorName)
return !this.blockUsersSet.has(authorName)
},
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
@@ -335,11 +427,11 @@ export default {
}
return this.$refs.renderer.mergeSimilarText(content)
},
mergeSimilarGift(authorName, price, giftName, num) {
mergeSimilarGift(authorName, price, freePrice, giftName, num) {
if (!this.config.mergeGift) {
return false
}
return this.$refs.renderer.mergeSimilarGift(authorName, price, giftName, num)
return this.$refs.renderer.mergeSimilarGift(authorName, price, freePrice, giftName, num)
},
getPronunciation(text) {
if (this.pronunciationConverter === null) {
@@ -347,16 +439,19 @@ export default {
}
return this.pronunciationConverter.getPronunciation(text)
},
getRichContent(data) {
async getRichContent(data) {
let richContent = []
// B站官方表情
// 官方的非文本表情
if (data.emoticon !== null) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.content,
url: data.emoticon
url: data.emoticon,
width: 0,
height: 0
})
await this.fillImageContentSizes(richContent)
return richContent
}
@@ -393,7 +488,9 @@ export default {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url
url: matchEmoticon.url,
width: 0,
height: 0
})
pos += matchEmoticon.keyword.length
startPos = pos
@@ -405,7 +502,50 @@ export default {
text: data.content.slice(startPos, pos)
})
}
await this.fillImageContentSizes(richContent)
return richContent
},
async fillImageContentSizes(richContent) {
let urlSizeMap = new Map()
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
urlSizeMap.set(content.url, { width: 0, height: 0 })
}
}
if (urlSizeMap.size === 0) {
return
}
let promises = []
for (let url of urlSizeMap.keys()) {
let urlInClosure = url
promises.push(new Promise(
resolve => {
let img = document.createElement('img')
img.onload = () => {
let size = urlSizeMap.get(urlInClosure)
size.width = img.naturalWidth
size.height = img.naturalHeight
resolve()
}
// 获取失败了默认为0
img.onerror = resolve
// 超时保底
window.setTimeout(resolve, 5000)
img.src = urlInClosure
}
))
}
await Promise.all(promises)
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
let size = urlSizeMap.get(content.url)
content.width = size.width
content.height = size.height
}
}
}
}
}

View File

@@ -1,15 +1,38 @@
<template>
<el-select :value="value" @input="val => $emit('input', val)" filterable allow-create default-first-option>
<el-option-group>
<el-option v-for="font in LOCAL_FONTS" :key="font" :value="font"></el-option>
</el-option-group>
<el-option-group>
<el-option v-for="font in NETWORK_FONTS" :key="font" :value="font"></el-option>
</el-option-group>
</el-select>
<el-tooltip :content="$t('stylegen.fontSelectTip')">
<el-select :value="innerValue" @input="onInnerInput" @visible-change="updateRecentFonts"
multiple filterable allow-create default-first-option popper-class="font-select-popper" style="position: relative;"
>
<el-option-group
v-for="(groupCfg, index) in [
{ fonts: recentFonts, label: $t('stylegen.recentFonts'), isRecent: true },
{ fonts: PRESET_FONTS, label: $t('stylegen.presetFonts') },
]"
:key="index"
:label="groupCfg.label"
>
<el-option v-for="font in groupCfg.fonts" :key="font" :value="font">
<span class="fonts-select-name-line">
<span>{{ font }}</span>
<el-button v-if="groupCfg.isRecent" type="text" class="fonts-select-btn" style="color: #f56c6c;"
@click="() => deleteRecentFont(font)"
>
<i class="el-icon-delete"></i>
</el-button>
</span>
<span class="fonts-select-sample" :style="{'font-family': font}">Sample 样例 サンプル</span>
</el-option>
</el-option-group>
<el-option-group :label="$t('stylegen.networkFonts')">
<el-option v-for="font in NETWORK_FONTS" :key="font" :value="font"></el-option>
</el-option-group>
</el-select>
</el-tooltip>
</template>
<script>
import * as common from './common'
import * as fonts from './fonts'
export default {
@@ -19,8 +42,61 @@ export default {
},
data() {
return {
LOCAL_FONTS: fonts.LOCAL_FONTS,
NETWORK_FONTS: fonts.NETWORK_FONTS
recentFonts: this.getRecentFonts(), // 这里只作为缓存以localStorage为准
PRESET_FONTS: fonts.PRESET_FONTS,
NETWORK_FONTS: fonts.NETWORK_FONTS,
innerValue: [],
}
},
watch: {
value: {
immediate: true,
handler(val) {
this.innerValue = common.fontsStrToArr(val)
}
}
},
methods: {
onInnerInput(val) {
let addedFonts = val.filter(font => this.innerValue.indexOf(font) === -1)
this.innerValue = val
this.$emit('input', common.fontsArrToStr(this.innerValue))
for (let font of addedFonts) {
this.addRecentFont(font)
}
},
updateRecentFonts() {
this.recentFonts = this.getRecentFonts()
},
getRecentFonts() {
return common.fontsStrToArr(window.localStorage.recentFonts || '')
},
setRecentFonts(recentFonts) {
window.localStorage.recentFonts = common.fontsArrToStr(recentFonts)
},
addRecentFont(font) {
let recentFonts = this.getRecentFonts()
let index = recentFonts.indexOf(font)
if (index !== -1) {
recentFonts.splice(index, 1)
}
recentFonts.unshift(font)
this.setRecentFonts(recentFonts)
this.updateRecentFonts()
},
deleteRecentFont(font) {
let recentFonts = this.getRecentFonts()
let index = recentFonts.indexOf(font)
if (index !== -1) {
recentFonts.splice(index, 1)
this.setRecentFonts(recentFonts)
}
this.updateRecentFonts()
}
}
}
@@ -28,6 +104,36 @@ export default {
<style scoped>
.el-select {
width: 100%
width: 100%;
}
.font-select-popper .el-select-group .el-select-dropdown__item {
display: flex;
flex-direction: column;
padding: 8px 20px;
height: fit-content;
}
.fonts-select-name-line {
display: flex;
justify-content: space-between;
align-items: center;
line-height: 1.5em;
}
.fonts-select-btn {
opacity: 0.2;
transition: 0.5s;
padding: 0 0 0 8px;
margin-right: 20px;
}
.fonts-select-btn:hover {
opacity: 1;
}
.fonts-select-sample {
opacity: 0.4;
line-height: 1.5em;
}
</style>

View File

@@ -135,6 +135,11 @@
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
@@ -384,6 +389,7 @@ export const DEFAULT_CONFIG = {
messageColor: '#ffffff',
messageOnNewLine: false,
emoticonSize: 18,
largeEmoticonSize: 36,
showTime: false,
timeFont: 'Imprima',
@@ -479,7 +485,7 @@ yt-live-chat-text-message-renderer {
return `/* Outlines */
yt-live-chat-renderer * {
${this.showOutlinesStyle}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.messageFont)};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
}`
@@ -519,7 +525,7 @@ yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="memb
yt-live-chat-text-message-renderer #author-name {
${this.form.showUserNames ? '' : 'display: none !important;'}
${this.form.userNameColor ? `color: ${this.form.userNameColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.userNameFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.userNameFont)};
font-size: ${this.form.userNameFontSize}px !important;
line-height: ${this.form.userNameLineHeight || this.form.userNameFontSize}px !important;
}
@@ -541,7 +547,7 @@ yt-live-chat-text-message-renderer #chat-badges {
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${this.form.messageColor ? `color: ${this.form.messageColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.messageFont)};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
}
@@ -554,6 +560,10 @@ ${!this.form.messageOnNewLine ? '' : `yt-live-chat-text-message-renderer #messag
yt-live-chat-text-message-renderer #message .emoji {
width: auto !important;
height: ${this.form.emoticonSize}px !important;
}
yt-live-chat-text-message-renderer #message .emoji.blc-large-emoji {
height: ${this.form.largeEmoticonSize}px !important;
}`
},
timeStyle() {
@@ -599,7 +609,7 @@ yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
${this.form.firstLineColor ? `color: ${this.form.firstLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.firstLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.firstLineFont)};
font-size: ${this.form.firstLineFontSize}px !important;
line-height: ${this.form.firstLineLineHeight || this.form.firstLineFontSize}px !important;
}
@@ -609,7 +619,7 @@ yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
${this.form.secondLineColor ? `color: ${this.form.secondLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.secondLineFont)};
font-size: ${this.form.secondLineFontSize}px !important;
line-height: ${this.form.secondLineLineHeight || this.form.secondLineFontSize}px !important;
}
@@ -617,7 +627,7 @@ yt-live-chat-membership-item-renderer #header-subtext * {
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
${this.form.scContentColor ? `color: ${this.form.scContentColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.scContentFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.scContentFont)};
font-size: ${this.form.scContentFontSize}px !important;
line-height: ${this.form.scContentLineHeight || this.form.scContentFontSize}px !important;
}`
@@ -643,7 +653,7 @@ yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
${this.form.secondLineColor ? `color: ${this.form.secondLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.secondLineFont)};
}`
},
animationStyle() {
@@ -656,7 +666,7 @@ yt-live-chat-ticker-sponsor-item-renderer * {
this.saveConfig()
}
},
created() {
mounted() {
this.$emit('input', this.result)
},
methods: {

View File

@@ -108,6 +108,11 @@
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
@@ -150,6 +155,11 @@
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showLargeEmoticonBg')">
<el-switch v-model="form.showLargeEmoticonBg"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
@@ -328,6 +338,7 @@ export const DEFAULT_CONFIG = {
messageLineHeight: 0,
messageColor: '#000000',
emoticonSize: 18,
largeEmoticonSize: 36,
showTime: false,
timeFont: 'Noto Sans SC',
@@ -340,6 +351,7 @@ export const DEFAULT_CONFIG = {
ownerMessageBgColor: 'rgba(231, 199, 30, 1)',
moderatorMessageBgColor: 'rgba(41, 95, 251, 1)',
memberMessageBgColor: 'rgba(43, 234, 43, 1)',
showLargeEmoticonBg: true,
firstLineFont: 'Noto Sans SC',
firstLineFontSize: 20,
@@ -439,9 +451,9 @@ yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="memb
yt-live-chat-text-message-renderer #author-name {
${this.form.showUserNames ? '' : 'display: none !important;'}
${this.form.userNameColor ? `color: ${this.form.userNameColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.userNameFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.userNameFont)};
font-size: ${this.form.userNameFontSize}px !important;
line-height: ${this.form.userNameLineHeight || this.form.userNameFontSize}px !important;
line-height: ${this.form.userNameLineHeight || (this.form.userNameFontSize + 2)}px !important;
}
/* Hide badges */
@@ -455,16 +467,22 @@ yt-live-chat-text-message-renderer #chat-badges {
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${this.form.messageColor ? `color: ${this.form.messageColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.messageFont)};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || (this.form.messageFontSize + 2)}px !important;
}
yt-live-chat-text-message-renderer #message {
display: block !important;
position: relative;
width: fit-content;
overflow: visible !important;
padding: 20px;
border-radius: 30px;
padding: 15px;
border-radius: 24px;
}
yt-live-chat-text-message-renderer #message:has(.emoji.blc-large-emoji) {
${this.form.showLargeEmoticonBg ? '' : 'padding: 0;'}
}
yt-live-chat-text-message-renderer #message .emoji {
@@ -472,16 +490,24 @@ yt-live-chat-text-message-renderer #message .emoji {
height: ${this.form.emoticonSize}px !important;
}
yt-live-chat-text-message-renderer #message .emoji.blc-large-emoji {
height: ${this.form.largeEmoticonSize}px !important;
}
/* The triangle beside dialog */
yt-live-chat-text-message-renderer #message::before {
content: "";
display: inline-block;
position: absolute;
top: ${this.form.showUserNames ? (this.form.userNameLineHeight || this.form.userNameFontSize) + 10 : 20}px;
left: ${this.form.showAvatars ? this.form.avatarSize + (this.form.avatarSize / 4) - 8 : -8}px;
top: -3px;
left: -10px;
border: 8px solid transparent;
border-right: 18px solid;
transform: rotate(35deg);
}
yt-live-chat-text-message-renderer #message:has(.emoji.blc-large-emoji)::before {
${this.form.showLargeEmoticonBg ? '' : 'content: none;'}
}`
},
timeStyle() {
@@ -500,7 +526,11 @@ ${this.getBgStyleForAuthorType('owner', this.form.ownerMessageBgColor)}
${this.getBgStyleForAuthorType('moderator', this.form.moderatorMessageBgColor)}
${this.getBgStyleForAuthorType('member', this.form.memberMessageBgColor)}`
${this.getBgStyleForAuthorType('member', this.form.memberMessageBgColor)}
yt-live-chat-text-message-renderer #message:has(.emoji.blc-large-emoji) {
${this.form.showLargeEmoticonBg ? '' : 'background-color: transparent !important;'}
}`
},
scAndNewMemberStyle() {
return `/* SuperChat/Fan Funding Messages */
@@ -526,25 +556,25 @@ ${this.form.showOtherThings ? '' : `yt-live-chat-item-list-renderer {
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
font-family: "${common.cssEscapeStr(this.form.firstLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.firstLineFont)};
font-size: ${this.form.firstLineFontSize}px !important;
line-height: ${this.form.firstLineLineHeight || this.form.firstLineFontSize}px !important;
line-height: ${this.form.firstLineLineHeight || (this.form.firstLineFontSize + 2)}px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.secondLineFont)};
font-size: ${this.form.secondLineFontSize}px !important;
line-height: ${this.form.secondLineLineHeight || this.form.secondLineFontSize}px !important;
line-height: ${this.form.secondLineLineHeight || (this.form.secondLineFontSize + 2)}px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
font-family: "${common.cssEscapeStr(this.form.scContentFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.scContentFont)};
font-size: ${this.form.scContentFontSize}px !important;
line-height: ${this.form.scContentLineHeight || this.form.scContentFontSize}px !important;
line-height: ${this.form.scContentLineHeight || (this.form.scContentFontSize + 2)}px !important;
}`
},
showNewMemberBgStyle() {
@@ -561,7 +591,7 @@ yt-live-chat-ticker-paid-message-item-renderer,
yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-family: ${common.fontsStrToCss(this.form.secondLineFont)};
}`
},
animationStyle() {
@@ -574,7 +604,7 @@ yt-live-chat-ticker-sponsor-item-renderer * {
this.saveConfig()
}
},
created() {
mounted() {
this.$emit('input', this.result)
},
methods: {

View File

@@ -1,6 +1,7 @@
import * as fonts from './fonts'
export const FALLBACK_FONTS = ', "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\\5FAE \\8F6F \\96C5 \\9ED1 ", SimHei, Arial, sans-serif'
const FALLBACK_FONTS_CSS = '"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", \
"\\5FAE \\8F6F \\96C5 \\9ED1 ", SimHei, Arial, sans-serif'
export const COMMON_STYLE = `/* Transparent background */
yt-live-chat-renderer {
@@ -53,7 +54,14 @@ yt-live-chat-membership-item-renderer a {
text-decoration: none !important;
}`
export function getImportStyle(allFonts) {
export function getImportStyle(allFontsStrs) {
let allFonts = new Set()
for (let fontsStr of allFontsStrs) {
for (let font of fontsStrToArr(fontsStr)) {
allFonts.add(font)
}
}
let fontsNeedToImport = new Set()
for (let font of allFonts) {
if (fonts.NETWORK_FONTS.indexOf(font) !== -1) {
@@ -88,7 +96,7 @@ export function getTimeStyle(config) {
yt-live-chat-text-message-renderer #timestamp {
display: ${config.showTime ? 'inline' : 'none'} !important;
${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
font-family: "${cssEscapeStr(config.timeFont)}"${FALLBACK_FONTS};
font-family: ${fontsStrToCss(config.timeFont)};
font-size: ${config.timeFontSize}px !important;
line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
}`
@@ -136,12 +144,30 @@ yt-live-chat-paid-message-renderer {
}`
}
export function cssEscapeStr(str) {
export function fontsStrToArr(fontsStr) {
return fontsStr ? fontsStr.split(',') : []
}
export function fontsArrToStr(fontsArr) {
return fontsArr.join(',')
}
export function fontsStrToCss(fontsStr) {
let fontsArr = fontsStrToArr(fontsStr)
if (fontsArr.length === 0) {
return FALLBACK_FONTS_CSS
}
fontsArr = fontsArr.map(cssEscapeStr)
fontsArr.push(FALLBACK_FONTS_CSS)
return fontsArr.join(', ')
}
function cssEscapeStr(str) {
let res = []
for (let char of str) {
res.push(cssEscapeChar(char))
}
return res.join('')
return `"${res.join('')}"`
}
function cssEscapeChar(char) {

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,9 @@
</el-form-item>
<el-form-item>
<el-button type="primary" @click="copyResult">{{ $t('stylegen.copy') }}</el-button>
<a target="_blank" href="https://vscode.dev/">
<el-button style="margin: 0 10px">{{ $t('stylegen.editor') }}</el-button>
</a>
<el-button @click="resetConfig">{{ $t('stylegen.resetConfig') }}</el-button>
</el-form-item>
</el-card>
@@ -25,19 +28,17 @@
</el-col>
<el-col :sm="24" :md="8">
<div :style="{ position: 'relative', top: `${exampleTop}px` }">
<div id="example-panel">
<el-form inline style="line-height: 40px">
<el-form-item :label="$t('stylegen.playAnimation')" style="margin: 0">
<el-switch v-model="playAnimation" @change="onPlayAnimationChange"></el-switch>
<el-switch v-model="playAnimation" @change="setExampleRoomClientStart"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.backgrounds')" style="margin: 0 0 0 30px">
<el-switch v-model="exampleBgLight" :active-text="$t('stylegen.light')" :inactive-text="$t('stylegen.dark')"></el-switch>
</el-form-item>
</el-form>
<div id="example-container" :class="{ light: exampleBgLight }">
<div id="fakebody">
<room ref="room"></room>
</div>
<iframe id="example-room-iframe" ref="exampleRoomIframe" :src="exampleRoomUrl" frameborder="0"></iframe>
</div>
</div>
</el-col>
@@ -49,19 +50,14 @@ import _ from 'lodash'
import Legacy from './Legacy'
import LineLike from './LineLike'
import Room from '@/views/Room'
export default {
name: 'StyleGenerator',
components: {
Legacy, LineLike, Room
},
components: { Legacy, LineLike },
data() {
let styleElement = document.createElement('style')
document.head.appendChild(styleElement)
// 数据流:
// 输入框 --\
// 子组件 -> subComponentResults -> subComponentResult -> inputResult -> 防抖延迟0.5s后 -> debounceResult -> exampleCss
// 子组件 -> subComponentResults -> subComponentResult -> inputResult -> 防抖延迟0.5s后 -> debounceResult
return {
// 子组件的结果
subComponentResults: {
@@ -74,20 +70,17 @@ export default {
// 防抖后延迟变化的结果
debounceResult: '',
styleElement,
exampleTop: 0,
playAnimation: true,
exampleBgLight: false
}
},
computed: {
exampleRoomUrl() {
return this.$router.resolve({ name: 'test_room', query: { lang: this.$i18n.locale } }).href
},
// 子组件的结果
subComponentResult() {
return this.subComponentResults[this.activeTab]
},
// 应用到预览上的CSS
exampleCss() {
return this.debounceResult.replace(/^body\b/gm, '#fakebody')
}
},
watch: {
@@ -97,35 +90,47 @@ export default {
inputResult: _.debounce(function(val) {
this.debounceResult = val
}, 500),
exampleCss(val) {
this.styleElement.innerText = val
}
debounceResult: 'setExampleRoomCustomCss'
},
mounted() {
this.debounceResult = this.inputResult = this.subComponentResult
this.$parent.$el.addEventListener('scroll', this.onParentScroll)
window.addEventListener('message', this.onWindowMessage)
},
beforeDestroy() {
this.$parent.$el.removeEventListener('scroll', this.onParentScroll)
document.head.removeChild(this.styleElement)
window.removeEventListener('message', this.onWindowMessage)
},
methods: {
onParentScroll(event) {
if (document.body.clientWidth <= 992) {
this.exampleTop = 0
} else {
this.exampleTop = event.target.scrollTop
sendMessageToExampleRoom(type, data = null) {
let msg = { type, data }
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
},
// 处理房间发送的消息
onWindowMessage(event) {
if (event.source !== this.$refs.exampleRoomIframe.contentWindow) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return
}
let { type } = event.data
switch (type) {
case 'stylegenExampleRoomLoad':
this.setExampleRoomCustomCss(this.debounceResult)
this.setExampleRoomClientStart(this.playAnimation)
break
}
},
onPlayAnimationChange(value) {
if (value) {
this.$refs.room.start()
} else {
this.$refs.room.stop()
}
setExampleRoomCustomCss(css) {
this.sendMessageToExampleRoom('roomSetCustomStyle', { css })
},
setExampleRoomClientStart(isStart) {
this.sendMessageToExampleRoom(isStart ? 'roomStartClient' : 'roomStopClient')
},
copyResult() {
this.$refs.result.select()
document.execCommand('Copy')
@@ -139,6 +144,13 @@ export default {
</script>
<style scoped>
@media only screen and (min-width: 992px) {
#example-panel {
position: fixed;
width: calc((100vw - 230px - 40px) / 3 - 20px);
}
}
#example-container {
height: calc(100vh - 150px);
@@ -180,8 +192,9 @@ export default {
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #eee));
}
#fakebody {
#example-room-iframe {
outline: 1px #999 dashed;
width: 100%;
height: 100%;
}
</style>

31
frontend/vercel.json Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/((?!api/)[^.]*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=180"
}
]
},
{
"source": "/((?!api/).+\\.\\w+)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=86400"
}
]
}
],
"rewrites": [
{
"source": "/((?!api/)[^.]+)",
"destination": "/"
}
]
}

View File

@@ -1,7 +1,16 @@
const { defineConfig } = require('@vue/cli-service')
// 不能用localhosthttps://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
const API_BASE_URL = 'http://127.0.0.1:12450'
module.exports = {
function toBool(val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return Boolean(val)
}
module.exports = defineConfig({
devServer: {
proxy: {
'/api': {
@@ -10,18 +19,41 @@ module.exports = {
},
'/emoticons': {
target: API_BASE_URL
}
},
'/custom_public': {
target: API_BASE_URL
},
}
},
productionSourceMap: toBool(process.env.PROD_SOURCE_MAP),
chainWebpack: config => {
const APP_VERSION = `v${process.env.npm_package_version}`
const LIB_USE_CDN = toBool(process.env.LIB_USE_CDN)
const ENV = {
APP_VERSION,
LIB_USE_CDN,
BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
}
config.plugin('define')
.tap(args => {
let defineMap = args[0]
let env = defineMap['process.env']
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
for (let [name, value] of Object.entries(ENV)) {
env[name] = JSON.stringify(value)
}
return args
})
if (LIB_USE_CDN) {
config.externals({
'element-ui': 'ELEMENT',
lodash: '_',
pako: 'pako',
vue: 'Vue',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
})
}
}
}
})

82
main.py Normal file → Executable file
View File

@@ -1,3 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import argparse
import asyncio
@@ -15,10 +16,13 @@ import tornado.web
import api.chat
import api.main
import api.open_live
import api.plugin
import config
import models.database
import services.avatar
import services.chat
import services.open_live
import services.plugin
import services.translate
import update
import utils.request
@@ -29,11 +33,13 @@ ROUTES = [
*api.main.ROUTES,
*api.chat.ROUTES,
*api.open_live.ROUTES,
*api.plugin.ROUTES,
*api.main.LAST_ROUTES,
]
server: Optional[tornado.httpserver.HTTPServer] = None
cmd_args = None
shut_down_event: Optional[asyncio.Event] = None
@@ -50,44 +56,65 @@ async def main():
def init():
init_signal_handlers()
args = parse_args()
global cmd_args
cmd_args = parse_args()
init_logging(args.debug)
init_logging(cmd_args.debug)
logger.info('App started, initializing')
config.init()
config.init(cmd_args)
utils.request.init()
models.database.init(args.debug)
models.database.init()
services.avatar.init()
services.translate.init()
services.open_live.init()
services.chat.init()
update.check_update()
init_server()
if server is None:
return False
init_server(args.host, args.port, args.debug)
return server is not None
services.plugin.init()
update.check_update()
return True
def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
is_win = sys.platform == 'win32'
loop = asyncio.get_running_loop()
if not is_win:
def add_signal_handler(signum, callback):
loop.add_signal_handler(signum, callback)
else:
def add_signal_handler(signum, callback):
# 不太安全但Windows只能用这个
signal.signal(signum, lambda _signum, _frame: loop.call_soon(callback))
shut_down_signums = (signal.SIGINT, signal.SIGTERM)
if not is_win:
reload_signum = signal.SIGHUP
else:
reload_signum = signal.SIGBREAK
for shut_down_signum in shut_down_signums:
add_signal_handler(shut_down_signum, on_shut_down_signal)
add_signal_handler(reload_signum, on_reload_signal)
def on_shut_down_signal(*_args):
def on_shut_down_signal():
shut_down_event.set()
def on_reload_signal():
logger.info('Received reload signal')
config.reload(cmd_args)
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
parser.add_argument('--host', help='服务器host默认和配置中的一样', default=None)
@@ -113,36 +140,31 @@ def init_logging(debug):
logging.getLogger('tornado.access').setLevel(logging.WARNING)
def init_server(host, port, debug):
def init_server():
cfg = config.get_config()
if host is None:
host = cfg.host
if port is None:
port = cfg.port
app = tornado.web.Application(
ROUTES,
websocket_ping_interval=10,
debug=debug,
debug=cfg.debug,
autoreload=False
)
try:
global server
server = app.listen(
port,
host,
cfg.port,
cfg.host,
xheaders=cfg.tornado_xheaders,
max_body_size=1024 * 1024,
max_buffer_size=1024 * 1024
)
except OSError:
logger.warning('Address is used %s:%d', host, port)
logger.warning('Address is used %s:%d', cfg.host, cfg.port)
return
finally:
if cfg.open_browser_at_startup:
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
url = 'http://localhost/' if cfg.port == 80 else f'http://localhost:{cfg.port}/'
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
logger.info('Server started: %s:%d', cfg.host, cfg.port)
async def run():
@@ -152,6 +174,8 @@ async def run():
async def shut_down():
services.plugin.shut_down()
logger.info('Closing server')
server.stop()
await server.close_all_connections()

View File

@@ -12,7 +12,7 @@ class OrmBase(sqlalchemy.orm.DeclarativeBase):
pass
def init(_debug):
def init():
cfg = config.get_config()
global _engine
_engine = sqlalchemy.create_engine(
@@ -22,7 +22,7 @@ def init(_debug):
pool_timeout=3, # 连接数达到最大时获取新连接的超时时间
# pool_pre_ping=True, # 获取连接时先检测是否可用
pool_recycle=60 * 60, # 回收超过1小时的连接防止数据库服务器主动断开不活跃的连接
# echo=debug, # 输出SQL语句
# echo=cfg.debug, # 输出SQL语句
)
OrmBase.metadata.create_all(_engine)

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 xfgryujk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
import os
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
LOG_PATH = os.path.join(BASE_PATH, 'log')

View File

@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
import __main__
import datetime
import logging
import os
import sys
from typing import *
import blcsdk
import blcsdk.models as sdk_models
import config
logger = logging.getLogger('msg-logging.' + __name__)
_msg_handler: Optional['MsgHandler'] = None
_id_room_dict: Dict[int, 'Room'] = {}
async def init():
global _msg_handler
_msg_handler = MsgHandler()
blcsdk.set_msg_handler(_msg_handler)
# 创建已有的房间。这一步失败了也没关系,只是有消息时才会创建文件
try:
blc_rooms = await blcsdk.get_rooms()
for blc_room in blc_rooms:
if blc_room.room_id is not None:
_get_or_add_room(blc_room.room_id)
except blcsdk.SdkError:
pass
def shut_down():
blcsdk.set_msg_handler(None)
while len(_id_room_dict) != 0:
room_id = next(iter(_id_room_dict))
_del_room(room_id)
class MsgHandler(blcsdk.BaseHandler):
def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]):
logger.info('blivechat disconnected')
__main__.start_shut_down()
def _on_open_plugin_admin_ui(
self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData
):
if sys.platform == 'win32':
os.startfile(config.LOG_PATH)
else:
logger.info('Log path is "%s"', config.LOG_PATH)
def _on_room_init(
self, client: blcsdk.BlcPluginClient, message: sdk_models.RoomInitMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
if message.is_success:
_get_or_add_room(extra.room_id)
def _on_del_room(self, client: blcsdk.BlcPluginClient, message: sdk_models.DelRoomMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
if extra.room_id is not None:
_del_room(extra.room_id)
def _on_add_text(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddTextMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_id)
room.log(f'[dm] {message.author_name}{message.content}')
def _on_add_gift(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddGiftMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_id)
if message.total_coin != 0:
content = (
f'[paid_gift] {message.author_name} 赠送了 {message.gift_name} x {message.num}'
f'总价 {message.total_coin / 1000:.1f}'
)
else:
content = (
f'[free_gift] {message.author_name} 赠送了 {message.gift_name} x {message.num}'
f'总价 {message.total_free_coin} 银瓜子'
)
room.log(content)
def _on_add_member(
self, client: blcsdk.BlcPluginClient, message: sdk_models.AddMemberMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_id)
if message.privilege_type == sdk_models.GuardLevel.LV1:
guard_name = '舰长'
elif message.privilege_type == sdk_models.GuardLevel.LV2:
guard_name = '提督'
elif message.privilege_type == sdk_models.GuardLevel.LV3:
guard_name = '总督'
else:
guard_name = '未知舰队等级'
room.log(f'[guard] {message.author_name} 购买了 {message.num}{message.unit} {guard_name}'
f'总价 {message.total_coin / 1000:.1f}')
def _on_add_super_chat(
self, client: blcsdk.BlcPluginClient, message: sdk_models.AddSuperChatMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_id)
room.log(f'[superchat] {message.author_name} 发送了 {message.price} 元的醒目留言:{message.content}')
def _get_or_add_room(room_id):
room = _id_room_dict.get(room_id, None)
if room is None:
if room_id is None:
raise TypeError('room_id is None')
room = _id_room_dict[room_id] = Room(room_id)
return room
def _del_room(room_id):
room = _id_room_dict.pop(room_id, None)
if room is not None:
room.close()
class Room:
def __init__(self, room_id):
cur_time = datetime.datetime.now()
time_str = cur_time.strftime('%Y%m%d_%H%M%S')
filename = f'room_{room_id}-{time_str}.txt'
self._file = open(os.path.join(config.LOG_PATH, filename), 'a', encoding='utf-8-sig')
def close(self):
self._file.close()
def log(self, content):
cur_time = datetime.datetime.now()
time_str = cur_time.strftime('%Y-%m-%d %H:%M:%S')
text = f'{time_str} {content}\n'
self._file.write(text)
self._file.flush()

View File

86
plugins/msg-logging/main.py Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import asyncio
import logging.handlers
import os
import signal
import sys
from typing import *
import blcsdk
import config
import listener
logger = logging.getLogger('msg-logging')
shut_down_event: Optional[asyncio.Event] = None
async def main():
try:
await init()
await run()
finally:
await shut_down()
return 0
async def init():
init_signal_handlers()
init_logging()
await blcsdk.init()
if not blcsdk.is_sdk_version_compatible():
raise RuntimeError('SDK version is not compatible')
await listener.init()
def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, start_shut_down)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, start_shut_down)
def start_shut_down(*_args):
shut_down_event.set()
def init_logging():
filename = os.path.join(config.LOG_PATH, 'msg-logging.log')
stream_handler = logging.StreamHandler()
file_handler = logging.handlers.TimedRotatingFileHandler(
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
)
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
style='{',
level=logging.INFO,
# level=logging.DEBUG,
handlers=[stream_handler, file_handler],
)
async def run():
logger.info('Running event loop')
await shut_down_event.wait()
logger.info('Start to shut down')
async def shut_down():
listener.shut_down()
await blcsdk.shut_down()
if __name__ == '__main__':
sys.exit(asyncio.run(main()))

View File

@@ -0,0 +1,84 @@
# -*- mode: python ; coding: utf-8 -*-
import typing
import subprocess
import sys
if typing.TYPE_CHECKING:
import os
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.build_main import Analysis
SPECPATH = ''
DISTPATH = ''
# exe文件名、打包目录名
NAME = 'msg-logging'
# 模块搜索路径
PYTHONPATH = [
os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
]
# 数据
DATAS = [
('plugin.json', '.'),
('LICENSE', '.'),
('log/.gitkeep', 'log'),
]
block_cipher = None
a = Analysis(
['main.py'],
pathex=PYTHONPATH,
binaries=[],
datas=DATAS,
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(
a.pure,
a.zipped_data,
cipher=block_cipher,
)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=NAME,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name=NAME,
)
# 打包
print('Start to package')
subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)

View File

@@ -0,0 +1,8 @@
{
"name": "消息日志",
"version": "1.0.0",
"author": "xfgryujk",
"description": "把收到的消息记录到文件中。点击管理打开日志目录。日志不会自动清理记得定期清理。发布地址https://github.com/xfgryujk/blivechat/discussions/163",
"run": "msg-logging.exe",
"enabled": true
}

21
plugins/native-ui/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 xfgryujk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

139
plugins/native-ui/config.py Normal file
View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
from typing import *
import pubsub.pub as pub
logger = logging.getLogger('native-ui.' + __name__)
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
LOG_PATH = os.path.join(BASE_PATH, 'log')
DATA_PATH = os.path.join(BASE_PATH, 'data')
SAVE_CONFIG_PATH = os.path.join(DATA_PATH, 'config.ini')
CONFIG_PATH_LIST = [
SAVE_CONFIG_PATH,
os.path.join(DATA_PATH, 'config.example.ini')
]
BLC_ICON_PATH = os.path.join(DATA_PATH, 'blivechat.ico')
_config: Optional['AppConfig'] = None
def init():
if reload():
return
logger.warning('Using default config')
set_config(AppConfig())
def reload():
config_path = ''
for path in CONFIG_PATH_LIST:
if os.path.exists(path):
config_path = path
break
if config_path == '':
return False
config = AppConfig()
if not config.load(config_path):
return False
set_config(config)
return True
def get_config():
return _config
def set_config(new_config: 'AppConfig'):
global _config
old_config = _config
_config = new_config
if old_config is not None and new_config is not old_config:
pub.sendMessage('config_change', new_config=new_config, old_config=old_config)
class AppConfig:
def __init__(self):
self.room_opacity = 100
self.chat_url_params = self._get_default_url_params()
self.paid_url_params = self._get_default_url_params()
@staticmethod
def _get_default_url_params():
return {
'minGiftPrice': '0',
'showGiftName': 'true',
'maxNumber': '200',
}
def is_url_params_changed(self, other: 'AppConfig'):
return self.chat_url_params != other.chat_url_params or self.paid_url_params != other.paid_url_params
def load(self, path):
try:
config = configparser.ConfigParser()
config.read(path, 'utf-8-sig')
self._load_ui_config(config)
self._load_url_params(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
return True
def save(self, path):
try:
config = configparser.ConfigParser()
self._save_ui_config(config)
self._save_url_params(config)
tmp_path = path + '.tmp'
with open(tmp_path, 'w', encoding='utf-8-sig') as f:
config.write(f)
os.replace(tmp_path, path)
except Exception: # noqa
logger.exception('Failed to save config:')
return False
return True
def _load_ui_config(self, config: configparser.ConfigParser):
ui_section = config['ui']
self.room_opacity = ui_section.getint('room_opacity', self.room_opacity)
def _save_ui_config(self, config: configparser.ConfigParser):
config['ui'] = {
'room_opacity': str(self.room_opacity),
}
def _load_url_params(self, config: configparser.ConfigParser):
self.chat_url_params = self._section_to_url_params(config['chat_url_params'])
self.paid_url_params = self._section_to_url_params(config['paid_url_params'])
@staticmethod
def _section_to_url_params(section: configparser.SectionProxy):
params = {}
for line in section.values():
key, _, value = line.partition('=')
params[key.strip()] = value.strip()
return params
def _save_url_params(self, config: configparser.ConfigParser):
config['chat_url_params'] = self._url_params_to_section(self.chat_url_params)
config['paid_url_params'] = self._url_params_to_section(self.paid_url_params)
@staticmethod
def _url_params_to_section(url_params: dict):
return {
str(index): f'{key} = {value}'
for index, (key, value) in enumerate(url_params.items(), 1)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -0,0 +1,47 @@
# 如果要修改配置可以复制此文件并重命名为“config.ini”再修改
[ui]
# 房间窗口不透明度
room_opacity = 100
# 评论栏浏览器URL中的参数
[chat_url_params]
# --- 这些参数不可改变 ---
# 通过服务器转发消息
# 1 = relayMessagesByServer = true
# --- 这些参数可以在UI中设置 ---
# 自动翻译弹幕到日语
1 = autoTranslate = false
# 标注打赏用户名读音
2 = giftUsernamePronunciation =
# 最低显示打赏价格(元)
3 = minGiftPrice = 0
# 屏蔽礼物弹幕
4 = blockGiftDanmaku = true
# --- 其他的参数自己发挥 ---
# 显示礼物名
5 = showGiftName = true
# 最大弹幕数
6 = maxNumber = 200
# 付费消息浏览器URL中的参数
[paid_url_params]
# --- 这些参数不可改变 ---
# 通过服务器转发消息
# 1 = relayMessagesByServer = true
# 显示弹幕
# 2 = showDanmaku = false
# --- 这些参数可以在UI中设置 ---
1 = autoTranslate = false
2 = giftUsernamePronunciation =
3 = minGiftPrice = 0
4 = blockGiftDanmaku = true
# --- 其他的参数自己发挥 ---
5 = showGiftName = true
6 = maxNumber = 200

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
###########################################################################
## Python code generated with wxFormBuilder (version 4.1.0-0-g733bf3d)
## http://www.wxformbuilder.org/
##
## PLEASE DO *NOT* EDIT THIS FILE!
###########################################################################
import wx
import wx.xrc
import wx.html2
###########################################################################
## Class RoomFrameBase
###########################################################################
class RoomFrameBase ( wx.Frame ):
def __init__( self, parent ):
wx.Frame.__init__ ( self, parent, id = wx.ID_ANY, title = u"blivechat - 房间 123456", pos = wx.DefaultPosition, size = wx.Size( 800,650 ), style = wx.DEFAULT_FRAME_STYLE|wx.TAB_TRAVERSAL )
self.SetSizeHints( wx.DefaultSize, wx.DefaultSize )
self.SetForegroundColour( wx.SystemSettings.GetColour( wx.SYS_COLOUR_WINDOW ) )
self.SetBackgroundColour( wx.SystemSettings.GetColour( wx.SYS_COLOUR_MENU ) )
bSizer1 = wx.BoxSizer( wx.HORIZONTAL )
bSizer2 = wx.BoxSizer( wx.VERTICAL )
bSizer3 = wx.BoxSizer( wx.HORIZONTAL )
self.config_button = wx.Button( self, wx.ID_ANY, u"设置", wx.DefaultPosition, wx.DefaultSize, 0 )
bSizer3.Add( self.config_button, 0, 0, 5 )
self.stay_on_top_button = wx.ToggleButton( self, wx.ID_ANY, u"置顶", wx.DefaultPosition, wx.DefaultSize, 0 )
bSizer3.Add( self.stay_on_top_button, 0, wx.LEFT, 5 )
bSizer3.Add( ( 0, 0), 1, wx.EXPAND, 5 )
self.collapse_console_button = wx.Button( self, wx.ID_ANY, u">>", wx.DefaultPosition, wx.DefaultSize, 0 )
bSizer3.Add( self.collapse_console_button, 0, wx.LEFT, 5 )
bSizer2.Add( bSizer3, 0, wx.ALL|wx.EXPAND, 5 )
self.chat_web_view = wx.html2.WebView.New(self)
bSizer2.Add( self.chat_web_view, 1, wx.EXPAND, 5 )
bSizer1.Add( bSizer2, 1, wx.EXPAND, 5 )
self.console_notebook = wx.Notebook( self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, 0 )
self.console_notebook.SetMinSize( wx.Size( 200,-1 ) )
self.paid_panel = wx.Panel( self.console_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
bSizer4 = wx.BoxSizer( wx.VERTICAL )
self.paid_web_view = wx.html2.WebView.New(self.paid_panel)
bSizer4.Add( self.paid_web_view, 1, wx.EXPAND, 5 )
self.paid_panel.SetSizer( bSizer4 )
self.paid_panel.Layout()
bSizer4.Fit( self.paid_panel )
self.console_notebook.AddPage( self.paid_panel, u"付费消息", True )
self.super_chat_panel = wx.Panel( self.console_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
bSizer5 = wx.BoxSizer( wx.VERTICAL )
self.super_chat_list = wx.ListCtrl( self.super_chat_panel, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LC_REPORT|wx.LC_SINGLE_SEL )
bSizer5.Add( self.super_chat_list, 1, wx.EXPAND, 5 )
self.super_chat_panel.SetSizer( bSizer5 )
self.super_chat_panel.Layout()
bSizer5.Fit( self.super_chat_panel )
self.console_notebook.AddPage( self.super_chat_panel, u"醒目留言", False )
self.gift_panel = wx.Panel( self.console_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
bSizer6 = wx.BoxSizer( wx.VERTICAL )
self.gift_list = wx.ListCtrl( self.gift_panel, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LC_REPORT|wx.LC_SINGLE_SEL )
bSizer6.Add( self.gift_list, 1, wx.EXPAND, 5 )
self.gift_panel.SetSizer( bSizer6 )
self.gift_panel.Layout()
bSizer6.Fit( self.gift_panel )
self.console_notebook.AddPage( self.gift_panel, u"礼物&&舰长", False )
self.statistics_panel = wx.Panel( self.console_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL )
bSizer7 = wx.BoxSizer( wx.VERTICAL )
sbSizer1 = wx.StaticBoxSizer( wx.StaticBox( self.statistics_panel, wx.ID_ANY, u"付费用户" ), wx.VERTICAL )
self.paid_user_list = wx.ListCtrl( sbSizer1.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LC_REPORT|wx.LC_SINGLE_SEL )
sbSizer1.Add( self.paid_user_list, 1, wx.EXPAND, 5 )
bSizer7.Add( sbSizer1, 1, wx.EXPAND, 5 )
self.statistics_text = wx.StaticText( self.statistics_panel, wx.ID_ANY, u"总弹幕数0 互动用户数0 总付费0 元", wx.DefaultPosition, wx.DefaultSize, 0 )
self.statistics_text.Wrap( -1 )
bSizer7.Add( self.statistics_text, 0, wx.ALL, 5 )
bSizer11 = wx.BoxSizer( wx.HORIZONTAL )
self.export_excel_button = wx.Button( self.statistics_panel, wx.ID_ANY, u"导出Excel", wx.DefaultPosition, wx.DefaultSize, 0 )
bSizer11.Add( self.export_excel_button, 0, 0, 5 )
bSizer7.Add( bSizer11, 0, wx.ALL|wx.EXPAND, 5 )
self.statistics_panel.SetSizer( bSizer7 )
self.statistics_panel.Layout()
bSizer7.Fit( self.statistics_panel )
self.console_notebook.AddPage( self.statistics_panel, u"统计", False )
bSizer1.Add( self.console_notebook, 1, wx.EXPAND, 5 )
self.SetSizer( bSizer1 )
self.Layout()
self.Centre( wx.BOTH )
# Connect Events
self.Bind( wx.EVT_CLOSE, self._on_close )
self.config_button.Bind( wx.EVT_BUTTON, self._on_config_button_click )
self.stay_on_top_button.Bind( wx.EVT_TOGGLEBUTTON, self._on_stay_on_top_button_toggle )
self.collapse_console_button.Bind( wx.EVT_BUTTON, self._on_collapse_console_button_click )
self.export_excel_button.Bind( wx.EVT_BUTTON, self._on_export_excel_button_click )
def __del__( self ):
pass
# Virtual event handlers, override them in your derived class
def _on_close( self, event ):
event.Skip()
def _on_config_button_click( self, event ):
event.Skip()
def _on_stay_on_top_button_toggle( self, event ):
event.Skip()
def _on_collapse_console_button_click( self, event ):
event.Skip()
def _on_export_excel_button_click( self, event ):
event.Skip()
###########################################################################
## Class RoomConfigDialogBase
###########################################################################
class RoomConfigDialogBase ( wx.Dialog ):
def __init__( self, parent ):
wx.Dialog.__init__ ( self, parent, id = wx.ID_ANY, title = u"房间设置", pos = wx.DefaultPosition, size = wx.Size( -1,-1 ), style = wx.DEFAULT_DIALOG_STYLE|wx.DIALOG_NO_PARENT )
self.SetSizeHints( wx.DefaultSize, wx.DefaultSize )
bSizer1 = wx.BoxSizer( wx.VERTICAL )
sbSizer1 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, u"界面" ), wx.VERTICAL )
fgSizer1 = wx.FlexGridSizer( 0, 2, 0, 0 )
fgSizer1.SetFlexibleDirection( wx.BOTH )
fgSizer1.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED )
self.opacity_label = wx.StaticText( sbSizer1.GetStaticBox(), wx.ID_ANY, u"窗口不透明度", wx.DefaultPosition, wx.DefaultSize, 0 )
self.opacity_label.Wrap( -1 )
fgSizer1.Add( self.opacity_label, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT|wx.ALL, 5 )
self.opacity_slider = wx.Slider( sbSizer1.GetStaticBox(), wx.ID_ANY, 100, 10, 100, wx.DefaultPosition, wx.Size( 200,-1 ), wx.SL_HORIZONTAL )
fgSizer1.Add( self.opacity_slider, 1, wx.ALL|wx.EXPAND, 5 )
self.auto_translate_label = wx.StaticText( sbSizer1.GetStaticBox(), wx.ID_ANY, u"自动翻译弹幕到日语", wx.DefaultPosition, wx.DefaultSize, 0 )
self.auto_translate_label.Wrap( -1 )
fgSizer1.Add( self.auto_translate_label, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT|wx.ALL, 5 )
self.auto_translate_check = wx.CheckBox( sbSizer1.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, 0 )
fgSizer1.Add( self.auto_translate_check, 1, wx.ALL|wx.EXPAND, 5 )
self.gift_pron_label = wx.StaticText( sbSizer1.GetStaticBox(), wx.ID_ANY, u"标注打赏用户名读音", wx.DefaultPosition, wx.DefaultSize, 0 )
self.gift_pron_label.Wrap( -1 )
fgSizer1.Add( self.gift_pron_label, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT|wx.ALL, 5 )
bSizer2 = wx.BoxSizer( wx.HORIZONTAL )
gift_pron_choiceChoices = [ u"不显示", u"拼音", u"日文假名" ]
self.gift_pron_choice = wx.Choice( sbSizer1.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, gift_pron_choiceChoices, 0 )
self.gift_pron_choice.SetSelection( 0 )
bSizer2.Add( self.gift_pron_choice, 1, wx.ALL|wx.EXPAND, 5 )
fgSizer1.Add( bSizer2, 1, wx.ALL|wx.EXPAND, 5 )
sbSizer1.Add( fgSizer1, 1, wx.EXPAND, 5 )
bSizer1.Add( sbSizer1, 0, wx.ALL|wx.EXPAND, 5 )
sbSizer2 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, u"消息屏蔽" ), wx.VERTICAL )
fgSizer2 = wx.FlexGridSizer( 0, 2, 0, 0 )
fgSizer2.SetFlexibleDirection( wx.BOTH )
fgSizer2.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED )
self.min_gift_price_label = wx.StaticText( sbSizer2.GetStaticBox(), wx.ID_ANY, u"最低显示打赏价格(元)", wx.DefaultPosition, wx.DefaultSize, 0 )
self.min_gift_price_label.Wrap( -1 )
fgSizer2.Add( self.min_gift_price_label, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT|wx.ALL, 5 )
self.min_gift_price_edit = wx.TextCtrl( sbSizer2.GetStaticBox(), wx.ID_ANY, u"0", wx.DefaultPosition, wx.Size( -1,-1 ), 0 )
self.min_gift_price_edit.SetMinSize( wx.Size( 180,-1 ) )
fgSizer2.Add( self.min_gift_price_edit, 1, wx.ALL|wx.EXPAND, 5 )
self.block_gift_danmaku_label = wx.StaticText( sbSizer2.GetStaticBox(), wx.ID_ANY, u"屏蔽礼物弹幕", wx.DefaultPosition, wx.DefaultSize, 0 )
self.block_gift_danmaku_label.Wrap( -1 )
fgSizer2.Add( self.block_gift_danmaku_label, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT|wx.ALL, 5 )
self.block_gift_danmaku_check = wx.CheckBox( sbSizer2.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, 0 )
self.block_gift_danmaku_check.SetValue(True)
fgSizer2.Add( self.block_gift_danmaku_check, 1, wx.ALL|wx.EXPAND, 5 )
sbSizer2.Add( fgSizer2, 1, wx.EXPAND, 5 )
bSizer1.Add( sbSizer2, 0, wx.ALL|wx.EXPAND, 5 )
bSizer3 = wx.BoxSizer( wx.HORIZONTAL )
bSizer3.Add( ( 0, 0), 1, wx.EXPAND, 5 )
self.ok_button = wx.Button( self, wx.ID_OK, u"确定", wx.DefaultPosition, wx.DefaultSize, 0 )
self.ok_button.SetDefault()
bSizer3.Add( self.ok_button, 0, wx.ALL, 5 )
self.cancel_button = wx.Button( self, wx.ID_CANCEL, u"取消", wx.DefaultPosition, wx.DefaultSize, 0 )
bSizer3.Add( self.cancel_button, 0, wx.ALL, 5 )
bSizer1.Add( bSizer3, 0, wx.ALL|wx.EXPAND, 5 )
self.SetSizer( bSizer1 )
self.Layout()
bSizer1.Fit( self )
self.Centre( wx.BOTH )
# Connect Events
self.opacity_slider.Bind( wx.EVT_SLIDER, self._on_opacity_slider_change )
self.ok_button.Bind( wx.EVT_BUTTON, self._on_ok )
self.cancel_button.Bind( wx.EVT_BUTTON, self._on_cancel )
def __del__( self ):
pass
# Virtual event handlers, override them in your derived class
def _on_opacity_slider_change( self, event ):
event.Skip()
def _on_ok( self, event ):
event.Skip()
def _on_cancel( self, event ):
event.Skip()

View File

@@ -0,0 +1,315 @@
# -*- coding: utf-8 -*-
import __main__
import dataclasses
import datetime
import logging
from typing import *
import pubsub.pub as pub
import wx
import blcsdk
import blcsdk.models as sdk_models
logger = logging.getLogger('native-ui.' + __name__)
_msg_handler: Optional['MsgHandler'] = None
_key_room_dict: Dict[sdk_models.RoomKey, 'Room'] = {}
async def init():
global _msg_handler
_msg_handler = MsgHandler()
blcsdk.set_msg_handler(_msg_handler)
# 创建已有的房间。这一步失败了也没关系,只是有消息时才会创建房间
try:
blc_rooms = await blcsdk.get_rooms()
for blc_room in blc_rooms:
if blc_room.room_id is not None:
wx.CallAfter(_get_or_add_room, blc_room.room_key, blc_room.room_id)
except blcsdk.SdkError:
pass
def shut_down():
blcsdk.set_msg_handler(None)
class MsgHandler(blcsdk.BaseHandler):
def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]):
logger.info('blivechat disconnected')
wx.CallAfter(__main__.start_shut_down)
def handle(self, client: blcsdk.BlcPluginClient, command: dict):
wx.CallAfter(super().handle, client, command)
def _on_open_plugin_admin_ui(
self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData
):
pub.sendMessage('open_admin_ui')
def _on_room_init(
self, client: blcsdk.BlcPluginClient, message: sdk_models.RoomInitMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
if message.is_success:
_get_or_add_room(extra.room_key, extra.room_id)
def _on_del_room(self, client: blcsdk.BlcPluginClient, message: sdk_models.DelRoomMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
if extra.room_id is not None:
_del_room(extra.room_key)
def _on_add_text(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddTextMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_key, extra.room_id)
room.add_danmaku(message.uid)
def _on_add_gift(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddGiftMsg, extra: sdk_models.ExtraData):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_key, extra.room_id)
room.add_gift(GiftRecord(
uid=message.uid,
author_name=message.author_name,
gift_name=message.gift_name,
num=message.num,
price=message.total_coin / 1000,
))
def _on_add_member(
self, client: blcsdk.BlcPluginClient, message: sdk_models.AddMemberMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_key, extra.room_id)
if message.privilege_type == sdk_models.GuardLevel.LV1:
guard_name = '舰长'
elif message.privilege_type == sdk_models.GuardLevel.LV2:
guard_name = '提督'
elif message.privilege_type == sdk_models.GuardLevel.LV3:
guard_name = '总督'
else:
guard_name = '未知舰队等级'
guard_name += f'{message.unit}'
room.add_gift(GiftRecord(
uid=message.uid,
author_name=message.author_name,
gift_name=guard_name,
num=message.num,
price=message.total_coin / 1000,
))
def _on_add_super_chat(
self, client: blcsdk.BlcPluginClient, message: sdk_models.AddSuperChatMsg, extra: sdk_models.ExtraData
):
if extra.is_from_plugin:
return
room = _get_or_add_room(extra.room_key, extra.room_id)
room.add_super_chat(SuperChatRecord(
uid=message.uid,
author_name=message.author_name,
price=message.price,
content=message.content,
))
def iter_rooms() -> Iterable['Room']:
return _key_room_dict.values()
def get_room(room_key: sdk_models.RoomKey):
return _key_room_dict.get(room_key, None)
def _get_or_add_room(room_key: sdk_models.RoomKey, room_id):
room = _key_room_dict.get(room_key, None)
if room is None:
if room_id is None:
raise TypeError('room_id is None')
room = _key_room_dict[room_key] = Room(room_key, room_id)
pub.sendMessage('add_room', room_key=room_key)
return room
def _del_room(room_key: sdk_models.RoomKey):
room = _key_room_dict.pop(room_key, None)
if room is not None:
pub.sendMessage('del_room', room_key=room_key)
@dataclasses.dataclass
class SuperChatRecord:
uid: str
author_name: str
price: float
content: str
time: datetime.datetime = dataclasses.field(default_factory=datetime.datetime.now)
@dataclasses.dataclass
class GiftRecord:
uid: str
author_name: str
gift_name: str
num: int
price: float
time: datetime.datetime = dataclasses.field(default_factory=datetime.datetime.now)
@dataclasses.dataclass
class PaidUserRecord:
uid: str
name: str
price: float
class Room:
def __init__(self, room_key: sdk_models.RoomKey, room_id: int):
self._room_key = room_key
self._room_id = room_id
self._super_chats: List[SuperChatRecord] = []
self._gifts: List[GiftRecord] = []
self._uid_paid_user_dict: Dict[str, PaidUserRecord] = {}
self._danmaku_num = 0
self._interact_uids: Set[str] = set()
self._total_paid_price = 0
@property
def room_key(self):
return self._room_key
@property
def room_id(self):
return self._room_id
@property
def super_chats(self):
return self._super_chats
@property
def gifts(self):
return self._gifts
@property
def uid_paid_user_dict(self):
return self._uid_paid_user_dict
@property
def danmaku_num(self):
return self._danmaku_num
@property
def interact_uids(self):
return self._interact_uids
@property
def total_paid_price(self):
return self._total_paid_price
def add_danmaku(self, uid):
self._danmaku_num += 1
pub.sendMessage('room_data_change.danmaku_num', room=self, value=self._danmaku_num)
self._add_interact_uid(uid)
def _add_interact_uid(self, uid):
if uid in self._interact_uids:
return
self._interact_uids.add(uid)
pub.sendMessage(
'room_data_change.interact_uids',
room=self,
value=self._interact_uids,
index=uid,
is_new=True,
)
def add_super_chat(self, super_chat: SuperChatRecord):
self._super_chats.append(super_chat)
pub.sendMessage(
'room_data_change.super_chats',
room=self,
value=self._super_chats,
index=len(self._super_chats) - 1,
is_new=True,
)
self._add_user_paid_price(PaidUserRecord(
uid=super_chat.uid,
name=super_chat.author_name,
price=super_chat.price,
))
self._total_paid_price += super_chat.price
pub.sendMessage('room_data_change.total_paid_price', room=self, value=self._total_paid_price)
self.add_danmaku(super_chat.uid)
def _add_user_paid_price(self, paid_user: PaidUserRecord):
old_paid_user = self._uid_paid_user_dict.get(paid_user.uid, None)
if old_paid_user is None:
old_paid_user = self._uid_paid_user_dict[paid_user.uid] = PaidUserRecord(
uid=paid_user.uid,
name=paid_user.name,
price=0,
)
is_new = True
else:
is_new = False
old_paid_user.price += paid_user.price
pub.sendMessage(
'room_data_change.uid_paid_user_dict',
room=self,
value=self._uid_paid_user_dict,
index=paid_user.uid,
is_new=is_new,
)
def add_gift(self, gift: GiftRecord):
# 尝试合并
is_merged = False
min_time_to_merge = gift.time - datetime.timedelta(seconds=10)
index = len(self._gifts)
for index in range(len(self._gifts) - 1, -1, -1):
old_gift = self._gifts[index]
if old_gift.time < min_time_to_merge:
break
if old_gift.uid == gift.uid and old_gift.gift_name == gift.gift_name:
old_gift.num += gift.num
old_gift.price += gift.price
is_merged = True
break
if not is_merged:
index = len(self._gifts)
self._gifts.append(gift)
pub.sendMessage(
'room_data_change.gifts',
room=self,
value=self._gifts,
index=index,
is_new=not is_merged,
)
if gift.price > 0.:
self._add_user_paid_price(PaidUserRecord(
uid=gift.uid,
name=gift.author_name,
price=gift.price,
))
self._add_interact_uid(gift.uid)
self._total_paid_price += gift.price
pub.sendMessage('room_data_change.total_paid_price', room=self, value=self._total_paid_price)

View File

187
plugins/native-ui/main.pyw Executable file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import asyncio
import concurrent.futures
import logging
import logging.handlers
import os
import signal
import threading
from typing import *
import pubsub.pub as pub
import wx
import blcsdk
import blcsdk.models as sdk_models
import config
import listener
import ui.room_config_dialog
import ui.room_frame
import ui.task_bar_icon
logger = logging.getLogger('native-ui')
app: Optional['App'] = None
def main():
init_signal_handlers()
init_logging()
config.init()
global app
app = App()
logger.info('Running event loop')
app.MainLoop()
def init_signal_handlers():
def signal_handler(*_args):
wx.CallAfter(start_shut_down)
for signum in (signal.SIGINT, signal.SIGTERM):
signal.signal(signum, signal_handler)
def start_shut_down():
if app is not None and app.IsMainLoopRunning():
app.ExitMainLoop()
else:
wx.Exit()
def init_logging():
filename = os.path.join(config.LOG_PATH, 'native-ui.log')
stream_handler = logging.StreamHandler()
file_handler = logging.handlers.TimedRotatingFileHandler(
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
)
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
style='{',
level=logging.INFO,
# level=logging.DEBUG,
handlers=[stream_handler, file_handler],
)
class App(wx.App):
def __init__(self, *args, **kwargs):
self._network_worker = NetworkWorker()
self._dummy_timer: Optional[wx.Timer] = None
self._task_bar_icon: Optional[ui.task_bar_icon.TaskBarIcon] = None
self._key_room_frame_dict: Dict[sdk_models.RoomKey, ui.room_frame.RoomFrame] = {}
self._room_config_dialog: Optional[ui.room_config_dialog.RoomConfigDialog] = None
super().__init__(*args, clearSigInt=False, **kwargs)
self.SetExitOnFrameDelete(False)
def OnInit(self):
# 这个定时器只是为了及时响应信号因为只有处理UI事件时才会唤醒主线程
self._dummy_timer = wx.Timer(self)
self._dummy_timer.Start(1000)
self.Bind(wx.EVT_TIMER, lambda _event: None, self._dummy_timer)
self._task_bar_icon = ui.task_bar_icon.TaskBarIcon()
pub.subscribe(self._on_add_room, 'add_room')
pub.subscribe(self._on_del_room, 'del_room')
pub.subscribe(self._on_room_frame_close, 'room_frame_close')
pub.subscribe(self._on_add_room, 'open_room')
pub.subscribe(self._on_open_room_config_dialog, 'open_room_config_dialog')
self._network_worker.init()
return True
def OnExit(self):
logger.info('Start to shut down')
self._network_worker.start_shut_down()
self._network_worker.join(10)
return super().OnExit()
def _on_add_room(self, room_key: sdk_models.RoomKey):
if room_key in self._key_room_frame_dict:
return
room_frame = self._key_room_frame_dict[room_key] = ui.room_frame.RoomFrame(None, room_key)
room_frame.Show()
def _on_del_room(self, room_key: sdk_models.RoomKey):
room_frame = self._key_room_frame_dict.pop(room_key, None)
if room_frame is not None:
room_frame.Close(True)
def _on_room_frame_close(self, room_key: sdk_models.RoomKey):
self._key_room_frame_dict.pop(room_key, None)
def _on_open_room_config_dialog(self):
if self._room_config_dialog is None or self._room_config_dialog.IsBeingDeleted():
self._room_config_dialog = ui.room_config_dialog.RoomConfigDialog(None)
self._room_config_dialog.Show()
class NetworkWorker:
def __init__(self):
self._worker_thread = threading.Thread(
target=asyncio.run, args=(self._worker_thread_func(),), daemon=True
)
self._thread_init_future = concurrent.futures.Future()
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._shut_down_event: Optional[asyncio.Event] = None
def init(self):
self._worker_thread.start()
self._thread_init_future.result(10)
def start_shut_down(self):
if self._shut_down_event is not None:
self._loop.call_soon_threadsafe(self._shut_down_event.set)
def join(self, timeout=None):
self._worker_thread.join(timeout)
return not self._worker_thread.is_alive()
async def _worker_thread_func(self):
self._loop = asyncio.get_running_loop()
try:
try:
await self._init_in_worker_thread()
self._thread_init_future.set_result(None)
except BaseException as e:
self._thread_init_future.set_exception(e)
return
await self._run()
finally:
await self._shut_down()
async def _init_in_worker_thread(self):
await blcsdk.init()
if not blcsdk.is_sdk_version_compatible():
raise RuntimeError('SDK version is not compatible')
await listener.init()
self._shut_down_event = asyncio.Event()
async def _run(self):
logger.info('Running network thread event loop')
await self._shut_down_event.wait()
logger.info('Network thread start to shut down')
@staticmethod
async def _shut_down():
listener.shut_down()
await blcsdk.shut_down()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,95 @@
# -*- mode: python ; coding: utf-8 -*-
import typing
import subprocess
import sys
if typing.TYPE_CHECKING:
import os
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.build_main import Analysis
SPECPATH = ''
DISTPATH = ''
# exe文件名、打包目录名
NAME = 'native-ui'
# 模块搜索路径
PYTHONPATH = [
os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
]
# 数据
DATAS = [
('plugin.json', '.'),
('LICENSE', '.'),
('data/config.example.ini', 'data'),
('data/blivechat.ico', 'data'),
('log/.gitkeep', 'log'),
]
# 动态库
BINARIES = []
if sys.platform == 'win32':
import wx
# https://docs.wxpython.org/wx.html2.WebView.html#phoenix-title-webview-backend-edge-msw
bin_path = os.path.join(os.path.dirname(wx.__file__), 'WebView2Loader.dll')
BINARIES.append((bin_path, '.'))
block_cipher = None
a = Analysis(
['main.pyw'],
pathex=PYTHONPATH,
binaries=BINARIES,
datas=DATAS,
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(
a.pure,
a.zipped_data,
cipher=block_cipher,
)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=NAME,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='data/blivechat.ico',
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name=NAME,
)
# 打包
print('Start to package')
subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)

View File

@@ -0,0 +1,8 @@
{
"name": "原生UI",
"version": "1.0.0",
"author": "xfgryujk",
"description": "提供托盘图标和用来看弹幕的窗口。支持置顶窗口和设置不透明度。支持付费消息分开显示。统计弹幕数、付费用户等信息。发布地址https://github.com/xfgryujk/blivechat/discussions/166",
"run": "native-ui.exe",
"enabled": true
}

View File

@@ -0,0 +1,3 @@
PyPubSub==4.0.3
wxPython==4.2.1
XlsxWriter==3.2.0

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
import copy
import logging
from typing import *
import pubsub.pub as pub
import wx
import config
import designer.ui_base
logger = logging.getLogger('native-ui.' + __name__)
class RoomConfigDialog(designer.ui_base.RoomConfigDialogBase):
_GIFT_PRON_CHOICES = ('', 'pinyin', 'kana')
def __init__(self, parent):
super().__init__(parent)
self._new_cfg_cache: Optional[config.AppConfig] = None
def TransferDataToWindow(self):
cfg = config.get_config()
url_params: dict = cfg.chat_url_params
self.opacity_slider.SetValue(cfg.room_opacity)
self.auto_translate_check.SetValue(self._to_bool(url_params.get('autoTranslate', 'false')))
try:
gift_pron_index = self._GIFT_PRON_CHOICES.index(url_params.get('giftUsernamePronunciation', ''))
except ValueError:
gift_pron_index = 0
self.gift_pron_choice.SetSelection(gift_pron_index)
self.min_gift_price_edit.SetValue(url_params.get('minGiftPrice', '0'))
self.block_gift_danmaku_check.SetValue(self._to_bool(url_params.get('blockGiftDanmaku', 'true')))
return super().TransferDataToWindow()
def _on_ok(self, event):
try:
self._new_cfg_cache = self._create_config_from_window()
except Exception as e:
logger.exception('_create_config_from_window failed:')
wx.MessageBox(str(e), '应用设置失败', wx.OK | wx.ICON_ERROR | wx.CENTRE, self)
return
if (
self._new_cfg_cache.is_url_params_changed(config.get_config())
and wx.MessageBox(
'修改部分设置需要刷新浏览器,是否继续?', '提示', wx.YES_NO | wx.CENTRE, self
) != wx.YES
):
return
super()._on_ok(event)
def _create_config_from_window(self):
cfg = copy.deepcopy(config.get_config())
cfg.room_opacity = self.opacity_slider.GetValue()
url_params = {
'autoTranslate': self._bool_to_str(self.auto_translate_check.GetValue()),
'giftUsernamePronunciation': self._GIFT_PRON_CHOICES[self.gift_pron_choice.GetSelection()],
'minGiftPrice': self.min_gift_price_edit.GetValue(),
'blockGiftDanmaku': self._bool_to_str(self.block_gift_danmaku_check.GetValue()),
}
cfg.chat_url_params.update(url_params)
cfg.paid_url_params.update(url_params)
return cfg
def TransferDataFromWindow(self):
if self._new_cfg_cache is None:
logger.warning('_new_cfg_cache is None')
return False
config.set_config(self._new_cfg_cache)
wx.CallAfter(self._new_cfg_cache.save, config.SAVE_CONFIG_PATH)
return super().TransferDataFromWindow()
def _on_cancel(self, event):
pub.sendMessage('room_config_dialog_cancel')
super()._on_cancel(event)
@staticmethod
def _to_bool(value):
if isinstance(value, str):
return value.lower() not in ('false', 'no', 'off', '0', '')
return bool(value)
@staticmethod
def _bool_to_str(value):
return 'true' if value else 'false'
def _on_opacity_slider_change(self, event):
pub.sendMessage('preview_room_opacity', room_opacity=self.opacity_slider.GetValue())

Some files were not shown because too many files have changed in this diff Show More