├── public
├── favicon.ico
├── img
│ ├── avater.png
│ ├── noface.gif
│ ├── tinder
│ │ ├── help.png
│ │ ├── like.png
│ │ ├── nope.png
│ │ ├── rewind.png
│ │ ├── history.png
│ │ ├── like-txt.png
│ │ ├── nope-txt.png
│ │ ├── super-txt.png
│ │ ├── rewind-txt.png
│ │ └── super-like.png
│ └── icons
│ │ ├── guard-level-1.png
│ │ ├── guard-level-2.png
│ │ └── guard-level-3.png
├── css
│ └── style.css
└── index.html
├── scripts
├── example
│ └── bilibili
│ │ ├── requirements.txt
│ │ ├── README.md
│ │ ├── LICENSE
│ │ └── sample.py
├── test-docker.sh
└── minify-docker.js
├── babel.config.js
├── docs
├── img
│ ├── manage_en.png
│ └── manage_zh.png
├── develop.md
└── develop.en.md
├── views
├── index.jade
├── error.jade
└── layout.jade
├── src
├── plugins
│ ├── clipboard.js
│ ├── axios.js
│ ├── element.js
│ ├── element-variables.scss
│ └── remotescript.js
├── views
│ ├── About.vue
│ ├── NotFound.vue
│ ├── Login.vue
│ ├── Register.vue
│ └── Wall.vue
├── langs
│ ├── en.json
│ ├── zh_CN.json
│ ├── zh_TW.json
│ └── ja.json
├── api
│ ├── chat
│ │ ├── avatar.js
│ │ ├── ChatClientComment.js
│ │ ├── ChatClientRelay.js
│ │ └── ChatClientTest.js
│ └── chatConfig.js
├── main.js
├── App.vue
├── i18n.js
├── components
│ ├── ChatRenderer
│ │ ├── ImgShadow.vue
│ │ ├── AuthorBadge.vue
│ │ ├── PaidMessage.vue
│ │ ├── MembershipItem.vue
│ │ ├── TextMessage.vue
│ │ ├── constants.js
│ │ └── Ticker.vue
│ └── VueDPlayer.vue
├── utils
│ ├── index.js
│ └── pronunciation
│ │ └── index.js
├── assets
│ └── css
│ │ └── youtube
│ │ ├── yt-icon.css
│ │ ├── yt-img-shadow.css
│ │ ├── yt-live-chat-author-badge-renderer.css
│ │ ├── yt-live-chat-author-chip.css
│ │ ├── yt-live-chat-ticker-renderer.css
│ │ ├── yt-live-chat-item-list-renderer.css
│ │ ├── yt-live-chat-ticker-paid-message-item-renderer.css
│ │ └── yt-live-chat-renderer.css
├── router
│ └── index.js
└── bing.js
├── utils
├── logger.js
├── redis.js
├── mongodb.js
├── tool.js
├── filter
│ ├── default.js
│ └── blacklist.js
├── audit.js
└── auth.js
├── ecosystem.config.js
├── vue.config.js
├── routes
├── index.js
├── sender
│ ├── develop.js
│ ├── export.js
│ ├── wechat.js
│ └── telegram.js
├── user.js
└── activity.js
├── .gitignore
├── .dockerignore
├── docker-compose.yml
├── .github
└── workflows
│ ├── docker-test.yml
│ └── docker-release.yml
├── models
├── counter.js
├── danmakuUser.js
├── user.js
├── danmaku.js
└── activity.js
├── Dockerfile
├── config.js
├── LICENSE
├── bin
└── www
├── app.js
├── package.json
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/img/avater.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/avater.png
--------------------------------------------------------------------------------
/public/img/noface.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/noface.gif
--------------------------------------------------------------------------------
/scripts/example/bilibili/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp==3.7.4
2 | python-socketio[client]==5.4.0
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ["@vue/cli-plugin-babel/preset"],
3 | };
4 |
--------------------------------------------------------------------------------
/docs/img/manage_en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/docs/img/manage_en.png
--------------------------------------------------------------------------------
/docs/img/manage_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/docs/img/manage_zh.png
--------------------------------------------------------------------------------
/public/img/tinder/help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/help.png
--------------------------------------------------------------------------------
/public/img/tinder/like.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/like.png
--------------------------------------------------------------------------------
/public/img/tinder/nope.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/nope.png
--------------------------------------------------------------------------------
/views/index.jade:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1= title
5 | p Welcome to #{title}
6 |
--------------------------------------------------------------------------------
/public/img/tinder/rewind.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/rewind.png
--------------------------------------------------------------------------------
/public/img/tinder/history.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/history.png
--------------------------------------------------------------------------------
/public/img/tinder/like-txt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/like-txt.png
--------------------------------------------------------------------------------
/public/img/tinder/nope-txt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/nope-txt.png
--------------------------------------------------------------------------------
/public/img/tinder/super-txt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/super-txt.png
--------------------------------------------------------------------------------
/public/img/tinder/rewind-txt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/rewind-txt.png
--------------------------------------------------------------------------------
/public/img/tinder/super-like.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/tinder/super-like.png
--------------------------------------------------------------------------------
/public/img/icons/guard-level-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/icons/guard-level-1.png
--------------------------------------------------------------------------------
/public/img/icons/guard-level-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/icons/guard-level-2.png
--------------------------------------------------------------------------------
/public/img/icons/guard-level-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prnake/Comment9/HEAD/public/img/icons/guard-level-3.png
--------------------------------------------------------------------------------
/views/error.jade:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1= message
5 | h2= error.status
6 | pre #{error.stack}
7 |
--------------------------------------------------------------------------------
/src/plugins/clipboard.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import VueClipboard from "vue-clipboard2";
3 |
4 | Vue.use(VueClipboard);
5 |
--------------------------------------------------------------------------------
/src/plugins/axios.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import axios from "axios";
3 | import VueAxios from "vue-axios";
4 |
5 | Vue.use(VueAxios, axios);
6 |
--------------------------------------------------------------------------------
/src/plugins/element.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import Element from "element-ui";
3 | import "./element-variables.scss";
4 |
5 | Vue.use(Element);
6 |
--------------------------------------------------------------------------------
/src/views/About.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
This is an about page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/public/css/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 50px;
3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
4 | }
5 |
6 | a {
7 | color: #00B7FF;
8 | }
9 |
--------------------------------------------------------------------------------
/views/layout.jade:
--------------------------------------------------------------------------------
1 | doctype html
2 | html
3 | head
4 | title= title
5 | link(rel='stylesheet', href='/stylesheets/style.css')
6 | body
7 | block content
8 |
--------------------------------------------------------------------------------
/src/views/NotFound.vue:
--------------------------------------------------------------------------------
1 |
2 | 404: Not Found
3 |
4 |
5 |
10 |
--------------------------------------------------------------------------------
/utils/logger.js:
--------------------------------------------------------------------------------
1 | const logger = require('pino')({
2 | prettyPrint: {
3 | levelFirst: true
4 | },
5 | prettifier: require('pino-pretty')
6 | });
7 |
8 | module.exports = logger;
--------------------------------------------------------------------------------
/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: "Comment9",
5 | exec_mode: "fork",
6 | instances: "1",
7 | script: "./bin/www",
8 | },
9 | ],
10 | };
11 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: "./",
3 | pluginOptions: {
4 | i18n: {
5 | locale: "en",
6 | fallbackLocale: "en",
7 | localeDir: "langs",
8 | enableInSFC: true,
9 | },
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/routes/index.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | /* GET home page. */
5 | router.get('/', function(req, res, next) {
6 | res.render('index', { title: 'Express' });
7 | });
8 |
9 | module.exports = router;
10 |
--------------------------------------------------------------------------------
/src/langs/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "sender:danmaku": "Basic danmaku sender",
3 | "sender:wechat": "Wechat",
4 | "sender:telegram": "Telegram",
5 | "sender:develop": "Develop",
6 | "sender:export": "Danmaku Export",
7 | "filter:default": "Default filter",
8 | "filter:blacklist": "Blacklist filter"
9 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | yarn.lock
4 | /dist
5 | /app-minimal
6 |
7 |
8 | # local env files
9 | .env*
10 |
11 | # Log files
12 | npm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 | pnpm-debug.log*
16 |
17 | # Editor directories and files
18 | .idea
19 | .vscode
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Python
27 | __pycache__
--------------------------------------------------------------------------------
/src/plugins/element-variables.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Write your variables here. All available variables can be
3 | found in element-ui/packages/theme-chalk/src/common/var.scss.
4 | For example, to overwrite the theme color:
5 | */
6 | $--color-primary: #1abc9c;
7 |
8 | /* icon font path, required */
9 | $--font-path: '~element-ui/lib/theme-chalk/fonts';
10 |
11 | @import "~element-ui/packages/theme-chalk/src/index";
12 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | Dockerfile*
4 | docker-compose*
5 | .dockerignore
6 | .gitignore
7 | LICENSE
8 | .vscode
9 | .github
10 | assets
11 | coverage
12 | docs
13 | test
14 | dist
15 | .codecov.yml
16 | .eslint*
17 | .prettier*
18 | .env*
19 | .(yarn|npm|nvm)rc
20 | *.md
21 | process.json
22 | app.json
23 |
24 |
25 | #git but keep the git commit hash
26 | .git/logs
27 | .git/objects
28 | .git/index
29 | .git/info
--------------------------------------------------------------------------------
/src/api/chat/avatar.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_AVATAR_URL = `img/noface.gif`;
2 | export const ADMIN_AVATAR_URL = `img/avater.png`;
3 |
4 | export function processAvatarUrl(avatarUrl) {
5 | // 去掉协议,兼容HTTP、HTTPS
6 | let m = avatarUrl.match(/(?:https?:)?(.*)/);
7 | if (m) {
8 | avatarUrl = m[1];
9 | }
10 | // 缩小图片加快传输
11 | if (!avatarUrl.endsWith("noface.gif")) {
12 | avatarUrl += "@48w_48h";
13 | }
14 | return avatarUrl;
15 | }
16 |
--------------------------------------------------------------------------------
/scripts/example/bilibili/README.md:
--------------------------------------------------------------------------------
1 | # blivedm
2 |
3 | 项目地址:[blivedm](https://github.com/xfgryujk/blivedm)
4 |
5 | python3获取bilibili直播弹幕,使用websocket协议
6 |
7 | [协议解释](https://blog.csdn.net/xfgryujk/article/details/80306776)(有点过时了,总体是没错的)
8 |
9 | ## 使用说明
10 |
11 | 1. 使用`pip install -r requirements.txt`命令安装依赖,具体有目录下[sample.py](./sample.py)和[blivedm.py](./blivedm.py)用到的相关python依赖
12 | 2. 将[sample.py](./sample.py)文件中的 `room_id` 替换为直播间ID,将``HOST``替换为部署域名,`activity` 填写活动名,`name` 和 `token` 中填写任意一个拥有 `pushmult` 权限的密钥,可能需要自己在后台创建。
13 | 3. 部署时如果使用反向代理,可能需要额外配置 websockets,否则 Socket.IO 将回退到 HTTP 长轮询模式,并且导致 `python-socketio` 无法正常工作
14 |
--------------------------------------------------------------------------------
/utils/redis.js:
--------------------------------------------------------------------------------
1 | const redis = require('redis');
2 | const { promisify } = require('util');
3 | const config = require('../config');
4 | const logger = require('./logger');
5 |
6 | const options = {
7 | host: config.redis.host,
8 | port: config.redis.port,
9 | password: config.redis.password,
10 | };
11 | if (!options.password) {
12 | delete options.password;
13 | }
14 | const client = redis.createClient(options);
15 |
16 | client.on('error', (e) => {
17 | logger.error('Redis error: ', e);
18 | });
19 | client.on('connect', () => {
20 | logger.info('Redis connected');
21 | });
22 |
23 | module.exports = client;
--------------------------------------------------------------------------------
/routes/sender/develop.js:
--------------------------------------------------------------------------------
1 | const tool = require("../../utils/tool");
2 |
3 | const info = function () {
4 | let data = { panel: {} };
5 |
6 | tool.setPanelTitle(data.panel, "Development", "Docs for developer.");
7 | tool.addPanelItem(
8 | data.panel,
9 | "Docs",
10 | [],
11 | "",
12 | "https://github.com/prnake/Comment9/blob/master/README.md",
13 | "open"
14 | );
15 | tool.addPanelItem(
16 | data.panel,
17 | "Backend Docs",
18 | [],
19 | "",
20 | "https://github.com/prnake/Comment9/blob/master/docs/develop.md",
21 | "open"
22 | );
23 |
24 | return data;
25 | };
26 |
27 | module.exports = { info };
28 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import App from "./App.vue";
3 | import router from "./router";
4 | import i18n from "./i18n";
5 | import element from "./plugins/element";
6 | import axios from "./plugins/axios";
7 | import clipboard from "./plugins/clipboard";
8 | import remotescript from "./plugins/remotescript";
9 |
10 | Vue.config.productionTip = false;
11 | Vue.prototype.$rootPath = window.location.pathname.replace(/\/$/, "");
12 |
13 | Vue.config.ignoredElements = [/^yt-/];
14 |
15 | new Vue({
16 | router,
17 | i18n,
18 | element,
19 | axios,
20 | clipboard,
21 | remotescript,
22 | render: (h) => h(App),
23 | }).$mount("#app");
24 |
--------------------------------------------------------------------------------
/scripts/test-docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | MAX_RETRIES=60
4 | # Try running the docker and get the output
5 | # then try getting homepage in 3 mins
6 |
7 | docker-compose up -d
8 |
9 | if [[ $? -ne 0 ]]
10 | then
11 | echo "failed to run docker"
12 | exit 1
13 | fi
14 |
15 | RETRY=1
16 | curl -m 10 localhost:3000
17 | while [[ $? -ne 0 ]] && [[ $RETRY -lt $MAX_RETRIES ]]; do
18 | sleep 5
19 | ((RETRY++))
20 | echo "RETRY: ${RETRY}"
21 | curl -m 10 localhost:3000
22 | done
23 |
24 | if [[ $RETRY -gt $MAX_RETRIES ]]
25 | then
26 | echo "Unable to run, aborted"
27 | exit 1
28 | else
29 | echo "Successfully acquire homepage, passing"
30 | exit 0
31 | fi
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Comment9
11 |
12 |
13 |
14 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/scripts/minify-docker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const fs = require('fs-extra');
3 | const path = require('path');
4 | const { nodeFileTrace } = require('@vercel/nft');
5 | const files = ['bin/www', 'app.js'];
6 | const resultFolder = 'app-minimal';
7 |
8 | (async () => {
9 | console.log('Start analyizing...');
10 | const { fileList } = await nodeFileTrace(files, {
11 | base: path.resolve(path.join(__dirname, '..')),
12 | ignore: ['./node_modules/transformers/node_modules/uglify-js/tools/exports.js']
13 | });
14 | console.log('Total files need to be copy: ' + fileList.length);
15 | return Promise.all(fileList.map((e) => fs.copy(e, path.resolve(path.join(resultFolder, e)))));
16 | })();
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | web:
4 | image: prnake/comment9
5 | restart: always
6 | links:
7 | - mongo
8 | - redis
9 | depends_on:
10 | - mongo
11 | - redis
12 | ports:
13 | - '3000:3000'
14 | environment:
15 | HOST: "https://comment.pka.moe"
16 | BASE_URL: ""
17 | INVITE_CODE: "Danmaku"
18 | REDIS_HOST: "redis"
19 | REDIS_PORT: 6379
20 | MONGO_HOST: "mongo"
21 | MONGO_PORT: 27017
22 | MONGO_DATABASE: "Comment9"
23 | mongo:
24 | image: mongo:latest
25 | restart: always
26 | volumes:
27 | - comment9-mongo-data:/data/db
28 | redis:
29 | image: redis:alpine
30 | restart: always
31 | volumes:
32 | comment9-mongo-data:
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
40 |
--------------------------------------------------------------------------------
/src/i18n.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import VueI18n from "vue-i18n";
3 | import enLocale from "element-ui/lib/locale/lang/en";
4 | import zh_CNLocale from "element-ui/lib/locale/lang/zh-CN";
5 | import zh_TWLocale from "element-ui/lib/locale/lang/zh-TW";
6 | import jaLocale from "element-ui/lib/locale/lang/ja";
7 | import Cookies from "js-cookie";
8 |
9 | Vue.use(VueI18n);
10 |
11 | export default new VueI18n({
12 | locale: Cookies.get("language") || "en",
13 | fallbackLocale: "en",
14 | messages: {
15 | en: { ...require("./langs/en"), ...enLocale },
16 | zh_CN: { ...require("./langs/zh_CN"), ...zh_CNLocale },
17 | zh_TW: { ...require("./langs/zh_TW"), ...zh_TWLocale },
18 | ja: { ...require("./langs/ja"), ...jaLocale },
19 | },
20 | });
21 |
--------------------------------------------------------------------------------
/.github/workflows/docker-test.yml:
--------------------------------------------------------------------------------
1 | name: 'build tests'
2 |
3 | on:
4 | pull_request:
5 | branches: master
6 | # Please, always create a pull request instead of push to master.
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 | - name: Set up QEMU
14 | uses: docker/setup-qemu-action@v1
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v1
17 | - name: Build dockerfile and Run (without push)
18 | run: |
19 | docker build \
20 | --tag comment9:latest \
21 | --file ./Dockerfile .
22 | chmod +x scripts/test-docker.sh
23 | scripts/test-docker.sh
--------------------------------------------------------------------------------
/models/counter.js:
--------------------------------------------------------------------------------
1 | const mongodb = require("../utils/mongodb");
2 |
3 | const counterSchema = mongodb.Schema({
4 | name: String,
5 | seq: Number,
6 | });
7 |
8 | counterSchema.set("autoIndex", false);
9 |
10 | counterSchema.statics.getNextFor = function (name, callback) {
11 | this.findOneAndUpdate(
12 | { name: name },
13 | { $inc: { seq: 1 } },
14 | { new: true, upsert: true },
15 | function (err, doc) {
16 | if (err) {
17 | return callback(err);
18 | }
19 | callback(null, doc.seq);
20 | }
21 | );
22 | };
23 |
24 | const Counter = mongodb.model("Counter", counterSchema);
25 |
26 | module.exports = function (name, callback) {
27 | if (!callback) {
28 | callback = function () {};
29 | }
30 | Counter.getNextFor(name, callback);
31 | };
32 |
--------------------------------------------------------------------------------
/src/plugins/remotescript.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 |
3 | Vue.component("remote-script", {
4 | render: function (createElement) {
5 | let self = this;
6 | return createElement("script", {
7 | attrs: {
8 | type: "text/javascript",
9 | src: this.src,
10 | },
11 | on: {
12 | load: function (event) {
13 | self.$emit("load", event);
14 | },
15 | error: function (event) {
16 | self.$emit("error", event);
17 | },
18 | readystatechange: function (event) {
19 | if (this.readyState == "complete") {
20 | self.$emit("load", event);
21 | }
22 | },
23 | },
24 | });
25 | },
26 | props: {
27 | src: {
28 | type: String,
29 | required: true,
30 | },
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/utils/mongodb.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose');
2 | const config = require('../config');
3 | const logger = require('./logger');
4 | const db = mongoose.connection;
5 |
6 | mongoose.connected = false;
7 | mongoose.set('useCreateIndex', true);
8 | mongoose.set('useFindAndModify', false);
9 | mongoose.connect(`mongodb://${(config.mongodb.username && config.mongodb.password) ? `${config.mongodb.username}:${config.mongodb.password}@` : ''}${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.database}`, { useNewUrlParser: true, useUnifiedTopology: true });
10 |
11 | db.on('open', function () {
12 | mongoose.connected = true;
13 | });
14 | db.on('error', (e) => {
15 | logger.error('Mongodb error: ', e);
16 | });
17 | db.once('open', () => {
18 | logger.info('Mongodb connected');
19 | });
20 |
21 | module.exports = mongoose;
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-buster-slim as dep-builder
2 |
3 | LABEL MAINTAINER https://github.com/prnake/Comment9
4 |
5 | ARG USE_CHINA_NPM_REGISTRY=0;
6 | RUN ln -sf /bin/bash /bin/sh
7 |
8 | RUN apt-get update && apt-get install -yq python3 build-essential dumb-init --no-install-recommends
9 |
10 | WORKDIR /app
11 | COPY . /app
12 |
13 | RUN if [ "$USE_CHINA_NPM_REGISTRY" = 1 ]; then \
14 | echo 'use npm mirror'; yarn config set registry https://registry.npm.taobao.org; \
15 | fi;
16 |
17 | RUN yarn install
18 | RUN yarn build
19 |
20 | RUN node scripts/minify-docker.js
21 |
22 | FROM node:16-slim as app
23 |
24 | ENV NODE_ENV production
25 | ENV TZ Asia/Shanghai
26 |
27 | WORKDIR /app
28 | COPY --from=dep-builder /app/dist /app/dist
29 | COPY --from=dep-builder /app/app-minimal /app
30 | COPY --from=dep-builder /usr/bin/dumb-init /usr/bin/dumb-init
31 |
32 | EXPOSE 3000
33 | ENTRYPOINT ["dumb-init", "--"]
34 |
35 | CMD ["yarn", "start"]
36 |
--------------------------------------------------------------------------------
/src/api/chatConfig.js:
--------------------------------------------------------------------------------
1 | import { mergeConfig } from "@/utils";
2 |
3 | export const DEFAULT_CONFIG = {
4 | minGiftPrice: 7, // $1
5 | showDanmaku: true,
6 | showGift: true,
7 | showGiftName: false,
8 | mergeSimilarDanmaku: false,
9 | mergeGift: true,
10 | maxNumber: 50,
11 |
12 | blockGiftDanmaku: true,
13 | blockLevel: 0,
14 | blockNewbie: false,
15 | blockNotMobileVerified: false,
16 | blockKeywords: "",
17 | blockUsers: "",
18 | blockMedalLevel: 0,
19 |
20 | relayMessagesByServer: false,
21 | autoTranslate: false,
22 | giftUsernamePronunciation: "",
23 | };
24 |
25 | export function setLocalConfig(config) {
26 | config = mergeConfig(config, DEFAULT_CONFIG);
27 | window.localStorage.config = JSON.stringify(config);
28 | }
29 |
30 | export function getLocalConfig() {
31 | try {
32 | return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG);
33 | } catch {
34 | return { ...DEFAULT_CONFIG };
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/models/danmakuUser.js:
--------------------------------------------------------------------------------
1 | const mongodb = require("../utils/mongodb");
2 |
3 | const danmakuUserSchema = mongodb.Schema({
4 | id: String,
5 | name: String,
6 | imgurl: String,
7 | });
8 |
9 | class DanmakuUserClass {
10 | static async getUser(id) {
11 | return await this.findOne({ id });
12 | }
13 |
14 | static async setImgUrl(id, url) {
15 | let user = await await this.findOne({ id });
16 | if (!user) {
17 | user = new this();
18 | user.id = id;
19 | }
20 | user.imgurl = url;
21 | await user.save();
22 | return user;
23 | }
24 |
25 | static async setName(id, name) {
26 | let user = await await this.findOne({ id });
27 | if (!user) {
28 | user = new this();
29 | user.id = id;
30 | }
31 | user.name = name;
32 | await user.save();
33 | return user;
34 | }
35 | }
36 |
37 | danmakuUserSchema.loadClass(DanmakuUserClass);
38 | const DanmakuUser = mongodb.model("DanmakuUser", danmakuUserSchema);
39 |
40 | module.exports = DanmakuUser;
41 |
--------------------------------------------------------------------------------
/config.js:
--------------------------------------------------------------------------------
1 | process.env.NTBA_FIX_319 = 1;
2 | require("dotenv").config({ path: ".env" });
3 | module.exports = {
4 | port: process.env.PORT || 3000,
5 | mongodb: {
6 | username: process.env.MONGO_USERNAME || null,
7 | password: process.env.MONGO_PASSWORD || null,
8 | host: process.env.MONGO_HOST || "127.0.0.1",
9 | port: process.env.MONGO_PORT || "27017",
10 | database: process.env.MONGO_DATABASE || "Comment9",
11 | },
12 | redis: {
13 | host: process.env.REDIS_HOST || "127.0.0.1",
14 | port: process.env.REDIS_PORT || "6379",
15 | password: process.env.REDIS_PASSWORD || null,
16 | },
17 | session: {
18 | cookieSecrect: process.env.SECRET || "Danmaku",
19 | },
20 | danmaku: {
21 | expire: process.env.EXPIRE_TIME || 5,
22 | default_senders: ["danmaku"],
23 | senders: ["danmaku", "wechat", "telegram", "develop", "export"],
24 | default_filters: ["default"],
25 | filters: ["default", "blacklist"],
26 | },
27 | host: process.env.HOST || "http://localhost:3000",
28 | rootPath: process.env.BASE_URL || "",
29 | inviteCode: process.env.INVITE_CODE || "",
30 | };
31 |
--------------------------------------------------------------------------------
/utils/tool.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 | const setPerms = (perms, name, description) =>
3 | perms.push({ name: name, description: description });
4 | const setAddons = (addons, name, description, type, def) =>
5 | addons.push({
6 | name: name,
7 | description: description,
8 | type: type,
9 | default: def,
10 | });
11 | const genToken = function () {
12 | let hash = crypto.createHash("sha1");
13 | hash.update(crypto.randomBytes(32));
14 | hash.update("t" + new Date().getTime());
15 | return hash.digest("hex");
16 | };
17 |
18 | const setPanelTitle = (panel, title, description) => {
19 | panel["title"] = title;
20 | panel["description"] = description;
21 | }
22 |
23 | const addPanelItem = (panel, name, perms, description,url,type) => {
24 | if (!panel["items"]) panel["items"] = [];
25 | panel["items"].push({
26 | name: name,
27 | perms: perms,
28 | description: description,
29 | url: url,
30 | type: type
31 | });
32 | }
33 |
34 | module.exports = { setPerms, setAddons, genToken, setPanelTitle, addPanelItem };
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 papersnake
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/scripts/example/bilibili/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 xfgryujk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/components/ChatRenderer/ImgShadow.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
18 |
19 |
20 |
21 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export function mergeConfig(config, defaultConfig) {
2 | let res = {};
3 | for (let i in defaultConfig) {
4 | res[i] = i in config ? config[i] : defaultConfig[i];
5 | }
6 | return res;
7 | }
8 |
9 | export function toBool(val) {
10 | if (typeof val === "string") {
11 | return ["false", "no", "off", "0", ""].indexOf(val.toLowerCase()) === -1;
12 | }
13 | return !!val;
14 | }
15 |
16 | export function toInt(val, _default) {
17 | let res = parseInt(val);
18 | if (isNaN(res)) {
19 | res = _default;
20 | }
21 | return res;
22 | }
23 |
24 | export function formatCurrency(price) {
25 | return new Intl.NumberFormat("zh-CN", {
26 | minimumFractionDigits: price < 100 ? 2 : 0,
27 | }).format(price);
28 | }
29 |
30 | export function getTimeTextHourMin(date) {
31 | let hour = date.getHours();
32 | let min = ("00" + date.getMinutes()).slice(-2);
33 | return `${hour}:${min}`;
34 | }
35 |
36 | export function getUuid4Hex() {
37 | let chars = [];
38 | for (let i = 0; i < 32; i++) {
39 | let char = Math.floor(Math.random() * 16).toString(16);
40 | chars.push(char);
41 | }
42 | return chars.join("");
43 | }
44 |
--------------------------------------------------------------------------------
/.github/workflows/docker-release.yml:
--------------------------------------------------------------------------------
1 | name: 'build'
2 |
3 | on:
4 | push:
5 | branches: master
6 |
7 | jobs:
8 | release:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Login to DockerHub
12 | uses: docker/login-action@v1
13 | with:
14 | username: ${{ secrets.DOCKER_USERNAME }}
15 | password: ${{ secrets.DOCKER_PASSWORD }}
16 | - name: Checkout
17 | uses: actions/checkout@v2
18 | - name: Set up QEMU
19 | uses: docker/setup-qemu-action@v1
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v1
22 | - name: Build dockerfile (with push)
23 | env:
24 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
25 | run: |
26 | docker buildx build \
27 | --platform=linux/amd64 \
28 | --output "type=image,push=true" \
29 | --file ./Dockerfile . \
30 | --tag $(echo "${DOCKER_USERNAME}" | tr '[:upper:]' '[:lower:]')/comment9:latest \
31 | --tag $(echo "${DOCKER_USERNAME}" | tr '[:upper:]' '[:lower:]')/comment9:$(date +%Y)-$(date +%m)-$(date +%d)
32 |
--------------------------------------------------------------------------------
/models/user.js:
--------------------------------------------------------------------------------
1 | const mongodb = require("../utils/mongodb");
2 |
3 | const userSchema = mongodb.Schema({
4 | uid: Number,
5 | name: { type: String, index: true, unique: true },
6 | password: String,
7 | logintime: Date,
8 | regtime: { type: Date, default: Date.now },
9 | });
10 |
11 | userSchema.statics.generateUid = function (callback) {
12 | this.find()
13 | .sort("-uid")
14 | .limit(1)
15 | .exec(function (err, doc) {
16 | if (err) return callback(err);
17 | callback(null, doc.length ? doc[0].uid + 1 : 1);
18 | });
19 | };
20 |
21 | userSchema.statics.createUser = function (name, password, callback) {
22 | this.generateUid(function (err, uid) {
23 | if (err) return callback(err);
24 | const doc = new User({ name: name, password: password, uid: uid });
25 | doc.save(function (err) {
26 | if (err) return callback(err);
27 | callback(null, doc.uid);
28 | });
29 | });
30 | };
31 |
32 | userSchema.statics.userLogin = function (name, password, callback) {
33 | this.findOne({ name: name }, function (err, doc) {
34 | if (err) return callback(err);
35 | if (!doc || doc.password !== password) return callback(null, false);
36 | doc.logintime = new Date();
37 | doc.save(function (err) {
38 | if (err) return callback(err);
39 | callback(null, true, doc.uid);
40 | });
41 | });
42 | };
43 |
44 | const User = mongodb.model("User", userSchema);
45 |
46 | module.exports = User;
47 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-icon.css:
--------------------------------------------------------------------------------
1 | canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-icon[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-icon, .yt-icon-container.yt-icon {
13 | display: inline-flex;
14 | -ms-flex-align: center;
15 | -webkit-align-items: center;
16 | align-items: center;
17 | -ms-flex-pack: center;
18 | -webkit-justify-content: center;
19 | justify-content: center;
20 | position: relative;
21 | vertical-align: middle;
22 | fill: currentcolor;
23 | stroke: none;
24 | width: var(--iron-icon-width, 24px);
25 | height: var(--iron-icon-height, 24px);
26 | }
27 |
28 | yt-icon.external-container {
29 | display: none !important;
30 | }
31 |
--------------------------------------------------------------------------------
/utils/filter/default.js:
--------------------------------------------------------------------------------
1 | const tool = require("../../utils/tool");
2 | const redis = require("../../utils/redis");
3 | const { promisify } = require("util");
4 |
5 | const info = function () {
6 | let data = { perms: [], addons: [] };
7 |
8 | tool.setAddons(
9 | data.addons,
10 | "limitLength",
11 | "limit danmaku length (0 for no-limit)",
12 | "Number",
13 | 0
14 | );
15 |
16 | tool.setAddons(
17 | data.addons,
18 | "limitFrequency",
19 | "danmaku send intervals(seconds, 0 for no-limit)",
20 | "Number",
21 | 0
22 | );
23 |
24 | return data;
25 | };
26 |
27 | async function filter(danmaku, activity, next) {
28 | const limitLength = parseInt(activity.addons.limitLength);
29 | const limitFrequency = parseInt(activity.addons.limitFrequency);
30 | if (limitLength && limitLength < danmaku.text.length) {
31 | return Error("danmaku length more than " + limitLength);
32 | }
33 | if (limitFrequency && limitFrequency > 0) {
34 | const key = `lock:acitvity:${activity.id}:user:${danmaku.userid}`;
35 | const ttl = promisify(redis.ttl).bind(redis);
36 | const time = await ttl(key);
37 | if (time == -1) redis.setex(key, 0, 1);
38 | else if (time >= 0) {
39 | return Error(
40 | "danmaku send too frequently, please retry after " + time + " seconds"
41 | );
42 | } else {
43 | redis.setex(key, limitFrequency, 1);
44 | }
45 | }
46 | return await next();
47 | }
48 |
49 | module.exports = { filter, info };
50 |
--------------------------------------------------------------------------------
/utils/audit.js:
--------------------------------------------------------------------------------
1 | // const Activity = require("../models/activity");
2 | const logger = require("../utils/logger");
3 |
4 | function compose(middleware) {
5 | if (!Array.isArray(middleware))
6 | throw new TypeError("Middleware stack must be an array!");
7 | for (const fn of middleware) {
8 | if (typeof fn !== "function")
9 | throw new TypeError("Middleware must be composed of functions!");
10 | }
11 | return function (danmaku, activity) {
12 | const next = () => {
13 | const fn = middleware.shift();
14 | if (fn) {
15 | return Promise.resolve(fn(danmaku, activity, next));
16 | } else {
17 | return Promise.resolve();
18 | }
19 | };
20 | return next();
21 | };
22 | }
23 |
24 | function filters(danmaku, activity, callback) {
25 | let middleware = [];
26 | for (const name of activity.filters)
27 | middleware.push(require("./filter/" + name).filter);
28 | compose(middleware)(danmaku, activity).then(function (err) {
29 | if (err) {
30 | logger.error(err.message);
31 | danmaku.updateStatus({ status: "reject" }, function () {
32 | callback(err);
33 | });
34 | } else {
35 | if (activity.audit) {
36 | danmaku.updateStatus({ status: "audit" }, function () {
37 | callback(null);
38 | });
39 | } else {
40 | danmaku.updateStatus({ status: "publish" }, function () {
41 | callback(null);
42 | });
43 | }
44 | }
45 | });
46 | }
47 |
48 | module.exports = { filters };
49 |
--------------------------------------------------------------------------------
/src/utils/pronunciation/index.js:
--------------------------------------------------------------------------------
1 | export const DICT_PINYIN = "pinyin";
2 | export const DICT_KANA = "kana";
3 |
4 | export class PronunciationConverter {
5 | constructor() {
6 | this.pronunciationMap = new Map();
7 | }
8 |
9 | async loadDict(dictName) {
10 | let promise;
11 | switch (dictName) {
12 | case DICT_PINYIN:
13 | promise = import("./dictPinyin");
14 | break;
15 | case DICT_KANA:
16 | promise = import("./dictKana");
17 | break;
18 | default:
19 | return;
20 | }
21 |
22 | let dictTxt = (await promise).default;
23 | let pronunciationMap = new Map();
24 | for (let item of dictTxt.split("\n")) {
25 | if (item.length === 0) {
26 | continue;
27 | }
28 | pronunciationMap.set(item.substring(0, 1), item.substring(1));
29 | }
30 | this.pronunciationMap = pronunciationMap;
31 | }
32 |
33 | getPronunciation(text) {
34 | let res = [];
35 | let lastHasPronunciation = null;
36 | for (let char of text) {
37 | let pronunciation = this.pronunciationMap.get(char);
38 | if (pronunciation === undefined) {
39 | if (lastHasPronunciation !== null && lastHasPronunciation) {
40 | res.push(" ");
41 | }
42 | lastHasPronunciation = false;
43 | res.push(char);
44 | } else {
45 | if (lastHasPronunciation !== null) {
46 | res.push(" ");
47 | }
48 | lastHasPronunciation = true;
49 | res.push(pronunciation);
50 | }
51 | }
52 | return res.join("");
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const { app, server } = require("../app");
3 | const logger = require("../utils/logger");
4 |
5 | const port = normalizePort(process.env.PORT || "3000");
6 | app.set("port", port);
7 | app.set("env", process.env.NODE_ENV || "production");
8 |
9 | server.listen(port);
10 | server.on("error", onError);
11 | server.on("listening", onListening);
12 |
13 | /**
14 | * Normalize a port into a number, string, or false.
15 | */
16 |
17 | function normalizePort(val) {
18 | var port = parseInt(val, 10);
19 | if (isNaN(port)) {
20 | return val;
21 | }
22 | if (port >= 0) {
23 | return port;
24 | }
25 | return false;
26 | }
27 |
28 | /**
29 | * Event listener for HTTP server "error" event.
30 | */
31 |
32 | function onError(error) {
33 | if (error.syscall !== "listen") {
34 | throw error;
35 | }
36 |
37 | var bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
38 |
39 | // handle specific listen errors with friendly messages
40 | switch (error.code) {
41 | case "EACCES":
42 | console.error(bind + " requires elevated privileges");
43 | process.exit(1);
44 | break;
45 | case "EADDRINUSE":
46 | console.error(bind + " is already in use");
47 | process.exit(1);
48 | break;
49 | default:
50 | throw error;
51 | }
52 | }
53 |
54 | /**
55 | * Event listener for HTTP server "listening" event.
56 | */
57 |
58 | function onListening() {
59 | var addr = server.address();
60 | var bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
61 | logger.info("Express server listening on " + bind);
62 | }
63 |
--------------------------------------------------------------------------------
/docs/develop.md:
--------------------------------------------------------------------------------
1 | 开发
2 |
3 |
4 | 中文
5 | |
6 | English
7 |
8 |
9 | ## 项目结构
10 |
11 | 本项目的 `src` 文件夹为前端 `Vue.js` 源码,其他大部分文件夹为后端 `Express` 源码。
12 |
13 | ## 创建弹幕发送器
14 |
15 | 弹幕发送器 `sender` 的目录位于 [routes/sender](routes/sender),可参考 [routes/sender/danmaku.js](routes/sender/danmaku.js)。
16 |
17 | ```js
18 | module.exports = { router, socket, info, init, pushDanmaku };
19 | ```
20 |
21 | 解释如下
22 |
23 | - `router`:用于创建 `Express Router`
24 | - `socket`:用于绑定 `Socket.IO`
25 | - `info`:用于生成后台面板渲染数据
26 | - `init`:用于插件启用时的初始化,例如创建 `Token`
27 | - `pushDanmaku`:文件自行实现的接口
28 |
29 | 这些部分按需取用,例如 [routes/sender/develop.js](routes/sender/develop.js) 中只实现了 `info` 部分。
30 |
31 | 鉴权函数在 [utils/auth.js](utils/auth.js)。请求鉴权分为三种情况:
32 | - `Vue` 后台鉴权:`auth.routerSessionAuth`
33 | - `Express Router` 使用 Token 鉴权:`auth.routerActivityByToken`
34 | - `Socket.IO` 使用 Token 鉴权:`auth.socketActivityByToken`
35 |
36 | 具体用法请参考源码。
37 |
38 | ## 创建弹幕过滤器
39 |
40 | 弹幕过滤器 `filter` 的目录位于 [utils/filter](utils/filter),`info` 用于创建额外配置,`filter` 应该被实现为一个中间件,在 [utils/audit.js](utils/audit.js) 中被使用。可参考 [utils/filter/default.js](utils/filter/default.js)。
41 |
42 | ## 管理 Web 后台面板
43 |
44 | Web 后台面板的数据由后端生成、前端渲染,因此只需要修改后端 `sender` 即可。可参考 [routes/sender/danmaku.js](routes/sender/danmaku.js) 中定义 `info` 的部分。我们规定每个 `sender` 至多拥有一个显示面板,也可在 `filter` 中添加额外配置。
45 |
46 | 如果当前的渲染器无法满足要求,也可以在原有基础上改进前端,增加新的渲染功能。
47 |
48 | ## 多语言支持
49 |
50 | 向主仓库提交代码时,应该保证前端新增的显示文本支持 [src/langs](src/langs) 下的所有语言,例如如下字段:
51 | - `sender:弹幕发送器名`
52 | - `filter:弹幕过滤器名`
53 | - 后端面板中新增的描述
54 |
55 | 整体而言,后台管理面板能看到的文本应该全部本地化。
56 |
57 |
58 |
--------------------------------------------------------------------------------
/routes/sender/export.js:
--------------------------------------------------------------------------------
1 | const config = require("../../config");
2 | const express = require("express");
3 | const router = express.Router();
4 | const tool = require("../../utils/tool");
5 | const auth = require("../../utils/auth");
6 | const Danmaku = require("../../models/danmaku");
7 |
8 | const info = function (activity) {
9 | let data = { panel: {} };
10 |
11 | tool.setPanelTitle(data.panel, "Danmaku Export", "");
12 | tool.addPanelItem(
13 | data.panel,
14 | "JSON",
15 | [],
16 | "",
17 | `${config.host}${config.rootPath}/export/danmaku.json?activity=${activity.id}`,
18 | "download"
19 | );
20 | tool.addPanelItem(
21 | data.panel,
22 | "TEXT",
23 | [],
24 | "",
25 | `${config.host}${config.rootPath}/export/danmaku.txt?activity=${activity.id}`,
26 | "download"
27 | );
28 | return data;
29 | };
30 |
31 | router.get(
32 | "/danmaku.json",
33 | auth.routerSessionAuth,
34 | auth.routerActivityByOwner,
35 | function (req, res) {
36 | Danmaku.getAllDanmaku(req.activity.id, (err, data) => {
37 | if (!err) res.send(data);
38 | else res.json([]);
39 | });
40 | }
41 | );
42 |
43 | router.get(
44 | "/danmaku.txt",
45 | auth.routerSessionAuth,
46 | auth.routerActivityByOwner,
47 | function (req, res) {
48 | Danmaku.getAllDanmakuText(req.activity.id, (err, data) => {
49 | if (!err) {
50 | let result = "";
51 | for (const item of data) result += item.text + "\n";
52 | res.contentType("text/plain");
53 | res.send(result);
54 | } else {
55 | res.contentType("text/plain");
56 | res.send("");
57 | }
58 | });
59 | }
60 | );
61 |
62 | module.exports = { info, router };
63 |
--------------------------------------------------------------------------------
/routes/user.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const config = require('../config');
4 | const User = require('../models/user');
5 | const auth = require('../utils/auth');
6 |
7 | router.post('/login', function (req, res) {
8 | const post = req.body;
9 | User.userLogin(post.user, post.password, function (err, success, uid) {
10 | if (err) {
11 | res.status(500).end();
12 | }
13 | else if (!success) {
14 | res.json({ success: false, reason: 'wrong user/password' });
15 | }
16 | else {
17 | req.session.manage_user_id = uid;
18 | res.json({ success: true });
19 | }
20 | });
21 | });
22 |
23 | router.post('/register', function (req, res) {
24 | var post = req.body;
25 | if (config.inviteCode && post.invite_code !== config.inviteCode){
26 | res.json({ success: false, reason: 'invalid invite code' });
27 | }
28 | else if (post.user && post.password && /\w+/.test(post.user)) {
29 | User.createUser(post.user, post.password, function (err, uid) {
30 | req.session.manage_user_id = uid;
31 | res.json({ success: err === null });
32 | });
33 | } else {
34 | res.json({ success: false, reason: 'invalid user/password' });
35 | }
36 | });
37 |
38 | router.get('/logout', function (req, res) {
39 | delete req.session.manage_user_id;
40 | res.json({ success: true });
41 | });
42 |
43 | router.get('/status', function (req, res) {
44 | res.json({ success: !!req.session.manage_user_id });
45 | });
46 |
47 | router.get('/logout', function (req, res) {
48 | delete req.session.manage_user_id;
49 | res.json({ success: true });
50 | });
51 |
52 | module.exports = router;
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const path = require("path");
3 | const cookieParser = require("cookie-parser");
4 | const session = require("express-session");
5 | const history = require("connect-history-api-fallback");
6 | const RedisStore = require("connect-redis")(session);
7 |
8 | const config = require("./config");
9 | const redis = require("./utils/redis");
10 |
11 | const user = require("./routes/user");
12 | const activity = require("./routes/activity");
13 |
14 | const app = express();
15 | const server = require("http").Server(app);
16 | const io = require("socket.io")(server, {
17 | path: `${config.rootPath}/socket.io`,
18 | cors: {
19 | origin: "*",
20 | },
21 | });
22 |
23 | app.set("socketio", io);
24 |
25 | // view engine setup
26 | app.set("views", path.join(__dirname, "views"));
27 | app.set("view engine", "jade");
28 |
29 | app.use(express.json());
30 | app.use(express.urlencoded({ extended: false }));
31 | app.use(cookieParser());
32 |
33 | app.use(
34 | session({
35 | secret: config.session.cookieSecrect,
36 | resave: false,
37 | saveUninitialized: false,
38 | store: new RedisStore({ client: redis }),
39 | })
40 | );
41 |
42 | app.use(config.rootPath + "/user", user);
43 | app.use(config.rootPath + "/activity", activity);
44 | for (const name of config.danmaku.senders) {
45 | const sender = require("./routes/sender/" + name);
46 | if (sender.router) app.use(config.rootPath + "/" + name, sender.router);
47 | if (sender.socket) sender.socket(io, "/" + name);
48 | }
49 |
50 | app.use(
51 | config.rootPath + "/livechat/",
52 | express.static(path.join(__dirname, "livechat", "dist"))
53 | );
54 | app.use(config.rootPath, express.static(path.join(__dirname, "dist")));
55 | app.use(history());
56 | app.use(config.rootPath, express.static(path.join(__dirname, "public")));
57 |
58 | // catch 404 and forward to error handler
59 | app.use(function (req, res) {
60 | // set locals, only providing error in development
61 | res.locals.message = "404: Not Found";
62 | res.locals.error = "";
63 | res.status(404);
64 | res.render("error");
65 | });
66 |
67 | // error handler
68 | app.use(function (err, req, res) {
69 | // set locals, only providing error in development
70 | res.locals.message = err.message;
71 | res.locals.error = req.app.get("env") === "development" ? err : {};
72 |
73 | // render the error page
74 | res.status(err.status || 500);
75 | res.render("error");
76 | });
77 |
78 | module.exports = { app, server };
79 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-img-shadow.css:
--------------------------------------------------------------------------------
1 | canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-img-shadow[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-img-shadow {
13 | display: inline-block;
14 | opacity: 0;
15 | transition: opacity 0.2s;
16 | -ms-flex: none;
17 | -webkit-flex: none;
18 | flex: none;
19 | }
20 |
21 | yt-img-shadow.no-transition {
22 | opacity: 1;
23 | transition: none;
24 | }
25 |
26 | yt-img-shadow.with-placeholder {
27 | background-color: transparent;
28 | min-height: unset;
29 | min-width: unset;
30 | }
31 |
32 | yt-img-shadow[loaded] {
33 | opacity: 1;
34 | }
35 |
36 | yt-img-shadow.empty img.yt-img-shadow {
37 | visibility: hidden;
38 | }
39 |
40 | yt-img-shadow[object-fit="FILL"] img.yt-img-shadow, yt-img-shadow[fit] img.yt-img-shadow {
41 | width: 100%;
42 | height: 100%;
43 | }
44 |
45 | yt-img-shadow[object-fit="COVER"] img.yt-img-shadow {
46 | width: 100%;
47 | height: 100%;
48 | object-fit: cover;
49 | }
50 |
51 | yt-img-shadow[object-fit="CONTAIN"] img.yt-img-shadow {
52 | width: 100%;
53 | height: 100%;
54 | object-fit: contain;
55 | }
56 |
57 | yt-img-shadow[object-position="LEFT"] img.yt-img-shadow {
58 | object-position: left;
59 | }
60 |
61 | img.yt-img-shadow {
62 | display: block;
63 | margin-left: auto;
64 | margin-right: auto;
65 | max-height: none;
66 | max-width: 100%;
67 | border-radius: none;
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/AuthorBadge.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
12 |
24 |
25 |
29 |
30 |
31 |
32 |
38 |
39 |
40 |
41 |
42 |
43 |
68 |
69 |
72 |
73 |
--------------------------------------------------------------------------------
/src/components/VueDPlayer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
107 |
108 |
--------------------------------------------------------------------------------
/src/router/index.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import VueRouter from "vue-router";
3 | import Login from "../views/Login.vue";
4 | import Register from "../views/Register.vue";
5 | import Manage from "../views/Manage.vue";
6 | import Wall from "../views/Wall.vue";
7 | import Sender from "../views/Sender.vue";
8 | import Audit from "../views/Audit.vue";
9 | import List from "../views/List.vue";
10 | import Player from "../views/Player.vue";
11 | import NotFound from "../views/NotFound.vue";
12 |
13 | Vue.use(VueRouter);
14 |
15 | const routes = [
16 | {
17 | path: "/",
18 | name: "Home",
19 | redirect: { name: "Login" },
20 | },
21 | {
22 | path: "/login",
23 | name: "Login",
24 | component: Login,
25 | },
26 | {
27 | path: "/register",
28 | name: "Register",
29 | component: Register,
30 | },
31 | {
32 | path: "/manage",
33 | name: "Manage",
34 | component: Manage,
35 | },
36 | {
37 | path: "/manage/:id",
38 | name: "Manage",
39 | component: Manage,
40 | },
41 | {
42 | path: "/wall/test",
43 | name: "Test Wall",
44 | component: Wall,
45 | },
46 | {
47 | path: "/wall/:id/:name/:token?",
48 | name: "Wall",
49 | component: Wall,
50 | },
51 | {
52 | path: "/list/test",
53 | name: "Test List",
54 | component: List,
55 | props: (route) => ({ strConfig: route.query }),
56 | },
57 | {
58 | path: "/list/:id/:name/:token?",
59 | name: "LIST",
60 | component: List,
61 | props: (route) => ({ strConfig: route.query }),
62 | },
63 | {
64 | path: "/sender/:id/:name/:token?",
65 | name: "Sender",
66 | component: Sender,
67 | },
68 | {
69 | path: "/audit/:id/:name/:token?",
70 | name: "Audit",
71 | component: Audit,
72 | },
73 | {
74 | path: "/player/test",
75 | name: "Test Player",
76 | component: Player,
77 | },
78 | {
79 | path: "/player/:id/:name/:token?",
80 | name: "Player",
81 | component: Player,
82 | },
83 | {
84 | path: "/about",
85 | name: "About",
86 | // route level code-splitting
87 | // this generates a separate chunk (about.[hash].js) for this route
88 | // which is lazy-loaded when the route is visited.
89 | component: () =>
90 | import(/* webpackChunkName: "about" */ "../views/About.vue"),
91 | },
92 | { path: "*", component: NotFound },
93 | ];
94 |
95 | const router = new VueRouter({
96 | routes,
97 | });
98 |
99 | // router.beforeEach((to, from, next) => {
100 | // if (to.name != 'Login') {
101 | // store.dispatch('checkLogin')
102 | // }
103 | // next()
104 | // });
105 |
106 | export default router;
107 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "comment9",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "vue-cli-service build",
7 | "start": "node bin/www",
8 | "start-dev": "env NODE_ENV=development node bin/www",
9 | "serve": "yarn build && yarn start",
10 | "lint": "vue-cli-service lint && eslint bin/www models/**/*.js routes/**/*.js utils/**/*.js --fix",
11 | "i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/langs/**/*.json\""
12 | },
13 | "dependencies": {
14 | "axios": "^0.21.1",
15 | "connect-history-api-fallback": "^1.6.0",
16 | "connect-redis": "^6.0.0",
17 | "cookie-parser": "~1.4.4",
18 | "dotenv": "^10.0.0",
19 | "express": "~4.16.1",
20 | "express-session": "^1.17.2",
21 | "http-errors": "~1.6.3",
22 | "jade": "~1.11.0",
23 | "mongoose": "^5.13.3",
24 | "node-telegram-bot-api": "^0.54.0",
25 | "pino": "^6.13.0",
26 | "pino-pretty": "^5.1.2",
27 | "redis": "^3.1.2",
28 | "socket.io": "^4.1.3",
29 | "tinycolor2": "^1.4.2",
30 | "wechat": "^2.1.0"
31 | },
32 | "devDependencies": {
33 | "@intlify/vue-i18n-loader": "^1.0.0",
34 | "@vercel/nft": "^0.15.0",
35 | "@vue/cli-plugin-babel": "~4.5.0",
36 | "@vue/cli-plugin-eslint": "~4.5.0",
37 | "@vue/cli-plugin-router": "~4.5.0",
38 | "@vue/cli-plugin-vuex": "~4.5.0",
39 | "@vue/cli-service": "~4.5.0",
40 | "@vue/eslint-config-prettier": "^6.0.0",
41 | "babel-eslint": "^10.1.0",
42 | "dayjs": "^1.10.6",
43 | "dplayer": "^1.26.0",
44 | "element-ui": "^2.4.5",
45 | "eslint": "^6.7.2",
46 | "eslint-plugin-prettier": "^3.3.1",
47 | "eslint-plugin-vue": "^6.2.2",
48 | "hls.js": "^1.0.10",
49 | "js-cookie": "^3.0.1",
50 | "node-sass": "^6.0.1",
51 | "prettier": "^2.2.1",
52 | "sass-loader": "^10.1.1",
53 | "socket.io-client": "^4.1.3",
54 | "vue": "^2.6.11",
55 | "vue-axios": "^3.2.5",
56 | "vue-cli-plugin-element": "~1.0.1",
57 | "vue-cli-plugin-i18n": "~2.1.2",
58 | "vue-clipboard2": "^0.3.1",
59 | "vue-i18n": "^8.22.3",
60 | "vue-router": "^3.2.0",
61 | "vue-template-compiler": "^2.6.11",
62 | "vue-tinder": "^2.0.3",
63 | "vuex": "^3.4.0"
64 | },
65 | "eslintConfig": {
66 | "root": true,
67 | "env": {
68 | "node": true
69 | },
70 | "extends": [
71 | "plugin:vue/essential",
72 | "eslint:recommended",
73 | "plugin:prettier/recommended",
74 | "@vue/prettier"
75 | ],
76 | "parserOptions": {
77 | "parser": "babel-eslint"
78 | },
79 | "rules": {}
80 | },
81 | "browserslist": [
82 | "> 1%",
83 | "last 2 versions",
84 | "not dead"
85 | ],
86 | "license": "MIT",
87 | "repository": "https://github.com/tuna/Comment9"
88 | }
89 |
--------------------------------------------------------------------------------
/docs/develop.en.md:
--------------------------------------------------------------------------------
1 | Development
2 |
3 |
4 | 中文
5 | |
6 | English
7 |
8 |
9 | ## Project Structure
10 |
11 | The `src` folder of this project is the front-end `Vue.js` source code, and most of the other folders are the back-end `Express` source code.
12 |
13 | ## Creating a danmaku sender
14 |
15 | The directory for the danmaku sender is located in [routes/sender](routes/sender), see [routes/sender/danmaku.js](routes/sender/danmaku.js).
16 |
17 | ```js
18 | module.exports = { router, socket, info, init, pushDanmaku };
19 | ```
20 |
21 | The explanation is as follows
22 |
23 | - `router`: used to create `Express Router`
24 | - `socket`: used to bind `Socket.IO`
25 | - `info`: for generating backend panel rendering data
26 | - `init`: used to initialize the plugin when it is enabled, e.g. to create `Token`
27 | - `pushDanmaku`: the interface that the file implements itself
28 |
29 | These sections are taken as needed, e.g. only the `info` section is implemented in [routes/sender/develop.js](routes/sender/develop.js).
30 |
31 | The authentication function is in [utils/auth.js](utils/auth.js). There are three cases of requesting authentication.
32 | - `Vue` backend authentication: `auth.routerSessionAuth`
33 | - `Express Router` uses Token authentication: `auth.routerActivityByToken`
34 | - `Socket.IO` using Token authentication: `auth.socketActivityByToken`
35 |
36 | Please refer to the source code for specific usage.
37 |
38 | ## Create danmaku filters
39 |
40 | The directory for the danmaku `filter` is located in [utils/filter](utils/filter), `info` is used to create additional configuration `addons`, and `filter` should be implemented as a middleware to be used in [utils/audit.js](utils/audit.js). See also [utils/filter/default.js](utils/filter/default.js).
41 |
42 | ## Managing the Web Backend Panel
43 |
44 | The data of the Web backend panel is generated by the backend and rendered by the frontend, so you only need to modify the backend `sender`. See the section defining `info` in [routes/sender/danmaku.js](routes/sender/danmaku.js). We specify that each `sender` has at most one display panel. And additional configuration `addons` can also be added in `filter`.
45 |
46 | If the current renderer does not meet the requirements, it is also possible to improve the front-end by adding new rendering features.
47 |
48 | ## Multi-language support
49 |
50 | When submitting code to the main repository, you should ensure that the display text added to the front-end supports all languages under [src/langs](src/langs), such as following fields:
51 | - `sender:danmaku sender name`
52 | - `filter:danmaku filter name`
53 | - New descriptions in the backend panel
54 |
55 | Overall, all text you see in the backend panel should be localized.
56 |
57 |
--------------------------------------------------------------------------------
/scripts/example/bilibili/sample.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import json
4 | import asyncio
5 | import blivedm
6 | import socketio
7 | import requests
8 |
9 | face = {}
10 |
11 | # https://live.bilibili.com/22637261
12 | room_id = 22637261
13 |
14 | # Change to your deploy url
15 | HOST = "https://comment.pka.moe"
16 |
17 | # 在 "demo" 活动中创建名称和值均为 "bilibili" 的密钥
18 | activity = "demo"
19 | name = "bilibili"
20 | token = "bilibili"
21 |
22 | sio = socketio.Client()
23 | sio.connect(
24 | HOST, auth={"activity": activity, "tokenName": name,"token":token}, namespaces=["/danmaku"])
25 |
26 | @sio.event(namespace='/danmaku')
27 | def message(data):
28 | print(data)
29 |
30 | class MyBLiveClient(blivedm.BLiveClient):
31 | # 演示如何自定义handler
32 | # _COMMAND_HANDLERS = blivedm.BLiveClient._COMMAND_HANDLERS.copy()
33 |
34 | # async def __on_vip_enter(self, command):
35 | # print(command)
36 | # _COMMAND_HANDLERS['WELCOME'] = __on_vip_enter # 老爷入场
37 |
38 | # async def _on_receive_popularity(self, popularity: int):
39 | # print(f'当前人气值:{popularity}')
40 |
41 | async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
42 | print(f'{danmaku.uname}:{danmaku.msg}')
43 | if not danmaku.uid in face:
44 | try:
45 | data = json.loads(requests.get(
46 | f'https://api.bilibili.com/x/space/acc/info?mid={danmaku.uid}').content)
47 | face[danmaku.uid] = data["data"]["face"]
48 | except:
49 | face[danmaku.uid] = "//static.hdslb.com/images/member/noface.gif"
50 | push_danmaku = {
51 | "mode": danmaku.mode,
52 | "text": danmaku.msg,
53 | "stime": 0,
54 | "size": danmaku.font_size,
55 | "color": danmaku.color,
56 | "username": danmaku.uname,
57 | "userid": "bilibili:"+danmaku.uname,
58 | "userimg": face[danmaku.uid]
59 | }
60 | sio.emit("push", push_danmaku, namespace='/danmaku')
61 |
62 | # async def _on_receive_gift(self, gift: blivedm.GiftMessage):
63 | # print(f'{gift.uname} 赠送{gift.gift_name}x{gift.num} ({gift.coin_type}币x{gift.total_coin})')
64 |
65 | # async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
66 | # print(f'{message.username} 购买{message.gift_name}')
67 |
68 | # async def _on_super_chat(self, message: blivedm.SuperChatMessage):
69 | # print(f'醒目留言 ¥{message.price} {message.uname}:{message.message}')
70 |
71 |
72 | async def main():
73 | # 参数1是直播间ID
74 | # 如果SSL验证失败就把ssl设为False
75 | client = MyBLiveClient(room_id, ssl=True)
76 | future = client.start()
77 | try:
78 | # 5秒后停止,测试用
79 | # await asyncio.sleep(5)
80 | # future = client.stop()
81 | # 或者
82 | # future.cancel()
83 |
84 | await future
85 | finally:
86 | await client.close()
87 |
88 |
89 | if __name__ == '__main__':
90 | asyncio.get_event_loop().run_until_complete(main())
91 |
--------------------------------------------------------------------------------
/src/views/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 | Comment9
11 | {{ $t("management") }}
12 |
13 |
14 |
15 |
16 |
17 |
18 | {{
19 | $t("login")
20 | }}
21 | {{ $t("register") }}
26 |
27 |
28 |
29 |
30 |
84 |
85 |
108 |
--------------------------------------------------------------------------------
/utils/filter/blacklist.js:
--------------------------------------------------------------------------------
1 | const tool = require("../../utils/tool");
2 |
3 | const info = function () {
4 | let data = { perms: [], addons: [] };
5 |
6 | tool.setAddons(data.addons, "blacklist", "forbidden user list", "List", []);
7 | tool.setAddons(
8 | data.addons,
9 | "blackwords",
10 | "custom forbidden word list",
11 | "List",
12 | []
13 | );
14 | tool.setAddons(
15 | data.addons,
16 | "blackwordsDict",
17 | "using built-in forbidden word list",
18 | "Boolean",
19 | false
20 | );
21 |
22 | return data;
23 | };
24 |
25 | const fs = require("fs");
26 | let map = {};
27 | const globalBlacklist = fs
28 | .readFileSync(__dirname + "/blackwords.txt", "utf8")
29 | .split("\n");
30 |
31 | for (let word of globalBlacklist) {
32 | if (word) addWord(map, word);
33 | }
34 |
35 | function addWord(map, word) {
36 | let parent = map;
37 | for (let i = 0; i < word.length; i++) {
38 | if (!parent[word[i]]) parent[word[i]] = {};
39 | parent = parent[word[i]];
40 | }
41 | parent.isEnd = true;
42 | }
43 |
44 | function censor(map, s) {
45 | let parent = map;
46 | for (let i = 0; i < s.length; i++) {
47 | if (s[i] == "*") {
48 | continue;
49 | }
50 |
51 | let found = false;
52 | let skip = 0;
53 | let sWord = "";
54 |
55 | for (let j = i; j < s.length; j++) {
56 | if (!parent[s[j]]) {
57 | found = false;
58 | skip = j - i;
59 | parent = map;
60 | break;
61 | }
62 |
63 | sWord = sWord + s[j];
64 | if (parent[s[j]].isEnd) {
65 | found = true;
66 | skip = j - i;
67 | break;
68 | }
69 | parent = parent[s[j]];
70 | }
71 |
72 | if (skip > 1) {
73 | i += skip - 1;
74 | }
75 |
76 | if (!found) {
77 | continue;
78 | } else {
79 | return sWord;
80 | }
81 |
82 | // let stars = '*'
83 | // for (let k = 0; k < skip; k++) {
84 | // stars = stars + '*'
85 | // }
86 |
87 | // let reg = new RegExp(sWord, 'g')
88 | // s = s.replace(reg, stars)
89 | }
90 |
91 | return "";
92 |
93 | // return s
94 | }
95 |
96 | async function filter(danmaku, activity, next) {
97 | const blacklist = activity.addons.blacklist;
98 | if (blacklist instanceof Array && blacklist.includes(danmaku.userid)) {
99 | return Error("access forbidden");
100 | }
101 | if (activity.addons.blackwordsDict) {
102 | const word = censor(map, danmaku.username + danmaku.text);
103 | if (word.length) {
104 | return Error("forbidden word " + word);
105 | }
106 | }
107 | if (activity.addons.blackwords instanceof Array) {
108 | let blackwords = {};
109 | for (const word of activity.addons.blackwords) {
110 | addWord(blackwords, word);
111 | }
112 | const word = censor(blackwords, danmaku.username + danmaku.text);
113 | if (word.length) {
114 | return Error("forbidden word " + word);
115 | }
116 | }
117 | return await next();
118 | }
119 |
120 | module.exports = { filter, info };
121 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css:
--------------------------------------------------------------------------------
1 | canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-live-chat-author-badge-renderer[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-live-chat-author-badge-renderer {
13 | display: inline-block;
14 | }
15 |
16 | yt-live-chat-author-badge-renderer[type='moderator'] {
17 | color: var(--yt-live-chat-moderator-color, #5e84f1);
18 | }
19 |
20 | yt-live-chat-author-badge-renderer[type='owner'] {
21 | color: var(--yt-live-chat-owner-color, #ffd600);
22 | }
23 |
24 | yt-live-chat-author-badge-renderer[type='member'] {
25 | color: var(--yt-live-chat-sponsor-color, #107516);
26 | }
27 |
28 | yt-live-chat-author-badge-renderer[type='verified'] {
29 | color: #999;
30 | }
31 |
32 | img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-renderer {
33 | display: block;
34 | width: 16px;
35 | height: 16px;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/PaidMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
52 |
53 |
58 | {{ content }}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
95 |
96 |
99 |
--------------------------------------------------------------------------------
/utils/auth.js:
--------------------------------------------------------------------------------
1 | const Activity = require('../models/activity');
2 | const logger = require('../utils/logger');
3 | const crypto = require("crypto");
4 |
5 | function routerSessionAuth(req, res, next) {
6 | if (!req.session.manage_user_id) {
7 | res.status(403).end();
8 | } else {
9 | next();
10 | }
11 | }
12 |
13 | function routerActivityByOwner(req, res, next) {
14 | const params = Object.assign({}, req.params, req.query, req.body);
15 | if (!params.activity)
16 | return res.status(500).end();
17 | Activity.getActivity(params.activity, function (err, activity) {
18 | if (err) {
19 | res.status(500).end();
20 | } else if (!activity) {
21 | res.json({ success: false, reason: 'activity not exist' });
22 | res.end();
23 | } else if (activity.owner != req.session.manage_user_id) {
24 | res.json({ success: false, reason: 'permission denied' });
25 | res.end();
26 | } else {
27 | req.activity = activity;
28 | next();
29 | }
30 | })
31 | }
32 |
33 | function routerActivityByToken(req, res, next) {
34 | const params = Object.assign({}, req.params, req.query, req.body);
35 | if (!params.activity || !params.name)
36 | return res.status(500).end();
37 | if (!params.token) params.token = "";
38 | Activity.getActivity(params.activity, function (err, activity) {
39 | if (err) {
40 | res.status(500).end();
41 | } else if (!activity) {
42 | res.json({ success: false, reason: 'activity not exist' });
43 | res.end();
44 | } else if (!activity.tokens.get(params.name) || activity.tokens.get(params.name).token !== params.token) {
45 | res.json({ success: false, reason: 'permission denied' });
46 | res.end();
47 | } else {
48 | req.activity = activity;
49 | req.activity_token = activity.tokens.get(params.name)
50 | req.activity_token_name = params.name
51 | next();
52 | }
53 | })
54 | }
55 |
56 | function socketActivityByToken(socket, next) {
57 | const query = Object.assign({}, socket.handshake.auth, socket.handshake.query);
58 | let activity = query["activity"], tokenName = query["tokenName"], token = query["token"];
59 | if (!token) token = "";
60 | if (!activity || !tokenName) {
61 | next(new Error("Unauthorized"));
62 | return;
63 | }
64 | Activity.getActivity(activity, function (err, activity) {
65 | if (err || !activity) {
66 | next(new Error("Unauthorized"));
67 | } else {
68 | const activity_token = activity.tokens.get(tokenName);
69 | if (activity_token === undefined || activity_token.token !== token) {
70 | next(new Error("Unauthorized"));
71 | }
72 | else {
73 | socket.activity = activity;
74 | socket.activity_token_name = tokenName;
75 | socket.activity_token = activity_token;
76 | next();
77 | }
78 | }
79 | })
80 | }
81 |
82 | module.exports = { routerSessionAuth, routerActivityByOwner, routerActivityByToken, socketActivityByToken };
--------------------------------------------------------------------------------
/src/views/Register.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 | Comment9
11 | {{ $t("management") }}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ $t("login") }}
26 | {{
27 | $t("register")
28 | }}
29 |
30 |
31 |
32 |
33 |
89 |
90 |
113 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/MembershipItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
71 |
72 |
73 |
74 |
75 |
100 |
101 |
104 |
--------------------------------------------------------------------------------
/src/api/chat/ChatClientComment.js:
--------------------------------------------------------------------------------
1 | import { getUuid4Hex } from "@/utils";
2 | import * as constants from "@/components/ChatRenderer/constants";
3 | import * as avatar from "./avatar";
4 | import { io } from "socket.io-client";
5 | import i18n from "@/i18n.js";
6 |
7 | export default class ChatClientComment {
8 | constructor() {
9 | this.minSleepTime = 800;
10 | this.maxSleepTime = 1200;
11 |
12 | this.onAddText = null;
13 | this.onAddGift = null;
14 | this.onAddMember = null;
15 | this.onAddSuperChat = null;
16 | this.onDelSuperChat = null;
17 | this.onUpdateTranslation = null;
18 | this.activityId = null;
19 | this.tokenName = null;
20 | this.token = null;
21 |
22 | this.timerId = null;
23 | }
24 |
25 | start() {
26 | this.setSocket();
27 | }
28 |
29 | stop() {}
30 |
31 | setSocket() {
32 | this.socket = io("/danmaku", {
33 | path: window.location.pathname.replace(/\/$/, "") + "/socket.io",
34 | query: {
35 | activity: this.activityId,
36 | tokenName: this.tokenName,
37 | token: this.token,
38 | },
39 | });
40 | this.socket.on("connect", () => {
41 | this.onAddText({
42 | authorType: constants.AUTHRO_TYPE_ADMIN,
43 | privilegeType: 0,
44 | avatarUrl: avatar.ADMIN_AVATAR_URL,
45 | timestamp: new Date().getTime() / 1000,
46 | authorName: "Comment9",
47 | content: i18n.t("Server connected"),
48 | isGiftDanmaku: false,
49 | authorLevel: 10,
50 | isNewbie: true,
51 | isMobileVerified: true,
52 | medalLevel: 10,
53 | id: getUuid4Hex(),
54 | translation: "",
55 | });
56 | console.log("Server connected");
57 | });
58 | this.socket.on("disconnect", () => {
59 | this.onAddText({
60 | authorType: constants.AUTHRO_TYPE_ADMIN,
61 | privilegeType: 0,
62 | avatarUrl: avatar.ADMIN_AVATAR_URL,
63 | timestamp: new Date().getTime() / 1000,
64 | authorName: "Comment9",
65 | content: i18n.t("Server disconnect"),
66 | isGiftDanmaku: false,
67 | authorLevel: 10,
68 | isNewbie: true,
69 | isMobileVerified: true,
70 | medalLevel: 10,
71 | id: getUuid4Hex(),
72 | translation: "",
73 | });
74 | console.log("Server disconnect");
75 | });
76 | this.socket.on("danmaku", (data) => {
77 | if (data.addons && data.addons.star) {
78 | this.onAddSuperChat({
79 | id: getUuid4Hex(),
80 | avatarUrl: avatar.DEFAULT_AVATAR_URL,
81 | timestamp: new Date().getTime() / 1000,
82 | authorName: data.username,
83 | price: 100,
84 | content: data.text,
85 | translation: "",
86 | });
87 | } else {
88 | this.onAddText({
89 | authorType: constants.AUTHRO_TYPE_NORMAL,
90 | privilegeType: 0,
91 | avatarUrl: data.userimg ? data.userimg : avatar.DEFAULT_AVATAR_URL,
92 | timestamp: new Date().getTime() / 1000,
93 | authorName: data.username,
94 | content: data.text,
95 | isGiftDanmaku: false,
96 | authorLevel: 10,
97 | isNewbie: true,
98 | isMobileVerified: true,
99 | medalLevel: 10,
100 | id: data.id,
101 | translation: "",
102 | });
103 | }
104 | });
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-author-chip.css:
--------------------------------------------------------------------------------
1 | canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-live-chat-author-chip[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-live-chat-author-chip {
13 | display: inline-flex;
14 | -ms-flex-align: baseline;
15 | -webkit-align-items: baseline;
16 | align-items: baseline;
17 | }
18 |
19 | #author-name.yt-live-chat-author-chip {
20 | box-sizing: border-box;
21 | border-radius: 2px;
22 | color: var(--yt-live-chat-secondary-text-color);
23 | font-weight: 500;
24 | }
25 |
26 | yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip {
27 | padding: 2px 4px;
28 | color: var(--yt-live-chat-author-chip-verified-text-color);
29 | background-color: var(--yt-live-chat-author-chip-verified-background-color);
30 | }
31 |
32 | #author-name.yt-live-chat-author-chip[type='moderator'] {
33 | color: var(--yt-live-chat-moderator-color);
34 | }
35 |
36 | yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip[type='owner'], #author-name.yt-live-chat-author-chip[type='owner'] {
37 | background-color: #ffd600;
38 | color: var(--yt-live-chat-author-chip-owner-text-color);
39 | }
40 |
41 | #author-name.yt-live-chat-author-chip[type='member'] {
42 | color: var(--yt-live-chat-sponsor-color);
43 | }
44 |
45 | #chip-badges.yt-live-chat-author-chip:empty {
46 | display: none;
47 | }
48 |
49 | yt-live-chat-author-chip[is-highlighted] #chat-badges.yt-live-chat-author-chip:not(:empty) {
50 | margin-left: 1px;
51 | }
52 |
53 | yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
54 | margin: 0 0 0 2px;
55 | vertical-align: sub;
56 | }
57 |
58 | yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
59 | color: inherit;
60 | }
61 |
62 | #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type {
63 | margin-right: -2px;
64 | }
65 |
--------------------------------------------------------------------------------
/models/danmaku.js:
--------------------------------------------------------------------------------
1 | const mongodb = require("../utils/mongodb");
2 | const logger = require("../utils/logger");
3 | const counter = require("./counter");
4 | const tinycolor = require("tinycolor2");
5 |
6 | const danmakuSchema = mongodb.Schema(
7 | {
8 | id: { type: Number, index: true },
9 | userid: { type: String }, // 发送者id
10 | username: { type: String }, // 发送者昵称
11 | userimg: { type: String, default: "" }, // 发送者头像
12 | mode: { type: Number, default: 1 }, // 模式
13 | text: { type: String }, // 内容
14 | dur: { type: Number, default: 4000 }, // 持续时间
15 | size: { type: Number, default: 25 }, // 文字大小
16 | color: { type: Number, default: 0x000000 }, // 文字颜色
17 | time: { type: Number }, // 发送时间
18 | status: {
19 | type: String,
20 | enum: ["draft", "audit", "publish", "reject"],
21 | default: "draft",
22 | },
23 | activity: {
24 | type: mongodb.Schema.Types.ObjectId,
25 | ref: "Activity",
26 | index: true,
27 | }, // 活动id
28 | addons: { type: Map, of: String },
29 | },
30 | {
31 | timestamps: true,
32 | toJSON: { virtuals: true },
33 | }
34 | );
35 |
36 | danmakuSchema.statics.createDanmaku = function (config, activity, callback) {
37 | const activity_id = activity.id;
38 | if (!callback) {
39 | callback = function () {};
40 | }
41 | if (!config.mode || !config.userid || !config.text || !config.time) {
42 | callback(new Error("missing params"));
43 | return;
44 | }
45 | counter(`activity:${activity_id}:danmaku:count`);
46 | counter(`danmaku:count`, function (err, id) {
47 | if (err) {
48 | callback("unknown error");
49 | return;
50 | } else {
51 | const item = new Danmaku({
52 | id: id,
53 | userid: config.userid,
54 | username: config.username || config.userid,
55 | userimg: config.userimg,
56 | mode: config.mode,
57 | text: config.text,
58 | dur: config.dur,
59 | size: config.size,
60 | color:
61 | config.color !== null
62 | ? config.color
63 | : parseInt(
64 | tinycolor(activity.addons.defaultDanmakuColor)
65 | .toHexString()
66 | .slice(1),
67 | 16
68 | ),
69 | time: config.time,
70 | status: "draft",
71 | activity: activity_id,
72 | addons: config.addons,
73 | });
74 | item.save(function (err) {
75 | if (err) logger.error(err);
76 | callback(err, item);
77 | });
78 | }
79 | });
80 | };
81 |
82 | danmakuSchema.methods.updateStatus = function (data, callback) {
83 | if (!callback) {
84 | callback = function () {};
85 | }
86 | this.status = data.status;
87 | if (data.star) {
88 | this.addons.set("star", true);
89 | this.addons.set("border", true);
90 | }
91 | this.save(callback);
92 | };
93 |
94 | danmakuSchema.statics.getDanmaku = function (id, callback) {
95 | if (!callback) {
96 | callback = function () {};
97 | }
98 | Danmaku.findOne({ id: id }, callback);
99 | };
100 |
101 | danmakuSchema.statics.getAllDanmaku = function (activity_id, callback) {
102 | if (!callback) {
103 | callback = function () {};
104 | }
105 | Danmaku.find({ activity: activity_id }, callback);
106 | };
107 |
108 | danmakuSchema.statics.getAllDanmakuText = function (activity_id, callback) {
109 | if (!callback) {
110 | callback = function () {};
111 | }
112 | Danmaku.find({ activity: activity_id, status: "publish" }, "text", callback);
113 | };
114 |
115 | const Danmaku = mongodb.model("Danmaku", danmakuSchema);
116 |
117 | module.exports = Danmaku;
118 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-ticker-renderer.css:
--------------------------------------------------------------------------------
1 | canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-live-chat-ticker-renderer[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-live-chat-ticker-renderer {
13 | display: block;
14 | background-color: var(--yt-live-chat-header-background-color);
15 | }
16 |
17 | #container.yt-live-chat-ticker-renderer {
18 | position: relative;
19 | }
20 |
21 | #items.yt-live-chat-ticker-renderer {
22 | height: 32px;
23 | overflow: hidden;
24 | white-space: nowrap;
25 | padding: 0 24px 8px 24px;
26 | }
27 |
28 | #items.yt-live-chat-ticker-renderer>*.yt-live-chat-ticker-renderer {
29 | margin-right: 8px;
30 | }
31 |
32 | #left-arrow-container.yt-live-chat-ticker-renderer {
33 | background: linear-gradient(to right, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
34 | left: 0;
35 | padding: 0 16px 0 12px;
36 | }
37 |
38 | #right-arrow-container.yt-live-chat-ticker-renderer {
39 | background: linear-gradient(to left, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
40 | right: 0;
41 | padding: 0 12px 0 16px;
42 | }
43 |
44 | #container.yt-live-chat-ticker-renderer:hover #left-arrow-container.yt-live-chat-ticker-renderer, #container.yt-live-chat-ticker-renderer:hover #right-arrow-container.yt-live-chat-ticker-renderer {
45 | opacity: 1;
46 | }
47 |
48 | #left-arrow-container.yt-live-chat-ticker-renderer, #right-arrow-container.yt-live-chat-ticker-renderer {
49 | height: 32px;
50 | opacity: 0;
51 | position: absolute;
52 | text-align: center;
53 | top: 0;
54 | transition: opacity 0.3s 0.1s;
55 | }
56 |
57 | yt-icon.yt-live-chat-ticker-renderer {
58 | background-color: #2196f3;
59 | border-radius: 999px;
60 | color: #fff;
61 | cursor: pointer;
62 | height: 24px;
63 | padding: 4px;
64 | width: 24px;
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/TextMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
{{ timeText }}
16 |
19 | {{ authorName
25 | }}
26 |
30 |
31 |
32 |
37 |
38 |
39 |
{{ content
41 | }}
48 |
49 |
50 |
51 |
52 |
53 |
105 |
106 |
125 |
126 |
129 |
130 |
--------------------------------------------------------------------------------
/models/activity.js:
--------------------------------------------------------------------------------
1 | const config = require("../config");
2 | const mongodb = require("../utils/mongodb");
3 | const logger = require("../utils/logger");
4 | // const auth = require("../utils/auth");
5 |
6 | const isContain = function (arr1, arr2) {
7 | for (let i = arr2.length - 1; i >= 0; i--) {
8 | if (!arr1.includes(arr2[i])) {
9 | return false;
10 | }
11 | }
12 | return true;
13 | };
14 |
15 | const activitySchema = mongodb.Schema(
16 | {
17 | name: { type: String, unique: true },
18 | owner: { type: Number, index: true },
19 | audit: { type: Boolean, default: false },
20 | senders: { type: [String], default: ["danmaku"] },
21 | filters: { type: [String], default: ["default"] },
22 | tokens: { type: Map, of: { token: String, perms: [String] }, default: {} },
23 | addons: { type: mongodb.Schema.Types.Mixed, default: {} },
24 | },
25 | {
26 | timestamps: true,
27 | toJSON: { virtuals: true },
28 | }
29 | );
30 |
31 | activitySchema.methods.updateInfo = function (data, callback) {
32 | if (data.audit !== undefined) this.audit = !!data.audit;
33 | if (data.senders && isContain(config.danmaku.senders, data.senders)) {
34 | data.senders
35 | .filter((name) => !this.senders.includes(name))
36 | .map((name) => {
37 | const init = require(`../routes/sender/${name}`).init;
38 | if (init) init(this);
39 | });
40 | this.senders = data.senders;
41 | }
42 | if (data.filters && isContain(config.danmaku.filters, data.filters))
43 | this.filters = data.filters;
44 | this.save(callback);
45 | };
46 |
47 | activitySchema.methods.updateName = function (name, callback) {
48 | this.name = name;
49 | this.save(callback);
50 | };
51 |
52 | activitySchema.methods.setAudit = function (audit, callback) {
53 | this.audit = !!audit;
54 | this.save(callback);
55 | };
56 |
57 | activitySchema.methods.setSenders = function (senders, callback) {
58 | if (senders && isContain(config.danmaku.senders, senders))
59 | this.senders = senders;
60 | this.save(callback);
61 | };
62 |
63 | activitySchema.methods.setFilters = function (filters, callback) {
64 | if (filters && isContain(config.danmaku.filters, filters))
65 | this.filters = filters;
66 | this.save(callback);
67 | };
68 |
69 | activitySchema.methods.setToken = function (name, token, perms, callback) {
70 | this.tokens.set(name, { token: token, perms: perms });
71 | this.save(callback);
72 | };
73 |
74 | activitySchema.methods.delToken = function (name, callback) {
75 | this.tokens.delete(name);
76 | this.save(callback);
77 | };
78 |
79 | activitySchema.methods.setAddon = function (name, value, callback) {
80 | if (!this.addons) this.addons = {};
81 | this.addons[name] = value;
82 | this.markModified("addons");
83 | this.save(callback);
84 | };
85 |
86 | activitySchema.methods.delAddon = function (name, callback) {
87 | delete this.addons[name];
88 | this.markModified("addons");
89 | this.save(callback);
90 | };
91 |
92 | activitySchema.statics.createActivity = function (name, owner, callback) {
93 | if (!callback) {
94 | callback = function () {};
95 | }
96 | const item = new Activity({ name: name, owner: owner });
97 | config.danmaku.default_senders.map((name) => {
98 | require(`../routes/sender/${name}`).init(item);
99 | });
100 | item.save(function (err) {
101 | if (err) logger.error(err);
102 | callback(err, item._id);
103 | });
104 | };
105 |
106 | activitySchema.statics.findByOwner = function (owner, callback) {
107 | if (!callback) {
108 | return;
109 | }
110 | Activity.find({ owner: owner }, "_id name", callback);
111 | };
112 |
113 | activitySchema.statics.deleteById = function (id, callback) {
114 | if (!callback) {
115 | callback = function () {};
116 | }
117 | Activity.findOneAndRemove({ _id: id }, callback);
118 | };
119 |
120 | activitySchema.statics.getActivity = function (id, callback) {
121 | if (!callback) return;
122 | Activity.findOne({ _id: id }, function (err, activity) {
123 | if (!activity) {
124 | Activity.findOne({ name: id }, callback);
125 | } else {
126 | callback(err, activity);
127 | }
128 | });
129 | };
130 |
131 | const Activity = mongodb.model("Activity", activitySchema);
132 |
133 | module.exports = Activity;
134 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/constants.js:
--------------------------------------------------------------------------------
1 | export const AUTHRO_TYPE_NORMAL = 0;
2 | export const AUTHRO_TYPE_MEMBER = 1;
3 | export const AUTHRO_TYPE_ADMIN = 2;
4 | export const AUTHRO_TYPE_OWNER = 3;
5 |
6 | export const AUTHOR_TYPE_TO_TEXT = [
7 | "",
8 | "member", // 舰队
9 | "moderator", // 房管
10 | "owner", // 主播
11 | ];
12 |
13 | export const GUARD_LEVEL_TO_TEXT = ["", "总督", "提督", "舰长"];
14 |
15 | export const MESSAGE_TYPE_TEXT = 0;
16 | export const MESSAGE_TYPE_GIFT = 1;
17 | export const MESSAGE_TYPE_MEMBER = 2;
18 | export const MESSAGE_TYPE_SUPER_CHAT = 3;
19 | export const MESSAGE_TYPE_DEL = 4;
20 | export const MESSAGE_TYPE_UPDATE = 5;
21 |
22 | // 美元 -> 人民币 汇率
23 | const EXCHANGE_RATE = 7;
24 | export const PRICE_CONFIGS = [
25 | {
26 | // $100红
27 | price: 100 * EXCHANGE_RATE,
28 | colors: {
29 | contentBg: "rgba(230,33,23,1)",
30 | headerBg: "rgba(208,0,0,1)",
31 | header: "rgba(255,255,255,1)",
32 | authorName: "rgba(255,255,255,0.701961)",
33 | time: "rgba(255,255,255,0.501961)",
34 | content: "rgba(255,255,255,1)",
35 | },
36 | pinTime: 60,
37 | },
38 | {
39 | // $50品红
40 | price: 50 * EXCHANGE_RATE,
41 | colors: {
42 | contentBg: "rgba(233,30,99,1)",
43 | headerBg: "rgba(194,24,91,1)",
44 | header: "rgba(255,255,255,1)",
45 | authorName: "rgba(255,255,255,0.701961)",
46 | time: "rgba(255,255,255,0.501961)",
47 | content: "rgba(255,255,255,1)",
48 | },
49 | pinTime: 30,
50 | },
51 | {
52 | // $20橙
53 | price: 20 * EXCHANGE_RATE,
54 | colors: {
55 | contentBg: "rgba(245,124,0,1)",
56 | headerBg: "rgba(230,81,0,1)",
57 | header: "rgba(255,255,255,0.87451)",
58 | authorName: "rgba(255,255,255,0.701961)",
59 | time: "rgba(255,255,255,0.501961)",
60 | content: "rgba(255,255,255,0.87451)",
61 | },
62 | pinTime: 10,
63 | },
64 | {
65 | // $10黄
66 | price: 10 * EXCHANGE_RATE,
67 | colors: {
68 | contentBg: "rgba(255,202,40,1)",
69 | headerBg: "rgba(255,179,0,1)",
70 | header: "rgba(0,0,0,0.87451)",
71 | authorName: "rgba(0,0,0,0.541176)",
72 | time: "rgba(0,0,0,0.501961)",
73 | content: "rgba(0,0,0,0.87451)",
74 | },
75 | pinTime: 5,
76 | },
77 | {
78 | // $5绿
79 | price: 5 * EXCHANGE_RATE,
80 | colors: {
81 | contentBg: "rgba(29,233,182,1)",
82 | headerBg: "rgba(0,191,165,1)",
83 | header: "rgba(0,0,0,1)",
84 | authorName: "rgba(0,0,0,0.541176)",
85 | time: "rgba(0,0,0,0.501961)",
86 | content: "rgba(0,0,0,1)",
87 | },
88 | pinTime: 2,
89 | },
90 | {
91 | // $2浅蓝
92 | price: 2 * EXCHANGE_RATE,
93 | colors: {
94 | contentBg: "rgba(0,229,255,1)",
95 | headerBg: "rgba(0,184,212,1)",
96 | header: "rgba(0,0,0,1)",
97 | authorName: "rgba(0,0,0,0.701961)",
98 | time: "rgba(0,0,0,0.501961)",
99 | content: "rgba(0,0,0,1)",
100 | },
101 | pinTime: 0,
102 | },
103 | {
104 | // $1蓝
105 | price: 1 * EXCHANGE_RATE,
106 | colors: {
107 | contentBg: "rgba(30,136,229,1)",
108 | headerBg: "rgba(21,101,192,1)",
109 | header: "rgba(255,255,255,1)",
110 | authorName: "rgba(255,255,255,0.701961)",
111 | time: "rgba(255,255,255,0.501961)",
112 | content: "rgba(255,255,255,1)",
113 | },
114 | pinTime: 0,
115 | },
116 | ];
117 |
118 | export function getPriceConfig(price) {
119 | for (const config of PRICE_CONFIGS) {
120 | if (price >= config.price) {
121 | return config;
122 | }
123 | }
124 | return PRICE_CONFIGS[PRICE_CONFIGS.length - 1];
125 | }
126 |
127 | export function getShowContent(message) {
128 | if (message.translation) {
129 | return `${message.content}(${message.translation})`;
130 | }
131 | return message.content;
132 | }
133 |
134 | export function getGiftShowContent(message, showGiftName) {
135 | if (!showGiftName) {
136 | return "";
137 | }
138 | return `Sent ${message.giftName}x${message.num}`;
139 | }
140 |
141 | export function getShowAuthorName(message) {
142 | if (
143 | message.authorNamePronunciation &&
144 | message.authorNamePronunciation !== message.authorName
145 | ) {
146 | return `${message.authorName}(${message.authorNamePronunciation})`;
147 | }
148 | return message.authorName;
149 | }
150 |
--------------------------------------------------------------------------------
/routes/activity.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const Activity = require('../models/activity');
4 | const auth = require('../utils/auth');
5 | const config = require('../config')
6 |
7 | router.get('/list', auth.routerSessionAuth, function (req, res) {
8 | Activity.findByOwner(req.session.manage_user_id, function (err, data) {
9 | if (err)
10 | res.json({ success: false, reason: 'unknown error' });
11 | else
12 | res.json({ success: true, activities: data });
13 | })
14 | });
15 |
16 | router.post('/new', auth.routerSessionAuth, function (req, res) {
17 | if (!req.body.name)
18 | res.json({ success: false, reason: 'invalid name' });
19 | else {
20 | Activity.createActivity(req.body.name, req.session.manage_user_id , function (err, _id) {
21 | if (err) {
22 | res.json({ success: false, reason: 'duplicate name' });
23 | }
24 | else {
25 | res.json({ success: true, id: _id });
26 | }
27 | });
28 | }
29 | });
30 |
31 | router.post('/delete', auth.routerSessionAuth, auth.routerActivityByOwner, function (req, res) {
32 | req.activity.remove(function (err) {
33 | res.json({ success: !err });
34 | })
35 | });
36 |
37 | router.post('/config', auth.routerSessionAuth, auth.routerActivityByOwner, function (req, res) {
38 | const info = [];
39 | const activity = req.activity;
40 | const data = activity.toJSON();
41 |
42 | data.permList = [];
43 | data.addonList = [];
44 | data.panelList = [];
45 | data.senderList = config.danmaku.senders;
46 | data.filterList = config.danmaku.filters;
47 |
48 | for (const name of activity.senders) {
49 | info.push(require("./sender/" + name).info(activity));
50 | }
51 |
52 | for (const name of activity.filters) {
53 | info.push(require("../utils/filter/" + name).info(activity));
54 | }
55 |
56 | info.filter(item => item.perms).map(item => data.permList.push(...item.perms));
57 | info.filter(item => item.addons).map(item => data.addonList.push(...item.addons));
58 | info.filter(item => item.panel && Object.keys(item.panel).length).map(item => data.panelList.push(item.panel));
59 |
60 | res.json({ success: true, data: data });
61 | });
62 |
63 | router.post('/set', auth.routerSessionAuth, auth.routerActivityByOwner, function (req, res) {
64 | switch (req.body.method) {
65 | case 'updateInfo':
66 | req.activity.updateInfo(req.body, function (err) {
67 | res.json({ success: !err });
68 | });
69 | break;
70 | case 'updateName':
71 | if (!req.body.name)
72 | res.json({ success: false, reason: 'invalid name' });
73 | req.activity.updateName(req.body.name, function (err) {
74 | if (err) {
75 | res.json({ success: false, reason: 'duplicate name' });
76 | }
77 | else {
78 | res.json({ success: true });
79 | }
80 | });
81 | break;
82 | case 'setAudit':
83 | req.activity.setAudit(req.body.audit, function (err) {
84 | res.json({ success: !err });
85 | });
86 | break;
87 | case 'setToken':
88 | req.activity.setToken(req.body.name, req.body.token, req.body.perms, function (err) {
89 | res.json({ success: !err });
90 | });
91 | break;
92 | case 'setSenders':
93 | req.activity.setSenders(req.body.senders, function (err) {
94 | res.json({ success: !err });
95 | });
96 | break;
97 | case 'setFilters':
98 | req.activity.setFilters(req.body.filters, function (err) {
99 | res.json({ success: !err });
100 | });
101 | break;
102 | case 'delToken':
103 | req.activity.delToken(req.body.name, function (err) {
104 | res.json({ success: !err });
105 | });
106 | break;
107 | case 'setAddon':
108 | req.activity.setAddon(req.body.name, req.body.value, function (err) {
109 | res.json({ success: !err });
110 | });
111 | break;
112 | case 'delAddon':
113 | req.activity.delAddon(req.body.name, function (err) {
114 | res.json({ success: !err });
115 | });
116 | break;
117 | default:
118 | res.json({ success: false, reason: "method not found" });
119 | }
120 | });
121 |
122 | module.exports = router;
--------------------------------------------------------------------------------
/src/bing.js:
--------------------------------------------------------------------------------
1 | export default [
2 | "AdelieBreeding_ZH-CN1750945258",
3 | "BarcolanaTrieste_ZH-CN5745744257",
4 | "RedRocksArches_ZH-CN5664546697",
5 | "NationalDay70_ZH-CN1636316274",
6 | "LofotenSurfing_ZH-CN5901239545",
7 | "UgandaGorilla_ZH-CN5826117482",
8 | "FeatherSerpent_ZH-CN5706017355",
9 | "VancouverFall_ZH-CN9824386829",
10 | "WallofPeace_ZH-CN5582031878",
11 | "SanSebastianFilm_ZH-CN5506786379",
12 | "CommonLoon_ZH-CN5437917206",
13 | "SunbeamsForest_ZH-CN5358008117",
14 | "StokePero_ZH-CN5293082939",
15 | "Wachsenburg_ZH-CN5224299503",
16 | "SurfboardRow_ZH-CN5154549470",
17 | "ToothWalkingSeahorse_ZH-CN5089043566",
18 | "midmoon_ZH-CN4973736313",
19 | "MilkyWayCanyonlands_ZH-CN2363274510",
20 | "DaintreeRiver_ZH-CN2284362798",
21 | "TsavoGerenuk_ZH-CN2231549718",
22 | "ArroyoGrande_ZH-CN2178202888",
23 | "SouthernYellow_ZH-CN2055825919",
24 | "MountFanjing_ZH-CN1999613800",
25 | "ElMorro_ZH-CN1911346184",
26 | "Tegallalang_ZH-CN1855493751",
27 | "AutumnTreesNewEngland_ZH-CN1766405773",
28 | "SquirrelHeather_ZH-CN1683129884",
29 | "RamsauWimbachklamm_ZH-CN1602837695",
30 | "Castelbouc_ZH-CN1475157551",
31 | "Slackers_ZH-CN1408656264",
32 | "AsburyParkNJ_ZH-CN1353073841",
33 | "HardeeCoFair_ZH-CN8647295545",
34 | "CorsiniGardens_ZH-CN8547012221",
35 | "Krakatoa_ZH-CN8471800710",
36 | "ParrotsIndia_ZH-CN8386276023",
37 | "WinnatsPass_ZH-CN8251326840",
38 | "AugustBears_ZH-CN8159736622",
39 | "FarmlandLandscape_ZH-CN8021236701",
40 | "DubaiFountain_ZH-CN7944507087",
41 | "MaraRiverCrossing_ZH-CN6598585392",
42 | "FinlandCamping_ZH-CN6418764403",
43 | "Feringasee_ZH-CN6335425001",
44 | "MagdalenCave_ZH-CN6279630125",
45 | "DrinkingNectar_ZH-CN6196689688",
46 | "GoldRushYukon_ZH-CN6132080652",
47 | "SmogenSweden_ZH-CN0457682922",
48 | "HornedAnole_ZH-CN0388959247",
49 | "MartianSouthPole_ZH-CN0324422893",
50 | "AmboseliHerd_ZH-CN0249135007",
51 | "TRNPThunderstorm_ZH-CN0178957327",
52 | "TrianaBridge_ZH-CN0107319931",
53 | "KluaneAspen_ZH-CN0028056280",
54 | "LinyantiLeopard_ZH-CN9934758728",
55 | "qixi_ZH-CN3534017617",
56 | "WhiteStorksNest_ZH-CN9809680903",
57 | "ApostleIslands_ZH-CN9543695883",
58 | "SwiftFox_ZH-CN9413097062",
59 | "UhuRLP_ZH-CN5421658032",
60 | "CrummockWater_ZH-CN9317792500",
61 | "LavaFlows_ZH-CN4235925500",
62 | "TreeTower_ZH-CN4181961177",
63 | "TortoiseMigration_ZH-CN4128473636",
64 | "TrilliumLake_ZH-CN4079462365",
65 | "PuffinSkomer_ZH-CN4039641381",
66 | "CahuitaNP_ZH-CN3985565209",
67 | "ElkFallsBridge_ZH-CN3921681387",
68 | "CathedralMountBuffalo_ZH-CN4341947983",
69 | "MeerkatMob_ZH-CN3788674757",
70 | "Skywalk_ZH-CN3725661090",
71 | "SardiniaHawkMoth_ZH-CN3672906054",
72 | "BuckinghamSummer_ZH-CN3519250117",
73 | "MiquelonPanorama_ZH-CN3614818937",
74 | "GodsGarden_ZH-CN3317703606",
75 | "LeatherbackTT_ZH-CN5495532728",
76 | "Narrenmuehle_ZH-CN5582540867",
77 | "VulpesVulpes_ZH-CN5650159325",
78 | "Ushitukiiwa_ZH-CN5710944706",
79 | "WaterperryGardens_ZH-CN5767279278",
80 | "CradleMountain_ZH-CN5817437189",
81 | "NightofNights_ZH-CN5872572560",
82 | "IndiaLitSpace_ZH-CN5941074986",
83 | "JaguarPantanal_ZH-CN6062516404",
84 | "ChefchaouenMorocco_ZH-CN6127993429",
85 | "WesternArcticHerd_ZH-CN6254887608",
86 | "SommerCalviCorsica_ZH-CN6313433064",
87 | "PeelCastle_ZH-CN6366204379",
88 | "Transfagarasan_ZH-CN5760731327",
89 | "BailysBeads_ZH-CN5728297739",
90 | "HKreuni_ZH-CN5683726370",
91 | "RedAnthiasCoralMayotte_ZH-CN5646370533",
92 | "BurrowingOwlet_ZH-CN5583013899",
93 | "Montreux_ZH-CN5485205583",
94 | "RootBridge_ZH-CN5173953292",
95 | "GlastonburyTor_ZH-CN4673691420",
96 | "SutherlandFalls_ZH-CN4602884079",
97 | "PhilippinesFirefly_ZH-CN4519927697",
98 | "Gnomesville_ZH-CN4402652527",
99 | "ManausBasin_ZH-CN4303809335",
100 | "HawksbillCrag_ZH-CN4429681235",
101 | "CommonSundewVosges_ZH-CN0507660055",
102 | "CherryLaurelMaze_ZH-CN9887470516",
103 | "HelixPomatia_ZH-CN9785223494",
104 | "AlaskaEagle_ZH-CN9957205086",
105 | "PantheraLeoDad_ZH-CN9580668524",
106 | "SaskFlowers_ZH-CN9497517721",
107 | "TreeFrog_ZH-CN9016355758",
108 | "SainteVictoireCezanneBirthday_ZH-CN8216109812",
109 | "RioGrande_ZH-CN8091224199",
110 | "FujiSakura_ZH-CN8005792871",
111 | "PontadaPiedade_ZH-CN7717691454",
112 | "OntWarbler_ZH-CN7999782156",
113 | "Biorocks_ZH-CN7851264095",
114 | "dragonboat_ZH-CN0697680986",
115 | "MulberryArtificialHarbour_ZH-CN3973249802",
116 | "PeruvianRainforest_ZH-CN4066508593",
117 | "VastPalmGrove_ZH-CN4145018538",
118 | "HeligolandSealPup_ZH-CN4217382978",
119 | "BassRock_ZH-CN4418828352",
120 | "HighTrestleTrail_ZH-CN4499525731",
121 | "ZumwaltPrairie_ZH-CN4572430876",
122 | "Manhattanhenge_ZH-CN4659585143",
123 | "BlumenwieseNRW_ZH-CN4774429225",
124 | "NFLDfog_ZH-CN4846953507",
125 | ];
126 |
--------------------------------------------------------------------------------
/src/langs/zh_CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "Send friendly danmaku to capture the moment": "发条友善的弹幕见证当下",
3 | "Color":"颜色",
4 | "Danmaku Mode":"弹幕类型",
5 | "Top scrolling":"上滚动",
6 | "Bottom scrolling":"下滚动",
7 | "Bottom":"底部",
8 | "Top":"顶部",
9 | "Reverse": "逆向",
10 | "Font Size": "字体大小",
11 | "Small": "小",
12 | "Middle": "中",
13 | "Big": "大",
14 | "Send": "发送",
15 | "Server connected": "服务器已连接",
16 | "Server disconnect": "服务器断开",
17 | "management": "管理面板",
18 | "username": "用户名",
19 | "password": "密码",
20 | "login": "登录",
21 | "register": "注册",
22 | "please input username": "请输入用户名",
23 | "please input password": "请输入密码",
24 | "ACTIVITY": "活动",
25 | "CREAT": "创建",
26 | "Log Out": "登出",
27 | "Welcome to Comment9": "欢迎使用 Comment9",
28 | "To start, creat an actvity first.": "请先创建一个活动。",
29 | "Activity Setting": "活动设置",
30 | "Activity Name": "活动名称",
31 | "Enable manual audit": "启用手动审核",
32 | "Sender List": "发送插件列表",
33 | "sender:": "发送插件名",
34 | "Filter List": "过滤插件列表",
35 | "filter:": "过滤插件名",
36 | "Save": "保存",
37 | "Delete": "删除",
38 | "Token Setting": "密钥设置",
39 | "Name": "名称",
40 | "Token": "密钥",
41 | "Perms": "权限",
42 | "Method": "方法",
43 | "Edit": "修改",
44 | "Remove": "删除",
45 | "Add": "添加",
46 | "Edit Token": "修改密钥",
47 | "Cancel": "取消",
48 | "Addon Setting": "额外配置",
49 | "Description": "描述",
50 | "Value": "值",
51 | "Clear": "清除",
52 | "Number": "数字",
53 | "String": "字符串",
54 | "Please input value split by line.": "请输入按行分隔的值。",
55 | "Copy": "复制",
56 | "Open in new tab": "在新标签页中打开",
57 | "input error": "输入错误",
58 | "Saved": "保存成功",
59 | "Advanced Settings": "高级设置",
60 | "Choose Language": "选择语言",
61 | "Creat An Activity": "创建新活动",
62 | "Yes": "是",
63 | "No": "否",
64 | "Copied": "已复制",
65 | "Creat Activity:": "创建活动:",
66 | "This operation will delete the activity permanently, continue?": "该操作将永久性地删除本活动,继续?",
67 | "Notice": "通知",
68 | "Clear nickname": "清除昵称",
69 | "Please set your nickname": "请设置您的昵称",
70 | "Success":"成功",
71 | "Input field can not be empty": "输入不能为空",
72 | "Your nickname:": "你的昵称:",
73 | "Activity Delete": "活动已删除",
74 | "sender:danmaku": "弹幕发送基础组件",
75 | "sender:wechat": "微信公众号",
76 | "sender:develop": "开发",
77 | "filter:default": "常规过滤器",
78 | "filter:blacklist": "黑名单过滤器",
79 | "permission to pull recent danmaku": "获取最近弹幕的权限",
80 | "permission to push danmaku for single user": "普通用户发送弹幕的权限",
81 | "permission to audit danmaku": "审核弹幕的权限",
82 | "permission to push danmaku for multi-users(customize userid)": "为多个用户发送弹幕的权限(能自定义用户id)",
83 | "permission to connect with wechat": "与微信连接的权限",
84 | "permission to connect with telegram": "与 Telegram 连接的权限",
85 | "only token can be edit": "只能修改密钥",
86 | "please set manually": "请手动设置",
87 | "forbidden user list": "封禁用户列表",
88 | "custom forbidden word list": "自定义过滤词",
89 | "using built-in forbidden word list": "使用内置过滤词库",
90 | "limit danmaku length (0 for no-limit)": "弹幕最大长度(0不限制)",
91 | "Danmaku Address":"弹幕地址",
92 | "These are basic urls used for web and danmaQ.": "这些是适用于 danmaQ 和网页端的地址。",
93 | "DanmaQ Player": "DanmaQ 播放器",
94 | "Copy to the address bar in danmaQ.": "复制到 danmaQ 地址栏中使用。",
95 | "Danmaku Wall": "弹幕墙",
96 | "Danmaku List Wall": "弹幕列表墙",
97 | "Danmaku Stream Player": "弹幕视频播放器",
98 | "Please set \"streamUrl\" first.": "请先设置 \"streamUrl\"。",
99 | "streaming url": "视频流地址",
100 | "streaming type(hls or empty)": "视频流类型(hls或空)",
101 | "Danmaku Web Sender": "弹幕网页发送器",
102 | "Danmaku Audit": "弹幕审核",
103 | "Wechat Configuration": "微信配置",
104 | "Please read the docs and manually set addons begin with \"wechat\".": "请阅读文档并手动设置以“wechat”开头的额外配置。",
105 | "Wechat Docs": "微信文档",
106 | "Server Url": "服务器地址",
107 | "Configure in wechat background.": "在微信后台配置。",
108 | "Telegram Configuration": "Telegram 配置",
109 | "Please manually set \"telegramToken\" in addons.": "请手动设置额外配置中的\"telegramToken\"。",
110 | "Set Webhook": "设置 Webhook",
111 | "Please open the url below once to set the webhook of your telegram bot.": "请打开一次下方的链接来设置您 telegram bot 的 webhook。",
112 | "Please manually set \"telegramToken\" first.": "请先手动设置 \"telegramToken\"。",
113 | "Development": "开发",
114 | "Docs for developer.": "开发者文档",
115 | "Docs": "文档",
116 | "Backend Docs": "后端文档",
117 | "unknown error": "未知错误",
118 | "invalid name": "名称不合法",
119 | "duplicate name": "名称重复",
120 | "wrong user/password": "错误的用户名/密码",
121 | "activity not exist": "活动不存在",
122 | "permission denied": "权限不足",
123 | "DanmaQ Address": "DanmaQ 地址",
124 | "DanmaQ Channel": "DanmaQ 频道",
125 | "DanmaQ Name": "DanmaQ 名称",
126 | "DanmaQ Token": "DanmaQ 密码",
127 | "This is the name of this activity.": "这是这个活动的名称。",
128 | "You can use any other token with perm \"pull\".": "您也可以使用其他拥有 \"pull\" 权限的密钥。",
129 | "This is the value of \"screen\" token.": "这是密钥 \"screen\" 的值。",
130 | "Danmaku Export": "弹幕导出",
131 | "sender:export": "弹幕导出",
132 | "danmaku send intervals(seconds, 0 for no-limit)": "弹幕发送频率(秒,0不限制)",
133 | "default danmaku color": "默认弹幕颜色",
134 | "publish": "发送成功",
135 | "reject": "发送失败",
136 | "audit": "审核中",
137 | "invite code": "邀请码",
138 | "sign": "签名"
139 | }
--------------------------------------------------------------------------------
/src/langs/zh_TW.json:
--------------------------------------------------------------------------------
1 | {
2 | "Send friendly danmaku to capture the moment": "發條友善的彈幕見證當下",
3 | "Color": "顏色",
4 | "Danmaku Mode": "彈幕類型",
5 | "Top scrolling": "上滾動",
6 | "Bottom scrolling": "下滾動",
7 | "Bottom": "底部",
8 | "Top": "頂部",
9 | "Reverse": "逆向",
10 | "Font Size": "字體大小",
11 | "Small": "小",
12 | "Middle": "中",
13 | "Big": "大",
14 | "Send": "發送",
15 | "Server connected": "服務器已連接",
16 | "Server disconnect": "服務器斷開",
17 | "management": "管理面板",
18 | "username": "用戶名",
19 | "password": "密碼",
20 | "login": "登錄",
21 | "register": "註冊",
22 | "please input username": "請輸入用戶名",
23 | "please input password": "請輸入密碼",
24 | "ACTIVITY": "活動",
25 | "CREAT": "創建",
26 | "Log Out": "登出",
27 | "Welcome to Comment9": "歡迎使用 Comment9",
28 | "To start, creat an actvity first.": "請先創建一個活動。",
29 | "Activity Setting": "活動設置",
30 | "Activity Name": "活動名稱",
31 | "Enable manual audit": "啟用手動審核",
32 | "Sender List": "發送插件列表",
33 | "sender:": "發送插件名",
34 | "Filter List": "過濾插件列表",
35 | "filter:": "過濾插件名",
36 | "Save": "保存",
37 | "Delete": "刪除",
38 | "Token Setting": "密鑰設置",
39 | "Name": "名稱",
40 | "Token": "密鑰",
41 | "Perms": "權限",
42 | "Method": "方法",
43 | "Edit": "修改",
44 | "Remove": "刪除",
45 | "Add": "添加",
46 | "Edit Token": "修改密鑰",
47 | "Cancel": "取消",
48 | "Addon Setting": "額外配置",
49 | "Description": "描述",
50 | "Value": "值",
51 | "Clear": "清除",
52 | "Number": "數字",
53 | "String": "字元串",
54 | "Please input value split by line.": "請輸入按行分隔的值。",
55 | "Copy": "複製",
56 | "Open in new tab": "在新標籤頁中打開",
57 | "input error": "輸入錯誤",
58 | "Saved": "保存成功",
59 | "Advanced Settings": "高級設置",
60 | "Choose Language": "選擇語言",
61 | "Creat An Activity": "創建新活動",
62 | "Yes": "是",
63 | "No": "否",
64 | "Copied": "已複製",
65 | "Creat Activity:": "創建活動:",
66 | "This operation will delete the activity permanently, continue?": "該操作將永久性地刪除本活動,繼續?",
67 | "Notice": "通知",
68 | "Clear nickname": "清除暱稱",
69 | "Please set your nickname": "請設置您的暱稱",
70 | "Success": "成功",
71 | "Input field can not be empty": "輸入不能為空",
72 | "Your nickname:": "你的暱稱:",
73 | "Activity Delete": "活動已刪除",
74 | "sender:danmaku": "彈幕發送基礎組件",
75 | "sender:wechat": "微信公眾號",
76 | "sender:develop": "開發",
77 | "filter:default": "常規過濾器",
78 | "filter:blacklist": "黑名單過濾器",
79 | "permission to pull recent danmaku": "獲取最近彈幕的權限",
80 | "permission to push danmaku for single user": "普通用戶發送彈幕的權限",
81 | "permission to audit danmaku": "審核彈幕的權限",
82 | "permission to push danmaku for multi-users(customize userid)": "為多個用戶發送彈幕的權限(能自定義用戶id)",
83 | "permission to connect with wechat": "與微信連接的權限",
84 | "permission to connect with telegram": "與 Telegram 連接的權限",
85 | "only token can be edit": "只能修改密鑰",
86 | "please set manually": "請手動設置",
87 | "forbidden user list": "封禁用戶列表",
88 | "custom forbidden word list": "自定義過濾詞",
89 | "using built-in forbidden word list": "使用內置過濾詞庫",
90 | "limit danmaku length (0 for no-limit)": "彈幕最大長度(0不限制)",
91 | "Danmaku Address": "彈幕地址",
92 | "These are basic urls used for web and danmaQ.": "這些是適用於 danmaQ 和網頁端的地址。",
93 | "DanmaQ Player": "DanmaQ 播放器",
94 | "Copy to the address bar in danmaQ.": "複製到 danmaQ 地址欄中使用。",
95 | "Danmaku Wall": "彈幕牆",
96 | "Danmaku List Wall": "彈幕列表牆",
97 | "Danmaku Web Sender": "彈幕網頁發送器",
98 | "Danmaku Stream Player": "彈幕視頻播放器",
99 | "Please set \"streamUrl\" first.": "請先設置 \"streamUrl\"。",
100 | "streaming url": "視頻流地址",
101 | "streaming type(hls or empty)": "視頻流類型(hls或空)",
102 | "Danmaku Audit": "彈幕審核",
103 | "Wechat Configuration": "微信配置",
104 | "Please read the docs and manually set addons begin with \"wechat\".": "請閲讀文檔並手動設置以“wechat”開頭的額外配置。",
105 | "Wechat Docs": "微信文檔",
106 | "Server Url": "服務器地址",
107 | "Configure in wechat background.": "在微信後台配置。",
108 | "Telegram Configuration": "Telegram 配置",
109 | "Please manually set \"telegramToken\" in addons.": "請手動設置額外配置中的\"telegramToken\"。",
110 | "Set Webhook": "設置 Webhook",
111 | "Please open the url below once to set the webhook of your telegram bot.": "請打開一次下方的連結來設置您 telegram bot 的 webhook。",
112 | "Please manually set \"telegramToken\" first.": "請先手動設置 \"telegramToken\"。",
113 | "Development": "開發",
114 | "Docs for developer.": "開發者文檔",
115 | "Docs": "文檔",
116 | "Backend Docs": "後端文檔",
117 | "unknown error": "未知錯誤",
118 | "invalid name": "名稱不合法",
119 | "duplicate name": "名稱重復",
120 | "wrong user/password": "錯誤的用戶名/密碼",
121 | "activity not exist": "活動不存在",
122 | "permission denied": "權限不足",
123 | "DanmaQ Address": "DanmaQ 地址",
124 | "DanmaQ Channel": "DanmaQ 頻道",
125 | "DanmaQ Name": "DanmaQ 名稱",
126 | "DanmaQ Token": "DanmaQ 密碼",
127 | "This is the name of this activity.": "這是這個活動的名稱。",
128 | "You can use any other token with perm \"pull\".": "您也可以使用其他擁有 \"pull\" 權限的密鑰。",
129 | "This is the value of \"screen\" token.": "這是密鑰 \"screen\" 的值。",
130 | "Danmaku Export": "彈幕導出",
131 | "sender:export": "彈幕導出",
132 | "danmaku send intervals(seconds, 0 for no-limit)": "彈幕發送頻率(秒,0不限制)",
133 | "default danmaku color": "默認彈幕顏色",
134 | "publish": "發送成功",
135 | "reject": "發送失敗",
136 | "audit": "審核中",
137 | "invite code": "邀請碼",
138 | "sign":"簽名"
139 | }
--------------------------------------------------------------------------------
/src/api/chat/ChatClientRelay.js:
--------------------------------------------------------------------------------
1 | const COMMAND_HEARTBEAT = 0;
2 | const COMMAND_JOIN_ROOM = 1;
3 | const COMMAND_ADD_TEXT = 2;
4 | const COMMAND_ADD_GIFT = 3;
5 | const COMMAND_ADD_MEMBER = 4;
6 | const COMMAND_ADD_SUPER_CHAT = 5;
7 | const COMMAND_DEL_SUPER_CHAT = 6;
8 | const COMMAND_UPDATE_TRANSLATION = 7;
9 |
10 | const HEARTBEAT_INTERVAL = 10 * 1000;
11 | const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000;
12 |
13 | export default class ChatClientRelay {
14 | constructor(roomId, autoTranslate) {
15 | this.roomId = roomId;
16 | this.autoTranslate = autoTranslate;
17 |
18 | this.onAddText = null;
19 | this.onAddGift = null;
20 | this.onAddMember = null;
21 | this.onAddSuperChat = null;
22 | this.onDelSuperChat = null;
23 | this.onUpdateTranslation = null;
24 |
25 | this.websocket = null;
26 | this.retryCount = 0;
27 | this.isDestroying = false;
28 | this.heartbeatTimerId = null;
29 | this.receiveTimeoutTimerId = null;
30 | }
31 |
32 | start() {
33 | this.wsConnect();
34 | }
35 |
36 | stop() {
37 | this.isDestroying = true;
38 | if (this.websocket) {
39 | this.websocket.close();
40 | }
41 | }
42 |
43 | wsConnect() {
44 | if (this.isDestroying) {
45 | return;
46 | }
47 | const protocol = window.location.protocol === "https:" ? "wss" : "ws";
48 | // 开发时使用localhost:12450
49 | const host =
50 | process.env.NODE_ENV === "development"
51 | ? "localhost:12450"
52 | : window.location.host;
53 | const url = `${protocol}://${host}/api/chat`;
54 | this.websocket = new WebSocket(url);
55 | this.websocket.onopen = this.onWsOpen.bind(this);
56 | this.websocket.onclose = this.onWsClose.bind(this);
57 | this.websocket.onmessage = this.onWsMessage.bind(this);
58 | }
59 |
60 | onWsOpen() {
61 | this.retryCount = 0;
62 | this.websocket.send(
63 | JSON.stringify({
64 | cmd: COMMAND_JOIN_ROOM,
65 | data: {
66 | roomId: this.roomId,
67 | config: {
68 | autoTranslate: this.autoTranslate,
69 | },
70 | },
71 | })
72 | );
73 | this.heartbeatTimerId = window.setInterval(
74 | this.sendHeartbeat.bind(this),
75 | HEARTBEAT_INTERVAL
76 | );
77 | this.refreshReceiveTimeoutTimer();
78 | }
79 |
80 | sendHeartbeat() {
81 | this.websocket.send(
82 | JSON.stringify({
83 | cmd: COMMAND_HEARTBEAT,
84 | })
85 | );
86 | }
87 |
88 | refreshReceiveTimeoutTimer() {
89 | if (this.receiveTimeoutTimerId) {
90 | window.clearTimeout(this.receiveTimeoutTimerId);
91 | }
92 | this.receiveTimeoutTimerId = window.setTimeout(
93 | this.onReceiveTimeout.bind(this),
94 | RECEIVE_TIMEOUT
95 | );
96 | }
97 |
98 | onReceiveTimeout() {
99 | window.console.warn("接收消息超时");
100 | this.receiveTimeoutTimerId = null;
101 |
102 | // 直接丢弃阻塞的websocket,不等onclose回调了
103 | this.websocket.onopen =
104 | this.websocket.onclose =
105 | this.websocket.onmessage =
106 | null;
107 | this.websocket.close();
108 | this.onWsClose();
109 | }
110 |
111 | onWsClose() {
112 | this.websocket = null;
113 | if (this.heartbeatTimerId) {
114 | window.clearInterval(this.heartbeatTimerId);
115 | this.heartbeatTimerId = null;
116 | }
117 | if (this.receiveTimeoutTimerId) {
118 | window.clearTimeout(this.receiveTimeoutTimerId);
119 | this.receiveTimeoutTimerId = null;
120 | }
121 |
122 | if (this.isDestroying) {
123 | return;
124 | }
125 | window.console.warn(`掉线重连中${++this.retryCount}`);
126 | window.setTimeout(this.wsConnect.bind(this), 1000);
127 | }
128 |
129 | onWsMessage(event) {
130 | this.refreshReceiveTimeoutTimer();
131 |
132 | let { cmd, data } = JSON.parse(event.data);
133 | switch (cmd) {
134 | case COMMAND_HEARTBEAT: {
135 | break;
136 | }
137 | case COMMAND_ADD_TEXT: {
138 | if (!this.onAddText) {
139 | break;
140 | }
141 | data = {
142 | avatarUrl: data[0],
143 | timestamp: data[1],
144 | authorName: data[2],
145 | authorType: data[3],
146 | content: data[4],
147 | privilegeType: data[5],
148 | isGiftDanmaku: !!data[6],
149 | authorLevel: data[7],
150 | isNewbie: !!data[8],
151 | isMobileVerified: !!data[9],
152 | medalLevel: data[10],
153 | id: data[11],
154 | translation: data[12],
155 | };
156 | this.onAddText(data);
157 | break;
158 | }
159 | case COMMAND_ADD_GIFT: {
160 | if (this.onAddGift) {
161 | this.onAddGift(data);
162 | }
163 | break;
164 | }
165 | case COMMAND_ADD_MEMBER: {
166 | if (this.onAddMember) {
167 | this.onAddMember(data);
168 | }
169 | break;
170 | }
171 | case COMMAND_ADD_SUPER_CHAT: {
172 | if (this.onAddSuperChat) {
173 | this.onAddSuperChat(data);
174 | }
175 | break;
176 | }
177 | case COMMAND_DEL_SUPER_CHAT: {
178 | if (this.onDelSuperChat) {
179 | this.onDelSuperChat(data);
180 | }
181 | break;
182 | }
183 | case COMMAND_UPDATE_TRANSLATION: {
184 | if (!this.onUpdateTranslation) {
185 | break;
186 | }
187 | data = {
188 | id: data[0],
189 | translation: data[1],
190 | };
191 | this.onUpdateTranslation(data);
192 | break;
193 | }
194 | }
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/langs/ja.json:
--------------------------------------------------------------------------------
1 | {
2 | "Send friendly danmaku to capture the moment": "友達に優しい弾幕を送って瞬間を捉える",
3 | "Color": "カラー",
4 | "Danmaku Mode": "弾幕種類",
5 | "Top scrolling": "上スクロール",
6 | "Bottom scrolling": "下スクロール",
7 | "Bottom": "ボトム",
8 | "Top": "トップ",
9 | "Reverse": "リバース",
10 | "Font Size": "文字サイズ",
11 | "Small": "スモール",
12 | "Middle": "ミドル",
13 | "Big": "ビッグ",
14 | "Send": "送信",
15 | "Server connected": "サーバへの接続",
16 | "Server disconnect": "サーバの中断",
17 | "management": "管理画面",
18 | "username": "ユーザー名",
19 | "password": "パスワード",
20 | "login": "登录",
21 | "register": "新规取得",
22 | "please input username": "ユーザー名",
23 | "please input password": "パスワード",
24 | "ACTIVITY": "イベント",
25 | "CREAT": "クリエイト",
26 | "Log Out": "ログアウト",
27 | "Welcome to Comment9": "Comment9 へようこそ",
28 | "To start, creat an actvity first.": "まずはイベントを作成してください。",
29 | "Activity Setting": "イベント設定",
30 | "Activity Name": "イベント名",
31 | "Enable manual audit": "マニュアルレビューの有効化",
32 | "Sender List": "送信プラグイン一覧",
33 | "sender:": "送信プラグイン名",
34 | "Filter List": "フィルタープラグインリスト",
35 | "filter:": "フィルターのプラグイン名",
36 | "Save": "保存",
37 | "Delete": "削除",
38 | "Token Setting": "トークン設定",
39 | "Name": "名前",
40 | "Token": "トークン",
41 | "Perms": "パーミッション",
42 | "Method": "方法",
43 | "Edit": "モディファイ",
44 | "Remove": "削除",
45 | "Add": "追加",
46 | "Edit Token": "トークンの編集",
47 | "Cancel": "キャンセル",
48 | "Addon Setting": "追加設定",
49 | "Description": "説明",
50 | "Value": "値",
51 | "Clear": "クリア",
52 | "Number": "番号",
53 | "String": "ストリング",
54 | "Please input value split by line.": "改行した値を入力してください。",
55 | "Copy": "コピー",
56 | "Open in new tab": "新しいタブで開く",
57 | "input error": "エラーを入力する",
58 | "Saved": "保存成功",
59 | "Advanced Settings": "詳細設定",
60 | "Choose Language": "言語選択",
61 | "Creat An Activity": "クリエイト活動",
62 | "Yes": "はい",
63 | "No": "いいえ",
64 | "Copied": "コピー",
65 | "Creat Activity:": "創造活動:",
66 | "This operation will delete the activity permanently, continue?": "このアクションは、このアクティビティを永久に削除しますが、続けますか?",
67 | "Notice": "お知らせ",
68 | "Clear nickname": "明確なニックネーム",
69 | "Please set your nickname": "あなたのニックネームを設定してください",
70 | "Success": "成功",
71 | "Input field can not be empty": "入力は空にできない",
72 | "Your nickname:": "あなたのニックネーム:",
73 | "Activity Delete": "イベント削除",
74 | "sender:danmaku": "弾幕送信のための基本コンポーネント",
75 | "sender:wechat": "WeChatパブリック",
76 | "sender:develop": "開発",
77 | "filter:default": "従来のフィルター",
78 | "filter:blacklist": "ブラックリストフィルター",
79 | "permission to pull recent danmaku": "最近の弾幕にアクセスする",
80 | "permission to push danmaku for single user": "一般ユーザーの弾幕送信の許可",
81 | "permission to audit danmaku": "弾幕を確認する許可",
82 | "permission to push danmaku for multi-users(customize userid)": "複数のユーザーに対する弾幕送信の許可(ユーザーIDのカスタマイズ機能)",
83 | "permission to connect with wechat": "WeChatへの接続許可",
84 | "permission to connect with telegram": "Telegramへの接続許可",
85 | "only token can be edit": "トークンのみ変更可能",
86 | "please set manually": "手動で設定してください",
87 | "forbidden user list": "禁止されたユーザーのリスト",
88 | "custom forbidden word list": "カスタマイズされたフィルターワード",
89 | "using built-in forbidden word list": "内蔵フィルターワードバンクの使用",
90 | "limit danmaku length (0 for no-limit)": "弾幕の最大長(0 無制限)",
91 | "Danmaku Address": "弾幕アドレス",
92 | "These are basic urls used for web and danmaQ.": "danmaQやWebページのアドレスです。",
93 | "DanmaQ Player": "DanmaQプレーヤー",
94 | "Copy to the address bar in danmaQ.": "danmaQのアドレスバーにコピーしてお使いください。",
95 | "Danmaku Wall": "弾幕ウォール",
96 | "Danmaku List Wall": "弾幕リストウォール",
97 | "Danmaku Stream Player": "弾幕ビデオストリーマー",
98 | "Please set \"streamUrl\" first.": "まずは手動で「streamUrl」を設定してください。",
99 | "streaming url": "ストリーミングURL",
100 | "streaming type(hls or empty)": "ストリーミングタイプ(hls or empty)",
101 | "Danmaku Web Sender": "弾幕Webページの送信者",
102 | "Danmaku Audit": "弾幕監査",
103 | "Wechat Configuration": "WeChatの設定",
104 | "Please read the docs and manually set addons begin with \"wechat\".": "ドキュメントを読み、「wechat」で始まる追加設定を手動で行ってください。",
105 | "Wechat Docs": "WeChatドキュメント",
106 | "Server Url": "サーバーアドレス",
107 | "Configure in wechat background.": "WeChatのバックエンドで設定します。",
108 | "Telegram Configuration": "Telegramの設定",
109 | "Please manually set \"telegramToken\" in addons.": "追加設定の「telegramToken」は、手動で設定してください。",
110 | "Set Webhook": "Webhookの設定",
111 | "Please open the url below once to set the webhook of your telegram bot.": "下記のリンクを一度開いて、あなたのtelegram botにwebhookを設定してください。",
112 | "Please manually set \"telegramToken\" first.": "まずは手動で「telegramToken」を設定してください。",
113 | "Development": "開発",
114 | "Docs for developer.": "開発者向けドキュメント",
115 | "Docs": "ドキュメント",
116 | "Backend Docs": "バックエンドドキュメント",
117 | "unknown error": "不明なエラー",
118 | "invalid name": "無効な名前",
119 | "duplicate name": "ネームリピート",
120 | "wrong user/password": "ユーザー名/パスワードの誤り",
121 | "activity not exist": "アクティビティが存在しない",
122 | "permission denied": "許可が下りない",
123 | "DanmaQ Address": "DanmaQアドレス",
124 | "DanmaQ Channel": "DanmaQチャンネル",
125 | "DanmaQ Name": "DanmaQルーム",
126 | "DanmaQ Token": "DanmaQパスワード",
127 | "This is the name of this activity.": "これはイベントの名前です。",
128 | "You can use any other token with perm \"pull\".": "また、「pull」の権限を持つ他のキーを使用することもできます。",
129 | "This is the value of \"screen\" token.": "これが「screen」の値です。",
130 | "Danmaku Export": "弾幕エクスポート",
131 | "sender:export": "弾幕エクスポート",
132 | "danmaku send intervals(seconds, 0 for no-limit)": "弾幕送信頻度(秒、0 無制限)",
133 | "default danmaku color": "デフォルトの弾幕の色",
134 | "publish": "送信の成功",
135 | "reject": "送信失敗",
136 | "audit": "監査中",
137 | "invite code": "招待コード",
138 | "sign": "サイン"
139 | }
140 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-item-list-renderer.css:
--------------------------------------------------------------------------------
1 | canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-live-chat-item-list-renderer[hidden] {
9 | display: none !important;
10 | }
11 |
12 | yt-live-chat-item-list-renderer {
13 | position: relative;
14 | display: block;
15 | overflow: hidden;
16 | z-index: 0;
17 | }
18 |
19 | yt-live-chat-item-list-renderer[moderation-mode-enabled] {
20 | --yt-live-chat-item-with-inline-actions-context-menu-display: none;
21 | --yt-live-chat-inline-action-button-container-display: flex;
22 | }
23 |
24 | #contents.yt-live-chat-item-list-renderer {
25 | position: absolute;
26 | top: 0;
27 | right: 0;
28 | bottom: 0;
29 | left: 0;
30 | display: flex;
31 | -ms-flex-direction: column;
32 | -webkit-flex-direction: column;
33 | flex-direction: column;
34 | }
35 |
36 | #empty-state-message.yt-live-chat-item-list-renderer {
37 | position: absolute;
38 | top: 0;
39 | right: 0;
40 | bottom: 0;
41 | left: 0;
42 | display: flex;
43 | -ms-flex-direction: column;
44 | -webkit-flex-direction: column;
45 | flex-direction: column;
46 | -ms-flex-pack: center;
47 | -webkit-justify-content: center;
48 | justify-content: center;
49 | }
50 |
51 | #empty-state-message.yt-live-chat-item-list-renderer>yt-live-chat-message-renderer.yt-live-chat-item-list-renderer {
52 | color: var(--yt-live-chat-tertiary-text-color);
53 | background: transparent;
54 | font-size: 18px;
55 | --yt-live-chat-message-renderer-text-align: center;
56 | }
57 |
58 | yt-icon-button.yt-live-chat-item-list-renderer {
59 | background-color: #2196f3;
60 | border-radius: 999px;
61 | bottom: 0;
62 | color: #fff;
63 | cursor: pointer;
64 | width: 32px;
65 | height: 32px;
66 | margin: 0 calc(50% - 16px) 8px calc(50% - 16px);
67 | padding: 4px;
68 | position: absolute;
69 | transition-property: bottom;
70 | transition-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
71 | transition-duration: 0.15s;
72 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
73 | }
74 |
75 | yt-icon-button.yt-live-chat-item-list-renderer[disabled] {
76 | bottom: -42px;
77 | color: #fff;
78 | transition-timing-function: cubic-bezier(0.4, 0.0, 1, 1);
79 | }
80 |
81 | #item-scroller.yt-live-chat-item-list-renderer {
82 | -ms-flex: 1 1 0.000000001px;
83 | -webkit-flex: 1;
84 | flex: 1;
85 | -webkit-flex-basis: 0.000000001px;
86 | flex-basis: 0.000000001px;
87 | overflow-x: hidden;
88 | overflow-y: hidden;
89 | padding-right: var(--scrollbar-width);
90 | }
91 |
92 | yt-live-chat-item-list-renderer[allow-scroll] #item-scroller.yt-live-chat-item-list-renderer {
93 | overflow-y: scroll;
94 | padding-right: 0;
95 | }
96 |
97 | #item-offset.yt-live-chat-item-list-renderer {
98 | position: relative;
99 | }
100 |
101 | #item-scroller.animated.yt-live-chat-item-list-renderer #item-offset.yt-live-chat-item-list-renderer {
102 | overflow: hidden;
103 | }
104 |
105 | #items.yt-live-chat-item-list-renderer {
106 | -ms-flex: 1 1 0.000000001px;
107 | -webkit-flex: 1;
108 | flex: 1;
109 | -webkit-flex-basis: 0.000000001px;
110 | flex-basis: 0.000000001px;
111 | padding: var(--yt-live-chat-item-list-renderer-padding, 4px 0);
112 | }
113 |
114 | #items.yt-live-chat-item-list-renderer>*.yt-live-chat-item-list-renderer:not(:first-child) {
115 | border-top: var(--yt-live-chat-item-list-item-border, none);
116 | }
117 |
118 | #item-scroller.animated.yt-live-chat-item-list-renderer #items.yt-live-chat-item-list-renderer {
119 | bottom: 0;
120 | left: 0;
121 | position: absolute;
122 | right: 0;
123 | transform: translateY(0);
124 | }
125 |
126 | #docked-messages.yt-live-chat-item-list-renderer {
127 | z-index: 1;
128 | position: absolute;
129 | left: 0;
130 | right: 0;
131 | top: 0;
132 | }
133 |
134 | yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer {
135 | padding: 4px 24px;
136 | }
137 |
138 | yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] {
139 | padding: 8px 16px;
140 | }
141 |
--------------------------------------------------------------------------------
/src/api/chat/ChatClientTest.js:
--------------------------------------------------------------------------------
1 | import { getUuid4Hex } from "@/utils";
2 | import * as constants from "@/components/ChatRenderer/constants";
3 | import * as avatar from "./avatar";
4 |
5 | const NAMES = [
6 | "xfgryujk",
7 | "Simon",
8 | "Il Harper",
9 | "Kinori",
10 | "shugen",
11 | "yuyuyzl",
12 | "3Shain",
13 | "光羊",
14 | "黑炎",
15 | "Misty",
16 | "孤梦星影",
17 | "ジョナサン・ジョースター",
18 | "ジョセフ・ジョースター",
19 | "ディオ・ブランドー",
20 | "空條承太郎",
21 | "博丽灵梦",
22 | "雾雨魔理沙",
23 | "Rick Astley",
24 | ];
25 |
26 | const CONTENTS = [
27 | "草",
28 | "kksk",
29 | "8888888888",
30 | "888888888888888888888888888888",
31 | "老板大气,老板身体健康",
32 | "The quick brown fox jumps over the lazy dog",
33 | "I can eat glass, it doesn't hurt me",
34 | "我不做人了,JOJO",
35 | "無駄無駄無駄無駄無駄無駄無駄無駄",
36 | "欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦",
37 | "逃げるんだよォ!",
38 | "嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么",
39 | "不要停下来啊",
40 | "已经没有什么好怕的了",
41 | "I am the bone of my sword. Steel is my body, and fire is my blood.",
42 | "言いたいことがあるんだよ!",
43 | "我忘不掉夏小姐了。如果不是知道了夏小姐,说不定我已经对这个世界没有留恋了",
44 | "迷えば、敗れる",
45 | "Farewell, ashen one. May the flame guide thee",
46 | "竜神の剣を喰らえ!",
47 | "竜が我が敌を喰らう!",
48 | "有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说",
49 | "让我看看",
50 | "我柜子动了,我不玩了",
51 | ];
52 |
53 | const AUTHOR_TYPES = [
54 | { weight: 10, value: constants.AUTHRO_TYPE_NORMAL },
55 | { weight: 5, value: constants.AUTHRO_TYPE_MEMBER },
56 | { weight: 2, value: constants.AUTHRO_TYPE_ADMIN },
57 | { weight: 1, value: constants.AUTHRO_TYPE_OWNER },
58 | ];
59 |
60 | function randGuardInfo() {
61 | let authorType = randomChoose(AUTHOR_TYPES);
62 | let privilegeType;
63 | if (
64 | authorType === constants.AUTHRO_TYPE_MEMBER ||
65 | authorType === constants.AUTHRO_TYPE_ADMIN
66 | ) {
67 | privilegeType = randInt(1, 3);
68 | } else {
69 | privilegeType = 0;
70 | }
71 | return { authorType, privilegeType };
72 | }
73 |
74 | const GIFT_INFO_LIST = [
75 | { giftName: "B坷垃", totalCoin: 9900 },
76 | { giftName: "礼花", totalCoin: 28000 },
77 | { giftName: "花式夸夸", totalCoin: 39000 },
78 | { giftName: "天空之翼", totalCoin: 100000 },
79 | { giftName: "摩天大楼", totalCoin: 450000 },
80 | { giftName: "小电视飞船", totalCoin: 1245000 },
81 | ];
82 |
83 | const SC_PRICES = [30, 50, 100, 200, 500, 1000];
84 |
85 | const MESSAGE_GENERATORS = [
86 | // 文字
87 | {
88 | weight: 20,
89 | value() {
90 | return {
91 | type: constants.MESSAGE_TYPE_TEXT,
92 | message: {
93 | ...randGuardInfo(),
94 | avatarUrl: avatar.DEFAULT_AVATAR_URL,
95 | timestamp: new Date().getTime() / 1000,
96 | authorName: randomChoose(NAMES),
97 | content: randomChoose(CONTENTS),
98 | isGiftDanmaku: randInt(1, 10) <= 1,
99 | authorLevel: randInt(0, 60),
100 | isNewbie: randInt(1, 10) <= 9,
101 | isMobileVerified: randInt(1, 10) <= 9,
102 | medalLevel: randInt(0, 40),
103 | id: getUuid4Hex(),
104 | translation: "",
105 | },
106 | };
107 | },
108 | },
109 | // 礼物
110 | {
111 | weight: 1,
112 | value() {
113 | return {
114 | type: constants.MESSAGE_TYPE_GIFT,
115 | message: {
116 | ...randomChoose(GIFT_INFO_LIST),
117 | id: getUuid4Hex(),
118 | avatarUrl: avatar.DEFAULT_AVATAR_URL,
119 | timestamp: new Date().getTime() / 1000,
120 | authorName: randomChoose(NAMES),
121 | num: 1,
122 | },
123 | };
124 | },
125 | },
126 | // SC
127 | {
128 | weight: 3,
129 | value() {
130 | return {
131 | type: constants.MESSAGE_TYPE_SUPER_CHAT,
132 | message: {
133 | id: getUuid4Hex(),
134 | avatarUrl: avatar.DEFAULT_AVATAR_URL,
135 | timestamp: new Date().getTime() / 1000,
136 | authorName: randomChoose(NAMES),
137 | price: randomChoose(SC_PRICES),
138 | content: randomChoose(CONTENTS),
139 | translation: "",
140 | },
141 | };
142 | },
143 | },
144 | // 新舰长
145 | {
146 | weight: 1,
147 | value() {
148 | return {
149 | type: constants.MESSAGE_TYPE_MEMBER,
150 | message: {
151 | id: getUuid4Hex(),
152 | avatarUrl: avatar.DEFAULT_AVATAR_URL,
153 | timestamp: new Date().getTime() / 1000,
154 | authorName: randomChoose(NAMES),
155 | privilegeType: randInt(1, 3),
156 | },
157 | };
158 | },
159 | },
160 | ];
161 |
162 | function randomChoose(nodes) {
163 | if (nodes.length === 0) {
164 | return null;
165 | }
166 | for (let node of nodes) {
167 | if (node.weight === undefined || node.value === undefined) {
168 | return nodes[randInt(0, nodes.length - 1)];
169 | }
170 | }
171 |
172 | let totalWeight = 0;
173 | for (let node of nodes) {
174 | totalWeight += node.weight;
175 | }
176 | let remainWeight = randInt(1, totalWeight);
177 | for (let node of nodes) {
178 | remainWeight -= node.weight;
179 | if (remainWeight > 0) {
180 | continue;
181 | }
182 | if (node.value instanceof Array) {
183 | return randomChoose(node.value);
184 | }
185 | return node.value;
186 | }
187 | return null;
188 | }
189 |
190 | function randInt(min, max) {
191 | return Math.floor(min + (max - min + 1) * Math.random());
192 | }
193 |
194 | export default class ChatClientTest {
195 | constructor() {
196 | this.minSleepTime = 800;
197 | this.maxSleepTime = 1200;
198 |
199 | this.onAddText = null;
200 | this.onAddGift = null;
201 | this.onAddMember = null;
202 | this.onAddSuperChat = null;
203 | this.onDelSuperChat = null;
204 | this.onUpdateTranslation = null;
205 |
206 | this.timerId = null;
207 | }
208 |
209 | start() {
210 | this.refreshTimer();
211 | }
212 |
213 | stop() {
214 | if (this.timerId) {
215 | window.clearTimeout(this.timerId);
216 | this.timerId = null;
217 | }
218 | }
219 |
220 | refreshTimer() {
221 | this.timerId = window.setTimeout(
222 | this.onTimeout.bind(this),
223 | randInt(this.minSleepTime, this.maxSleepTime)
224 | );
225 | }
226 |
227 | onTimeout() {
228 | this.refreshTimer();
229 |
230 | let { type, message } = randomChoose(MESSAGE_GENERATORS)();
231 | switch (type) {
232 | case constants.MESSAGE_TYPE_TEXT:
233 | this.onAddText(message);
234 | break;
235 | case constants.MESSAGE_TYPE_GIFT:
236 | this.onAddGift(message);
237 | break;
238 | case constants.MESSAGE_TYPE_MEMBER:
239 | this.onAddMember(message);
240 | break;
241 | case constants.MESSAGE_TYPE_SUPER_CHAT:
242 | this.onAddSuperChat(message);
243 | break;
244 | }
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Comment9
2 |
3 | > A simple & powerful danmaku framework.
4 |
5 |
6 | 中文
7 | |
8 | English
9 |
10 |
11 | [](#)
12 | [](https://github.com/prnake/comment9/actions/workflows/docker-release.yml)
13 | [](https://hub.docker.com/r/prnake/comment9)
14 | [](http://opensource.org/licenses/MIT)
15 | [](https://app.fossa.com/projects/git%2Bgithub.com%2Fprnake%2FComment9?ref=badge_shield)
16 |
17 | ## 介绍
18 |
19 | Comment9 是一个开源、简单易用、易于扩展的实时弹幕服务框架。
20 |
21 | ## 特性
22 |
23 | - 弹幕服务器
24 | - 支持多样化、可拓展的高级弹幕
25 | - 弹幕发送系统能够使用网页、微信、Telegram、API等多种方式接入
26 | - 使用 Socket.IO 实现弹幕的稳定推送
27 | - 支持弹幕的自动和人工审核
28 | - 易用的 Web 后台管理系统
29 | - 支持独立的多用户与多个活动
30 | - 发布、订阅、审核等不同角色相互独立的权限控制系统
31 | - 支持导出弹幕历史数据
32 | - 易于拓展的弹幕发送系统和弹幕审核系统
33 | - 中日英多语言支持
34 | - 弹幕网页端
35 | - 支持弹幕墙、弹幕列表、直播等多样化的弹幕展示与发送方式
36 | - 基于 CommentCoreLibrary 的高级弹幕支持
37 | - 基于 blivechat 的可用于 OBS 的 YouTube 风格弹幕列表
38 | - 弹幕桌面端 [danmaQ](https://github.com/tuna/danmaQ)
39 | - Qt5 实现的跨平台桌面弹幕播放器
40 | - 支持高分屏与多显示器选择的全屏弹幕置顶播放层
41 | - 快捷订阅 Comment9 服务器,支持自动重连
42 | - 支持带色彩的滚动、置顶和底部弹幕
43 |
44 | ## Demo
45 |
46 | 我们使用 Comment9 部署了一个测试活动,提供[弹幕墙](https://comment.pka.moe/#/Wall/demo/screen)、[弹幕列表墙](https://comment.pka.moe/#/List/demo/screen)、[弹幕视频播放器](https://comment.pka.moe/#/Player/demo/screen)、[弹幕网页发送器](https://comment.pka.moe/#/Sender/demo/user/userpass)与 Telegram 机器人 [@comment9_bot](https://t.me/comment9_bot) 进行测试,可能需要自行发送弹幕来查看效果。
47 |
48 | 也可以直接访问[弹幕墙](https://comment.pka.moe/#/Wall/test)、[弹幕列表墙](https://comment.pka.moe/#/List/test)和[弹幕视频播放器](https://comment.pka.moe/#/Player/test)的测试页面查看弹幕较多时的效果。
49 |
50 | 下面是 Comment9 的 Web 后台管理系统截图
51 |
52 | 
53 |
54 | ## 部署
55 |
56 | ### Docker Compose 部署
57 |
58 | #### 安装
59 |
60 | 下载 [docker-compose.yml](https://github.com/prnake/Comment9/blob/master/docker-compose.yml)
61 |
62 | ```bash
63 | wget https://raw.githubusercontent.com/prnake/Comment9/master/docker-compose.yml
64 | ```
65 |
66 | 该部署方式只需要将 `docker-compose.yml` 中 `environment` 部分的 `HOST` 字段 `https://comment.pka.moe` 修改为实际的部署域名,更多配置项请看 [#配置](#配置)。
67 |
68 | ```bash
69 | url="实际部署域名"
70 | sed -i '' "s/https:\/\/comment.pka.moe/${url}/g" docker-compose.yml
71 | ```
72 |
73 | 创建 volume 持久化 MongoDB 缓存
74 |
75 | ```bash
76 | docker volume create comment9-mongo-data
77 | ```
78 |
79 | 启动
80 |
81 | ```bash
82 | docker-compose up -d
83 | ```
84 |
85 | #### 更新
86 |
87 | 删除旧容器
88 |
89 | ```bash
90 | docker-compose down
91 | ```
92 |
93 | 如果之前已经下载 / 使用过镜像,下方命令可以帮助你获取最新版本
94 |
95 | ```bash
96 | docker pull prnake/comment9
97 | ```
98 |
99 | 然后重复安装步骤
100 |
101 | ### Docker 部署
102 |
103 | #### 安装
104 |
105 | 修改下方命令中的环境变量并运行,对配置项的解释请看 [#配置](#配置)
106 |
107 | ```bash
108 | docker run -it --name comment9 -p 3000:3000 \
109 | -e "HOST=" \
110 | -e "BASE_URL=" \
111 | -e "INVITE_CODE=" \
112 | -e "REDIS_HOST=" \
113 | -e "REDIS_PORT=" \
114 | -e "MONGO_HOST=" \
115 | -e "MONGO_DATABASE=" \
116 | prnake/comment9:latest
117 | ```
118 |
119 | 该部署方式需要额外配置 MongoDB 与 Redis 服务,如有需要请改用 Docker Compose 部署方式或自行部署外部依赖。
120 |
121 | ### 手动部署
122 |
123 | #### 准备
124 |
125 | 建议在 Node.js 版本不低于 12 的环境下使用 `yarn` 安装,如果没有 MongoDB 与 Redis 服务,可以使用 Docker 启动。
126 |
127 | ```bash
128 | docker run -d --name mongo -p 27017:27017 mongo
129 | docker run -d --name redis -p 6379:6379 redis
130 | ```
131 |
132 | #### 安装
133 |
134 | 拉取源码
135 |
136 | ```bash
137 | git clone https://github.com/prnake/Comment9
138 | cd Comment9
139 | ```
140 |
141 | 在项目根目录新建一个 `.env` 文件,每行以 `NAME=VALUE` 格式添加环境变量,例如填入部署域名
142 |
143 | ```env
144 | HOST="实际部署域名"
145 | ```
146 |
147 | 如果使用了自己的 MongoDB 与 Redis 服务则还需要额外配置,具体配置项请看 [#配置](#配置)
148 |
149 | 构建前端
150 |
151 | ```bash
152 | yarn
153 | yarn build
154 | ```
155 |
156 | 启动
157 |
158 | ```bash
159 | yarn start
160 | ```
161 |
162 | 或使用 [PM2](https://pm2.keymetrics.io/docs/usage/quick-start/) 启动
163 |
164 | ```bash
165 | pm2 start
166 | ```
167 |
168 | ## 配置
169 |
170 | 通过设置环境变量来配置 Comment9,可以在项目根目录新建一个 `.env` 文件,每行以 `NAME=VALUE` 格式添加环境变量
171 |
172 | | 环境变量 | 默认值 | 说明 |
173 | | -------------- | --------------------- | ---------------------------- |
174 | | PORT | 3000 | 运行端口 |
175 | | HOST | http://localhost:3000 | 部署域名 |
176 | | BASE_URL | | 部署子路径,例如 "/comment" |
177 | | INVITE_CODE | | 注册时要求输入,用于限制注册 |
178 | | MONGO_USERNAME | null | mongodb 配置 |
179 | | MONGO_PASSWORD | null | mongodb 配置 |
180 | | MONGO_HOST | 127.0.0.1 | mongodb 配置 |
181 | | MONGO_PORT | 27017 | mongodb 配置 |
182 | | MONGO_DATABASE | Comment9 | mongodb 配置 |
183 | | REDIS_HOST | | redis 配置 |
184 | | REDIS_PORT | | redis 配置 |
185 | | REDIS_PASSWORD | | redis 配置 |
186 | | SECRET | Danmaku | express session 密钥 |
187 |
188 | 还可以阅读 `config.js` 文件查看可配置的环境变量
189 |
190 | ## 使用
191 |
192 | ### 使用 API 主动接入B站直播弹幕
193 |
194 | 这里使用 [blivedm](https://github.com/xfgryujk/blivedm) 实现对B站直播弹幕的收集,通过 [python-socketio](https://python-socketio.readthedocs.io/en/latest) 发送到 Comment9 服务器,详情查看 [scripts/example/bilibili](scripts/example/bilibili) 文件夹。
195 |
196 | ### 存在的 Features & Bugs
197 |
198 | - 在 url 中可以使用活动名称代替活动 id 进行索引,例如 [#Demo](#Demo) 中就使用了这个方法
199 | - 弹幕列表可以使用 [样式生成器](https://style.vtbs.moe) 生成 OBS 中使用的自定义样式,也可访问 [blivechat](https://github.com/xfgryujk/blivechat) 查看详情
200 | - 审核界面聚焦在输入框,键盘向右通过,向左拒绝
201 | - 每条弹幕在审核处只会出现一次,如果刷新网页前有未审核的弹幕,该弹幕将保持未审核状态
202 | - 必须手动配置部署域名,例如向微信和 Telegram 发送的消息中需要带有这一字段
203 | - 部署时如果使用反向代理,可能需要额外配置 websockets,否则 Socket.IO 将回退到 HTTP 长轮询模式,并且导致 `python-socketio` 等模块无法正常工作
204 | - ElementUI 的 i18n 不能正常工作
205 |
206 | ## 开发
207 |
208 | 见 [开发](docs/develop.md)
209 |
210 | ## 致谢
211 |
212 | ### 开发者
213 |
214 | [](https://github.com/prnake/Comment9/graphs/contributors)
215 |
216 | ### 核心项目
217 |
218 | - 项目使用 [Node.js](https://nodejs.org) 开发,前端使用 [Vue2](https://vuejs.org),后端使用 [Express](https://expressjs.com) 和 [Socket.IO](https://socket.io),数据库使用 [MongoDB](https://www.mongodb.com) 和 [Redis](https://redis.io)
219 | - 使用 [CommentCoreLibrary](https://github.com/jabbany/CommentCoreLibrary) 规范设计弹幕格式,实现网页端的高级弹幕显示
220 | - 使用 [blivechat](https://github.com/xfgryujk/blivechat) 实现可用于 OBS 的 YouTube 风格弹幕列表
221 | - 参考 [vue-tinder](https://github.com/shanlh/vue-tinder) 实现卡片样式的审核界面
222 | - 使用 [blivedm](https://github.com/xfgryujk/blivedm) 实现对B站直播弹幕的收集
223 | - 参考 [RSSHub](https://github.com/DIYgod/RSSHub/) 完善文档与自动化流程
224 | - 使用 [tencent-sensitive-words](https://github.com/cjh0613/tencent-sensitive-words) 提供的过滤词库
225 |
226 | ## 许可证
227 |
228 | [](https://app.fossa.com/projects/git%2Bgithub.com%2Fprnake%2FComment9?ref=badge_large)
229 |
--------------------------------------------------------------------------------
/routes/sender/wechat.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const auth = require("../../utils/auth");
4 | const logger = require("../../utils/logger");
5 | const { pushDanmaku } = require("./danmaku");
6 | const tool = require("../../utils/tool");
7 | const config = require("../../config");
8 | const wechat = require("wechat");
9 | const DanmakuUser = require("../../models/danmakuUser");
10 |
11 | const info = function (activity) {
12 | let data = { perms: [], addons: [], panel: {} };
13 |
14 | tool.setPerms(data.perms, "wechat", "permission to connect with wechat");
15 |
16 | tool.setAddons(
17 | data.addons,
18 | "wechatToken",
19 | "please set manually",
20 | "String",
21 | ""
22 | );
23 | tool.setAddons(
24 | data.addons,
25 | "wechatAppid",
26 | "please set manually",
27 | "String",
28 | ""
29 | );
30 | tool.setAddons(
31 | data.addons,
32 | "wechatAESKey",
33 | "please set manually",
34 | "String",
35 | ""
36 | );
37 |
38 | tool.setPanelTitle(
39 | data.panel,
40 | "Wechat Configuration",
41 | 'Please read the docs and manually set addons begin with "wechat".'
42 | );
43 |
44 | tool.addPanelItem(
45 | data.panel,
46 | "Wechat Docs",
47 | ["wechat"],
48 | "",
49 | "https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html",
50 | "open"
51 | );
52 |
53 | tool.addPanelItem(
54 | data.panel,
55 | "Server Url",
56 | ["wechat"],
57 | "Configure in wechat background.",
58 | `${config.host}${config.rootPath}/wechat/${activity.id}/wechat/${
59 | activity.tokens.get("wechat").token
60 | }`,
61 | "copy"
62 | );
63 |
64 | return data;
65 | };
66 |
67 | const generate_url = function (activity) {
68 | const wechatScreenToken = activity.tokens.get("wechatScreen");
69 | return {
70 | wechat_screen_url: `${config.host}${config.rootPath}/#/wall/${activity.id}/wechatScreen/${wechatScreenToken.token}`,
71 | wechat_screen_list_url: `${config.host}${config.rootPath}/#/list/${activity.id}/wechatScreen/${wechatScreenToken.token}`,
72 | };
73 | };
74 |
75 | const init = function (activity) {
76 | if (!activity.tokens.get("wechat"))
77 | activity.tokens.set("wechat", {
78 | token: tool.genToken(),
79 | perms: ["wechat", "protect"],
80 | });
81 | if (!activity.tokens.get("wechatScreen"))
82 | activity.tokens.set("wechatScreen", {
83 | token: tool.genToken(),
84 | perms: ["pull", "protect"],
85 | });
86 | };
87 |
88 | router.all(
89 | "/:activity/:name/:token",
90 | auth.routerActivityByToken,
91 | async function (req, res) {
92 | if (!req.activity_token.perms.includes("wechat"))
93 | return res.json({ success: false });
94 | const activity = req.activity;
95 | const wechatConfig = {
96 | token: activity.addons.wechatToken,
97 | appid: activity.addons.wechatAppid,
98 | encodingAESKey: activity.addons.wechatAESKey,
99 | checkSignature: true,
100 | };
101 |
102 | // return res.json({ success: true, danmaku: wechatConfig });
103 |
104 | const middleware = wechat(wechatConfig, async function (req, res) {
105 | // 微信输入信息都在req.weixin上
106 | const message = req.weixin;
107 | const urls = generate_url(activity);
108 | logger.info("Got wechat message %o", message);
109 | const user_name =
110 | "wechat:" + wechatConfig.appid + ":" + message.FromUserName;
111 | const user_info = await DanmakuUser.getUser(user_name);
112 | if (message.MsgType == "text") {
113 | let content = message.Content.toString();
114 | const command = {
115 | dm: 1,
116 | to: 1,
117 | bo: 2,
118 | bs: 4,
119 | ts: 5,
120 | co: 1,
121 | };
122 | if (content.substr(0, 2).toLowerCase() === "xm") {
123 | DanmakuUser.setName(user_name, content.substr(2).trim())
124 | .then(() => {
125 | res.reply("姓名设置成功,您还可以发送图片到公众号以设置您的头像");
126 | })
127 | .catch((err) => {
128 | logger.error(err);
129 | res.reply("姓名设置失败, 请稍后再试");
130 | });
131 | } else if (!user_info || !user_info.name) {
132 | res.reply(`请先发送xm+您的姓名到公众号设置您的姓名`);
133 | } else if (command[content.substr(0, 2).toLowerCase()]) {
134 | let danmaku = {
135 | userid: user_name,
136 | username: user_info.name,
137 | userimg: user_info.imgurl,
138 | mode: command[content.substr(0, 2).toLowerCase()],
139 | text: content.substr(2).trim(),
140 | time: Date.now(),
141 | };
142 | if (content.substr(0, 2).toLowerCase() == "co")
143 | danmaku.color = [
144 | 0xff4500, 0xff8c00, 0xffd700, 0x90ee90, 0x00ced1, 0x1e90ff,
145 | 0xc71585,
146 | ][Math.floor(Math.random() * 7)];
147 | pushDanmaku(
148 | danmaku,
149 | activity,
150 | req.app.get("socketio"),
151 | (err, danmaku) => {
152 | if (err) {
153 | logger.error(err);
154 | res.reply(err.message + "\n弹幕发送失败, 请稍后再试");
155 | } else {
156 | if (danmaku.status == "publish") res.reply("弹幕发送成功");
157 | else res.reply("弹幕发送成功,审核中");
158 | }
159 | }
160 | );
161 | } else if (content.toLowerCase() === "help") {
162 | res.reply(
163 | "Usage: \n" +
164 | `发送弹幕: dm + 弹幕内容\n` +
165 | `顶部弹幕: ts + 弹幕内容\n` +
166 | `底部弹幕: bs + 弹幕内容\n` +
167 | `上端滚动弹幕: to + 弹幕内容\n` +
168 | `下端滚动弹幕: bo + 弹幕内容\n` +
169 | `随机颜色滚动弹幕: co + 弹幕内容\n` +
170 | `设置姓名: xm + 您的姓名\n` +
171 | `设置头像: 发送图片\n` +
172 | `\n${activity.addons.sign}`
173 | );
174 | }
175 |
176 | // const user_info = await WeChatUser.getWeChatUser(wechatConfig, message.FromUserName);
177 | // logger.info(user_info);
178 | // if (!user_info || !user_info.nickname) {
179 | // res.reply(`请先发送xm+您的姓名到公众号设置您的姓名`);
180 | // } else {
181 | // } else if (/^[Xx][Mm]/.test(content)) {
182 | // WeChatUser.setNickname(wechatConfig, message.FromUserName, content.substr(2)).then(() => {
183 | // res.reply("姓名设置成功,您还可以发送图片到公众号以设置您的头像");
184 | // }).catch((err) => {
185 | // debug(err);
186 | // res.reply('姓名设置失败, 请稍后再试');
187 | // });
188 | // }
189 | else {
190 | // res.reply('Name:\n\tcomment9 - 酒井人的弹幕及微信墙\n\n' +
191 | // 'Usage: \n\t发送xm + 姓名:设置姓名\n\t发送dm + 姓名或者sq + 姓名:我要上墙\n\t发送图片:设置头像\n\n' +
192 | // 'Made with love of DCSTSAST');
193 | res.reply(
194 | "Comment9 - 弹幕墙\n\n" +
195 | "Usage: \n发送弹幕请输入 dm + 弹幕内容\n更多帮助请输入 help\n\n" +
196 | `弹幕墙地址: \n${urls.wechat_screen_url}\n\n` +
197 | `弹幕列表墙地址: \n${urls.wechat_screen_list_url}\n` +
198 | `\n${activity.addons.sign}`
199 | );
200 | }
201 | } else if (message.MsgType == "image") {
202 | DanmakuUser.setImgUrl(user_name, message.PicUrl)
203 | .then(() => {
204 | res.reply(
205 | "头像设置成功,您还可以发送xm+文字到公众号以设置您的姓名"
206 | );
207 | })
208 | .catch((err) => {
209 | logger.error(err);
210 | res.reply("头像设置失败, 请稍后再试");
211 | });
212 | } else {
213 | res.reply("不支持此类消息");
214 | }
215 | });
216 | return middleware(req, res);
217 | }
218 | );
219 |
220 | module.exports = { router, info, init };
221 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css:
--------------------------------------------------------------------------------
1 | canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | background: transparent;
6 | }
7 |
8 | .yt-live-chat-ticker-paid-message-item-renderer[hidden] {
9 | display: none !important;
10 | }
11 |
12 | canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
13 | margin: 0;
14 | padding: 0;
15 | border: 0;
16 | background: transparent;
17 | }
18 |
19 | .yt-live-chat-ticker-paid-message-item-renderer[hidden] {
20 | display: none !important;
21 | }
22 |
23 | yt-live-chat-ticker-paid-message-item-renderer {
24 | display: inline-block;
25 | font-size: 14px;
26 | outline: none;
27 | transition: width 0.2s;
28 | vertical-align: top;
29 | cursor: pointer;
30 | -moz-user-select: none;
31 | -ms-user-select: none;
32 | -webkit-user-select: none;
33 | user-select: none;
34 | }
35 |
36 | #container.yt-live-chat-ticker-paid-message-item-renderer {
37 | border-radius: 999px;
38 | padding: 4px;
39 | }
40 |
41 | yt-live-chat-ticker-paid-message-item-renderer.sliding-down #container.yt-live-chat-ticker-paid-message-item-renderer {
42 | opacity: 0.5;
43 | transform: translateY(44px);
44 | transition: opacity 0.2s, transform 0.2s cubic-bezier(0.4, 0.0, 1, 1);
45 | }
46 |
47 | yt-live-chat-ticker-paid-message-item-renderer.collapsing {
48 | margin-right: 0;
49 | transition: margin-right 0.2s cubic-bezier(0.4, 0.0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
50 | }
51 |
52 | yt-live-chat-ticker-paid-message-item-renderer[dimmed] {
53 | opacity: 0.5;
54 | }
55 |
56 | yt-img-shadow.yt-live-chat-ticker-paid-message-item-renderer {
57 | margin-right: -4px;
58 | overflow: hidden;
59 | border-radius: 50%;
60 | }
61 |
62 | #content.yt-live-chat-ticker-paid-message-item-renderer {
63 | height: 24px;
64 | display: flex;
65 | -ms-flex-direction: row;
66 | -webkit-flex-direction: row;
67 | flex-direction: row;
68 | -ms-flex-align: center;
69 | -webkit-align-items: center;
70 | align-items: center;
71 | }
72 |
73 | #text.yt-live-chat-ticker-paid-message-item-renderer {
74 | margin: 0 8px;
75 | font-weight: 500;
76 | }
77 |
78 | yt-live-chat-ticker-paid-message-item-renderer[is-deleted] #author-photo.yt-live-chat-ticker-paid-message-item-renderer {
79 | display: none;
80 | }
81 |
--------------------------------------------------------------------------------
/routes/sender/telegram.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const auth = require("../../utils/auth");
4 | const logger = require("../../utils/logger");
5 | const { pushDanmaku } = require("./danmaku");
6 | const config = require("../../config");
7 | const TelegramBot = require("node-telegram-bot-api");
8 | const tool = require("../../utils/tool");
9 |
10 | const info = function (activity) {
11 | let data = { perms: [], addons: [], panel: {} };
12 | const urls = generate_url(activity);
13 |
14 | tool.setPerms(data.perms, "telegram", "permission to connect with telegram");
15 |
16 | tool.setAddons(
17 | data.addons,
18 | "telegramToken",
19 | "please set manually",
20 | "String",
21 | ""
22 | );
23 |
24 | tool.setPanelTitle(
25 | data.panel,
26 | "Telegram Configuration",
27 | 'Please manually set "telegramToken" in addons.'
28 | );
29 |
30 | if (activity.addons.telegramToken) {
31 | tool.addPanelItem(
32 | data.panel,
33 | "Set Webhook",
34 | ["telegram"],
35 | "Please open the url below once to set the webhook of your telegram bot.",
36 | urls.telegram_webhook_set_url,
37 | "open"
38 | );
39 | } else {
40 | tool.addPanelItem(
41 | data.panel,
42 | "Set Webhook",
43 | ["telegram"],
44 | 'Please manually set "telegramToken" first.',
45 | "",
46 | "open"
47 | );
48 | }
49 |
50 | return data;
51 | };
52 |
53 | const generate_url = function (activity) {
54 | const telegramToken = activity.addons.telegramToken;
55 | const telegramWebhookToken = activity.tokens.get("telegram");
56 | const telegramScreenToken = activity.tokens.get("telegramScreen");
57 | const telegram_screen_url = `${config.host}${config.rootPath}/#/wall/${activity.id}/telegramScreen/${telegramScreenToken.token}`;
58 | const telegram_screen_list_url = `${config.host}${config.rootPath}/#/list/${activity.id}/telegramScreen/${telegramScreenToken.token}`;
59 | const telegram_webhook = `${config.host}${config.rootPath}/telegram/push/${activity.id}/telegram/${telegramWebhookToken.token}`;
60 | const telegram_webhook_set_url = `https://api.telegram.org/bot${telegramToken}/setWebhook?url=${telegram_webhook}`;
61 | return {
62 | telegram_screen_url: telegram_screen_url,
63 | telegram_screen_list_url: telegram_screen_list_url,
64 | telegram_webhook: telegram_webhook,
65 | telegram_webhook_set_url: telegram_webhook_set_url,
66 | };
67 | };
68 |
69 | const init = function (activity) {
70 | if (!activity.tokens.get("telegram"))
71 | activity.tokens.set("telegram", {
72 | token: tool.genToken(),
73 | perms: ["telegram", "protect"],
74 | });
75 | if (!activity.tokens.get("telegramScreen"))
76 | activity.tokens.set("telegramScreen", {
77 | token: tool.genToken(),
78 | perms: ["pull", "protect"],
79 | });
80 | };
81 |
82 | router.all(
83 | "/update",
84 | auth.routerSessionAuth,
85 | auth.routerActivityByOwner,
86 | function (req, res) {
87 | const token = req.activity.addons.telegramToken;
88 | if (token) {
89 | const bot = new TelegramBot(token);
90 | bot.setWebHook(generate_url(req.activity).telegram_webhook);
91 | } else {
92 | res.json({ success: false, reason: "telegram token not set" });
93 | }
94 | }
95 | );
96 |
97 | router.all(
98 | "/push/:activity/:name/:token",
99 | auth.routerActivityByToken,
100 | async function (req, res) {
101 | if (!req.activity_token.perms.includes("telegram"))
102 | return res.json({ success: false });
103 | const activity = req.activity;
104 | const urls = generate_url(activity);
105 | const token = activity.addons.telegramToken;
106 | if (!token)
107 | return res.json({ success: false, reason: "telegram token not set" });
108 | const bot = new TelegramBot(token);
109 | bot.on("message", (msg) => {
110 | let content = msg.text;
111 | const user_profile = bot.getUserProfilePhotos(msg.from.id);
112 | user_profile.then(function (res) {
113 | const file_id = res.photos[0][0].file_id;
114 | const file = bot.getFile(file_id);
115 | file.then(function (result) {
116 | const file_path = result.file_path;
117 | const photo_url = `https://api.telegram.org/file/bot${token}/${file_path}`;
118 | const command = {
119 | "/dm": 1,
120 | "/to": 1,
121 | "/bo": 2,
122 | "/bs": 4,
123 | "/ts": 5,
124 | "/co": 1,
125 | };
126 | if (command[content.substr(0, 3).toLowerCase()]) {
127 | let danmaku = {
128 | userid: "telegram:" + msg.chat.username.toString(),
129 | username: msg.chat.username.toString(),
130 | userimg: photo_url,
131 | mode: command[content.substr(0, 3).toLowerCase()],
132 | text: content.substr(3).trim(),
133 | time: Date.now(),
134 | };
135 | if (content.substr(0, 3).toLowerCase() == "/co")
136 | danmaku.color = [
137 | 0xff4500, 0xff8c00, 0xffd700, 0x90ee90, 0x00ced1, 0x1e90ff,
138 | 0xc71585,
139 | ][Math.floor(Math.random() * 7)];
140 | if (!danmaku.text) {
141 | bot.sendMessage(msg.chat.id, "弹幕内容为空\nDanmuku is empty");
142 | } else {
143 | pushDanmaku(
144 | danmaku,
145 | activity,
146 | req.app.get("socketio"),
147 | (err, danmaku) => {
148 | if (err) {
149 | logger.error(err);
150 | bot.sendMessage(
151 | msg.chat.id,
152 | err.message +
153 | "\n弹幕发送失败, 请稍后再试\nFailed to send, please try again later"
154 | );
155 | } else {
156 | if (danmaku.status == "publish")
157 | bot.sendMessage(
158 | msg.chat.id,
159 | "弹幕发送成功\nSend successfully"
160 | );
161 | else
162 | bot.sendMessage(
163 | msg.chat.id,
164 | "弹幕发送成功,审核中\nSend successfully, review in progress"
165 | );
166 | }
167 | }
168 | );
169 | }
170 | } else if (content.toLowerCase() === "/help") {
171 | bot.sendMessage(
172 | msg.chat.id,
173 | "Usage: \n" +
174 | `发送弹幕: /dm + 弹幕内容\n` +
175 | `顶部弹幕: /ts + 弹幕内容\n` +
176 | `底部弹幕: /bs + 弹幕内容\n` +
177 | `上端滚动弹幕: /to + 弹幕内容\n` +
178 | `下端滚动弹幕: /bo + 弹幕内容\n` +
179 | `随机颜色滚动弹幕: /co + 弹幕内容\n` +
180 | `\n${activity.addons.sign}`
181 | );
182 | } else if (content.toLowerCase() === "/help_en") {
183 | bot.sendMessage(
184 | msg.chat.id,
185 | "Usage: \n" +
186 | `Send danmaku: /dm + content\n` +
187 | `Top static danmaku: /ts + content\n` +
188 | `Bottom static danmaku: /bs + content\n` +
189 | `Top scrolling danmaku: /to + content\n` +
190 | `Bottom scrolling danmaku: /bo + content\n` +
191 | `Scrolling danmaku with random color: /co + content\n` +
192 | `\n${activity.addons.sign}`
193 | );
194 | } else {
195 | bot.sendMessage(
196 | msg.chat.id,
197 | "Comment9 - 弹幕墙\n\n" +
198 | "Usage: \n发送弹幕请输入 /dm + 弹幕内容\n更多帮助请输入 /help\nFor English help please input /help_en\n\n" +
199 | `弹幕墙地址: \n${urls.telegram_screen_url}\n\n` +
200 | `弹幕列表墙地址: \n${urls.telegram_screen_list_url}\n` +
201 | `\n${activity.addons.sign}`
202 | );
203 | }
204 | });
205 | });
206 | });
207 | bot.processUpdate(req.body);
208 | res.json({ success: true });
209 | }
210 | );
211 |
212 | module.exports = { router, info, init };
213 |
--------------------------------------------------------------------------------
/src/views/Wall.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
{{ activityName }}
8 |
9 |
10 |
11 |
12 |
13 |
189 |
190 |
290 |
--------------------------------------------------------------------------------
/src/assets/css/youtube/yt-live-chat-renderer.css:
--------------------------------------------------------------------------------
1 | yt-live-chat-renderer, yt-live-chat-item-list-renderer #item-scroller {
2 | height: 100%;
3 | }
4 |
5 | yt-live-chat-renderer ::-webkit-scrollbar {
6 | content: '';
7 | }
8 |
9 | yt-live-chat-renderer ::-webkit-scrollbar-thumb {
10 | background-color: hsla(0, 0%, 53.3%, .2);
11 | border: 2px solid #fcfcfc;
12 | min-height: 30px;
13 | }
14 |
15 | yt-live-chat-renderer ::-webkit-scrollbar-track {
16 | background-color: #fcfcfc;
17 | }
18 |
19 |
20 | canvas.yt-live-chat-renderer, caption.yt-live-chat-renderer, center.yt-live-chat-renderer, cite.yt-live-chat-renderer, code.yt-live-chat-renderer, dd.yt-live-chat-renderer, del.yt-live-chat-renderer, dfn.yt-live-chat-renderer, div.yt-live-chat-renderer, dl.yt-live-chat-renderer, dt.yt-live-chat-renderer, em.yt-live-chat-renderer, embed.yt-live-chat-renderer, fieldset.yt-live-chat-renderer, font.yt-live-chat-renderer, form.yt-live-chat-renderer, h1.yt-live-chat-renderer, h2.yt-live-chat-renderer, h3.yt-live-chat-renderer, h4.yt-live-chat-renderer, h5.yt-live-chat-renderer, h6.yt-live-chat-renderer, hr.yt-live-chat-renderer, i.yt-live-chat-renderer, iframe.yt-live-chat-renderer, img.yt-live-chat-renderer, ins.yt-live-chat-renderer, kbd.yt-live-chat-renderer, label.yt-live-chat-renderer, legend.yt-live-chat-renderer, li.yt-live-chat-renderer, menu.yt-live-chat-renderer, object.yt-live-chat-renderer, ol.yt-live-chat-renderer, p.yt-live-chat-renderer, pre.yt-live-chat-renderer, q.yt-live-chat-renderer, s.yt-live-chat-renderer, samp.yt-live-chat-renderer, small.yt-live-chat-renderer, span.yt-live-chat-renderer, strike.yt-live-chat-renderer, strong.yt-live-chat-renderer, sub.yt-live-chat-renderer, sup.yt-live-chat-renderer, table.yt-live-chat-renderer, tbody.yt-live-chat-renderer, td.yt-live-chat-renderer, tfoot.yt-live-chat-renderer, th.yt-live-chat-renderer, thead.yt-live-chat-renderer, tr.yt-live-chat-renderer, tt.yt-live-chat-renderer, u.yt-live-chat-renderer, ul.yt-live-chat-renderer, var.yt-live-chat-renderer {
21 | margin: 0;
22 | padding: 0;
23 | border: 0;
24 | background: transparent;
25 | }
26 |
27 | .yt-live-chat-renderer[hidden] {
28 | display: none !important;
29 | }
30 |
31 | yt-live-chat-renderer {
32 | font-size: 13px;
33 | --yt-emoji-picker-renderer-height: 180px;
34 | --yt-button-default-text-color: var(--yt-live-chat-button-default-text-color);
35 | --yt-button-default-background-color: var(--yt-live-chat-button-default-background-color);
36 | --yt-button-dark-text-color: var(--yt-live-chat-button-dark-text-color);
37 | --yt-button-dark-background-color: var(--yt-live-chat-button-dark-background-color);
38 | --yt-button-payment-background-color: var(--yt-live-chat-sponsor-color);
39 | }
40 |
41 | yt-live-chat-renderer {
42 | position: relative;
43 | background: var(--yt-live-chat-background-color);
44 | color: var(--yt-live-chat-primary-text-color);
45 | overflow: hidden;
46 | z-index: 0;
47 | display: flex;
48 | -ms-flex-direction: column;
49 | -webkit-flex-direction: column;
50 | flex-direction: column;
51 | }
52 |
53 | yt-live-chat-renderer[hide-timestamps] {
54 | --yt-live-chat-item-timestamp-display: none;
55 | }
56 |
57 | #separator.yt-live-chat-renderer {
58 | border-bottom: var(--yt-live-chat-header-bottom-border, none);
59 | }
60 |
61 | #content-pages.yt-live-chat-renderer {
62 | display: flex;
63 | -ms-flex-direction: column;
64 | -webkit-flex-direction: column;
65 | flex-direction: column;
66 | -ms-flex: 1 1 0.000000001px;
67 | -webkit-flex: 1;
68 | flex: 1;
69 | -webkit-flex-basis: 0.000000001px;
70 | flex-basis: 0.000000001px;
71 | }
72 |
73 | #panel-pages.yt-live-chat-renderer {
74 | max-height: 100%;
75 | overflow-x: hidden;
76 | overflow-y: auto;
77 | }
78 |
79 | #contents.yt-live-chat-renderer {
80 | overflow: hidden;
81 | position: relative;
82 | z-index: 0;
83 | }
84 |
85 | #chat-messages.yt-live-chat-renderer, #contents.yt-live-chat-renderer, #item-list.yt-live-chat-renderer {
86 | display: flex;
87 | -ms-flex-direction: column;
88 | -webkit-flex-direction: column;
89 | flex-direction: column;
90 | -ms-flex: 1 1 0.000000001px;
91 | -webkit-flex: 1;
92 | flex: 1;
93 | -webkit-flex-basis: 0.000000001px;
94 | flex-basis: 0.000000001px;
95 | }
96 |
97 | #ticker.yt-live-chat-renderer {
98 | z-index: 1;
99 | }
100 |
101 | #chat.yt-live-chat-renderer {
102 | position: relative;
103 | display: flex;
104 | -ms-flex-direction: column;
105 | -webkit-flex-direction: column;
106 | flex-direction: column;
107 | -ms-flex: 1 1 0.000000001px;
108 | -webkit-flex: 1;
109 | flex: 1;
110 | -webkit-flex-basis: 0.000000001px;
111 | flex-basis: 0.000000001px;
112 | }
113 |
114 | #chat.yt-live-chat-renderer::after {
115 | content: '';
116 | display: none;
117 | animation: gradient-slide 1.2s ease infinite;
118 | animation-name: gradient-slide;
119 | background-color: var(--yt-live-chat-shimmer-background-color);
120 | background-image: var(--yt-live-chat-shimmer-linear-gradient);
121 | background-size: 300% 300%;
122 | transform: rotateX(180deg);
123 | position: absolute;
124 | top: 0;
125 | right: 0;
126 | bottom: 0;
127 | left: 0;
128 | }
129 |
130 | yt-live-chat-renderer[loading] #chat.yt-live-chat-renderer::after {
131 | display: block;
132 | }
133 |
134 | yt-live-chat-pinned-message-renderer.yt-live-chat-renderer {
135 | bottom: 0;
136 | left: 0;
137 | position: absolute;
138 | right: 0;
139 | top: 0;
140 | }
141 |
142 | yt-live-chat-item-list-renderer.yt-live-chat-renderer, yt-live-chat-ninja-message-renderer.yt-live-chat-renderer {
143 | -ms-flex: 1 1 0.000000001px;
144 | -webkit-flex: 1;
145 | flex: 1;
146 | -webkit-flex-basis: 0.000000001px;
147 | flex-basis: 0.000000001px;
148 | }
149 |
150 | #action-panel.yt-live-chat-renderer {
151 | position: absolute;
152 | bottom: 0;
153 | left: 0;
154 | right: 0;
155 | overflow: hidden;
156 | }
157 |
158 | yt-live-chat-renderer[has-action-panel-renderer] yt-live-chat-action-panel-renderer.yt-live-chat-renderer {
159 | animation: slideUp cubic-bezier(0.05, 0.00, 0.00, 1.00) forwards;
160 | animation-duration: 0.5s;
161 | }
162 |
163 | #action-panel-backdrop.yt-live-chat-renderer {
164 | position: absolute;
165 | top: 0;
166 | right: 0;
167 | bottom: 0;
168 | left: 0;
169 | visibility: hidden;
170 | }
171 |
172 | yt-live-chat-renderer[has-action-panel-renderer] #action-panel-backdrop.yt-live-chat-renderer {
173 | visibility: visible;
174 | animation: fadeIn cubic-bezier(0.05, 0.00, 0.00, 1.00) forwards;
175 | animation-duration: 0.5s;
176 | }
177 |
178 | #input-panel.yt-live-chat-renderer {
179 | -ms-flex: none;
180 | -webkit-flex: none;
181 | flex: none;
182 | }
183 |
184 | #input-panel.yt-live-chat-renderer:not(:empty) {
185 | border-top: var(--yt-live-chat-action-panel-top-border, none);
186 | }
187 |
188 | .hide-on-collapse.yt-live-chat-renderer {
189 | transition: opacity 0.3s;
190 | }
191 |
192 | yt-live-chat-renderer[collapsed] .hide-on-collapse.yt-live-chat-renderer {
193 | opacity: 0;
194 | }
195 |
196 | #loading.yt-live-chat-renderer {
197 | height: 387px;
198 | background-color: var(--yt-live-chat-action-panel-background-color);
199 | display: flex;
200 | -ms-flex-direction: column;
201 | -webkit-flex-direction: column;
202 | flex-direction: column;
203 | -ms-flex-align: center;
204 | -webkit-align-items: center;
205 | align-items: center;
206 | -ms-flex-pack: center;
207 | -webkit-justify-content: center;
208 | justify-content: center;
209 | }
210 |
211 | #loading.yt-live-chat-renderer>paper-spinner-lite.yt-live-chat-renderer {
212 | --paper-spinner-color: var(--yt-live-chat-primary-text-color);
213 | }
214 |
215 | #nitrate-promo.yt-live-chat-renderer>*.yt-live-chat-renderer {
216 | background: var(--yt-live-chat-overlay-color);
217 | z-index: 3;
218 | position: absolute;
219 | top: 0;
220 | right: 0;
221 | bottom: 0;
222 | left: 0;
223 | }
224 |
225 | @keyframes gradient-slide {
226 | 0% {
227 | background-position: 100% 100%;
228 | }
229 | to {
230 | background-position: 0% 0%;
231 | }
232 | }
233 |
234 | @keyframes slideUp {
235 | 0% {
236 | transform: translateY(15%);
237 | opacity: 0;
238 | }
239 | 100% {
240 | transform: translateY(0);
241 | opacity: 1;
242 | }
243 | }
244 |
245 | @keyframes fadeIn {
246 | 0% {
247 | background-color: transparent;
248 | }
249 | 100% {
250 | background-color: rgba(0, 0, 0, 0.60);
251 | }
252 | }
253 |
--------------------------------------------------------------------------------
/src/components/ChatRenderer/Ticker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
16 |
24 |
32 |
39 |
49 |
{{ message.text }}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
74 |
84 |
85 |
86 |
87 |
88 |
247 |
248 |
249 |
252 |
--------------------------------------------------------------------------------