├── 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 | 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 | 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 | 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 | 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 | 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 | 42 | 43 | 68 | 69 | 72 | 73 | -------------------------------------------------------------------------------- /src/components/VueDPlayer.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 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 | 32 | 33 | 89 | 90 | 113 | -------------------------------------------------------------------------------- /src/components/ChatRenderer/MembershipItem.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | [![GitHub Top Language](https://img.shields.io/github/languages/top/prnake/Comment9.svg)](#) 12 | [![Build Status](https://github.com/prnake/Comment9/actions/workflows/docker-release.yml/badge.svg)](https://github.com/prnake/comment9/actions/workflows/docker-release.yml) 13 | [![Docker Image Size](https://img.shields.io/docker/image-size/prnake/comment9/latest)](https://hub.docker.com/r/prnake/comment9) 14 | [![License](http://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) 15 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fprnake%2FComment9.svg?type=shield)](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 | ![manage](docs/img/manage_zh.png) 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://contrib.rocks/image?repo=prnake/Comment9)](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 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fprnake%2FComment9.svg?type=large)](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 | 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 | 87 | 88 | 247 | 248 | 249 | 252 | --------------------------------------------------------------------------------