├── .dockerignore ├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── ci.yaml │ └── docker-publish.yml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── admin ├── .adminjs │ └── .entry.js ├── README.md ├── package.json ├── src │ ├── api.ts │ ├── dashboard.tsx │ ├── index.ts │ └── resources.ts └── static │ └── images │ └── logo.svg ├── devops ├── README.md ├── config │ ├── grafana-dashboards.yml │ ├── grafana-dashboards │ │ └── tailchat-server.json │ ├── grafana-prometheus-datasource.yml │ └── prometheus.yml └── docker-compose.devops.yml ├── docker-compose.env ├── docker-compose.yml ├── jest.config.js ├── lib ├── __tests__ │ ├── const.spec.ts │ └── utils.spec.ts ├── call.ts ├── const.ts ├── crypto │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── des.spec.ts.snap │ │ └── des.spec.ts │ └── des.ts ├── errors.ts └── utils.ts ├── locales ├── en-US │ └── translation.json └── zh-CN │ └── translation.json ├── mixins ├── __tests__ │ └── socketio.spec.ts ├── cache.cleaner.mixin.ts ├── health.mixin.ts └── socketio.mixin.ts ├── models ├── README.md ├── base.ts ├── chat │ ├── ack.ts │ ├── converse.ts │ ├── inbox.ts │ └── message.ts ├── file.ts ├── group │ ├── group.ts │ └── invite.ts ├── openapi │ ├── __tests__ │ │ └── app.spec.ts │ └── app.ts ├── plugin │ └── manifest.ts └── user │ ├── dmlist.ts │ ├── friend.ts │ ├── friendRequest.ts │ ├── mail.ts │ └── user.ts ├── moleculer.config.ts ├── package.json ├── packages └── sdk │ ├── .gitignore │ ├── package.json │ ├── src │ ├── db │ │ ├── index.ts │ │ ├── mongoose.ts │ │ └── typegoose.ts │ ├── index.ts │ ├── runner │ │ ├── cli.ts │ │ ├── index.ts │ │ └── moleculer.config.ts │ ├── services │ │ ├── base.ts │ │ ├── broker.ts │ │ ├── lib │ │ │ ├── i18n │ │ │ │ ├── __tests__ │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── parser.spec.ts │ │ │ │ ├── index.ts │ │ │ │ └── parser.ts │ │ │ ├── moleculer-db-adapter-mongoose │ │ │ │ ├── README.md │ │ │ │ └── index.ts │ │ │ └── settings.ts │ │ ├── mixins │ │ │ └── db.mixin.ts │ │ └── types.ts │ └── structs │ │ ├── chat.ts │ │ ├── events.ts │ │ ├── group.ts │ │ └── user.ts │ └── tsconfig.json ├── plugins ├── com.msgbyte.github │ ├── .ministarrc.js │ ├── models │ │ └── subscribe.ts │ ├── package.json │ ├── services │ │ └── subscribe.service.ts │ └── web │ │ └── plugins │ │ └── com.msgbyte.github │ │ ├── manifest.json │ │ ├── package.json │ │ ├── src │ │ ├── GroupSubscribePanel │ │ │ ├── AddGroupSubscribeModal.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── request.ts │ │ └── translate.ts │ │ ├── tsconfig.json │ │ └── types │ │ └── tailchat.d.ts ├── com.msgbyte.linkmeta │ ├── .ministarrc.js │ ├── models │ │ └── linkmeta.ts │ ├── package.json │ ├── services │ │ └── linkmeta.service.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── linkmeta.spec.ts.snap │ │ └── linkmeta.spec.ts │ ├── utils │ │ ├── __tests__ │ │ │ └── fetchLinkPreview.spec.ts │ │ ├── fetchLinkPreview.ts │ │ └── specialWebsiteMeta.ts │ └── web │ │ └── plugins │ │ └── com.msgbyte.linkmeta │ │ ├── manifest.json │ │ ├── package.json │ │ ├── src │ │ ├── UrlMetaPreviewer │ │ │ ├── Audio.tsx │ │ │ ├── Base.tsx │ │ │ ├── Image.tsx │ │ │ ├── Video.tsx │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── render.tsx │ │ │ └── types.ts │ │ ├── index.tsx │ │ ├── request.ts │ │ └── translate.ts │ │ ├── tsconfig.json │ │ └── types │ │ └── tailchat.d.ts ├── com.msgbyte.meeting │ ├── .ministarrc.js │ ├── models │ │ └── meeting.ts │ ├── package.json │ ├── services │ │ └── meeting.service.dev.ts │ └── web │ │ └── plugins │ │ └── com.msgbyte.meeting │ │ ├── manifest.json │ │ ├── package.json │ │ ├── src │ │ ├── FloatWindow │ │ │ ├── PeerView.tsx │ │ │ ├── index.tsx │ │ │ ├── window.less │ │ │ └── window.tsx │ │ ├── MeetingPanel │ │ │ └── index.tsx │ │ ├── MeetingUrlWrapper.tsx │ │ ├── helper.ts │ │ ├── hooks │ │ │ └── useCreateMeeting.ts │ │ ├── index.tsx │ │ ├── meeting │ │ │ ├── client.ts │ │ │ ├── context.tsx │ │ │ ├── index.ts │ │ │ └── useClientState.ts │ │ ├── request.ts │ │ └── translate.ts │ │ ├── tsconfig.json │ │ └── types │ │ └── tailchat.d.ts ├── com.msgbyte.simplenotify │ ├── .ministarrc.js │ ├── models │ │ └── simplenotify.ts │ ├── package.json │ ├── services │ │ └── simplenotify.service.ts │ └── web │ │ └── plugins │ │ └── com.msgbyte.simplenotify │ │ ├── manifest.json │ │ ├── package.json │ │ ├── src │ │ ├── GroupSubscribePanel │ │ │ ├── AddGroupSubscribeModal.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── request.ts │ │ └── translate.ts │ │ ├── tsconfig.json │ │ └── types │ │ └── tailchat.d.ts └── com.msgbyte.tasks │ ├── .ministarrc.js │ ├── models │ └── task.ts │ ├── package.json │ ├── services │ └── tasks.service.ts │ ├── test │ └── tasks.spec.ts │ └── web │ └── plugins │ └── com.msgbyte.tasks │ ├── manifest.json │ ├── package.json │ ├── src │ ├── TasksPanel │ │ ├── NewTask.tsx │ │ ├── TaskItem.tsx │ │ ├── index.less │ │ ├── index.tsx │ │ └── type.ts │ ├── index.tsx │ ├── request.ts │ └── translate.ts │ ├── tsconfig.json │ └── types │ └── tailchat.d.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public ├── .gitkeep └── css │ └── bulma.min.css ├── runner.ts ├── scripts ├── avatar-url-transform.ts ├── build.ts ├── buildDocker.js ├── dashboard.ts ├── installPlugin.js ├── k8s │ ├── README.md │ └── kompose │ │ ├── data-persistentvolumeclaim.yaml │ │ ├── default-networkpolicy.yaml │ │ ├── docker-compose-env-configmap.yaml │ │ ├── internal-networkpolicy.yaml │ │ ├── minio-pod.yaml │ │ ├── mongo-pod.yaml │ │ ├── redis-pod.yaml │ │ ├── service-chat-deployment.yaml │ │ ├── service-file-deployment.yaml │ │ ├── service-gateway-deployment.yaml │ │ ├── service-group-deployment.yaml │ │ ├── service-user-deployment.yaml │ │ ├── storage-persistentvolumeclaim.yaml │ │ ├── traefik-claim0-persistentvolumeclaim.yaml │ │ ├── traefik-deployment.yaml │ │ └── traefik-service.yaml ├── migrate-mongo-config.js ├── migrations │ └── 20220507114141-plugin_simplenotify_defaults_type.ts └── scanTranslation.js ├── services ├── README.md ├── core │ ├── chat │ │ ├── ack.service.ts │ │ ├── converse.service.ts │ │ ├── inbox.service.dev.ts │ │ └── message.service.ts │ ├── config.service.ts │ ├── file.service.ts │ ├── gateway.service.ts │ ├── group │ │ ├── group.service.ts │ │ └── invite.service.ts │ ├── plugin │ │ └── registry.service.ts │ └── user │ │ ├── dmlist.service.ts │ │ ├── friend.service.ts │ │ ├── friendRequest.service.ts │ │ ├── mail.service.ts │ │ └── user.service.ts ├── debug.service.ts └── openapi │ ├── app.service.ts │ └── oidc │ ├── account.ts │ ├── adapter.ts │ ├── model.ts │ ├── oidc.service.ts │ └── views │ ├── _footer.ejs │ ├── _header.ejs │ ├── authorize.ejs │ ├── error.ejs │ └── login.ejs ├── test ├── demo │ ├── openapi-client-simple │ │ ├── app.html │ │ ├── index.ts │ │ └── package.json │ └── openapi-client │ │ ├── index.ts │ │ └── package.json ├── integration │ ├── chat │ │ ├── ack.spec.ts │ │ └── message.spec.ts │ ├── group │ │ └── group.spec.ts │ ├── openapi │ │ └── app.spec.ts │ └── user │ │ ├── dmlist.spec.ts │ │ └── user.spec.ts ├── setup.ts └── utils.ts ├── tsconfig.json ├── types ├── neo-blessed │ └── index.d.ts └── plugins.d.ts └── views └── mail.ejs /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | logs 4 | dist 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [.gitconfig] 12 | indent_style = tab 13 | 14 | [Makefile] 15 | indent_style = tab 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ############################ 2 | # 该环境变量用于单机运行 3 | ############################ 4 | 5 | PORT=11000 6 | SECRET= 7 | # 示例: mongodb://user:pass@127.0.0.1:27017/tailchat 8 | MONGO_URL= 9 | REDIS_URL=redis://localhost:6379/ 10 | TRANSPORTER= 11 | 12 | # 填写服务端可访问的接口地址 13 | API_URL= 14 | 15 | # 文件存储 16 | MINIO_URL=127.0.0.1:19000 17 | MINIO_USER=tailchat 18 | MINIO_PASS=com.msgbyte.tailchat 19 | 20 | # SMTP 服务 21 | # 示例: "Tailchat" example@163.com 22 | SMTP_SENDER= 23 | # 示例: smtp://username:password@smtp.example.com/?pool=true 24 | SMTP_URI= 25 | 26 | # 视频会议服务(optional) 不包含结尾的/ 27 | TAILCHAT_MEETING_URL= 28 | 29 | # Admin 后台密码 30 | ADMIN_PASS=com.msgbyte.tailchat 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | # 单元测试还有点问题。先注释 5 | # push: 6 | # branches: 7 | # - master 8 | workflow_dispatch: 9 | 10 | jobs: 11 | ci: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | services: 17 | redis: 18 | image: redis:alpine 19 | mongo: 20 | image: mongo:4 21 | minio: 22 | image: minio/minio 23 | env: 24 | MINIO_ROOT_USER: tailchat 25 | MINIO_ROOT_PASSWORD: com.msgbyte.tailchat 26 | steps: 27 | - name: checkout 28 | uses: actions/checkout@v2 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | - name: Cache pnpm modules 34 | uses: actions/cache@v2 35 | with: 36 | path: ~/.pnpm-store 37 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}- 40 | - uses: pnpm/action-setup@v2.0.1 41 | with: 42 | run_install: false 43 | - name: Install packages 44 | run: pnpm install --frozen-lockfile 45 | - name: Test 46 | run: pnpm test 47 | env: 48 | TZ: Asia/Shanghai 49 | MONGO_URL: mongodb://localhost:27017/tailchat 50 | REDIS_URL: redis://localhost:6379 51 | MINIO_URL: localhost:9000 52 | MINIO_USER: tailchat 53 | MINIO_PASS: com.msgbyte.tailchat 54 | - name: Check Build 55 | run: pnpm build 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | # Reference: https://github.com/docker/build-push-action/blob/master/docs/advanced/tags-labels.md 2 | 3 | name: "Docker Publish" 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*.*.*" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | dockerize: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Docker meta 18 | id: meta 19 | uses: docker/metadata-action@v3 20 | with: 21 | images: moonrailgun/tailchat-server 22 | # generate Docker tags based on the following events/attributes 23 | tags: | 24 | type=ref,event=branch 25 | type=ref,event=pr 26 | type=semver,pattern={{version}} 27 | type=semver,pattern={{major}}.{{minor}} 28 | type=semver,pattern={{major}} 29 | type=sha 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v1 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v1 34 | - name: Login to DockerHub 35 | if: github.event_name != 'pull_request' 36 | uses: docker/login-action@v1 37 | with: 38 | username: ${{ secrets.DOCKERHUB_USERNAME }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | - name: Build and push 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | push: ${{ github.event_name != 'pull_request' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/local.* 2 | data/ 3 | __uploads/ 4 | 5 | # 插件 6 | public/plugins/ 7 | public/registry.json 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | 86 | # Next.js build output 87 | .next 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # https://npmmirror.com/ 2 | registry = https://registry.npmmirror.com 3 | ignore-workspace-root-check = true 4 | strict-peer-dependencies = false # 因为一些旧依赖(特别是mongoose相关) 比较糟糕,因此关掉 5 | # For docker: https://pnpm.io/npmrc#unsafe-perm 6 | unsafe-perm = true 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "parser": "babel", 11 | "jsxBracketSameLine": false, 12 | "overrides": [ 13 | { 14 | "files": "*.{tsx,ts}", 15 | "options": { 16 | "parser": "typescript" 17 | } 18 | }, 19 | { 20 | "files": ["*.json"], 21 | "options": { 22 | "parser": "json" 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | # Working directory 4 | WORKDIR /app 5 | 6 | # Install dependencies 7 | RUN npm install -g pnpm@7.1.9 8 | 9 | # Install plugins and sdk dependency 10 | COPY ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml ./tsconfig.json ./.npmrc ./ 11 | COPY ./packages ./packages 12 | COPY ./plugins ./plugins 13 | RUN pnpm install 14 | 15 | # Copy source 16 | COPY . . 17 | RUN pnpm install 18 | 19 | # Build and cleanup 20 | ENV NODE_ENV=production 21 | RUN pnpm run build 22 | 23 | # Install plugins(whitelist) 24 | RUN pnpm run plugin:install com.msgbyte.tasks com.msgbyte.linkmeta com.msgbyte.github com.msgbyte.simplenotify 25 | 26 | # Copy public files 27 | RUN mkdir -p ./dist/public && cp -r ./public/plugins ./dist/public && cp ./public/registry.json ./dist/public 28 | 29 | # web static service port 30 | EXPOSE 3000 31 | 32 | # Start server 33 | CMD ["pnpm", "start:service"] 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailchat-server 2 | 3 | ## 启动开发服务器 4 | 5 | ```bash 6 | cp .env.example .env 7 | vim .env 8 | ``` 9 | 10 | 编辑`.env`的配置为自己的 11 | 12 | ```bash 13 | pnpm install # 安装环境变量 14 | pnpm dev # 启动开发服务器 15 | ``` 16 | 17 | ## 开发环境 18 | 19 | 强烈建议使用 `Docker` 初始化第三方开发环境, 隔离性更加好 并且无需复杂的安装配置。 20 | 21 | mongodb 22 | ```bash 23 | docker run -d --name mongo -p 127.0.0.1:27017:27017 mongo:4 24 | ``` 25 | 26 | redis 27 | ```bash 28 | docker run -d --name redis -p 127.0.0.1:6379:6379 redis 29 | ``` 30 | 31 | minio 32 | ```bash 33 | docker run -d \ 34 | -p 127.0.0.1:19000:9000 \ 35 | -p 127.0.0.1:19001:9001 \ 36 | --name minio \ 37 | -e "MINIO_ROOT_USER=tailchat" \ 38 | -e "MINIO_ROOT_PASSWORD=com.msgbyte.tailchat" \ 39 | minio/minio server /data --console-address ":9001" 40 | ``` 41 | 42 | #### 服务端插件安装方式 43 | 44 | 安装所有插件 45 | ``` 46 | pnpm plugin:install all 47 | ``` 48 | 49 | 安装单个插件 50 | ``` 51 | pnpm plugin:install com.msgbyte.tasks 52 | ``` 53 | 54 | ## 单节点部署 55 | 56 | #### docker-compose 一键部署 57 | 58 | 请确保已经安装了: 59 | - docker 60 | - docker-compose 61 | 62 | 63 | 在项目根目录下执行 64 | ```bash 65 | docker-compose build # 需要编译 66 | docker-compose up -d 67 | ``` 68 | 69 | ## 运维 70 | 71 | ### 使用mongo工具进行管理 72 | 73 | #### 从docker中取出mongodb的数据 74 | 75 | ```bash 76 | docker exec -it mongodump -h 127.0.0.1 --port 27017 -d -o /opt/backup/ 77 | docker exec -it tar -zcvf /tmp/mongodata.tar.gz /opt/backup/ 78 | docker cp :/tmp/mongodata.tar.gz ${PWD}/ 79 | ``` 80 | 81 | #### 将本地的备份存储到mongodb镜像 82 | 83 | ```bash 84 | docker cp mongodata.tar.gz :/tmp/ 85 | docker exec -it tar -zxvf /tmp/mongodata.tar.gz 86 | docker exec -it mongorestore -h 127.0.0.1 --port 27017 -d /opt/backup/ 87 | ``` 88 | 89 | ### 通过docker volume 90 | 91 | #### 备份 92 | ```bash 93 | docker run -it --rm --volumes-from -v ${PWD}:/opt/backup --name export busybox sh 94 | 95 | # 进入容器 96 | tar -zcvf /opt/backup/data.tar 97 | 98 | exit 99 | ``` 100 | 此处, 如果是minio则为`/data/`如果是mongo则为`/data/db` 101 | 102 | #### 恢复 103 | ```bash 104 | docker run -it --rm --volumes-from -v ${PWD}:/opt/backup --name importer busybox sh 105 | tar -zxvf /opt/backup/data.tar 106 | exit 107 | ``` 108 | 109 | 110 | ## Benchmark 111 | 112 | ### Case 1 113 | 114 | 部署环境 115 | ``` 116 | hash: 4771a830b0787280d53935948c99c340c81de977 117 | env: development 118 | cpu: i7-8700K 119 | memory: 32G 120 | 节点数: 1 121 | 测试终端: tailchat-cli 122 | 测试脚本: bench --time 60 --num 10000 "chat.message.sendMessage" '{"converseId": "61fa58845aff4f8a3e68ccf3", "groupId": "61fa58845aff4f8a3e68ccf4", "content": "123"}' 123 | 124 | 备注: 125 | - 使用`Redis`作为消息中转中心, `Redis`部署在局域网的nas上 126 | - 使用一个真实账户作为消息推送的接收方 127 | ``` 128 | 129 | ``` 130 | Benchmark result: 131 | 132 | 3,845 requests in 1m, 0 error 133 | 134 | Requests/sec: 64 135 | 136 | Latency: 137 | Avg: 15ms 138 | Min: 9ms 139 | Max: 91ms 140 | ``` 141 | 142 | ### Case 2 143 | 144 | 145 | -------------------------------------------------------------------------------- /admin/.adminjs/.entry.js: -------------------------------------------------------------------------------- 1 | AdminJS.UserComponents = {} 2 | import Component1 from '../src/dashboard' 3 | AdminJS.UserComponents.Component1 = Component1 -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | ## tailchat-admin 2 | 3 | **WIP** 4 | 5 | tailchat的后台管理系统 6 | -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailchat-admin", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "ts-node src/index.ts", 9 | "dev": "nodemon --watch 'src/**' --ext 'ts' --ignore 'src/**/*.spec.ts' --exec 'ts-node src/index.ts'", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [], 13 | "author": "moonrailgun", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@adminjs/koa": "^2.1.0", 17 | "@adminjs/mongoose": "^2.0.4", 18 | "@koa/router": "^10.1.1", 19 | "adminjs": "^5.10.4", 20 | "koa": "^2.13.4", 21 | "koa-static": "^5.0.0", 22 | "koa2-formidable": "^1.0.3", 23 | "react-use": "^17.4.0", 24 | "recharts": "^2.1.12" 25 | }, 26 | "devDependencies": { 27 | "@types/koa": "^2.13.4", 28 | "@types/koa-static": "^4.0.2", 29 | "@types/node": "16.11.7", 30 | "@types/react": "^17.0.38", 31 | "nodemon": "^2.0.18", 32 | "ts-node": "^10.8.0", 33 | "typescript": "^4.3.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /admin/src/api.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from 'adminjs'; 2 | 3 | export const api = new ApiClient(); 4 | -------------------------------------------------------------------------------- /admin/src/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { api } from './api'; 3 | import { useAsync } from 'react-use'; 4 | 5 | /** 6 | * WIP 7 | */ 8 | const Dashboard = (props) => { 9 | useAsync(async () => { 10 | try { 11 | const all = await api 12 | .resourceAction({ resourceId: 'User', actionName: 'list' }) 13 | .then(({ data }) => data.meta.total); 14 | 15 | const temporary = await api 16 | .searchRecords({ 17 | resourceId: 'User', 18 | // actionName: 'list', 19 | query: '?filters.temporary=true&page=1', 20 | }) 21 | .then((list) => list.length); 22 | 23 | console.log('用户情况:', all, temporary); 24 | } catch (err) { 25 | console.error(err); 26 | } 27 | }, []); 28 | 29 | return
My custom dashboard
; 30 | }; 31 | 32 | export default Dashboard; 33 | -------------------------------------------------------------------------------- /admin/src/index.ts: -------------------------------------------------------------------------------- 1 | import AdminJS from 'adminjs'; 2 | import { buildAuthenticatedRouter } from '@adminjs/koa'; 3 | import AdminJSMongoose from '@adminjs/mongoose'; 4 | import Koa from 'koa'; 5 | import { mongoose } from '@typegoose/typegoose'; 6 | import dotenv from 'dotenv'; 7 | import path from 'path'; 8 | import { getResources } from './resources'; 9 | import serve from 'koa-static'; 10 | 11 | dotenv.config({ path: path.resolve(__dirname, '../../.env') }); 12 | 13 | const mongoUri = process.env.MONGO_URL; 14 | const adminPass = process.env.ADMIN_PASS; 15 | 16 | AdminJS.registerAdapter(AdminJSMongoose); 17 | 18 | async function run() { 19 | if (!mongoUri) { 20 | console.warn(`MONGO_URL has not been set.`); 21 | return; 22 | } 23 | 24 | if (!adminPass) { 25 | console.warn(`ADMIN_PASS has not been set.`); 26 | return; 27 | } 28 | 29 | const app = new Koa(); 30 | app.keys = ['tailchat-admin-secret']; 31 | const mongooseDb = await mongoose.connect(mongoUri); 32 | 33 | const adminJs = new AdminJS({ 34 | branding: { 35 | companyName: 'tailchat', 36 | logo: '/images/logo.svg', 37 | favicon: '/images/logo.svg', 38 | softwareBrothers: false, 39 | }, 40 | databases: [mongooseDb], 41 | rootPath: '/admin', 42 | resources: getResources(), 43 | // dashboard: { 44 | // component: AdminJS.bundle('./dashboard'), 45 | // }, 46 | }); 47 | 48 | const router = buildAuthenticatedRouter(adminJs, app, { 49 | authenticate: async (email, password) => { 50 | if (email === 'tailchat@msgbyte.com' && password === adminPass) { 51 | return { email, title: 'Admin' }; 52 | } 53 | 54 | return null; 55 | }, 56 | }); 57 | 58 | app.use(router.routes()).use(router.allowedMethods()); 59 | app.use(serve(path.resolve(__dirname, '../static'))); 60 | 61 | app.listen(14100, () => { 62 | console.log('AdminJS is under http://localhost:14100/admin'); 63 | console.log(`please login with: tailchat@msgbyte.com/${adminPass}`); 64 | }); 65 | } 66 | 67 | run(); 68 | -------------------------------------------------------------------------------- /admin/src/resources.ts: -------------------------------------------------------------------------------- 1 | import type { ResourceWithOptions } from 'adminjs'; 2 | import User from '../../models/user/user'; 3 | import Group from '../../models/group/group'; 4 | import Message from '../../models/chat/message'; 5 | import File from '../../models/file'; 6 | 7 | export function getResources() { 8 | return [ 9 | { 10 | resource: User, 11 | options: { 12 | properties: { 13 | email: { 14 | isDisabled: true, 15 | }, 16 | username: { 17 | isVisible: false, 18 | }, 19 | password: { 20 | isVisible: false, 21 | }, 22 | }, 23 | sort: { 24 | direction: 'desc', 25 | sortBy: 'createdAt', 26 | }, 27 | }, 28 | } as ResourceWithOptions, 29 | Group, 30 | { 31 | resource: Message, 32 | options: { 33 | properties: { 34 | content: { 35 | type: 'textarea', 36 | }, 37 | }, 38 | }, 39 | } as ResourceWithOptions, 40 | File, 41 | ]; 42 | } 43 | -------------------------------------------------------------------------------- /devops/README.md: -------------------------------------------------------------------------------- 1 | # 可选启用 2 | 3 | WIP 4 | 5 | 该文件夹用于运维 6 | 7 | ## 用法 Usage 8 | 9 | ```bash 10 | cd ./devops 11 | docker-compose -f ../docker-compose.yml -f docker-compose.devops.yml up -d 12 | ``` 13 | -------------------------------------------------------------------------------- /devops/config/grafana-dashboards.yml: -------------------------------------------------------------------------------- 1 | - name: 'default' 2 | org_id: 1 3 | folder: '' 4 | type: 'file' 5 | options: 6 | folder: '/var/lib/grafana/dashboards' 7 | -------------------------------------------------------------------------------- /devops/config/grafana-prometheus-datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | deleteDatasources: 4 | - name: Prometheus 5 | orgId: 1 6 | 7 | datasources: 8 | - name: Prometheus 9 | type: prometheus 10 | access: proxy 11 | url: http://prometheus:9090 12 | password: 13 | user: 14 | database: prometheus 15 | basicAuth: false 16 | basicAuthUser: 17 | basicAuthPassword: 18 | withCredentials: 19 | isDefault: true 20 | jsonData: 21 | tlsAuth: false 22 | tlsAuthWithCACert: false 23 | secureJsonData: 24 | tlsCACert: "" 25 | tlsClientCert: "" 26 | tlsClientKey: "" 27 | version: 1 28 | editable: true 29 | -------------------------------------------------------------------------------- /devops/config/prometheus.yml: -------------------------------------------------------------------------------- 1 | # global config 2 | global: 3 | scrape_interval: 120s # By default, scrape targets every 15 seconds. 4 | evaluation_interval: 120s # By default, scrape targets every 15 seconds. 5 | # scrape_timeout is set to the global default (10s). 6 | # Attach these labels to any time series or alerts when communicating with 7 | # external systems (federation, remote storage, Alertmanager). 8 | external_labels: 9 | monitor: 'tailchat-devops' 10 | 11 | # Load and evaluate rules in this file every 'evaluation_interval' seconds. 12 | rule_files: 13 | # - "alert.rules" 14 | # - "first.rules" 15 | # - "second.rules" 16 | 17 | scrape_configs: 18 | - job_name: 'prometheus' 19 | scrape_interval: 15s 20 | static_configs: 21 | - targets: ['localhost:9090'] 22 | - job_name: 'tailchat-server' 23 | scrape_interval: 15s 24 | metrics_path: /metrics 25 | scheme: http 26 | static_configs: 27 | - targets: ['tailchat-server:13030'] 28 | -------------------------------------------------------------------------------- /devops/docker-compose.devops.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | # 应用网关 5 | prometheus: 6 | image: prom/prometheus:v2.26.0 7 | user: root 8 | container_name: tailchat-prometheus 9 | restart: unless-stopped 10 | volumes: 11 | - ./config/prometheus.yml:/etc/prometheus/prometheus.yml 12 | - ./data/prometheus:/prometheus 13 | command: 14 | - '--config.file=/etc/prometheus/prometheus.yml' 15 | - '--storage.tsdb.path=/prometheus' 16 | ports: 17 | - 9090 18 | links: 19 | - service-gateway:tailchat-server 20 | depends_on: 21 | - service-gateway 22 | networks: 23 | - internal 24 | 25 | grafana: 26 | image: grafana/grafana:7.5.3 27 | user: root 28 | container_name: tailchat-grafana 29 | restart: unless-stopped 30 | links: 31 | - prometheus:prometheus 32 | ports: 33 | - 13000:3000 34 | volumes: 35 | - ./config/grafana-prometheus-datasource.yml:/etc/grafana/provisioning/datasources/prometheus.yml 36 | # - ./config/grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/all.yml 37 | # - ./config/grafana-dashboards:/var/lib/grafana/dashboards 38 | - ./data/grafana:/var/lib/grafana 39 | environment: 40 | - GF_SECURITY_ADMIN_USER=tailchat 41 | - GF_SECURITY_ADMIN_PASSWORD=tailchat 42 | - GF_USERS_ALLOW_SIGN_UP=false 43 | depends_on: 44 | - prometheus 45 | networks: 46 | - internal 47 | -------------------------------------------------------------------------------- /docker-compose.env: -------------------------------------------------------------------------------- 1 | LOGGER=true 2 | LOGLEVEL=info 3 | SERVICEDIR=services 4 | 5 | TRANSPORTER=redis://redis:6379 6 | 7 | CACHER=redis://redis:6379 8 | 9 | REDIS_URL=redis://redis:6379 10 | MONGO_URL=mongodb://mongo/tailchat 11 | SECRET= 12 | 13 | # file 14 | API_URL=https://paw-server-nightly.moonrailgun.com 15 | 16 | # minio 17 | MINIO_URL=minio:9000 18 | MINIO_USER=tailchat 19 | MINIO_PASS=com.msgbyte.tailchat 20 | 21 | # SMTP 22 | SMTP_SENDER= 23 | SMTP_URI= 24 | 25 | # metrics 26 | PROMETHEUS=1 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFiles: [ 6 | '/test/setup.ts' 7 | ], 8 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 9 | globals: { 10 | 'ts-jest': { 11 | tsconfig: 'tsconfig.json', 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/__tests__/const.spec.ts: -------------------------------------------------------------------------------- 1 | import { NAME_REGEXP } from '../const'; 2 | 3 | describe('NAME_REGEXP', () => { 4 | describe('allow', () => { 5 | test.each([ 6 | 'test', 7 | 'test01', 8 | '你好世界', 9 | '你好world', 10 | '最大八个汉字内容', 11 | 'maxis16charactor', 12 | '1234567812345678', 13 | ])('%s', (input) => { 14 | expect(NAME_REGEXP.test(input)).toBe(true); 15 | }); 16 | }); 17 | 18 | describe('deny', () => { 19 | test.each([ 20 | '世 界', 21 | '你好 world', 22 | '超过了八个汉字内容', 23 | 'overmax16charactor', 24 | '12345678123456781', 25 | ])('%s', (input) => { 26 | expect(NAME_REGEXP.test(input)).toBe(false); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkPathMatch, 3 | generateRandomStr, 4 | getEmailAddress, 5 | isValidStr, 6 | sleep, 7 | } from '../utils'; 8 | 9 | describe('getEmailAddress', () => { 10 | test.each([ 11 | ['foo@example.com', 'foo'], 12 | ['foo.bar@example.com', 'foo.bar'], 13 | ['foo$bar@example.com', 'foo$bar'], 14 | ])('%s', (input, output) => { 15 | expect(getEmailAddress(input)).toBe(output); 16 | }); 17 | }); 18 | 19 | describe('generateRandomStr', () => { 20 | test('should generate string with length 10(default)', () => { 21 | expect(generateRandomStr()).toHaveLength(10); 22 | }); 23 | 24 | test('should generate string with manual length', () => { 25 | expect(generateRandomStr(4)).toHaveLength(4); 26 | }); 27 | }); 28 | 29 | describe('isValidStr', () => { 30 | test.each<[any, boolean]>([ 31 | [false, false], 32 | [true, false], 33 | [0, false], 34 | [1, false], 35 | ['', false], 36 | [{}, false], 37 | [[], false], 38 | ['foo', true], 39 | ])('%p is %p', (input, output) => { 40 | expect(isValidStr(input)).toBe(output); 41 | }); 42 | }); 43 | 44 | test('sleep', async () => { 45 | const start = new Date().valueOf(); 46 | await sleep(1000); 47 | const end = new Date().valueOf(); 48 | 49 | const duration = end - start; 50 | expect(duration).toBeGreaterThanOrEqual(1000); 51 | expect(duration).toBeLessThan(1050); 52 | }); 53 | 54 | describe('checkPathMatch', () => { 55 | const testList = ['/foo/bar']; 56 | 57 | test.each([ 58 | ['/foo/bar', true], 59 | ['/foo/bar?query=1', true], 60 | ['/foo', false], 61 | ['/foo/baz', false], 62 | ['/foo/baz?bar=', false], 63 | ])('%s', (input, output) => { 64 | expect(checkPathMatch(testList, input)).toBe(output); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /lib/call.ts: -------------------------------------------------------------------------------- 1 | import type { TcContext } from 'tailchat-server-sdk'; 2 | import type { Group } from '../models/group/group'; 3 | import type { User } from '../models/user/user'; 4 | import { SYSTEM_USERID } from './const'; 5 | 6 | export function call(ctx: TcContext) { 7 | return { 8 | /** 9 | * 发送系统消息 10 | * 如果为群组消息则需要增加groupId 11 | */ 12 | async sendSystemMessage( 13 | message: string, 14 | converseId: string, 15 | groupId?: string 16 | ) { 17 | await ctx.call( 18 | 'chat.message.sendMessage', 19 | { 20 | converseId, 21 | groupId, 22 | content: message, 23 | }, 24 | { 25 | meta: { 26 | ...ctx.meta, 27 | userId: SYSTEM_USERID, 28 | }, 29 | } 30 | ); 31 | }, 32 | /** 33 | * 添加群组系统信息 34 | */ 35 | async addGroupSystemMessage(groupId: string, message: string) { 36 | const lobbyConverseId = await ctx.call('group.getGroupLobbyConverseId', { 37 | groupId, 38 | }); 39 | 40 | if (!lobbyConverseId) { 41 | // 如果没有文本频道则跳过 42 | return; 43 | } 44 | 45 | await ctx.call( 46 | 'chat.message.sendMessage', 47 | { 48 | converseId: lobbyConverseId, 49 | groupId: groupId, 50 | content: message, 51 | }, 52 | { 53 | meta: { 54 | ...ctx.meta, 55 | userId: SYSTEM_USERID, 56 | }, 57 | } 58 | ); 59 | }, 60 | 61 | /** 62 | * 获取用户信息 63 | */ 64 | async getUserInfo(userId: string): Promise { 65 | return await ctx.call('user.getUserInfo', { 66 | userId, 67 | }); 68 | }, 69 | /** 70 | * 获取群组信息 71 | */ 72 | async getGroupInfo(groupId: string): Promise { 73 | return await ctx.call('group.getGroupInfo', { 74 | groupId, 75 | }); 76 | }, 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /lib/const.ts: -------------------------------------------------------------------------------- 1 | export const NAME_REGEXP = 2 | /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]|[\u3040-\u309Fー]|[\u30A0-\u30FF]){1,8}$/; 3 | 4 | /** 5 | * TODO: 待实现权限相关逻辑 6 | * 7 | * 标准群组权限 8 | * key为权限 9 | * value为默认值 10 | */ 11 | export const BUILTIN_GROUP_PERM = { 12 | /** 13 | * 查看频道 14 | */ 15 | displayChannel: true, 16 | 17 | /** 18 | * 管理频道 19 | */ 20 | manageChannel: false, 21 | 22 | /** 23 | * 管理角色 24 | */ 25 | manageRole: false, 26 | 27 | /** 28 | * 管理群组 29 | */ 30 | manageGroup: false, 31 | 32 | /** 33 | * 发送消息 34 | */ 35 | sendMessage: true, 36 | 37 | /** 38 | * 发送图片 39 | */ 40 | sendImage: true, 41 | }; 42 | 43 | /** 44 | * 系统用户id 45 | */ 46 | export const SYSTEM_USERID = '000000000000000000000000'; 47 | -------------------------------------------------------------------------------- /lib/crypto/__tests__/__snapshots__/des.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`des encrypt D 1`] = `"ihmnn4VBPYE="`; 4 | 5 | exports[`des encrypt bar 1`] = `"p/PIC32MPm4="`; 6 | 7 | exports[`des encrypt foo 1`] = `"NP3+ABhEiY4="`; 8 | 9 | exports[`des encrypt 你 1`] = `"O5kF0LXzjpE="`; 10 | -------------------------------------------------------------------------------- /lib/crypto/__tests__/des.spec.ts: -------------------------------------------------------------------------------- 1 | import { desEncrypt, desDecrypt } from '../des'; 2 | 3 | describe('des', () => { 4 | const key = '12345678'; 5 | 6 | describe('encrypt', () => { 7 | test.each([['foo'], ['bar'], ['你'], ['D']])('%s', (input) => { 8 | expect(desEncrypt(input, key)).toMatchSnapshot(); 9 | }); 10 | }); 11 | 12 | describe('decrypt', () => { 13 | test.each([['foo'], ['bar'], ['你'], ['D']])('%s', (input) => { 14 | expect(desDecrypt(desEncrypt(input, key), key)).toBe(input); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/crypto/des.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { config } from 'tailchat-server-sdk'; 3 | 4 | // DES 加密 5 | export function desEncrypt(message: string, key: string = config.secret) { 6 | key = 7 | key.length >= 8 ? key.slice(0, 8) : key.concat('0'.repeat(8 - key.length)); 8 | const keyHex = new Buffer(key); 9 | const cipher = crypto.createCipheriv('des-cbc', keyHex, keyHex); 10 | let c = cipher.update(message, 'utf8', 'base64'); 11 | c += cipher.final('base64'); 12 | return c; 13 | } 14 | 15 | // DES 解密 16 | export function desDecrypt(text: string, key: string = config.secret) { 17 | key = 18 | key.length >= 8 ? key.slice(0, 8) : key.concat('0'.repeat(8 - key.length)); 19 | const keyHex = new Buffer(key); 20 | const cipher = crypto.createDecipheriv('des-cbc', keyHex, keyHex); 21 | let c = cipher.update(text, 'base64', 'utf8'); 22 | c += cipher.final('utf8'); 23 | return c; 24 | } 25 | -------------------------------------------------------------------------------- /lib/errors.ts: -------------------------------------------------------------------------------- 1 | import ExtendableError from 'es6-error'; 2 | 3 | class TcError extends ExtendableError { 4 | public code: number; 5 | public type: string; 6 | public data: any; 7 | public retryable: boolean; 8 | 9 | constructor(message?: string, code?: number, type?: string, data?: unknown) { 10 | super(message ?? '服务器出错'); 11 | this.code = code ?? this.code ?? 500; 12 | this.type = type ?? this.type; 13 | this.data = data ?? this.data; 14 | this.retryable = this.retryable ?? false; 15 | } 16 | } 17 | 18 | export class DataNotFoundError extends TcError { 19 | constructor(message?: string, code?: number, type?: string, data?: unknown) { 20 | super(message ?? '找不到数据', code ?? 404, type, data); 21 | } 22 | } 23 | 24 | export class EntityError extends TcError { 25 | constructor( 26 | message?: string, 27 | code?: number, 28 | type?: string, 29 | data?: { field: string; message: string }[] 30 | ) { 31 | super(message ?? '表单不正确', code ?? 442, type, data); 32 | } 33 | } 34 | 35 | export class NoPermissionError extends TcError { 36 | constructor(message?: string, code?: number, type?: string, data?: unknown) { 37 | super(message ?? '没有操作权限', code ?? 403, type, data); 38 | } 39 | } 40 | 41 | export class ServiceUnavailableError extends TcError { 42 | constructor(data?: unknown) { 43 | super('Service unavailable', 503, 'SERVICE_NOT_AVAILABLE', data); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import randomString from 'crypto-random-string'; 2 | import _ from 'lodash'; 3 | 4 | /** 5 | * 返回电子邮箱的地址 6 | * @param email 电子邮箱 7 | * @returns 电子邮箱 8 | */ 9 | export function getEmailAddress(email: string) { 10 | return email.split('@')[0]; 11 | } 12 | 13 | /** 14 | * 生成随机字符串 15 | * @param length 随机字符串长度 16 | */ 17 | export function generateRandomStr(length = 10): string { 18 | return randomString({ length }); 19 | } 20 | 21 | export function generateRandomNumStr(length = 6) { 22 | return randomString({ 23 | length, 24 | type: 'numeric', 25 | }); 26 | } 27 | 28 | /** 29 | * 是否一个可用的字符串 30 | * 定义为有长度的字符串 31 | */ 32 | export function isValidStr(str: unknown): str is string { 33 | return typeof str == 'string' && str !== ''; 34 | } 35 | 36 | /** 37 | * 检测一个地址是否是一个合法的资源地址 38 | */ 39 | export function isValidStaticAssetsUrl(str: unknown): str is string { 40 | if (typeof str !== 'string') { 41 | return false; 42 | } 43 | 44 | const filename = _.last(str.split('/')); 45 | if (filename.indexOf('.') === -1) { 46 | return false; 47 | } 48 | 49 | return true; 50 | } 51 | 52 | /** 53 | * 休眠一定时间 54 | */ 55 | export function sleep(ms: number): Promise { 56 | return new Promise((resolve) => 57 | setTimeout(() => { 58 | resolve(); 59 | }, ms) 60 | ); 61 | } 62 | 63 | /** 64 | * 检查url地址是否匹配 65 | */ 66 | export function checkPathMatch(urlList: string[], url: string): boolean { 67 | return urlList.includes(url.split('?')[0]); 68 | } 69 | -------------------------------------------------------------------------------- /locales/en-US/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "k158d2868": "No delete permission", 3 | "k16605863": "Token content is incorrect", 4 | "k1b3d8c72": "Group not found", 5 | "k1bdc50f": "Please enter a username with a unique identifier such as: Nickname#0000", 6 | "k21e507de": "Unable to delete private message", 7 | "k248b6d4d": "Not a group owner, don't have permission to view", 8 | "k2bb4fb6d": "OTP incorrect", 9 | "k313eb9b3": "User does not exist, please check your username", 10 | "k3b4422dc": "This user is not a temporary user", 11 | "k429851b9": "No operation authority", 12 | "k42cdd273": "Username already exists!", 13 | "k45c8d1bf": "Claimed user does not exist", 14 | "k4fd701fe": "Email does not exist", 15 | "k590cb8b6": "Account does not exist", 16 | "k64a3c830": "User does not exist", 17 | "k719464e0": "This message has been recall", 18 | "k721cca1f": "User does not exist, please check your email", 19 | "k7f4ea7c": "The file address is not a valid resource address", 20 | "k82f5a5d4": "Password incorrect", 21 | "k89bf46fc": "Unable to recall messages from {{minutes}} minutes ago", 22 | "k986040de": "No group found", 23 | "ka8b712f7": "Email already exists!", 24 | "kb143afe": "This data is not allowed to be modified", 25 | "kb32d3d62": "Anonymous", 26 | "kb5971793": "Username or email is empty", 27 | "kb8be9969": "Recall failed, no permission", 28 | "kb99bb8ae": "Not a group owner, no sharing permissions", 29 | "kbe83caeb": "Too frequent requests, please try again in 10 minutes", 30 | "kcb07c88f": "Personal message subscription created, subscribeId: {{subscribeId}}", 31 | "kcfcea753": "This panel was not found", 32 | "kd3052d25": "No modification permission", 33 | "kd389d15d": "This message was not found", 34 | "ke050bc7a": "Username does not exist", 35 | "ke19c80a5": "User has no upload permission", 36 | "ke5849544": "Wrong password", 37 | "ke99cd649": "No access to converse information permission", 38 | "ke9fabda8": "Token Invalid" 39 | } 40 | -------------------------------------------------------------------------------- /locales/zh-CN/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "k158d2868": "没有删除权限", 3 | "k16605863": "Token 内容不正确", 4 | "k1b3d8c72": "群组未找到", 5 | "k1bdc50f": "请输入带唯一标识的用户名 如: Nickname#0000", 6 | "k21e507de": "无法删除私人信息", 7 | "k248b6d4d": "不是群组所有者, 没有查看权限", 8 | "k2bb4fb6d": "OTP 不正确", 9 | "k313eb9b3": "用户不存在, 请检查您的用户名", 10 | "k3b4422dc": "该用户不是临时用户", 11 | "k429851b9": "没有操作权限", 12 | "k42cdd273": "用户名已存在!", 13 | "k45c8d1bf": "认领用户不存在", 14 | "k4fd701fe": "邮箱不存在", 15 | "k590cb8b6": "账号不存在", 16 | "k64a3c830": "用户不存在", 17 | "k719464e0": "该消息已被撤回", 18 | "k721cca1f": "用户不存在, 请检查您的邮箱", 19 | "k7f4ea7c": "文件地址不是一个合法的资源地址", 20 | "k82f5a5d4": "密码不正确", 21 | "k89bf46fc": "无法撤回 {{minutes}} 分钟前的消息", 22 | "k986040de": "没有找到群组", 23 | "ka8b712f7": "邮箱已存在!", 24 | "kb143afe": "该数据不允许修改", 25 | "kb32d3d62": "匿名用户", 26 | "kb5971793": "用户名或邮箱为空", 27 | "kb8be9969": "撤回失败, 没有权限", 28 | "kb99bb8ae": "不是群组所有者, 没有分享权限", 29 | "kbe83caeb": "过于频繁的请求,请 10 分钟以后再试", 30 | "kcb07c88f": "个人消息订阅已创建, subscribeId: {{subscribeId}}", 31 | "kcfcea753": "没有找到该面板", 32 | "kd3052d25": "没有修改权限", 33 | "kd389d15d": "该消息未找到", 34 | "ke050bc7a": "用户名不存在", 35 | "ke19c80a5": "用户无上传权限", 36 | "ke5849544": "密码错误", 37 | "ke99cd649": "没有获取会话信息权限", 38 | "ke9fabda8": "Token不合规" 39 | } 40 | -------------------------------------------------------------------------------- /mixins/__tests__/socketio.spec.ts: -------------------------------------------------------------------------------- 1 | import { TcSocketIOService } from '../socketio.mixin'; 2 | import { io } from 'socket.io-client'; 3 | import ApiGateway from 'moleculer-web'; 4 | import { createTestUserToken } from '../../test/utils'; 5 | import { UserJWTPayload, TcBroker } from 'tailchat-server-sdk'; 6 | 7 | require('dotenv').config(); 8 | 9 | const PORT = 28193; 10 | 11 | async function createAndEmitMessage( 12 | eventName: string, 13 | eventData: unknown = {} 14 | ): Promise { 15 | const socket = io(`http://localhost:${PORT}/`, { 16 | transports: ['websocket'], 17 | auth: { 18 | token: createTestUserToken(), 19 | }, 20 | }); 21 | 22 | await new Promise((resolve, reject) => { 23 | socket.on('connect', () => { 24 | resolve(null); 25 | }); 26 | socket.on('connect_error', (err) => { 27 | reject(err); 28 | }); 29 | }); 30 | 31 | const res = await new Promise((resolve) => { 32 | socket.emit(eventName, eventData, (ret) => { 33 | resolve(ret); 34 | }); 35 | }); 36 | 37 | socket.close(); 38 | 39 | return res; 40 | } 41 | 42 | describe('Testing "socketio.mixin"', () => { 43 | const broker = new TcBroker({ logger: false }); 44 | const actionHandler1 = jest.fn(); 45 | const actionHandler2 = jest.fn(); 46 | const service = broker.createService({ 47 | name: 'test', 48 | mixins: [ 49 | ApiGateway, 50 | TcSocketIOService({ 51 | async userAuth(token): Promise { 52 | return { 53 | _id: 'any some', 54 | nickname: '', 55 | email: '', 56 | avatar: '', 57 | }; 58 | }, 59 | }), 60 | ], 61 | settings: { 62 | port: PORT, 63 | }, 64 | actions: { 65 | hello: actionHandler1, 66 | publicAction: { 67 | visibility: 'public', 68 | handler: actionHandler2, 69 | }, 70 | }, 71 | }); 72 | 73 | beforeAll(async () => { 74 | await broker.start(); 75 | }); 76 | 77 | afterAll(async () => { 78 | await broker.stop(); 79 | }); 80 | 81 | test('actions should be ok', () => { 82 | expect(service.actions).toHaveProperty('joinRoom'); 83 | expect(service.actions).toHaveProperty('leaveRoom'); 84 | expect(service.actions).toHaveProperty('notify'); 85 | expect(service.actions).toHaveProperty('checkUserOnline'); 86 | }); 87 | 88 | test('socketio should be call action', async () => { 89 | const res = await createAndEmitMessage('test.hello'); 90 | 91 | expect(actionHandler1.mock.calls.length).toBeGreaterThanOrEqual(1); 92 | expect(res).toEqual({ result: true }); 93 | }); 94 | 95 | test('socketio should not call non-published action', async () => { 96 | const res = await createAndEmitMessage('test.publicAction'); 97 | 98 | expect(actionHandler2.mock.calls.length).toBe(0); 99 | expect(res).toEqual({ 100 | result: false, 101 | message: "Service 'test.publicAction' is not found.", 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /mixins/cache.cleaner.mixin.ts: -------------------------------------------------------------------------------- 1 | import type { PureServiceSchema } from 'tailchat-server-sdk'; 2 | 3 | /** 4 | * 缓存清理工具 5 | * 6 | * @deprecated 请使用 this.cleanActionCache 7 | */ 8 | export const TcCacheCleaner = ( 9 | eventNames: string[] 10 | ): Partial => { 11 | const events = {}; 12 | 13 | eventNames.forEach((name) => { 14 | events[name] = function () { 15 | if (this.broker.cacher) { 16 | this.logger.debug(`Clear local '${this.name}' cache`); 17 | this.broker.cacher.clean(`${this.name}.**`); 18 | } 19 | }; 20 | }); 21 | 22 | return { 23 | events, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /mixins/health.mixin.ts: -------------------------------------------------------------------------------- 1 | import type { PureContext, PureServiceSchema } from 'tailchat-server-sdk'; 2 | 3 | /** 4 | * 增加一个action 5 | * 用于返回当前节点的健康信息 6 | */ 7 | export const TcHealth = (): Partial => { 8 | return { 9 | actions: { 10 | async health(ctx: PureContext) { 11 | const status = ctx.broker.getHealthStatus(); 12 | 13 | const services: any[] = await ctx.call('$node.services'); 14 | 15 | return { 16 | nodeID: this.broker.nodeID, 17 | cpu: status.cpu, 18 | memory: status.mem, 19 | services: services 20 | .filter((s) => s.available === true) 21 | .map((s) => s.fullName), 22 | }; 23 | }, 24 | }, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /models/README.md: -------------------------------------------------------------------------------- 1 | Reference: https://typegoose.github.io/typegoose/docs/guides/quick-start-guide 2 | -------------------------------------------------------------------------------- /models/base.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msgbyte/tailchat-server/e2851bf271544be007c5c1c062eb7306aa245be3/models/base.ts -------------------------------------------------------------------------------- /models/chat/ack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | ReturnModelType, 7 | index, 8 | } from '@typegoose/typegoose'; 9 | import type { Base } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import type { Types } from 'mongoose'; 11 | import { User } from '../user/user'; 12 | import { Converse } from './converse'; 13 | import { Message } from './message'; 14 | 15 | /** 16 | * 消息已读管理 17 | */ 18 | @index({ userId: 1, converseId: 1 }, { unique: true }) // 一组userId和converseId应当唯一(用户为先) 19 | export class Ack implements Base { 20 | _id: Types.ObjectId; 21 | id: string; 22 | 23 | @prop({ 24 | ref: () => User, 25 | }) 26 | userId: Ref; 27 | 28 | @prop({ 29 | ref: () => Converse, 30 | }) 31 | converseId: Ref; 32 | 33 | @prop({ 34 | ref: () => Message, 35 | }) 36 | lastMessageId: Ref; 37 | } 38 | 39 | export type AckDocument = DocumentType; 40 | 41 | const model = getModelForClass(Ack); 42 | 43 | export type AckModel = typeof model; 44 | 45 | export default model; 46 | -------------------------------------------------------------------------------- /models/chat/converse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | ReturnModelType, 7 | } from '@typegoose/typegoose'; 8 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 9 | import { Types } from 'mongoose'; 10 | import { NAME_REGEXP } from '../../lib/const'; 11 | import { User } from '../user/user'; 12 | 13 | /** 14 | * 设计参考: https://discord.com/developers/docs/resources/channel 15 | */ 16 | 17 | const converseType = [ 18 | 'DM', // 私信 19 | 'Group', // 群组 20 | ] as const; 21 | 22 | /** 23 | * 聊天会话 24 | */ 25 | export class Converse extends TimeStamps implements Base { 26 | _id: Types.ObjectId; 27 | id: string; 28 | 29 | @prop({ 30 | trim: true, 31 | match: NAME_REGEXP, 32 | }) 33 | name?: string; 34 | 35 | /** 36 | * 会话类型 37 | */ 38 | @prop({ 39 | enum: converseType, 40 | type: () => String, 41 | }) 42 | type!: typeof converseType[number]; 43 | 44 | /** 45 | * 会话参与者 46 | * DM会话与多人会话有值 47 | */ 48 | @prop({ ref: () => User }) 49 | members?: Ref[]; 50 | 51 | /** 52 | * 查找固定成员已存在的会话 53 | */ 54 | static async findConverseWithMembers( 55 | this: ReturnModelType, 56 | members: string[] 57 | ): Promise | null> { 58 | const converse = await this.findOne({ 59 | members: { 60 | $all: [...members], 61 | }, 62 | }); 63 | 64 | return converse; 65 | } 66 | 67 | /** 68 | * 获取用户所有加入的会话 69 | */ 70 | static async findAllJoinedConverseId( 71 | this: ReturnModelType, 72 | userId: string 73 | ): Promise { 74 | const conserves = await this.find( 75 | { 76 | members: new Types.ObjectId(userId), 77 | }, 78 | { 79 | _id: 1, 80 | } 81 | ); 82 | 83 | return conserves 84 | .map((c) => c.id) 85 | .filter(Boolean) 86 | .map(String); 87 | } 88 | } 89 | 90 | export type ConverseDocument = DocumentType; 91 | 92 | const model = getModelForClass(Converse); 93 | 94 | export type ConverseModel = typeof model; 95 | 96 | export default model; 97 | -------------------------------------------------------------------------------- /models/chat/inbox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | index, 7 | } from '@typegoose/typegoose'; 8 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 9 | import type { Types } from 'mongoose'; 10 | import { User } from '../user/user'; 11 | import { Message } from './message'; 12 | 13 | /** 14 | * 收件箱管理 15 | */ 16 | @index({ userId: 1 }) 17 | export class Inbox extends TimeStamps implements Base { 18 | _id: Types.ObjectId; 19 | id: string; 20 | 21 | @prop({ 22 | ref: () => Message, 23 | }) 24 | messageId: Ref; 25 | 26 | /** 27 | * 消息片段,用于消息的预览/发送通知 28 | */ 29 | @prop() 30 | messageSnippet: string; 31 | 32 | /** 33 | * 接收方的id 34 | */ 35 | @prop({ 36 | ref: () => User, 37 | }) 38 | userId: Ref; 39 | } 40 | 41 | export type InboxDocument = DocumentType; 42 | 43 | const model = getModelForClass(Inbox); 44 | 45 | export type InboxModel = typeof model; 46 | 47 | export default model; 48 | -------------------------------------------------------------------------------- /models/chat/message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | ReturnModelType, 7 | modelOptions, 8 | Severity, 9 | } from '@typegoose/typegoose'; 10 | import { Group } from '../group/group'; 11 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 12 | import { Converse } from './converse'; 13 | import { User } from '../user/user'; 14 | import type { FilterQuery, Types } from 'mongoose'; 15 | import type { MessageMetaStruct } from 'tailchat-server-sdk'; 16 | 17 | class MessageReaction { 18 | /** 19 | * 消息反应名 20 | * 可以直接为emoji表情 21 | */ 22 | @prop() 23 | name: string; 24 | 25 | @prop({ ref: () => User }) 26 | author?: Ref; 27 | } 28 | 29 | @modelOptions({ 30 | options: { 31 | allowMixed: Severity.ALLOW, 32 | }, 33 | }) 34 | export class Message extends TimeStamps implements Base { 35 | _id: Types.ObjectId; 36 | id: string; 37 | 38 | @prop() 39 | content: string; 40 | 41 | @prop({ ref: () => User }) 42 | author?: Ref; 43 | 44 | @prop({ ref: () => Group }) 45 | groupId?: Ref; 46 | 47 | /** 48 | * 会话ID 必填 49 | * 私信的本质就是创建一个双人的会话 50 | */ 51 | @prop({ ref: () => Converse }) 52 | converseId!: Ref; 53 | 54 | @prop({ type: () => MessageReaction }) 55 | reactions?: MessageReaction[]; 56 | 57 | /** 58 | * 是否已撤回 59 | */ 60 | @prop({ 61 | default: false, 62 | }) 63 | hasRecall: boolean; 64 | 65 | /** 66 | * 消息的其他数据 67 | */ 68 | @prop() 69 | meta?: MessageMetaStruct; 70 | 71 | /** 72 | * 获取会话消息 73 | */ 74 | static async fetchConverseMessage( 75 | this: ReturnModelType, 76 | converseId: string, 77 | startId: string | null, 78 | limit = 50 79 | ) { 80 | const conditions: FilterQuery> = { 81 | converseId, 82 | }; 83 | if (startId !== null) { 84 | conditions['_id'] = { 85 | $lt: startId, 86 | }; 87 | } 88 | 89 | const res = await this.find({ ...conditions }) 90 | .sort({ _id: -1 }) 91 | .limit(limit) 92 | .exec(); 93 | 94 | return res; 95 | } 96 | } 97 | 98 | export type MessageDocument = DocumentType; 99 | 100 | const model = getModelForClass(Message); 101 | 102 | export type MessageModel = typeof model; 103 | 104 | export default model; 105 | -------------------------------------------------------------------------------- /models/file.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | modelOptions, 7 | Severity, 8 | } from '@typegoose/typegoose'; 9 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import type { Types } from 'mongoose'; 11 | import { User } from './user/user'; 12 | 13 | /** 14 | * 聊天会话 15 | */ 16 | @modelOptions({ 17 | options: { 18 | allowMixed: Severity.ALLOW, 19 | }, 20 | }) 21 | export class File extends TimeStamps implements Base { 22 | _id: Types.ObjectId; 23 | id: string; 24 | 25 | @prop() 26 | etag: string; 27 | 28 | @prop({ ref: () => User }) 29 | userId?: Ref; 30 | 31 | @prop() 32 | bucketName: string; 33 | 34 | @prop() 35 | objectName: string; 36 | 37 | @prop() 38 | url: string; 39 | 40 | /** 41 | * 文件大小, 单位: Byte 42 | */ 43 | @prop() 44 | size: number; 45 | 46 | @prop() 47 | metaData: object; 48 | } 49 | 50 | export type FileDocument = DocumentType; 51 | 52 | const model = getModelForClass(File); 53 | 54 | export type FileModel = typeof model; 55 | 56 | export default model; 57 | -------------------------------------------------------------------------------- /models/group/invite.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | ReturnModelType, 7 | } from '@typegoose/typegoose'; 8 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 9 | import moment from 'moment'; 10 | import type { Types } from 'mongoose'; 11 | import { nanoid } from 'nanoid'; 12 | import { User } from '../user/user'; 13 | import { Group } from './group'; 14 | 15 | function generateCode() { 16 | return nanoid(8); 17 | } 18 | 19 | export class GroupInvite extends TimeStamps implements Base { 20 | _id: Types.ObjectId; 21 | id: string; 22 | 23 | @prop({ 24 | index: true, 25 | default: () => generateCode(), 26 | }) 27 | code!: string; 28 | 29 | @prop({ 30 | ref: () => User, 31 | }) 32 | creator: Ref; 33 | 34 | @prop({ 35 | ref: () => Group, 36 | }) 37 | groupId!: Ref; 38 | 39 | @prop() 40 | expiredAt?: Date; 41 | 42 | /** 43 | * 创建群组邀请 44 | * @param groupId 群组id 45 | * @param type 普通(7天) 永久 46 | */ 47 | static async createGroupInvite( 48 | this: ReturnModelType, 49 | groupId: string, 50 | creator: string, 51 | inviteType: 'normal' | 'permanent' 52 | ): Promise { 53 | let expiredAt = moment().add(7, 'day').toDate(); // 默认7天 54 | if (inviteType === 'permanent') { 55 | expiredAt = undefined; 56 | } 57 | 58 | const invite = await this.create({ 59 | groupId, 60 | code: generateCode(), 61 | creator, 62 | expiredAt, 63 | }); 64 | 65 | return invite; 66 | } 67 | } 68 | 69 | export type GroupInviteDocument = DocumentType; 70 | 71 | const model = getModelForClass(GroupInvite); 72 | 73 | export type GroupInviteModel = typeof model; 74 | 75 | export default model; 76 | -------------------------------------------------------------------------------- /models/openapi/__tests__/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { filterAvailableAppCapability } from '../app'; 2 | 3 | describe('openapp', () => { 4 | describe('filterAvailableAppCapability', () => { 5 | test.each([ 6 | [['bot'], ['bot']], 7 | [['bot', 'foo'], ['bot']], 8 | [ 9 | ['bot', 'webpage', 'oauth'], 10 | ['bot', 'webpage', 'oauth'], 11 | ], 12 | [ 13 | ['bot', 'webpage', 'oauth', 'a', 'b', 'c'], 14 | ['bot', 'webpage', 'oauth'], 15 | ], 16 | ])('%p', (input, output) => { 17 | expect(filterAvailableAppCapability(input)).toEqual(output); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /models/openapi/app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | index, 6 | ReturnModelType, 7 | Ref, 8 | } from '@typegoose/typegoose'; 9 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import type { Types } from 'mongoose'; 11 | import { User } from '../user/user'; 12 | 13 | const openAppCapability = [ 14 | 'bot', // 机器人 15 | 'webpage', // 网页 16 | 'oauth', // 第三方登录 17 | ] as const; 18 | 19 | type OpenAppCapability = typeof openAppCapability[number]; 20 | 21 | /** 22 | * 确保输出类型为应用能力 23 | */ 24 | export function filterAvailableAppCapability( 25 | input: string[] 26 | ): OpenAppCapability[] { 27 | return input.filter((item) => 28 | openAppCapability.includes(item as OpenAppCapability) 29 | ) as OpenAppCapability[]; 30 | } 31 | 32 | class OpenAppOAuth { 33 | @prop({ 34 | type: () => String, 35 | }) 36 | redirectUrls: string[]; 37 | } 38 | 39 | /** 40 | * 开放平台应用 41 | */ 42 | @index({ appId: 1 }, { unique: true }) 43 | export class OpenApp extends TimeStamps implements Base { 44 | _id: Types.ObjectId; 45 | id: string; 46 | 47 | @prop({ 48 | ref: () => User, 49 | }) 50 | owner: Ref; 51 | 52 | @prop() 53 | appId: string; 54 | 55 | @prop() 56 | appSecret: string; 57 | 58 | @prop() 59 | appName: string; 60 | 61 | @prop() 62 | appDesc: string; 63 | 64 | @prop() 65 | appIcon: string; // url 66 | 67 | @prop({ 68 | enum: openAppCapability, 69 | type: () => String, 70 | }) 71 | capability: OpenAppCapability[]; 72 | 73 | @prop({ 74 | type: () => OpenAppOAuth, 75 | }) 76 | oauth?: OpenAppOAuth; 77 | 78 | /** 79 | * 根据appId获取openapp的实例 80 | * 用于获得获得完整数据(包括secret) 81 | * 并顺便判断是否拥有该开放平台用户的修改权限 82 | */ 83 | static async findAppByIdAndOwner( 84 | this: ReturnModelType, 85 | appId: string, 86 | ownerId: string 87 | ) { 88 | const res = await this.findOne({ 89 | appId, 90 | owner: ownerId, 91 | }).exec(); 92 | 93 | return res; 94 | } 95 | } 96 | 97 | export type OpenAppDocument = DocumentType; 98 | 99 | const model = getModelForClass(OpenApp); 100 | 101 | export type OpenAppModel = typeof model; 102 | 103 | export default model; 104 | -------------------------------------------------------------------------------- /models/plugin/manifest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | ReturnModelType, 7 | modelOptions, 8 | } from '@typegoose/typegoose'; 9 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import type { Types } from 'mongoose'; 11 | import { User } from '../user/user'; 12 | 13 | export class PluginManifest extends TimeStamps implements Base { 14 | _id: Types.ObjectId; 15 | id: string; 16 | 17 | @prop() 18 | label: string; 19 | 20 | @prop({ 21 | unique: true, 22 | }) 23 | name: string; 24 | 25 | /** 26 | * 插件入口地址 27 | */ 28 | @prop() 29 | url: string; 30 | 31 | @prop() 32 | icon?: string; 33 | 34 | @prop() 35 | version: string; 36 | 37 | @prop() 38 | author: string; 39 | 40 | @prop() 41 | description: string; 42 | 43 | @prop() 44 | requireRestart: string; 45 | 46 | @prop({ ref: () => User }) 47 | uploader?: Ref; 48 | } 49 | 50 | export type PluginManifestDocument = DocumentType; 51 | 52 | const model = getModelForClass(PluginManifest); 53 | 54 | export type PluginManifestModel = typeof model; 55 | 56 | export default model; 57 | -------------------------------------------------------------------------------- /models/user/dmlist.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | modelOptions, 7 | } from '@typegoose/typegoose'; 8 | import { Base, FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses'; 9 | import { Converse } from '../chat/converse'; 10 | import { User } from './user'; 11 | import findorcreate from 'mongoose-findorcreate'; 12 | import { plugin } from '@typegoose/typegoose'; 13 | import type { Types } from 'mongoose'; 14 | 15 | /** 16 | * 用户私信列表管理 17 | */ 18 | 19 | @plugin(findorcreate) 20 | @modelOptions({ 21 | schemaOptions: { 22 | collection: 'userdmlist', 23 | }, 24 | }) 25 | export class UserDMList extends FindOrCreate implements Base { 26 | _id: Types.ObjectId; 27 | id: string; 28 | 29 | @prop({ 30 | ref: () => User, 31 | index: true, 32 | }) 33 | userId: Ref; 34 | 35 | @prop({ 36 | ref: () => Converse, 37 | }) 38 | converseIds: Ref[]; 39 | } 40 | 41 | export type UserDMListDocument = DocumentType; 42 | 43 | const model = getModelForClass(UserDMList); 44 | 45 | export type UserDMListModel = typeof model; 46 | 47 | export default model; 48 | -------------------------------------------------------------------------------- /models/user/friend.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | plugin, 7 | ReturnModelType, 8 | } from '@typegoose/typegoose'; 9 | import { Base, FindOrCreate } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import { User } from './user'; 11 | import findorcreate from 'mongoose-findorcreate'; 12 | import type { Types } from 'mongoose'; 13 | 14 | /** 15 | * 好友请求 16 | * 单向好友结构 17 | */ 18 | @plugin(findorcreate) 19 | export class Friend extends FindOrCreate implements Base { 20 | _id: Types.ObjectId; 21 | id: string; 22 | 23 | @prop({ 24 | ref: () => User, 25 | index: true, 26 | }) 27 | from: Ref; 28 | 29 | @prop({ 30 | ref: () => User, 31 | }) 32 | to: Ref; 33 | 34 | @prop() 35 | createdAt: Date; 36 | 37 | static async buildFriendRelation( 38 | this: ReturnModelType, 39 | user1: string, 40 | user2: string 41 | ) { 42 | await Promise.all([ 43 | this.findOrCreate({ 44 | from: user1, 45 | to: user2, 46 | }), 47 | this.findOrCreate({ 48 | from: user2, 49 | to: user1, 50 | }), 51 | ]); 52 | } 53 | } 54 | 55 | export type FriendDocument = DocumentType; 56 | 57 | const model = getModelForClass(Friend); 58 | 59 | export type FriendModel = typeof model; 60 | 61 | export default model; 62 | -------------------------------------------------------------------------------- /models/user/friendRequest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | } from '@typegoose/typegoose'; 7 | import type { Base } from '@typegoose/typegoose/lib/defaultClasses'; 8 | import type { Types } from 'mongoose'; 9 | import { User } from './user'; 10 | 11 | /** 12 | * 好友请求 13 | */ 14 | 15 | export class FriendRequest implements Base { 16 | _id: Types.ObjectId; 17 | id: string; 18 | 19 | @prop({ 20 | ref: () => User, 21 | index: true, 22 | }) 23 | from: Ref; 24 | 25 | @prop({ 26 | ref: () => User, 27 | }) 28 | to: Ref; 29 | 30 | @prop() 31 | message: string; 32 | } 33 | 34 | export type FriendRequestDocument = DocumentType; 35 | 36 | export default getModelForClass(FriendRequest); 37 | -------------------------------------------------------------------------------- /moleculer.config.ts: -------------------------------------------------------------------------------- 1 | import brokerConfig from 'tailchat-server-sdk/dist/runner/moleculer.config'; 2 | 3 | export default brokerConfig; 4 | -------------------------------------------------------------------------------- /packages/sdk/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailchat-server-sdk", 3 | "version": "0.0.12", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "tailchat-runner": "./dist/runner/cli.js" 8 | }, 9 | "scripts": { 10 | "build": "tsc", 11 | "watch": "tsc --watch", 12 | "test": "echo \"Error: no test specified\" && exit 1", 13 | "prepare": "npm run build", 14 | "release": "npm version patch && npm publish --registry https://registry.npmjs.com/" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/msgbyte/tailchat-server.git" 19 | }, 20 | "keywords": [ 21 | "msgbyte", 22 | "moonrailgun", 23 | "tailchat" 24 | ], 25 | "author": "moonrailgun ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/msgbyte/tailchat-server/issues" 29 | }, 30 | "homepage": "https://github.com/msgbyte/tailchat-server#readme", 31 | "devDependencies": { 32 | "typescript": "^4.3.3" 33 | }, 34 | "dependencies": { 35 | "@typegoose/typegoose": "9.3.1", 36 | "accept-language": "^3.0.18", 37 | "crc": "^3.8.0", 38 | "dotenv": "^10.0.0", 39 | "fastest-validator": "^1.12.0", 40 | "i18next": "^20.3.5", 41 | "i18next-fs-backend": "^1.1.1", 42 | "ioredis": "^4.27.6", 43 | "kleur": "^4.1.4", 44 | "lodash": "^4.17.21", 45 | "moleculer": "0.14.18", 46 | "moleculer-db": "0.8.16", 47 | "moleculer-repl": "^0.6.5", 48 | "moment": "^2.29.1", 49 | "mongodb": "4.2.1", 50 | "mongoose": "6.1.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/sdk/src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from './typegoose'; 2 | export * from './mongoose'; 3 | -------------------------------------------------------------------------------- /packages/sdk/src/db/mongoose.ts: -------------------------------------------------------------------------------- 1 | export { Types } from 'mongoose'; 2 | -------------------------------------------------------------------------------- /packages/sdk/src/db/typegoose.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | Ref, 6 | modelOptions, 7 | Severity, 8 | } from '@typegoose/typegoose'; 9 | export { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 10 | -------------------------------------------------------------------------------- /packages/sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export { TcService } from './services/base'; 2 | export { TcBroker } from './services/broker'; 3 | export type { TcDbService } from './services/mixins/db.mixin'; 4 | export type { 5 | TcContext, 6 | TcPureContext, 7 | PureContext, 8 | UserJWTPayload, 9 | GroupBaseInfo, 10 | PureServiceSchema, 11 | PureService, 12 | } from './services/types'; 13 | export { parseLanguageFromHead } from './services/lib/i18n/parser'; 14 | export { t } from './services/lib/i18n'; 15 | export { 16 | config, 17 | buildUploadUrl, 18 | builtinAuthWhitelist, 19 | checkEnvTrusty, 20 | } from './services/lib/settings'; 21 | 22 | // struct 23 | export type { MessageMetaStruct } from './structs/chat'; 24 | export type { BuiltinEventMap } from './structs/events'; 25 | export type { 26 | GroupStruct, 27 | GroupRoleStruct, 28 | GroupPanelStruct, 29 | } from './structs/group'; 30 | export type { UserStruct } from './structs/user'; 31 | 32 | // db 33 | export * as db from './db'; 34 | 35 | // other 36 | export { Utils, Errors } from 'moleculer'; 37 | 38 | /** 39 | * 统一处理未捕获的错误 40 | * NOTICE: 未经测试 41 | */ 42 | process.on('unhandledRejection', (reason, promise) => { 43 | console.error('unhandledRejection', reason); 44 | }); 45 | process.on('uncaughtException', (error, origin) => { 46 | console.error('uncaughtException', error); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/sdk/src/runner/cli.ts: -------------------------------------------------------------------------------- 1 | import { Runner } from 'moleculer'; 2 | 3 | const runner = new Runner(); 4 | runner.start(process.argv); 5 | -------------------------------------------------------------------------------- /packages/sdk/src/runner/index.ts: -------------------------------------------------------------------------------- 1 | import { Runner } from 'moleculer'; 2 | import path from 'path'; 3 | import cluster from 'cluster'; 4 | import { config } from '../services/lib/settings'; 5 | 6 | declare module 'moleculer' { 7 | class Runner { 8 | flags?: { 9 | config?: string; 10 | repl?: boolean; 11 | hot?: boolean; 12 | silent?: boolean; 13 | env?: boolean; 14 | envfile?: string; 15 | instances?: number; 16 | mask?: string; 17 | }; 18 | servicePaths: string[]; 19 | 20 | start(args: any[]): void; 21 | startWorkers(instances: number): void; 22 | _run(): void; 23 | } 24 | } 25 | 26 | interface DevRunnerOptions { 27 | config?: string; 28 | } 29 | 30 | const isProd = config.env === 'production'; 31 | 32 | /** 33 | * 开始一个启动器 34 | */ 35 | export function startDevRunner(options: DevRunnerOptions) { 36 | const runner = new Runner(); 37 | runner.flags = { 38 | hot: isProd ? false : true, 39 | repl: isProd ? false : true, 40 | env: true, 41 | config: options.config ?? path.resolve(__dirname, './moleculer.config.ts'), 42 | }; 43 | runner.servicePaths = [ 44 | 'services/**/*.service.ts', 45 | 'services/**/*.service.dev.ts', // load plugins in dev mode 46 | 'plugins/**/*.service.ts', 47 | 'plugins/**/*.service.dev.ts', // load plugins in dev mode 48 | ]; 49 | 50 | if (runner.flags.instances !== undefined && cluster.isPrimary) { 51 | return runner.startWorkers(runner.flags.instances); 52 | } 53 | 54 | return runner._run(); 55 | } 56 | -------------------------------------------------------------------------------- /packages/sdk/src/services/broker.ts: -------------------------------------------------------------------------------- 1 | import Moleculer from 'moleculer'; 2 | 3 | /** 4 | * 用于不暴露moleculer让外部手动启动一个broker 5 | * 6 | * 如tailchat-cli 7 | */ 8 | export class TcBroker extends Moleculer.ServiceBroker {} 9 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/i18n/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { t } from '../index'; 2 | /** 3 | * 休眠一定时间 4 | */ 5 | function sleep(ms: number): Promise { 6 | return new Promise((resolve) => 7 | setTimeout(() => { 8 | resolve(); 9 | }, ms) 10 | ); 11 | } 12 | 13 | describe('i18n', () => { 14 | test('should be work', async () => { 15 | await sleep(2000); // 等待异步加载完毕 16 | 17 | expect(t('Token不合规')).toBe('Token不合规'); 18 | expect( 19 | t('Token不合规', undefined, { 20 | lng: 'en-US', 21 | }) 22 | ).toBe('Token Invalid'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/i18n/__tests__/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseLanguageFromHead } from '../parser'; 2 | 3 | describe('parseLanguageFromHead', () => { 4 | test.each([ 5 | // zh 6 | ['zh-CN,zh;q=0.9', 'zh-CN'], 7 | ['zh-TW,zh;q=0.9', 'zh-CN'], 8 | ['zh;q=0.9', 'zh-CN'], 9 | ['zh', 'zh-CN'], 10 | 11 | // en 12 | ['en-US,en;q=0.8,sv', 'en-US'], 13 | ['en-GB,en;q=0.8,sv', 'en-US'], 14 | ['en;q=0.8,sv', 'en-US'], 15 | ['en', 'en-US'], 16 | 17 | // other 18 | ['de-CH;q=0.8,sv', 'en-US'], 19 | ['jp', 'en-US'], 20 | ])('%s', (input, output) => { 21 | expect(parseLanguageFromHead(input)).toBe(output); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18next, { TFunction, TOptionsBase } from 'i18next'; 2 | import Backend from 'i18next-fs-backend'; 3 | import path from 'path'; 4 | import { crc32 } from 'crc'; 5 | 6 | i18next.use(Backend).init({ 7 | // initImmediate: false, 8 | lng: 'zh-CN', 9 | fallbackLng: 'zh-CN', 10 | preload: ['zh-CN', 'en-US'], 11 | ns: ['translation'], 12 | defaultNS: 'translation', 13 | backend: { 14 | /** 15 | * 加载启动目录下的 16 | */ 17 | loadPath: path.resolve(process.cwd(), './locales/{{lng}}/{{ns}}.json'), 18 | }, 19 | }); 20 | 21 | /** 22 | * 国际化翻译 23 | */ 24 | export const t: TFunction = ( 25 | key: string, 26 | defaultValue?: string, 27 | options?: TOptionsBase 28 | ) => { 29 | try { 30 | const hashKey = `k${crc32(key).toString(16)}`; 31 | let words = i18next.t(hashKey, defaultValue, options); 32 | if (words === hashKey) { 33 | words = key; 34 | console.info(`[i18n] 翻译缺失: [${hashKey}]${key}`); 35 | } 36 | return words; 37 | } catch (err) { 38 | console.error(err); 39 | return key; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/i18n/parser.ts: -------------------------------------------------------------------------------- 1 | import acceptLanguage from 'accept-language'; 2 | 3 | type AllowedLanguage = 'en-US' | 'zh-CN'; 4 | acceptLanguage.languages(['en-US', 'zh-CN', 'zh', 'zh-TW']); 5 | 6 | /** 7 | * 解析请求头的 Accept-Language 8 | */ 9 | export function parseLanguageFromHead( 10 | headerLanguage: string = 'zh-CN' 11 | ): AllowedLanguage { 12 | const language = acceptLanguage.get(headerLanguage); 13 | 14 | if (language === 'zh' || language === 'zh-TW') { 15 | return 'zh-CN'; 16 | } 17 | 18 | return language as AllowedLanguage; 19 | } 20 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/moleculer-db-adapter-mongoose/README.md: -------------------------------------------------------------------------------- 1 | fork from `moleculer-db-adapter-mongoose` 2 | -------------------------------------------------------------------------------- /packages/sdk/src/services/lib/settings.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import _ from 'lodash'; 3 | 4 | dotenv.config(); 5 | 6 | /** 7 | * 配置信息 8 | */ 9 | const port = process.env.PORT ? Number(process.env.PORT) : 11000; 10 | const apiUrl = process.env.API_URL || `http://127.0.0.1:${port}`; 11 | export const config = { 12 | port, 13 | secret: process.env.SECRET || 'tailchat', 14 | env: process.env.NODE_ENV || 'development', 15 | /** 16 | * 是否打开socket admin ui 17 | */ 18 | enableSocketAdmin: !!process.env.ADMIN, 19 | redisUrl: process.env.REDIS_URL, 20 | mongoUrl: process.env.MONGO_URL, 21 | storage: { 22 | type: 'minio', // 可选: minio 23 | minioUrl: process.env.MINIO_URL, 24 | user: process.env.MINIO_USER, 25 | pass: process.env.MINIO_PASS, 26 | bucketName: process.env.MINIO_BUCKET_NAME || 'tailchat', 27 | 28 | /** 29 | * 文件上传限制 30 | * 单位byte 31 | * 默认 1m 32 | */ 33 | limit: process.env.FILE_LIMIT 34 | ? Number(process.env.FILE_LIMIT) 35 | : 1 * 1024 * 1024, 36 | }, 37 | apiUrl, 38 | staticUrl: `${apiUrl}/static/`, 39 | enableOpenapi: true, // 是否开始openapi 40 | smtp: { 41 | senderName: process.env.SMTP_SENDER, // 发邮件者显示名称 42 | connectionUrl: process.env.SMTP_URI || '', 43 | }, 44 | enablePrometheus: checkEnvTrusty(process.env.PROMETHEUS), 45 | feature: { 46 | disableFileCheck: checkEnvTrusty(process.env.DISABLE_FILE_CHECK), 47 | }, 48 | }; 49 | 50 | export const builtinAuthWhitelist = [ 51 | '/gateway/health', 52 | '/debug/hello', 53 | '/user/login', 54 | '/user/register', 55 | '/user/createTemporaryUser', 56 | '/user/resolveToken', 57 | '/user/getUserInfo', 58 | '/group/getGroupBasicInfo', 59 | '/group/invite/findInviteByCode', 60 | ]; 61 | 62 | /** 63 | * 构建上传地址 64 | */ 65 | export function buildUploadUrl(objectName: string) { 66 | return config.staticUrl + objectName; 67 | } 68 | 69 | /** 70 | * 判断环境变量是否为true 71 | */ 72 | export function checkEnvTrusty(env: string): boolean { 73 | return env === '1' || env === 'true'; 74 | } 75 | -------------------------------------------------------------------------------- /packages/sdk/src/services/mixins/db.mixin.ts: -------------------------------------------------------------------------------- 1 | import { Context, Errors, ServiceSchema } from 'moleculer'; 2 | import BaseDBService, { MoleculerDB } from 'moleculer-db'; 3 | import { MongooseDbAdapter } from '../lib/moleculer-db-adapter-mongoose'; 4 | import type { Document, FilterQuery, Model } from 'mongoose'; 5 | import { config } from '../lib/settings'; 6 | import type { ReturnModelType } from '@typegoose/typegoose'; 7 | import type { 8 | AnyParamConstructor, 9 | BeAnObject, 10 | } from '@typegoose/typegoose/lib/types'; 11 | 12 | type EntityChangedType = 'created' | 'updated'; 13 | 14 | // type MoleculerDBMethods = MoleculerDB['methods']; 15 | type MoleculerDBMethods = MoleculerDB['methods']; 16 | 17 | // fork from moleculer-db-adapter-mongoose/index.d.ts 18 | interface FindFilters { 19 | query?: FilterQuery; 20 | search?: string; 21 | searchFields?: string[]; // never used??? 22 | sort?: string | string[]; 23 | offset?: number; 24 | limit?: number; 25 | } 26 | 27 | // 复写部分 adapter 的方法类型 28 | interface TcDbAdapterOverwrite> { 29 | model: M; 30 | insert(entity: Partial): Promise; 31 | find(filters: FindFilters): Promise; 32 | findOne(query: FilterQuery): Promise; 33 | } 34 | 35 | export interface TcDbService< 36 | T extends Document = Document, 37 | M extends Model = Model 38 | > extends MoleculerDBMethods { 39 | entityChanged(type: EntityChangedType, json: {}, ctx: Context): Promise; 40 | 41 | adapter: Omit, keyof TcDbAdapterOverwrite> & 42 | TcDbAdapterOverwrite; 43 | 44 | /** 45 | * 转换fetch出来的文档, 变成一个json 46 | */ 47 | transformDocuments: MoleculerDB< 48 | // @ts-ignore 49 | MongooseDbAdapter 50 | >['methods']['transformDocuments']; 51 | } 52 | 53 | export type TcDbModel = ReturnModelType, BeAnObject>; 54 | 55 | /** 56 | * Tc 数据库mixin 57 | * @param model 数据模型 58 | */ 59 | export function TcDbService(model: TcDbModel): Partial { 60 | const actions = { 61 | /** 62 | * 自动操作全关 63 | */ 64 | find: false, 65 | count: false, 66 | list: false, 67 | create: false, 68 | insert: false, 69 | get: false, 70 | update: false, 71 | remove: false, 72 | }; 73 | 74 | const methods = { 75 | /** 76 | * 实体变更时触发事件 77 | */ 78 | async entityChanged(type, json, ctx) { 79 | await this.clearCache(); 80 | const eventName = `${this.name}.entity.${type}`; 81 | this.broker.emit(eventName, { meta: ctx.meta, entity: json }); 82 | }, 83 | }; 84 | 85 | if (!config.mongoUrl) { 86 | throw new Errors.MoleculerClientError('需要环境变量 MONGO_URL'); 87 | } 88 | 89 | return { 90 | mixins: [BaseDBService], 91 | adapter: new MongooseDbAdapter(config.mongoUrl, { 92 | useNewUrlParser: true, 93 | useUnifiedTopology: true, 94 | }), 95 | model, 96 | actions, 97 | methods, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /packages/sdk/src/services/types.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'moleculer'; 2 | import type { TFunction } from 'i18next'; 3 | import type { UserStruct } from '../structs/user'; 4 | import type { GroupStruct } from '../structs/group'; 5 | import type { BuiltinEventMap } from '../structs/events'; 6 | 7 | export type { 8 | ServiceSchema as PureServiceSchema, 9 | Service as PureService, 10 | } from 'moleculer'; 11 | 12 | export interface UserJWTPayload { 13 | _id: string; 14 | nickname: string; 15 | email: string; 16 | avatar: string; 17 | } 18 | 19 | interface TranslationMeta { 20 | t: TFunction; 21 | language: string; 22 | } 23 | 24 | export type PureContext

= Context; 25 | 26 | export interface TcPureContext

27 | extends Omit, 'emit'> { 28 | meta: TranslationMeta & M; 29 | 30 | // 事件类型重写 31 | emit( 32 | eventName: K, 33 | data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown, 34 | groups?: string | string[] 35 | ): Promise; 36 | emit(eventName: string): Promise; 37 | } 38 | 39 | export type TcContext

= TcPureContext< 40 | P, 41 | { 42 | user: UserJWTPayload; 43 | token: string; 44 | userId: string; 45 | 46 | /** 47 | * 仅在 socket.io 的请求中会出现 48 | */ 49 | socketId?: string; 50 | } & M 51 | >; 52 | 53 | export type GroupBaseInfo = Pick & { 54 | memberCount: number; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/sdk/src/structs/chat.ts: -------------------------------------------------------------------------------- 1 | export interface MessageMetaStruct { 2 | mentions?: string[]; 3 | reply?: { 4 | _id: string; 5 | author: string; 6 | content: string; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /packages/sdk/src/structs/events.ts: -------------------------------------------------------------------------------- 1 | import type { MessageMetaStruct } from './chat'; 2 | 3 | /** 4 | * 默认服务的事件映射 5 | */ 6 | export interface BuiltinEventMap { 7 | 'gateway.auth.addWhitelists': { urls: string[] }; 8 | 'chat.message.updateMessage': 9 | | { 10 | type: 'add'; 11 | messageId: string; 12 | content: string; 13 | meta: MessageMetaStruct; 14 | } 15 | | { 16 | type: 'recall' | 'delete'; 17 | messageId: string; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/sdk/src/structs/group.ts: -------------------------------------------------------------------------------- 1 | export enum GroupPanelType { 2 | TEXT = 0, 3 | GROUP = 1, 4 | PLUGIN = 2, 5 | } 6 | 7 | interface GroupMemberStruct { 8 | roles?: string[]; // 角色 9 | 10 | userId: string; 11 | } 12 | 13 | export interface GroupPanelStruct { 14 | id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型 15 | 16 | name: string; // 用于显示的名称 17 | 18 | parentId?: string; // 父节点id 19 | 20 | type: number; // 面板类型: Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types 21 | 22 | provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效 23 | 24 | pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel 25 | 26 | /** 27 | * 面板的其他数据 28 | */ 29 | meta?: object; 30 | } 31 | 32 | /** 33 | * 群组权限组 34 | */ 35 | export interface GroupRoleStruct { 36 | name: string; // 权限组名 37 | permissions: string[]; // 拥有的权限, 是一段字符串 38 | } 39 | 40 | export interface GroupStruct { 41 | name: string; 42 | 43 | avatar?: string; 44 | 45 | owner: string; 46 | 47 | members: GroupMemberStruct[]; 48 | 49 | panels: GroupPanelStruct[]; 50 | 51 | roles?: GroupRoleStruct[]; 52 | } 53 | -------------------------------------------------------------------------------- /packages/sdk/src/structs/user.ts: -------------------------------------------------------------------------------- 1 | const userType = ['normalUser', 'pluginBot', 'thirdpartyBot']; 2 | type UserType = typeof userType[number]; 3 | 4 | export interface UserStruct { 5 | /** 6 | * 用户名 不可被修改 7 | * 与email必有一个 8 | */ 9 | username?: string; 10 | 11 | /** 12 | * 邮箱 不可被修改 13 | * 必填 14 | */ 15 | email: string; 16 | 17 | password: string; 18 | 19 | /** 20 | * 可以被修改的显示名 21 | */ 22 | nickname: string; 23 | 24 | /** 25 | * 识别器, 跟username构成全局唯一的用户名 26 | * 用于搜索 27 | * # 28 | */ 29 | discriminator: string; 30 | 31 | /** 32 | * 是否为临时用户 33 | * @default false 34 | */ 35 | temporary: boolean; 36 | 37 | /** 38 | * 头像 39 | */ 40 | avatar?: string; 41 | 42 | type: UserType[]; 43 | } 44 | -------------------------------------------------------------------------------- /packages/sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "rootDir": "./src", 6 | "outDir": "dist" 7 | }, 8 | "include": ["./src/**/*"], 9 | "exclude": ["node_modules/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/.ministarrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | externalDeps: ['react'], 5 | pluginRoot: path.resolve(__dirname, './web'), 6 | outDir: path.resolve(__dirname, '../../public'), 7 | }; 8 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/models/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | DocumentType, 5 | modelOptions, 6 | } from '@typegoose/typegoose'; 7 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 8 | import type { Types } from 'mongoose'; 9 | 10 | @modelOptions({ 11 | options: { 12 | customName: 'p_githubSubscribe', 13 | }, 14 | }) 15 | export class Subscribe extends TimeStamps implements Base { 16 | _id: Types.ObjectId; 17 | id: string; 18 | 19 | @prop() 20 | groupId: string; 21 | 22 | @prop() 23 | textPanelId: string; 24 | 25 | @prop() 26 | repoName: string; 27 | } 28 | 29 | export type SubscribeDocument = DocumentType; 30 | 31 | const model = getModelForClass(Subscribe); 32 | 33 | export type SubscribeModel = typeof model; 34 | 35 | export default model; 36 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailchat-plugin-github", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "moonrailgun", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build:web": "ministar buildPlugin all", 10 | "build:web:watch": "ministar watchPlugin all" 11 | }, 12 | "dependencies": { 13 | "@octokit/webhooks-types": "^5.4.0", 14 | "react": "^17.0.2" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^17.0.38", 18 | "less": "^4.1.2", 19 | "mini-star": "^1.2.8", 20 | "rollup-plugin-less": "^1.1.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Github 订阅", 3 | "name": "com.msgbyte.github", 4 | "url": "{BACKEND}/plugins/com.msgbyte.github/index.js", 5 | "version": "0.0.0", 6 | "author": "msgbyte", 7 | "description": "订阅Github项目动态到群组", 8 | "requireRestart": true 9 | } 10 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@plugins/com.msgbyte.github", 3 | "main": "src/index.tsx", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": {} 7 | } 8 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/src/GroupSubscribePanel/AddGroupSubscribeModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { 3 | ModalWrapper, 4 | createFastFormSchema, 5 | fieldSchema, 6 | useAsyncRequest, 7 | showToasts, 8 | } from '@capital/common'; 9 | import { WebFastForm, GroupPanelSelector } from '@capital/component'; 10 | import { request } from '../request'; 11 | import { Translate } from '../translate'; 12 | 13 | interface Values { 14 | repoName: string; 15 | textPanelId: string; 16 | } 17 | 18 | const schema = createFastFormSchema({ 19 | repoName: fieldSchema.string().required(Translate.repoNameEmpty), 20 | textPanelId: fieldSchema.string().required(Translate.textPanelEmpty), 21 | }); 22 | 23 | export const AddGroupSubscribeModal: React.FC<{ 24 | groupId: string; 25 | onSuccess?: () => void; 26 | }> = React.memo((props) => { 27 | const groupId = props.groupId; 28 | const [, handleSubmit] = useAsyncRequest( 29 | async (values: Values) => { 30 | const { repoName, textPanelId } = values; 31 | await request.post('subscribe.add', { 32 | groupId, 33 | textPanelId, 34 | repoName, 35 | }); 36 | 37 | showToasts(Translate.success, 'success'); 38 | props.onSuccess?.(); 39 | }, 40 | [groupId, props.onSuccess] 41 | ); 42 | 43 | const fields = useMemo( 44 | () => [ 45 | { 46 | type: 'text', 47 | name: 'repoName', 48 | label: Translate.repoName, 49 | placeholder: 'msgbyte/tailchat', 50 | }, 51 | { 52 | type: 'custom', 53 | name: 'textPanelId', 54 | label: Translate.textPanel, 55 | render: (props: { 56 | value: any; 57 | error: string | undefined; 58 | onChange: (val: any) => void; // 修改数据的回调函数 59 | }) => { 60 | return ( 61 | 66 | ); 67 | }, 68 | }, 69 | ], 70 | [groupId] 71 | ); 72 | 73 | return ( 74 | 75 | 76 | 77 | ); 78 | }); 79 | AddGroupSubscribeModal.displayName = 'AddGroupSubscribeModal'; 80 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { regCustomPanel, Loadable, regInspectService } from '@capital/common'; 2 | import { Translate } from './translate'; 3 | 4 | regCustomPanel({ 5 | position: 'groupdetail', 6 | name: 'com.msgbyte.github/groupSubscribe', 7 | label: Translate.groupSubscribe, 8 | render: Loadable(() => import('./GroupSubscribePanel')), 9 | }); 10 | 11 | regInspectService({ 12 | name: 'plugin:com.msgbyte.github.subscribe', 13 | label: Translate.githubService, 14 | }); 15 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/src/request.ts: -------------------------------------------------------------------------------- 1 | import { createPluginRequest } from '@capital/common'; 2 | 3 | export const request = createPluginRequest('com.msgbyte.github'); 4 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/src/translate.ts: -------------------------------------------------------------------------------- 1 | import { localTrans } from '@capital/common'; 2 | 3 | export const Translate = { 4 | groupSubscribe: localTrans({ 5 | 'zh-CN': 'Github 群组订阅', 6 | 'en-US': 'Github Group Subscribe', 7 | }), 8 | githubService: localTrans({ 9 | 'zh-CN': 'Github 群组订阅服务', 10 | 'en-US': 'Github Group Subscribe Service', 11 | }), 12 | add: localTrans({ 13 | 'zh-CN': '新增', 14 | 'en-US': 'Add', 15 | }), 16 | repo: localTrans({ 17 | 'zh-CN': '项目', 18 | 'en-US': 'Repository', 19 | }), 20 | panel: localTrans({ 21 | 'zh-CN': '面板', 22 | 'en-US': 'Panel', 23 | }), 24 | createdTime: localTrans({ 25 | 'zh-CN': '创建时间', 26 | 'en-US': 'Created Time', 27 | }), 28 | action: localTrans({ 29 | 'zh-CN': '操作', 30 | 'en-US': 'Action', 31 | }), 32 | delete: localTrans({ 33 | 'zh-CN': '删除', 34 | 'en-US': 'Delete', 35 | }), 36 | repoName: localTrans({ 37 | 'zh-CN': '仓库名', 38 | 'en-US': 'Repo Name', 39 | }), 40 | textPanel: localTrans({ 41 | 'zh-CN': '文本频道', 42 | 'en-US': 'Text Channel', 43 | }), 44 | success: localTrans({ 45 | 'zh-CN': '成功', 46 | 'en-US': 'Success', 47 | }), 48 | createApplication: localTrans({ 49 | 'zh-CN': '创建应用', 50 | 'en-US': 'Create Application', 51 | }), 52 | repoNameEmpty: localTrans({ 53 | 'zh-CN': '仓库名不能为空', 54 | 'en-US': 'Github Repo Name Not Allowd Empty', 55 | }), 56 | textPanelEmpty: localTrans({ 57 | 'zh-CN': '文本频道不能为空', 58 | 'en-US': 'Text Panel Not Allowd Empty', 59 | }), 60 | }; 61 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react", 5 | "importsNotUsedAsValues": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.github/web/plugins/com.msgbyte.github/types/tailchat.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@capital/common'; 2 | declare module '@capital/component'; 3 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/.ministarrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | externalDeps: ['react'], 5 | pluginRoot: path.resolve(__dirname, './web'), 6 | outDir: path.resolve(__dirname, '../../public'), 7 | }; 8 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/models/linkmeta.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | DocumentType, 4 | modelOptions, 5 | prop, 6 | Severity, 7 | index, 8 | } from '@typegoose/typegoose'; 9 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 10 | import type { Types } from 'mongoose'; 11 | 12 | @modelOptions({ 13 | options: { 14 | customName: 'p_linkmeta', 15 | allowMixed: Severity.ALLOW, 16 | }, 17 | }) 18 | @index({ url: 1 }) 19 | export class Linkmeta extends TimeStamps implements Base { 20 | _id: Types.ObjectId; 21 | id: string; 22 | 23 | @prop() 24 | url: string; 25 | 26 | @prop() 27 | data: any; 28 | } 29 | 30 | export type LinkmetaDocument = DocumentType; 31 | 32 | const model = getModelForClass(Linkmeta); 33 | 34 | export type LinkmetaModel = typeof model; 35 | 36 | export default model; 37 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailchat-plugin-linkmeta", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "moonrailgun", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build:web": "ministar buildPlugin all", 10 | "build:web:watch": "ministar watchPlugin all" 11 | }, 12 | "devDependencies": { 13 | "less": "^4.1.2", 14 | "mini-star": "^1.2.8" 15 | }, 16 | "dependencies": { 17 | "got": "11.8.3", 18 | "link-preview-js": "^2.1.10", 19 | "lodash": "^4.17.21" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/services/linkmeta.service.ts: -------------------------------------------------------------------------------- 1 | import { TcService, TcContext, TcDbService } from 'tailchat-server-sdk'; 2 | import type { LinkmetaDocument, LinkmetaModel } from '../models/linkmeta'; 3 | import { fetchLinkPreview } from '../utils/fetchLinkPreview'; 4 | import { fetchSpecialWebsiteMeta } from '../utils/specialWebsiteMeta'; 5 | 6 | /** 7 | * 链接信息服务 8 | */ 9 | interface LinkmetaService 10 | extends TcService, 11 | TcDbService {} 12 | class LinkmetaService extends TcService { 13 | get serviceName() { 14 | return 'plugin:com.msgbyte.linkmeta'; 15 | } 16 | 17 | onInit() { 18 | this.registerLocalDb(require('../models/linkmeta').default); 19 | 20 | this.registerAction('fetch', this.fetch, { 21 | params: { 22 | url: 'string', 23 | }, 24 | }); 25 | } 26 | 27 | /** 28 | * 获取连接预览信息 29 | */ 30 | private async fetch(ctx: TcContext<{ url: string }>) { 31 | const url = ctx.params.url; 32 | 33 | const meta = await this.adapter.model.findOne( 34 | { 35 | url, 36 | }, 37 | undefined, 38 | { 39 | sort: { 40 | _id: -1, 41 | }, 42 | } 43 | ); 44 | 45 | if ( 46 | !meta || 47 | new Date(meta.createdAt).valueOf() < 48 | new Date().valueOf() - 1000 * 60 * 60 * 24 49 | ) { 50 | // 没有找到或已过期(过期时间24小时) 51 | const data = await fetchLinkPreview(url); 52 | 53 | // 转存图片 54 | if (Array.isArray(data.images) && data.images.length > 0) { 55 | try { 56 | const { url } = await ctx.call('file.saveFileWithUrl', { 57 | fileUrl: data.images[0], 58 | }); 59 | data.images[0] = url; 60 | } catch (e) {} 61 | } 62 | 63 | // 尝试对特定网站获取更多信息 64 | const overwrite = await fetchSpecialWebsiteMeta(url); 65 | Object.assign(data, overwrite); 66 | 67 | await this.adapter.model.create({ 68 | url, 69 | data, 70 | }); 71 | 72 | return { ...data, isCache: false }; 73 | } 74 | 75 | return { 76 | ...meta.data, 77 | isCache: true, 78 | }; 79 | } 80 | } 81 | 82 | export default LinkmetaService; 83 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/test/__snapshots__/linkmeta.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" normal 1`] = ` 4 | Object { 5 | "contentType": "text/html", 6 | "description": undefined, 7 | "favicons": Array [ 8 | "https://www.baidu.com/favicon.ico", 9 | ], 10 | "images": Array [], 11 | "isCache": false, 12 | "mediaType": "website", 13 | "siteName": undefined, 14 | "title": "", 15 | "url": "https://www.baidu.com/?fortest", 16 | "videos": Array [], 17 | } 18 | `; 19 | 20 | exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure image 1`] = ` 21 | Object { 22 | "contentType": "image/jpeg", 23 | "favicons": Array [ 24 | "https://www.w3schools.com/favicon.ico", 25 | ], 26 | "isCache": true, 27 | "mediaType": "image", 28 | "url": "https://www.w3schools.com/html/pic_trulli.jpg", 29 | } 30 | `; 31 | 32 | exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure mp3 1`] = ` 33 | Object { 34 | "contentType": "audio/mpeg", 35 | "favicons": Array [ 36 | "https://www.w3schools.com/favicon.ico", 37 | ], 38 | "isCache": true, 39 | "mediaType": "audio", 40 | "url": "https://www.w3schools.com/html/horse.mp3", 41 | } 42 | `; 43 | 44 | exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure ogg 1`] = ` 45 | Object { 46 | "contentType": "video/ogg", 47 | "favicons": Array [ 48 | "https://www.w3schools.com/favicon.ico", 49 | ], 50 | "isCache": true, 51 | "mediaType": "video", 52 | "url": "https://www.w3schools.com/html/horse.ogg", 53 | } 54 | `; 55 | 56 | exports[`Test "plugin:com.msgbyte.linkinfo" service Test "plugin:com.msgbyte.linkmeta.fetch" pure video 1`] = ` 57 | Object { 58 | "contentType": "video/mp4", 59 | "favicons": Array [ 60 | "https://www.w3schools.com/favicon.ico", 61 | ], 62 | "isCache": true, 63 | "mediaType": "video", 64 | "url": "https://www.w3schools.com/html/mov_bbb.mp4", 65 | } 66 | `; 67 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/test/linkmeta.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTestServiceBroker } from '../../../test/utils'; 2 | import LinkmetaService from '../services/linkmeta.service'; 3 | import { Types } from 'mongoose'; 4 | import _ from 'lodash'; 5 | 6 | describe('Test "plugin:com.msgbyte.linkinfo" service', () => { 7 | const { broker, service, insertTestData } = 8 | createTestServiceBroker(LinkmetaService); 9 | 10 | describe('Test "plugin:com.msgbyte.linkmeta.fetch"', () => { 11 | test('normal', async () => { 12 | const url = 'https://www.baidu.com/?fortest'; 13 | const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', { 14 | url, 15 | }); 16 | 17 | try { 18 | expect(meta).toHaveProperty('url', url); 19 | expect(meta).toHaveProperty('isCache', false); 20 | expect(meta).toHaveProperty('title'); 21 | expect(meta).toHaveProperty('siteName'); 22 | expect(meta).toHaveProperty('description'); 23 | expect(meta).toHaveProperty('mediaType', 'website'); 24 | expect(meta).toHaveProperty('contentType', 'text/html'); 25 | expect(meta).toHaveProperty('images'); 26 | expect(meta).toHaveProperty('videos'); 27 | expect(meta).toHaveProperty('favicons'); 28 | expect(meta).toMatchSnapshot(); 29 | 30 | const metaWithCache: any = await broker.call( 31 | 'plugin:com.msgbyte.linkmeta.fetch', 32 | { 33 | url, 34 | } 35 | ); 36 | expect(metaWithCache).toHaveProperty('isCache', true); 37 | } finally { 38 | await service.adapter.model.deleteOne({ 39 | url, 40 | }); 41 | } 42 | }); 43 | 44 | test('pure video', async () => { 45 | const url = 'https://www.w3schools.com/html/mov_bbb.mp4'; 46 | const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', { 47 | url, 48 | }); 49 | 50 | expect(meta).toMatchSnapshot(); 51 | }); 52 | 53 | test('pure image', async () => { 54 | const url = 'https://www.w3schools.com/html/pic_trulli.jpg'; 55 | const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', { 56 | url, 57 | }); 58 | 59 | expect(meta).toMatchSnapshot(); 60 | }); 61 | 62 | test('pure ogg', async () => { 63 | const url = 'https://www.w3schools.com/html/horse.ogg'; 64 | const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', { 65 | url, 66 | }); 67 | 68 | expect(meta).toMatchSnapshot(); 69 | }); 70 | 71 | test('pure mp3', async () => { 72 | const url = 'https://www.w3schools.com/html/horse.mp3'; 73 | const meta: any = await broker.call('plugin:com.msgbyte.linkmeta.fetch', { 74 | url, 75 | }); 76 | 77 | expect(meta).toMatchSnapshot(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/utils/__tests__/fetchLinkPreview.spec.ts: -------------------------------------------------------------------------------- 1 | import { fetchLinkPreview } from '../fetchLinkPreview'; 2 | 3 | const mockGetLinkPreviewFn = jest.fn(); 4 | jest.mock('link-preview-js', () => ({ 5 | getLinkPreview: async () => { 6 | mockGetLinkPreviewFn(); 7 | }, 8 | })); 9 | 10 | describe('Test "fetchLinkPreview"', () => { 11 | test( 12 | 'fetchLinkPreview should merge same request', 13 | async () => { 14 | await Promise.all([ 15 | fetchLinkPreview('foo'), 16 | fetchLinkPreview('foo'), 17 | fetchLinkPreview('foo'), 18 | fetchLinkPreview('foo'), 19 | fetchLinkPreview('foo'), 20 | fetchLinkPreview('foo'), 21 | fetchLinkPreview('foo'), 22 | fetchLinkPreview('foo'), 23 | ]); 24 | 25 | expect(mockGetLinkPreviewFn.mock.calls.length).toBe(1); 26 | 27 | await sleep(5 * 1000); // 度过窗口期 28 | 29 | await Promise.all([ 30 | fetchLinkPreview('foo'), 31 | fetchLinkPreview('foo'), 32 | fetchLinkPreview('foo'), 33 | fetchLinkPreview('foo'), 34 | fetchLinkPreview('foo'), 35 | fetchLinkPreview('foo'), 36 | fetchLinkPreview('foo'), 37 | fetchLinkPreview('foo'), 38 | ]); 39 | 40 | expect(mockGetLinkPreviewFn.mock.calls.length).toBe(2); 41 | }, 42 | 10 * 1000 43 | ); 44 | }); 45 | 46 | function sleep(ms: number): Promise { 47 | return new Promise((resolve) => 48 | setTimeout(() => { 49 | resolve(); 50 | }, ms) 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/utils/fetchLinkPreview.ts: -------------------------------------------------------------------------------- 1 | import { getLinkPreview } from 'link-preview-js'; 2 | 3 | /** 4 | * 请求管理 5 | */ 6 | const cacheRequestList: Record> = {}; 7 | 8 | /** 9 | * 获取网页元数据信息 10 | * @param url 网址 11 | * @returns 12 | */ 13 | export async function fetchLinkPreview(url: string): Promise { 14 | if (cacheRequestList[url]) { 15 | // 如果有正在请求的信息 16 | return Promise.resolve(cacheRequestList[url]); 17 | } 18 | 19 | const promise = getLinkPreview(url); 20 | cacheRequestList[url] = promise; 21 | 22 | return Promise.resolve(promise).finally(() => { 23 | setTimeout(() => { 24 | delete cacheRequestList[url]; 25 | }, 2 * 1000); // 窗口期, 请求完毕后2s内依旧会复用原来的接口 26 | }); 27 | 28 | // return promise; 29 | } 30 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/utils/specialWebsiteMeta.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import _ from 'lodash'; 3 | 4 | /** 5 | * 获取特定页面的信息 6 | */ 7 | 8 | // 9 | 10 | const specialWebsiteMetaFetchers = [ 11 | { 12 | // bilibili 13 | match: (url: string) => url.startsWith('https://www.bilibili.com/video/BV'), 14 | overwrite: async (url: string) => { 15 | // from https://github.com/simon300000/bili-api/blob/master/src/api/api.bilibili.com.js 16 | const bvid = _.last(url.split('?')[0].split('/')); 17 | 18 | const { data } = await got( 19 | `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}` 20 | ).json(); 21 | 22 | const aid = _.get(data, 'aid'); 23 | const cid = _.get(data, 'cid'); 24 | if (aid && bvid && cid) { 25 | return { 26 | videos: [ 27 | `https://player.bilibili.com/player.html?aid=${aid}&bvid=${bvid}&cid=${cid}&page=1`, 28 | ], 29 | }; 30 | } 31 | }, 32 | }, 33 | ]; 34 | 35 | /** 36 | * 获取更多的信息 37 | * @param url 请求数据的地址 38 | */ 39 | export async function fetchSpecialWebsiteMeta(url: string) { 40 | const matched = specialWebsiteMetaFetchers.find((f) => f.match(url)); 41 | 42 | if (matched) { 43 | const overwrite = await matched.overwrite(url); 44 | 45 | return overwrite ?? {}; 46 | } 47 | 48 | return {}; 49 | } 50 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/web/plugins/com.msgbyte.linkmeta/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Url元数据展示", 3 | "name": "com.msgbyte.linkmeta", 4 | "url": "{BACKEND}/plugins/com.msgbyte.linkmeta/index.js", 5 | "version": "0.0.0", 6 | "author": "msgbyte", 7 | "description": "解析并获取在聊天信息中的url信息概述", 8 | "requireRestart": false 9 | } 10 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/web/plugins/com.msgbyte.linkmeta/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@plugins/com.msgbyte.linkmeta", 3 | "main": "src/index.tsx", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "lodash-es": "^4.17.21", 8 | "url-regex": "^5.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /plugins/com.msgbyte.linkmeta/web/plugins/com.msgbyte.linkmeta/src/UrlMetaPreviewer/Audio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _get from 'lodash/get'; 3 | import type { LinkMeta } from './types'; 4 | 5 | export const UrlMetaAudio: React.FC<{ 6 | meta: LinkMeta; 7 | }> = React.memo(({ meta }) => { 8 | return

12 |
window.open(meta.url)}> 13 |
{_get(meta, 'title')}
14 |
{_get(meta, 'description')}
15 |
16 | {_get(meta, 'images.0') && ( 17 |
18 | 19 |
20 | )} 21 |
22 | {_get(meta, 'videos.0') && ( 23 |
24 |
{ 27 | e.stopPropagation(); 28 | window.open(_get(meta, 'videos.0')); 29 | }} 30 | > 31 | 32 |
33 |