├── .dockerignore
├── .env.example
├── .eslintrc.json
├── .github
└── workflows
│ └── docker.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .prettierrc.json
├── Dockerfile
├── LICENSE
├── Procfile
├── README.md
├── docs
├── delete.md
├── file.md
├── grant.md
├── help.md
├── info.md
├── push.md
├── random.md
├── tag.md
├── ungrant.md
└── update.md
├── jest.config.js
├── package.json
├── src
├── api
│ ├── functions
│ │ └── preview.ts
│ ├── index.ts
│ └── routers
│ │ ├── root.ts
│ │ └── webhook.ts
├── app.ts
├── bot
│ ├── index.ts
│ ├── middlewares
│ │ ├── actions
│ │ │ ├── contribution-publish.ts
│ │ │ └── delete.ts
│ │ ├── commands
│ │ │ ├── debug.ts
│ │ │ ├── delete.ts
│ │ │ ├── file.ts
│ │ │ ├── grant.ts
│ │ │ ├── help.ts
│ │ │ ├── info.ts
│ │ │ ├── push.ts
│ │ │ ├── random.ts
│ │ │ ├── start.ts
│ │ │ ├── tag.ts
│ │ │ ├── ungrant.ts
│ │ │ └── update.ts
│ │ ├── guard.ts
│ │ ├── hears
│ │ │ └── contribute.ts
│ │ └── inline
│ │ │ └── index.ts
│ ├── modules
│ │ └── push.ts
│ └── wrappers
│ │ └── command-wrapper.ts
├── config.ts
├── database
│ ├── index.ts
│ ├── models
│ │ ├── AdminModel.ts
│ │ ├── ArtistModel.ts
│ │ ├── ArtworkModel.ts
│ │ ├── ConfigModel.ts
│ │ ├── CountributionModel.ts
│ │ ├── FileModel.ts
│ │ ├── MessageModel.ts
│ │ ├── PhotoModel.ts
│ │ └── TagModel.ts
│ └── operations
│ │ ├── admin.ts
│ │ ├── artist.ts
│ │ ├── artwork.ts
│ │ ├── config.ts
│ │ ├── contribution.ts
│ │ ├── file.ts
│ │ ├── message.ts
│ │ ├── photo.ts
│ │ └── tag.ts
├── platforms
│ ├── bilibili-api
│ │ ├── dynamic.ts
│ │ ├── fp.ts
│ │ ├── sign.ts
│ │ ├── tools.ts
│ │ └── utils.ts
│ ├── bilibili.ts
│ ├── danbooru.ts
│ ├── index.ts
│ ├── pixiv.ts
│ ├── twitter-web-api
│ │ ├── constants.js
│ │ ├── fxtwitter.ts
│ │ ├── tweet.ts
│ │ ├── twitter-api.js
│ │ └── twitter-got.js
│ └── twitter.ts
├── services
│ ├── artwork-service.ts
│ ├── graph
│ │ └── auth.ts
│ └── storage
│ │ ├── blackblaze.ts
│ │ └── upload.ts
├── tests
│ ├── bilibili.test.ts
│ └── twitter.test.ts
├── types
│ ├── Admin.d.ts
│ ├── Artwork.d.ts
│ ├── Bilibili.d.ts
│ ├── Command.d.ts
│ ├── Config.d.ts
│ ├── Contribution.d.ts
│ ├── Event.d.ts
│ ├── File.d.ts
│ ├── FxTwitter.d.ts
│ ├── Message.d.ts
│ ├── Photo.d.ts
│ ├── Pixiv.d.ts
│ └── global.d.ts
└── utils
│ ├── axios.ts
│ ├── caption.ts
│ ├── decorators.ts
│ ├── download.ts
│ ├── logger.ts
│ ├── param-parser.ts
│ └── sharp.ts
├── tsconfig.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
4 | /.vscode
5 |
6 | .env*
7 |
8 | !.env.example
9 |
10 | temp
11 |
12 | test
13 |
14 | yarn-error.log
15 |
16 | src/scripts/*.ts
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | BOT_TOKEN=
2 | PUSH_CHANNEL=
3 | CLIENT_ID=
4 | CLIENT_SECRET=
5 | REFRESH_TOKEN=
6 | ADMIN_LIST=
7 | DB_URL=
8 | B2_ENDPOINT=
9 | B2_KEY_ID=
10 | B2_KEY=
11 | B2_BUCKET=
12 | PIXIV_COOKIE=
13 | STORAGE_TYPE=
14 | STORAGE_BASE=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "prettier"
10 | ],
11 | "overrides": [],
12 | "parser": "@typescript-eslint/parser",
13 | "parserOptions": {
14 | "ecmaVersion": "latest",
15 | "sourceType": "module"
16 | },
17 | "plugins": ["@typescript-eslint"],
18 | "rules": {
19 | "no-case-declarations": "off"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker Build and Publish
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | paths-ignore:
7 | - '**.md'
8 |
9 | env:
10 | REGISTRY: ghcr.io
11 | IMAGE_NAME: ${{ github.repository_owner }}/someacg-bot
12 |
13 | jobs:
14 | build-and-push:
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: read
18 | packages: write
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Log in to Container Registry
25 | uses: docker/login-action@v3
26 | with:
27 | registry: ${{ env.REGISTRY }}
28 | username: ${{ github.actor }}
29 | password: ${{ secrets.GITHUB_TOKEN }}
30 |
31 | - name: Extract metadata
32 | id: meta
33 | uses: docker/metadata-action@v5
34 | with:
35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
36 | tags: |
37 | type=ref,event=branch
38 | type=ref,event=pr
39 | type=sha
40 |
41 | - name: Build and push Docker image
42 | uses: docker/build-push-action@v5
43 | with:
44 | context: .
45 | push: true
46 | tags: ${{ steps.meta.outputs.tags }}
47 | labels: ${{ steps.meta.outputs.labels }}
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
4 | /.vscode
5 |
6 | .env*
7 |
8 | !.env.example
9 |
10 | temp
11 |
12 | test
13 |
14 | yarn-error.log
15 |
16 | src/scripts
17 |
18 | fly.toml
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | .vscode
3 | node_modules
4 | test
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 4,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "arrowParens": "avoid",
8 | "endOfLine": "lf",
9 | "bracketSpacing": true,
10 | "htmlWhitespaceSensitivity": "strict"
11 | }
12 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax = docker/dockerfile:1
2 |
3 | # Adjust NODE_VERSION as desired
4 | ARG NODE_VERSION=20.16.0
5 | FROM node:${NODE_VERSION}-slim AS base
6 |
7 | # Node.js app lives here
8 | WORKDIR /app
9 |
10 | # Set production environment
11 | ENV NODE_ENV="production"
12 | ARG YARN_VERSION=1.22.22
13 | RUN npm install -g yarn@$YARN_VERSION --force
14 |
15 | # Throw-away build stage to reduce size of final image
16 | FROM base AS build
17 |
18 | # Install packages needed to build node modules
19 | RUN apt-get update -qq && \
20 | apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python3
21 |
22 | # Install node modules
23 | COPY package.json yarn.lock ./
24 | RUN yarn install --frozen-lockfile --production=false
25 |
26 | # Copy application code
27 | COPY . .
28 |
29 | # Build application
30 | RUN yarn run build
31 |
32 | # Remove development dependencies
33 | RUN yarn install --production=true
34 |
35 |
36 | # Final stage for app image
37 | FROM base
38 |
39 | # Copy built application
40 | COPY --from=build /app /app
41 |
42 | ENV PORT=3001
43 |
44 | # Start the server by default, this can be overwritten at runtime
45 | EXPOSE ${PORT}
46 |
47 | CMD [ "yarn", "run", "start" ]
48 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: TS_NODE_BASEURL=./dist node -r tsconfig-paths/register ./dist/src/app.js
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SomeACG Bot
5 |
6 |
7 | 这里是 [@SomeACG](https://t.me/SomeACG) 自用的壁纸推送机器人的源码仓库。
8 |
9 | ### 架构概述
10 |
11 | 数据库:[MongoDB](https://www.mongodb.com/)
12 |
13 | 云存储:[Blackblaze](https://www.backblaze.com/) | [OneDrive](https://www.office.com/onedrive)
14 |
15 | 所支持的图源平台:
16 |
17 | * [Pixiv](https://www.pixiv.net/)
18 | * [Twitter](https://twitter.com/?lang=en)
19 | * [Danbooru](https://danbooru.donmai.us/)
20 | * [Bilibili 动态](https://t.bilibili.com)
21 |
22 | 目前所使用的 PaaS 托管平台:[Fly.io](https://fly.io/)
23 |
24 | ### 配置文件
25 |
26 | 项目在开发环境下使用 DotEnv 格式的配置文件,其中主要的配置项已在 `.env.example` 中给出。
27 |
28 | 其中各项配置的说明如下:
29 |
30 | | 名称 | 示例 | 说明 |
31 | | ------------- | ------------------------------------------------------------ | -------------------------------------------------- |
32 | | BOT_TOKEN | `123456789:ABCDEFGYgsSi` | Telegram 机器人的 Bot Token |
33 | | PUSH_CHANNEL | `@SomeACG` | 壁纸推送的目标频道 |
34 | | STORAGE_TYPE | `sharepoint` | 云存储类型,目前只支持 sharepoint 和 b2 |
35 | | STORAGE_BASE | `SomeACG` | 云存储的目标上传目录,开头无斜杠,留空上传至根目录 |
36 | | CLIENT_ID | `8e9771c9-a07c-45f6-93e3-9bc4062125d0` | Microsoft Graph 的客户端 ID |
37 | | CLIENT_SECRET | `jCO8Q~Wt6kJJQLYGBD5O6Kk8EjO76sQIYlm9c_xZ` | Microsoft Graph 的客户端密钥 |
38 | | ADMIN_LIST | `123456732` | 默认管理员的 User ID |
39 | | DB_URL | `mongodb://localhost:27017/SomeACG?replicaSet=rs0` | MongoDB 数据库连接字符串 |
40 | | B2_ENDPOINT | `s3.us-west-001.backblazeb2.com` | Blackblaze 的存储桶地址 |
41 | | B2_KEY_ID | `1b002831244` | Blackblaze 的应用 ID |
42 | | B2_KEY | `O923+1uaJH686d7hTw2` | Blackblaze 的应用密钥 |
43 | | PIXIV_COOKIE | `yuid_b=IyTgsd8; PHPSESSID=91263823_7H4nvJHtguiu6yYiu7OIOIomS;` | Pixiv 的网站 Cookie |
44 |
45 | 一些其他需要注意的配置项:
46 |
47 | * REFRESH_TOKEN:第一次运行时需要手动获取 Microsoft Graph API 的 Refresh Token,并设置到 `REFRESH_TOKEN` 环境变量中。之后的 Refresh Token 便会自动存入到数据库并自动刷新。
48 | * DOTENV_NAME:开发环境下设置此环境变量用来指定以哪个文件作为启动时的配置文件,比如设置了 `DOTENV_NAME=development` 则会使用 `.env.development` 这个文件作为配置文件,默认情况下使用 `yarn dev` 启动时也会使用此配置文件。如果不设置这个变量时贼会使用 `.env` 作为配置文件。
49 | * USE_PROXY:启动时设置此环境变量为1时,程序会读取系统内设置的 `HTTPS_PROXY_HOST` 和 `HTTPS_PROXY_PORT` 作为 Telegram Bot API 的连接代理。
50 | * DEV_MODE:启动时设置此环境变量为1时,机器人将不再回复除了默认管理员以外的任何用户的指令,以避免其他用户的干扰。同时设置了此变量后程序会使用 `./temp` 作为临时下载目录,未设置时将会使用 `/tmp` 作为临时下载目录。
51 | * SP_SITE_ID:如果使用 SharePoint 站点作为存储源,则需要配置此变量为站点 ID。
52 | * BOT_LAUNCH_WAIT: 启动 Bot 时等待一定的时长,用来防止 PaaS 平台的上一部署仍在运行对 Telegram API 造成抢占。单位:秒。
53 | * 数据库环境:由于程序使用了 MongoDB 的数据库事务功能来进行失败时的回滚操作,而 MongoDB 的事务功能需要数据库运行在 Replica Set 模式下才能正常使用。如果需要在本地进行开发测试,请先确保 MongoDB 数据库已经正确配置了 Replica 模式。
54 |
55 | ### 测试
56 |
57 | 目前项目只有一个单元测试,用来测试 Twitter Web API 的可用性。可以使用下面的命令来运行这个测试。
58 |
59 | ```shell
60 | yarn jest --runTestsByPath src/tests/twitter.test.ts
61 | ```
62 |
63 | ### 编译和运行
64 |
65 | 本项目使用 `yarn` 作为 Node.js 包管理工具,使用 TypeScript 作为主要编程语言,运行 `yarn build` 后的编译产物默认在 `dist` 文件夹下。
66 |
67 | 如上所述,默认的 `yarn dev` 默认使用 `.env.development` 文件作为配置文件。如需在本地进行开发,请先将 `.env.example` 复制一份并填写上面表格中所必须的配置项。
68 |
69 | ### 感谢
70 |
71 | 感谢以下项目为本项目所提供的代码、思路和灵感。
72 |
73 | * [RSSHub](https://github.com/DIYgod/RSSHub):提供 Twitter Web API 的实现代码。
74 | * [Telegraf](https://github.com/telegraf/telegraf):完全类型支持的 Telegram 机器人框架。
75 |
--------------------------------------------------------------------------------
/docs/delete.md:
--------------------------------------------------------------------------------
1 | 使用 /delete 从数据库和频道中删除作品
2 | 当回复一条频道消息时,将会自动查找目标作品
3 | 也可使用参数 `index` 来指定目标作品序号
4 |
5 | *具有 DELETE 权限的用户才能使用此命令*
6 |
--------------------------------------------------------------------------------
/docs/file.md:
--------------------------------------------------------------------------------
1 | 使用 /file 命令添加一个文件到数据库以供未来使用
2 |
3 | 使用该命令时必须回复一个文件。
4 | 使用方法:/file <参数> [文件名]
5 | 可选参数:
6 | desc: 文件描述
7 |
8 | *具有 PUBLISH 权限的用户才能使用此命令*
9 |
--------------------------------------------------------------------------------
/docs/grant.md:
--------------------------------------------------------------------------------
1 | 使用 /grant 命令来对一位用户授权
2 | 可以直接回复某位用户的消息以指定目标用户
3 | 也可以设置参数 `user` 为目标用户的 UserID 来指定目标用户
4 |
5 | *具有 GRANT 权限的用户才能使用此命令*
6 |
--------------------------------------------------------------------------------
/docs/help.md:
--------------------------------------------------------------------------------
1 | 你是来找茬的是不是?
2 |
--------------------------------------------------------------------------------
/docs/info.md:
--------------------------------------------------------------------------------
1 | 使用 /info 命令获取一副作品的原图以及相关信息
2 | 可选参数:
3 | picture\_index: 图片序号,多个序号可以使用英文隔开
4 |
5 | 示例:
6 | `/info https://www.pixiv.net/artworks/80365250`
7 | `/info index=0 https://www.pixiv.net/artworks/80365250`
8 |
--------------------------------------------------------------------------------
/docs/push.md:
--------------------------------------------------------------------------------
1 | 使用 /push 指令发布一张作品
2 |
3 | 必选参数:
4 | tags: 图片标签,用逗号隔开
5 | 语法糖: 直接在命令中用#指定
6 |
7 | 可选参数:
8 | quality: 是否精选
9 | index: 图片序号,多个序号可以使用英文隔开
10 | contribure\_from: 投稿来源
11 | 示例:
12 | `/push tags=少女 quality=1 https://www.pixiv.net/artworks/80365250`
13 | 标签语法糖示例:
14 | `/push #少女 quality=1 https://www.pixiv.net/artworks/80365250`
15 |
16 | 若使用此命令时回复了一个文件,则会使用回复的文件作为作品原图。
17 |
18 | *具有 PUBLISH 权限的用户才能使用此命令*
19 |
--------------------------------------------------------------------------------
/docs/random.md:
--------------------------------------------------------------------------------
1 | 使用 /random 指令随机获取一张图片
2 |
--------------------------------------------------------------------------------
/docs/tag.md:
--------------------------------------------------------------------------------
1 | 使用 /tag 命令修改指定作品的标签
2 | 当回复一条频道消息时,将会自动查找目标作品
3 | 也可使用参数 `index` 来指定目标作品序号
4 | 作品标签使用英文逗号隔开,或者直接用#号指定
5 |
6 | *具有 TAG 权限的用户才能使用此命令*
7 |
--------------------------------------------------------------------------------
/docs/ungrant.md:
--------------------------------------------------------------------------------
1 | 使用 /ungrant 命令来撤销某用户的所有权限
2 | 可以直接回复某位用户的消息以指定目标用户
3 | 也可以设置参数 `user` 为目标用户的 UserID 来指定目标用户
4 |
5 | *具有 GRANT 权限的用户才能使用此命令*
6 |
--------------------------------------------------------------------------------
/docs/update.md:
--------------------------------------------------------------------------------
1 | 使用 /update 直接修改指定作品的数据库字段
2 | 当回复一条频道消息时,将会自动查找目标作品
3 | 也可使用参数 `index` 来指定目标作品序号
4 | 示例:
5 | /update index=233 quality=true
6 |
7 | *具有 UPDATE 权限的用户才能使用此命令*
8 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | // eslint-disable-next-line no-undef
3 | module.exports = {
4 | preset: 'ts-jest',
5 | testEnvironment: 'node',
6 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "someacg-bot",
3 | "version": "2.5.5",
4 | "license": "MIT",
5 | "author": {
6 | "name": "Revincx",
7 | "email": "i@revincx.icu"
8 | },
9 | "repository": {
10 | "type": "github",
11 | "url": "https://github.com/SomeACG/SomeACG-Bot.git"
12 | },
13 | "engines": {
14 | "node": "20.x"
15 | },
16 | "dependencies": {
17 | "@renmu/bili-api": "^2.4.0",
18 | "@types/koa": "^2.13.4",
19 | "@types/koa-bodyparser": "^4.3.5",
20 | "@types/koa-router": "^7.4.4",
21 | "aws-sdk": "^2.1207.0",
22 | "axios": "^0.28.0",
23 | "dotenv": "^10.0.0",
24 | "env-var": "^7.1.1",
25 | "https-proxy-agent": "^5.0.0",
26 | "koa": "^2.13.4",
27 | "koa-bodyparser": "^4.3.0",
28 | "koa-router": "^10.1.1",
29 | "module-alias": "^2.2.2",
30 | "mongoose": "^8.9.5",
31 | "pino": "^8.7.0",
32 | "pino-pretty": "^9.1.1",
33 | "sharp": "^0.34.3",
34 | "telegraf": "^4.16.3",
35 | "tough-cookie": "^4.1.3",
36 | "ts-node": "^10.4.0",
37 | "tsconfig-paths": "^3.12.0",
38 | "typescript": "^5.5.3"
39 | },
40 | "scripts": {
41 | "prepare": "husky install",
42 | "build": "tsc --outDir ./dist && cp -r docs ./dist",
43 | "dev": "npm run build && mkdir -p ./dist/temp && TS_NODE_BASEURL=./dist DEV_MODE=1 DOTENV_NAME=development LOG_LEVEL=debug node --inspect -r tsconfig-paths/register -r wirebird-client/inject ./dist/src/app.js",
44 | "dev:prod": "npm run build && mkdir -p ./dist/temp && TS_NODE_BASEURL=./dist DEV_MODE=1 DOTENV_NAME=production node --inspect -r tsconfig-paths/register ./dist/src/app.js",
45 | "start": "mkdir -p ./dist/temp && cp -r docs ./dist && TS_NODE_BASEURL=./dist node -r tsconfig-paths/register ./dist/src/app.js",
46 | "lint": "prettier --write . && eslint --ext .ts src --fix"
47 | },
48 | "devDependencies": {
49 | "@flydotio/dockerfile": "^0.5.9",
50 | "@jest/globals": "^29.5.0",
51 | "@types/gm": "^1.18.12",
52 | "@types/jest": "^29.5.2",
53 | "@types/minio": "^7.0.11",
54 | "@types/tough-cookie": "^4.0.2",
55 | "@typescript-eslint/eslint-plugin": "^5.42.1",
56 | "@typescript-eslint/parser": "^5.42.1",
57 | "eslint": "^8.27.0",
58 | "eslint-config-prettier": "^8.5.0",
59 | "husky": "^8.0.2",
60 | "jest": "^29.5.0",
61 | "lint-staged": "^13.0.4",
62 | "prettier": "2.7.1",
63 | "ts-jest": "^29.1.0",
64 | "wirebird-client": "^0.2.4"
65 | },
66 | "lint-staged": {
67 | "*.ts": [
68 | "prettier -w",
69 | "eslint --fix"
70 | ]
71 | },
72 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
73 | }
74 |
--------------------------------------------------------------------------------
/src/api/functions/preview.ts:
--------------------------------------------------------------------------------
1 | import getArtworkInfoByUrl from '~/platforms';
2 |
3 | export async function getArtworkImgLink(
4 | url: string,
5 | original?: boolean
6 | ): Promise {
7 | const artworkInfo = await getArtworkInfoByUrl(url);
8 |
9 | if (original) {
10 | return artworkInfo.photos[0].url_origin;
11 | }
12 |
13 | return artworkInfo.photos[0].url_thumb;
14 | }
15 |
--------------------------------------------------------------------------------
/src/api/index.ts:
--------------------------------------------------------------------------------
1 | import Koa from 'koa';
2 | import BodyParser from 'koa-bodyparser';
3 |
4 | import webhookRouter from './routers/webhook';
5 | import rootRouter from './routers/root';
6 |
7 | const server = new Koa();
8 |
9 | server.use(BodyParser());
10 |
11 | server.use(rootRouter.routes());
12 | server.use(webhookRouter.routes());
13 |
14 | export default server;
15 |
--------------------------------------------------------------------------------
/src/api/routers/root.ts:
--------------------------------------------------------------------------------
1 | import Router from 'koa-router';
2 | import { getArtworkImgLink } from '../functions/preview';
3 |
4 | const rootRouter = new Router();
5 |
6 | export default rootRouter.get(
7 | /^(\/origin)?\/(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/,
8 | async ctx => {
9 | let target = ctx.url.slice(1);
10 | let original = false;
11 |
12 | if (target.startsWith('origin/')) {
13 | target = target.slice(7);
14 | original = true;
15 | }
16 |
17 | try {
18 | const img_link = await getArtworkImgLink(target, original);
19 |
20 | ctx.redirect(img_link.replace('i.pximg.net', 'i.pixiv.cat'));
21 | } catch (err) {
22 | ctx.body = {
23 | code: 400,
24 | message: 'Bad Request: ' + err
25 | };
26 | }
27 | }
28 | );
29 |
--------------------------------------------------------------------------------
/src/api/routers/webhook.ts:
--------------------------------------------------------------------------------
1 | import { ParameterizedContext } from 'koa';
2 | import Router from 'koa-router';
3 |
4 | const webhookRouter = new Router();
5 |
6 | export default webhookRouter.all(
7 | '/webhook',
8 | async (ctx: ParameterizedContext) => {
9 | ctx.body = {
10 | code: 400,
11 | message: '功能尚在开发中...'
12 | };
13 | }
14 | );
15 |
--------------------------------------------------------------------------------
/src/app.ts:
--------------------------------------------------------------------------------
1 | import config from '~/config';
2 | import server from '~/api';
3 | import bot from '~/bot';
4 | import logger from './utils/logger';
5 |
6 | logger.info('SomeACG-Bot Version: ' + config.VERSION);
7 | server.listen(config.PORT);
8 | logger.info('Koa server started');
9 |
10 | let launch_wait = 0;
11 |
12 | if (process.env.BOT_LAUNCH_WAIT) {
13 | launch_wait = parseInt(process.env.BOT_LAUNCH_WAIT);
14 | logger.info('Waiting for bot launch...');
15 | }
16 |
17 | setTimeout(() => {
18 | bot.launch();
19 | logger.info('Bot instance launched successfully');
20 | }, launch_wait * 1000);
21 |
--------------------------------------------------------------------------------
/src/bot/index.ts:
--------------------------------------------------------------------------------
1 | import config from '~/config';
2 | import { Telegraf } from 'telegraf';
3 | import { genAdminPredicate } from './middlewares/guard';
4 | import { HttpsProxyAgent } from 'https-proxy-agent';
5 |
6 | // Development Mode
7 |
8 | const { HTTPS_PROXY_HOST, HTTPS_PROXY_PORT } = process.env;
9 |
10 | const agent = new HttpsProxyAgent({
11 | host: HTTPS_PROXY_HOST,
12 | port: HTTPS_PROXY_PORT
13 | });
14 |
15 | const bot = new Telegraf(
16 | config.BOT_TOKEN,
17 | config.USE_PROXY
18 | ? {
19 | telegram: { agent },
20 | handlerTimeout: 600000
21 | }
22 | : {
23 | handlerTimeout: 600000
24 | }
25 | );
26 |
27 | bot.use(async (ctx, next) => {
28 | logger.debug(ctx.update, 'new update');
29 | if (config.DEV_MODE && ctx.from?.id) {
30 | if (!config.ADMIN_LIST.includes(ctx.from.id.toString())) return;
31 | }
32 | await next();
33 | });
34 |
35 | // Version command middleware
36 | bot.use(
37 | Telegraf.command('version', async ctx => {
38 | const environment = process.env.DEV_MODE ? 'debug' : 'production';
39 | return await ctx.reply(
40 | `当前版本:${config.VERSION}\n工作环境:${environment}`,
41 | {
42 | reply_parameters:
43 | ctx.chat.type == 'private'
44 | ? undefined
45 | : {
46 | message_id: ctx.message.message_id,
47 | allow_sending_without_reply: true
48 | },
49 | parse_mode: 'HTML'
50 | }
51 | );
52 | })
53 | );
54 |
55 | // Commands
56 | import startCommand from './middlewares/commands/start';
57 | bot.use(startCommand);
58 | import infoCommand from './middlewares/commands/info';
59 | bot.use(infoCommand);
60 | import randomCommand from './middlewares/commands/random';
61 | bot.use(randomCommand);
62 | import helpCommand from './middlewares/commands/help';
63 | bot.use(helpCommand);
64 | import debugCommand from './middlewares/commands/debug';
65 | bot.use(Telegraf.optional(genAdminPredicate(), debugCommand));
66 | import pushCommand from './middlewares/commands/push';
67 | bot.use(
68 | Telegraf.optional(genAdminPredicate(AdminPermission.PUBLISH), pushCommand)
69 | );
70 | import tagCommand from './middlewares/commands/tag';
71 | bot.use(Telegraf.optional(genAdminPredicate(AdminPermission.TAG), tagCommand));
72 | import updateCommand from './middlewares/commands/update';
73 | bot.use(
74 | Telegraf.optional(genAdminPredicate(AdminPermission.UPDATE), updateCommand)
75 | );
76 | import deleteCommand from './middlewares/commands/delete';
77 | bot.use(
78 | Telegraf.optional(genAdminPredicate(AdminPermission.DELETE), deleteCommand)
79 | );
80 | import grantCommand from './middlewares/commands/grant';
81 | bot.use(
82 | Telegraf.optional(genAdminPredicate(AdminPermission.GRANT), grantCommand)
83 | );
84 | import ungrantCommand from './middlewares/commands/ungrant';
85 | bot.use(
86 | Telegraf.optional(genAdminPredicate(AdminPermission.GRANT), ungrantCommand)
87 | );
88 | import fileCommand from './middlewares/commands/file';
89 | bot.use(
90 | Telegraf.optional(genAdminPredicate(AdminPermission.PUBLISH), fileCommand)
91 | );
92 |
93 | // Actions
94 | import contributionPublishAction from './middlewares/actions/contribution-publish';
95 | bot.use(
96 | Telegraf.optional(
97 | genAdminPredicate(AdminPermission.PUBLISH),
98 | contributionPublishAction
99 | )
100 | );
101 | import deleteAction from './middlewares/actions/delete';
102 | bot.use(
103 | Telegraf.optional(genAdminPredicate(AdminPermission.PUBLISH), deleteAction)
104 | );
105 |
106 | // Hears
107 | import contributeHear from './middlewares/hears/contribute';
108 | import { AdminPermission } from '~/types/Admin';
109 | bot.use(contributeHear);
110 |
111 | // Inline Search
112 |
113 | import inlineSearch from './middlewares/inline';
114 | import logger from '~/utils/logger';
115 | bot.use(inlineSearch);
116 |
117 | // Setup my commands
118 |
119 | bot.telegram.setMyCommands([
120 | {
121 | command: 'random',
122 | description: '随机获取一张图片'
123 | },
124 | {
125 | command: 'info',
126 | description: '获取作品原图以及作品信息'
127 | },
128 | {
129 | command: 'push',
130 | description: '[管理员]发布作品到频道与网站'
131 | },
132 | {
133 | command: 'tag',
134 | description: '[管理员]修改指定作品的标签'
135 | },
136 | {
137 | command: 'update',
138 | description: '[管理员]更新作品信息'
139 | },
140 | {
141 | command: 'delete',
142 | description: '[管理员]删除作品'
143 | },
144 | {
145 | command: 'grant',
146 | description: '[管理员]授予某用户权限'
147 | },
148 | {
149 | command: 'ungrant',
150 | description: '[管理员]撤销某用户的权限'
151 | },
152 | {
153 | command: 'help',
154 | description: '获取命令使用帮助'
155 | },
156 | {
157 | command: 'version',
158 | description: '获取Bot版本号'
159 | }
160 | ]);
161 |
162 | export default bot;
163 |
--------------------------------------------------------------------------------
/src/bot/middlewares/actions/contribution-publish.ts:
--------------------------------------------------------------------------------
1 | import { Telegraf } from 'telegraf';
2 | import { getContributionById } from '~/database/operations/contribution';
3 | import { CallbackQuery } from 'telegraf/typings/core/types/typegram';
4 | import logger from '~/utils/logger';
5 |
6 | export default Telegraf.action(/publish-/, async ctx => {
7 | const query = ctx.callbackQuery as CallbackQuery & { data: string };
8 |
9 | const query_params = query.data.split('-');
10 |
11 | try {
12 | const contribution = await getContributionById(
13 | parseInt(query_params[1])
14 | );
15 |
16 | if (!ctx.from) return;
17 |
18 | let pushCommand = '/push';
19 | if (query_params[2] == 'q') pushCommand += ' quality=true';
20 | pushCommand += ' index=0';
21 | pushCommand += ' contribute_from=' + contribution.message_id;
22 | pushCommand += ' ' + contribution.post_url;
23 |
24 | await ctx.telegram.sendMessage(
25 | ctx.from.id,
26 | '请修改此命令并添加tags参数后发送给我:\n' +
27 | pushCommand +
28 | '',
29 | {
30 | parse_mode: 'HTML'
31 | }
32 | );
33 | await ctx.answerCbQuery();
34 | } catch (err) {
35 | logger.error(err, "error occured when processing 'publish' action");
36 | if (err instanceof Error) return await ctx.answerCbQuery(err.message);
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/src/bot/middlewares/actions/delete.ts:
--------------------------------------------------------------------------------
1 | import { Telegraf } from 'telegraf';
2 | import { deleteContribution } from '~/database/operations/contribution';
3 | import { CallbackQuery } from 'telegraf/typings/core/types/typegram';
4 |
5 | export default Telegraf.action(/delete-/, async ctx => {
6 | const query = ctx.callbackQuery as CallbackQuery & { data: string };
7 | const message_id = parseInt(query.data.split('-')[1]);
8 |
9 | try {
10 | const delete_count = await deleteContribution(message_id);
11 | if (delete_count > 0) {
12 | await ctx.answerCbQuery('投稿已删除~');
13 | await ctx.deleteMessage(message_id);
14 | await ctx.deleteMessage();
15 | }
16 | await ctx.answerCbQuery('投稿删除失败');
17 | } catch (err) {
18 | if (err instanceof Error) {
19 | ctx.answerCbQuery('操作失败:' + err.message);
20 | }
21 | ctx.answerCbQuery('操作失败: 未知原因');
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/debug.ts:
--------------------------------------------------------------------------------
1 | import { Telegraf } from 'telegraf';
2 | import {
3 | Message,
4 | MessageOriginChannel
5 | } from 'telegraf/typings/core/types/typegram';
6 | import { getArtwork } from '~/database/operations/artwork';
7 | import { getMessage } from '~/database/operations/message';
8 | import { Artwork } from '~/types/Artwork';
9 | import logger from '~/utils/logger';
10 | import { parseParams } from '~/utils/param-parser';
11 |
12 | export default Telegraf.command('debug', async ctx => {
13 | const command = parseParams(ctx.message.text);
14 | if (!command.target) {
15 | return await ctx.reply('No debug type specfiied!', {
16 | reply_parameters: {
17 | message_id: ctx.message.message_id,
18 | allow_sending_without_reply: true
19 | }
20 | });
21 | }
22 | switch (command.target) {
23 | case 'chat_id':
24 | return await ctx.reply(ctx.chat.id.toString(), {
25 | reply_parameters: {
26 | message_id: ctx.message.message_id,
27 | allow_sending_without_reply: true
28 | }
29 | });
30 | case 'file_id':
31 | const msg = ctx.message.reply_to_message as Partial<
32 | Message.PhotoMessage & Message.DocumentMessage
33 | >;
34 |
35 | let file_id = '';
36 |
37 | if (msg?.photo) file_id = msg.photo[msg.photo.length - 1].file_id;
38 | if (msg?.document) file_id = msg.document.file_id;
39 |
40 | return await ctx.reply(
41 | file_id
42 | ? `file_id: ${file_id}`
43 | : 'No file found in the message',
44 | {
45 | reply_parameters: {
46 | message_id: ctx.message.message_id,
47 | allow_sending_without_reply: true
48 | },
49 | parse_mode: 'HTML'
50 | }
51 | );
52 | case 'dump':
53 | return await ctx.reply(
54 | '' +
55 | JSON.stringify(
56 | ctx.message.reply_to_message
57 | ? ctx.message.reply_to_message
58 | : ctx.message,
59 | undefined,
60 | ' '
61 | ) +
62 | '
',
63 | {
64 | reply_parameters: {
65 | message_id: ctx.message.message_id,
66 | allow_sending_without_reply: true
67 | },
68 | parse_mode: 'HTML'
69 | }
70 | );
71 | case 'parser':
72 | if (!ctx.message.reply_to_message)
73 | return await ctx.reply('Reply a message to parse', {
74 | reply_parameters: {
75 | message_id: ctx.message.message_id,
76 | allow_sending_without_reply: true
77 | }
78 | });
79 |
80 | const target_msg = ctx.message
81 | .reply_to_message as Message.TextMessage;
82 |
83 | return await ctx.reply(
84 | '' +
85 | JSON.stringify(
86 | parseParams(target_msg.text, target_msg.entities),
87 | undefined,
88 | ' '
89 | ) +
90 | '
',
91 | {
92 | reply_parameters: {
93 | message_id: ctx.message.message_id,
94 | allow_sending_without_reply: true
95 | },
96 | parse_mode: 'HTML'
97 | }
98 | );
99 | case 'artwork':
100 | try {
101 | const reply_to_message = ctx.message
102 | .reply_to_message as Message.CommonMessage;
103 | if (!reply_to_message.is_automatic_forward) return;
104 | const message = await getMessage(
105 | (reply_to_message.forward_origin as MessageOriginChannel)
106 | .message_id
107 | );
108 | const artwork = await getArtwork(message.artwork_index);
109 |
110 | // In case that the title or desc field has special characters
111 | // Just remove them
112 |
113 | artwork.title = '...';
114 | artwork.desc = '...';
115 |
116 | return await ctx.reply(
117 | '' +
118 | JSON.stringify(artwork, undefined, ' ') +
119 | '
',
120 | {
121 | reply_parameters: {
122 | message_id: ctx.message.message_id,
123 | allow_sending_without_reply: true
124 | },
125 | parse_mode: 'HTML'
126 | }
127 | );
128 | } catch (err) {
129 | logger.error(err);
130 | return await ctx.reply('Cannot get artwork info', {
131 | reply_parameters: {
132 | message_id: ctx.message.message_id,
133 | allow_sending_without_reply: true
134 | }
135 | });
136 | }
137 | default:
138 | return await ctx.reply(`Debug type '${command.target}' not found`, {
139 | reply_parameters: {
140 | message_id: ctx.message.message_id,
141 | allow_sending_without_reply: true
142 | }
143 | });
144 | }
145 | });
146 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/delete.ts:
--------------------------------------------------------------------------------
1 | import { delArtwork } from '~/services/artwork-service';
2 | import { getMessage } from '~/database/operations/message';
3 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
4 | import { Artwork } from '~/types/Artwork';
5 | import { getArtwork } from '~/database/operations/artwork';
6 | import { MessageOriginChannel } from 'telegraf/typings/core/types/typegram';
7 |
8 | export default wrapCommand('delete', async ctx => {
9 | if (!ctx.command.params['index'] && !ctx.is_reply)
10 | return await ctx.directlyReply(
11 | '命令语法不正确,请回复一条消息或指定要删除的作品序号!'
12 | );
13 | await ctx.wait('正在删除作品...', true);
14 | let artwork_index = -1;
15 | if (ctx.is_reply) {
16 | if (!ctx.reply_to_message?.is_automatic_forward)
17 | return await ctx.resolveWait('回复的消息不是有效的频道消息!');
18 | const message = await getMessage(
19 | (ctx.reply_to_message.forward_origin as MessageOriginChannel)
20 | .message_id
21 | );
22 | artwork_index = message.artwork_index;
23 | }
24 | let artwork: Artwork;
25 | if (ctx.reply_to_message) {
26 | const message_id: number = (
27 | ctx.reply_to_message.forward_origin as MessageOriginChannel
28 | ).message_id;
29 | const message = await getMessage(message_id);
30 | artwork = await getArtwork(message.artwork_index);
31 | artwork_index = artwork.index;
32 | } else if (ctx.command.params['index'])
33 | artwork_index = parseInt(ctx.command.params['index']);
34 | const result = await delArtwork(artwork_index);
35 | if (result.succeed) return;
36 | else await ctx.resolveWait('作品删除失败: ' + result.message);
37 | });
38 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/file.ts:
--------------------------------------------------------------------------------
1 | import { Message } from 'telegraf/typings/core/types/typegram';
2 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
3 | import { insertFile } from '~/database/operations/file';
4 | import { File } from '~/types/File';
5 |
6 | export default wrapCommand('file', async ctx => {
7 | const documet_message = ctx.reply_to_message as Message.DocumentMessage;
8 | if (!documet_message?.document)
9 | return ctx.directlyReply('请回复一个文件消息!');
10 | if (!ctx.command.target) return ctx.directlyReply('请指定一个文件名称!');
11 | const file_name = ctx.command.target;
12 | const regex = /^[a-zA-Z0-9_]+$/;
13 | if (!regex.test(file_name))
14 | return ctx.directlyReply('文件名称只能包含字母、数字和下划线!');
15 | const file: File = {
16 | name: file_name,
17 | file_id: documet_message.document.file_id,
18 | description: ctx.command.params['desc'],
19 | create_time: new Date()
20 | };
21 |
22 | await insertFile(file);
23 | return ctx.resolveWait(
24 | `文件成功添加到数据库~\n链接:https://t.me/${ctx.me}?start=file-${file.name}`
25 | );
26 | });
27 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/grant.ts:
--------------------------------------------------------------------------------
1 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
2 | import { grantPermissons } from '~/database/operations/admin';
3 | import { AdminPermission } from '~/types/Admin';
4 |
5 | export default wrapCommand('grant', async ctx => {
6 | if (!ctx.reply_to_message?.from)
7 | return await ctx.directlyReply(
8 | '命令语法不正确,请回复一条其他用户的消息!'
9 | );
10 | if (!ctx.command.target)
11 | return await ctx.directlyReply('命令语法不正确,请指定要授予的权限!');
12 | const user_id = ctx.reply_to_message.from.id;
13 | const str_array =
14 | ctx.command.target.search(',') == -1
15 | ? [ctx.command.target]
16 | : ctx.command.target.split(',');
17 | const permissons: Array = [];
18 | for (const str of str_array) {
19 | const permisson = str.toUpperCase() as AdminPermission;
20 | permissons.push(permisson);
21 | }
22 | const succeed = await grantPermissons({
23 | user_id: user_id,
24 | grant_by: ctx.message.from.id,
25 | permissions: permissons
26 | });
27 | if (succeed)
28 | return await ctx.directlyReply(
29 | `成功将 ${permissons.toString()} 权限授予用户 ${
30 | ctx.reply_to_message.from.id
31 | }`,
32 | 'HTML'
33 | );
34 | return await ctx.resolveWait('权限修改失败');
35 | });
36 |
37 | // export default Telegraf.command('grant', async ctx => {
38 | // let command = parseParams(ctx.message.text)
39 | // if (!command.params['user'] && !ctx.message.reply_to_message) {
40 | // return await ctx.reply('命令语法不正确,请回复一条消息或授予权限的用户ID!', {
41 | // reply_to_message_id: ctx.message.message_id
42 | // })
43 | // }
44 | // if (!ctx.message.reply_to_message?.from) return await ctx.reply('命令语法不正确,请回复一条用户发送的消息!', {
45 | // reply_to_message_id: ctx.message.message_id
46 | // })
47 | // if (!command.target) return await ctx.reply('命令语法不正确,请指定要授予的权限!', {
48 | // reply_to_message_id: ctx.message.message_id
49 | // })
50 | // let user_id = -1
51 | // if (command.params['user']) user_id = parseInt(command.params['user'])
52 | // if (ctx.message.reply_to_message) user_id = ctx.message.reply_to_message.from.id
53 | // let array = command.target.search(',') == -1 ? [command.target] : command.target.split(',')
54 | // let permissons: Array = []
55 |
56 | // for (let str of array) {
57 | // let permisson = str.toUpperCase() as AdminPermission
58 | // permissons.push(permisson)
59 | // }
60 |
61 | // try {
62 | // let succeed = await grantPermissons({
63 | // user_id: user_id,
64 | // grant_by: ctx.message.from.id,
65 | // permissions: permissons
66 | // })
67 | // if (succeed) return ctx.reply('成功将 ' + permissons.toString() + ' 权限授予用户 ' + user_id, {
68 | // reply_to_message_id: ctx.message.message_id
69 | // })
70 | // return ctx.reply('权限修改失败', {
71 | // reply_to_message_id: ctx.message.message_id
72 | // })
73 | // }
74 | // catch (err) {
75 | // console.log(err)
76 | // if (err instanceof Error) {
77 | // return await ctx.reply('操作失败: ' + err.message, {
78 | // reply_to_message_id: ctx.message.message_id
79 | // })
80 | // }
81 | // return await ctx.reply('操作失败: 未知错误', {
82 | // reply_to_message_id: ctx.message.message_id
83 | // })
84 | // }
85 | // })
86 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/help.ts:
--------------------------------------------------------------------------------
1 | import config from '~/config';
2 | import path from 'path';
3 | import fs from 'fs';
4 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
5 |
6 | export default wrapCommand('help', async ctx => {
7 | if (!ctx.command.target)
8 | return await ctx.directlyReply(
9 | '使用 /help [命令名称] 来查看一条命令的使用方法'
10 | );
11 | const help_path = path.resolve(
12 | config.BASE_DIR,
13 | 'docs',
14 | ctx.command.target + '.md'
15 | );
16 | const file_exist = fs.existsSync(help_path);
17 | if (!file_exist)
18 | return await ctx.directlyReply(`命令 ${ctx.command.target} 不存在`);
19 | const str = fs.readFileSync(help_path).toString().trim();
20 | return await ctx.resolveWait(str, 'Markdown');
21 | });
22 |
23 | // export default Telegraf.command('help', async ctx => {
24 | // let waiting_reply: Message
25 | // setTimeout(async () => {
26 | // if (waiting_reply && waiting_reply.chat.type != 'private') await ctx.deleteMessage(waiting_reply.message_id)
27 | // }, 10000)
28 | // let command = parseParams(ctx.message.text)
29 | // if (!command.target) return waiting_reply = await ctx.reply("使用 /help [命令名称] 来查看一条命令的使用方法", {
30 | // reply_to_message_id: ctx.message.message_id
31 | // })
32 | // try {
33 | // let help_path = path.resolve(config.BASE_DIR, 'docs', command.target + '.md')
34 | // let file_exist = fs.existsSync(help_path)
35 |
36 | // if (!file_exist) return waiting_reply = await ctx.reply("该命令不存在", {
37 | // reply_to_message_id: ctx.message.message_id
38 | // })
39 | // let str = fs.readFileSync(help_path).toString().trim()
40 |
41 | // waiting_reply = await ctx.reply(str, {
42 | // reply_to_message_id: ctx.message.message_id,
43 | // parse_mode: 'Markdown'
44 | // })
45 | // }
46 | // catch (err) {
47 | // console.log(err)
48 | // if (err instanceof Error) {
49 | // return await ctx.reply('操作失败: ' + err.message, {
50 | // reply_to_message_id: ctx.message.message_id
51 | // })
52 | // }
53 | // return await ctx.reply('操作失败: 未知错误', {
54 | // reply_to_message_id: ctx.message.message_id
55 | // })
56 | // }
57 |
58 | // })
59 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/info.ts:
--------------------------------------------------------------------------------
1 | import getArtworkInfoByUrl from '~/platforms';
2 | import config from '~/config';
3 | import path from 'path';
4 | import downloadFile from '~/utils/download';
5 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
6 | import { infoCmdCaption } from '~/utils/caption';
7 | import { semiIntArray } from '~/utils/param-parser';
8 |
9 | export default wrapCommand('info', async ctx => {
10 | if (!ctx.command.urls || ctx.command.urls.length == 0)
11 | return await ctx.directlyReply(
12 | '使用方法:\n/info <参数> [作品链接]\n可选参数: index 图片序号,默认为0'
13 | );
14 | const artwork_info = await getArtworkInfoByUrl(
15 | ctx.command.urls[0],
16 | ctx.command.params['index']
17 | ? semiIntArray(ctx.command.params['index'])
18 | : [-1] // -1 means all pictures, it should be handled in the platforms module
19 | );
20 | await ctx.wait('正在获取图片信息并下载图片,请稍后~~');
21 |
22 | const files = await Promise.all(
23 | artwork_info.photos.map(async photo => {
24 | const file_name = await downloadFile(
25 | photo.url_origin,
26 | path.basename(new URL(photo.url_origin).pathname)
27 | );
28 | return file_name;
29 | })
30 | );
31 |
32 | const caption = infoCmdCaption(artwork_info);
33 |
34 | await ctx.replyWithMediaGroup(
35 | files.map(file_name => ({
36 | type: 'document',
37 | media: {
38 | source: path.resolve(config.TEMP_DIR, file_name)
39 | },
40 | caption:
41 | file_name === files[files.length - 1] ? caption : undefined,
42 | parse_mode: 'HTML'
43 | })),
44 | {
45 | reply_parameters: {
46 | message_id: ctx.message.message_id,
47 | allow_sending_without_reply: true
48 | }
49 | }
50 | );
51 | return await ctx.deleteWaiting();
52 | });
53 |
54 | // export default Telegraf.command('info', async ctx => {
55 | // let command = parseParams(ctx.message.text)
56 | // if (!command.target) {
57 | // return await ctx.reply(`使用方法:\n/info <参数> [作品链接]\n可选参数: picture_index 图片序号,默认为0`, {
58 | // reply_to_message_id: ctx.message.message_id
59 | // })
60 | // }
61 | // try {
62 | // let artwork_info = await getArtworkInfoByUrl(command.target, command.params['picture_index'])
63 | // let waiting_reply = await ctx.reply('正在获取图片信息并下载图片,请稍后~~', {
64 | // reply_to_message_id: ctx.message.message_id
65 | // })
66 | // let file_name = await downloadFile(artwork_info.url_origin, path.basename(new URL(artwork_info.url_origin).pathname))
67 | // let caption = "图片下载成功!\n"
68 | // if (artwork_info.title) caption += `作品标题: ${artwork_info.title}\n`
69 | // if (artwork_info.desc) caption += `作品描述: ${artwork_info.desc}\n`
70 | // caption += `尺寸: ${artwork_info.size.width}x${artwork_info.size.height}`
71 | // await ctx.replyWithDocument({
72 | // source: path.resolve(config.TEMP_DIR, file_name),
73 | // filename: file_name
74 | // }, {
75 | // caption: caption,
76 | // parse_mode: 'HTML'
77 | // })
78 | // return await ctx.deleteMessage(waiting_reply.message_id)
79 | // }
80 | // catch (err) {
81 | // console.log(err)
82 | // if (err instanceof Error) {
83 | // return await ctx.reply('操作失败: ' + err.message, {
84 | // reply_to_message_id: ctx.message.message_id
85 | // })
86 | // }
87 | // return await ctx.reply('操作失败: 未知错误', {
88 | // reply_to_message_id: ctx.message.message_id
89 | // })
90 | // }
91 | // })
92 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/push.ts:
--------------------------------------------------------------------------------
1 | // import { adminPredicate, NonAdminHandler } from "~/bot/middlewares/guard"
2 | import { Message } from 'telegraf/typings/core/types/typegram';
3 | import getArtworkInfoByUrl from '~/platforms';
4 | import { publishArtwork } from '~/services/artwork-service';
5 | import { Contribution } from '~/types/Contribution';
6 | import { getContributionById } from '~/database/operations/contribution';
7 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
8 | import logger from '~/utils/logger';
9 | import { semiIntArray } from '~/utils/param-parser';
10 |
11 | export default wrapCommand('push', async ctx => {
12 | if (!ctx.command.urls || ctx.command.urls.length == 0)
13 | return await ctx.directlyReply(
14 | '命令语法不正确\n命令语法: /push [args(key=value)] '
15 | );
16 | if (
17 | !ctx.command.params['tags'] &&
18 | (!ctx.command.hashtags || ctx.command.hashtags.length == 0)
19 | )
20 | return await ctx.directlyReply('请至少设置一个标签!');
21 | if (
22 | ctx.reply_to_message &&
23 | (ctx.reply_to_message as Message.DocumentMessage).document == undefined
24 | )
25 | return await ctx.directlyReply('回复的消息必须是一个文件!');
26 | await ctx.wait('正在发布作品...');
27 | const tags_set = new Set();
28 | const artwork_info = await getArtworkInfoByUrl(
29 | ctx.command.urls[0],
30 | ctx.command.params['index']
31 | ? semiIntArray(ctx.command.params['index'])
32 | : undefined
33 | );
34 | let contribution: Contribution | undefined;
35 | if (ctx.command.params['contribute_from'])
36 | contribution = await getContributionById(
37 | parseInt(ctx.command.params['contribute_from'])
38 | );
39 |
40 | const origin_file_msg = ctx.reply_to_message as Message.DocumentMessage;
41 |
42 | if (origin_file_msg?.document?.file_id) {
43 | const file_url = await ctx.telegram.getFileLink(
44 | origin_file_msg.document.file_id
45 | );
46 | artwork_info.photos[0].url_origin = file_url.href;
47 | }
48 |
49 | if (ctx.command.params['tags']) {
50 | const tags_string = ctx.command.params['tags'] as string;
51 | if (tags_string.search(',') == -1) tags_set.add(tags_string);
52 | else tags_string.split(/,|,/).forEach(tag => tags_set.add(tag));
53 | }
54 |
55 | if (ctx.command.hashtags) {
56 | ctx.command.hashtags.forEach(tag => tags_set.add(tag));
57 | }
58 |
59 | const result = await publishArtwork(artwork_info, {
60 | is_quality: ctx.command.params['quality'] ? true : false,
61 | picture_index: ctx.command.params['index']
62 | ? semiIntArray(ctx.command.params['index'])
63 | : [0],
64 | artwork_tags: Array.from(tags_set),
65 | origin_file_name: origin_file_msg?.document?.file_name,
66 | origin_file_id: origin_file_msg?.document?.file_id,
67 | origin_file_modified: origin_file_msg?.document?.file_name
68 | ? true
69 | : false,
70 | contribution
71 | });
72 | if (result.succeed) {
73 | await ctx.resolveWait('作品发布成功~');
74 | if (contribution) {
75 | await ctx.telegram.sendMessage(
76 | contribution.chat_id,
77 | '您的投稿已经审核通过并发布到频道~',
78 | {
79 | reply_parameters: {
80 | message_id: contribution.message_id,
81 | allow_sending_without_reply: true
82 | }
83 | }
84 | );
85 | try {
86 | await ctx.telegram.deleteMessage(
87 | contribution.chat_id,
88 | contribution.reply_message_id
89 | );
90 | } catch (e) {
91 | logger.warn(e);
92 | }
93 | }
94 | return;
95 | }
96 | await ctx.resolveWait('作品发布失败: ' + result.message);
97 | });
98 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/random.ts:
--------------------------------------------------------------------------------
1 | import { Markup } from 'telegraf';
2 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
3 | import MessageModel from '~/database/models/MessageModel';
4 | import { getArtwork } from '~/database/operations/artwork';
5 | import { ChannelMessage } from '~/types/Message';
6 | import { pushChannelUrl, randomCaption } from '~/utils/caption';
7 |
8 | export default wrapCommand('random', async ctx => {
9 | const messages = await MessageModel.aggregate([
10 | {
11 | $match: { type: 'photo' }
12 | },
13 | {
14 | $sample: { size: 1 }
15 | }
16 | ]);
17 | if (messages.length == 0)
18 | return await ctx.directlyReply('诶呀,获取图片失败了~');
19 |
20 | const artwork = await getArtwork(messages[0].artwork_index);
21 |
22 | return await ctx.replyWithPhoto(messages[0].file_id, {
23 | parse_mode: 'HTML',
24 | reply_parameters:
25 | ctx.chat.type == 'private'
26 | ? undefined
27 | : {
28 | message_id: ctx.message.message_id,
29 | allow_sending_without_reply: true
30 | },
31 | caption: randomCaption(artwork),
32 | ...Markup.inlineKeyboard([
33 | Markup.button.url(
34 | '查看详情',
35 | pushChannelUrl(messages[0].message_id)
36 | ),
37 | Markup.button.url(
38 | '获取原图',
39 | 'https://t.me/SomeACGbot?start=document-' +
40 | messages[0].artwork_index
41 | )
42 | ])
43 | });
44 | });
45 |
46 | // export default Telegraf.command('random', async ctx => {
47 | // // let waiting_message = await ctx.reply('正在随机获取一张壁纸...', {
48 | // // reply_to_message_id: ctx.message.message_id
49 | // // })
50 | // try {
51 | // let messages = await MessageModel.aggregate([
52 | // {
53 | // $match: { type: 'photo' }
54 | // },
55 | // {
56 | // $sample: { size: 1 }
57 | // }
58 | // ])
59 | // if (messages.length == 0) {
60 | // return await ctx.reply("诶呀,获取图片失败了~", {
61 | // reply_to_message_id: ctx.message.message_id
62 | // })
63 | // }
64 | // let message = messages[0] as ChannelMessage
65 | // let artwork = await getArtwork(message.artwork_index)
66 | // return await ctx.replyWithPhoto(message.file_id, {
67 | // reply_to_message_id: ctx.message.message_id,
68 | // caption: '这是你要的壁纸~',
69 | // ...Markup.inlineKeyboard([
70 | // Markup.button.url('作品来源', artwork.source.post_url)
71 | // ])
72 | // })
73 | // }
74 | // catch (err) {
75 | // console.log(err)
76 | // if (err instanceof Error) {
77 | // return await ctx.reply('操作失败: ' + err.message, {
78 | // reply_to_message_id: ctx.message.message_id
79 | // })
80 | // }
81 | // return await ctx.reply('操作失败: 未知错误', {
82 | // reply_to_message_id: ctx.message.message_id
83 | // })
84 | // }
85 |
86 | // // return await ctx.deleteMessage(waiting_message.message_id)
87 | // })
88 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/start.ts:
--------------------------------------------------------------------------------
1 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
2 | import { getFileByName } from '~/database/operations/file';
3 | import { getMessageByArtwork } from '~/database/operations/message';
4 |
5 | export default wrapCommand('start', async ctx => {
6 | if (ctx.chat.type == 'private') {
7 | if (!ctx.command.target) return await ctx.reply('喵喵喵~');
8 | const start_params =
9 | ctx.command.target.search('-') == -1
10 | ? [ctx.command.target]
11 | : ctx.command.target.split('-');
12 |
13 | switch (start_params[0]) {
14 | case 'document':
15 | const artwork_index = parseInt(start_params[1]);
16 | const document_message = await getMessageByArtwork(
17 | artwork_index,
18 | 'document'
19 | );
20 | await ctx.replyWithDocument(document_message.file_id, {
21 | caption: '这是你要的原图~'
22 | });
23 | break;
24 | case 'file':
25 | const file_name = start_params[1];
26 | const file = await getFileByName(file_name);
27 | await ctx.replyWithDocument(file.file_id, {
28 | caption: file.description
29 | ? file.description
30 | : '这是你要的文件,请自取~'
31 | });
32 | break;
33 | default:
34 | break;
35 | }
36 | }
37 | });
38 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/tag.ts:
--------------------------------------------------------------------------------
1 | import { MessageOriginChannel } from 'telegraf/typings/core/types/typegram';
2 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
3 | import { getArtwork } from '~/database/operations/artwork';
4 | import { getMessage } from '~/database/operations/message';
5 | import { getTagsByNamesAndInsert } from '~/database/operations/tag';
6 | import { modifyArtwork } from '~/services/artwork-service';
7 | import { Artwork } from '~/types/Artwork';
8 |
9 | export default wrapCommand('tag', async ctx => {
10 | if (!ctx.is_reply && !ctx.command.params['index'])
11 | return await ctx.directlyReply(
12 | '参数不正确,请回复一条消息或在在参数中指定作品序号!'
13 | );
14 | if (
15 | !ctx.command.target &&
16 | (!ctx.command.hashtags || ctx.command.hashtags.length == 0)
17 | )
18 | return await ctx.directlyReply('参数不正确,请在命令中添加标签!');
19 | if (ctx.is_reply && !ctx.reply_to_message.is_automatic_forward)
20 | return await ctx.resolveWait('回复的消息不是有效的频道消息!');
21 | await ctx.wait('正在修改作品标签...', true);
22 |
23 | const tags_set = new Set();
24 |
25 | if (ctx.command.target) {
26 | const tags_string = ctx.command.target as string;
27 | if (tags_string.search(',') == -1) tags_set.add(tags_string);
28 | else tags_string.split(/,|,/).forEach(tag => tags_set.add(tag));
29 | }
30 |
31 | if (ctx.command.hashtags?.length > 0) {
32 | // 使用 hashtags 时屏蔽 target 的内容
33 | tags_set.clear();
34 | ctx.command.hashtags.forEach(tag => tags_set.add(tag));
35 | }
36 |
37 | let artwork: Artwork;
38 | if (ctx.reply_to_message) {
39 | const message_id: number = (
40 | ctx.reply_to_message.forward_origin as MessageOriginChannel
41 | ).message_id;
42 | const message = await getMessage(message_id);
43 | artwork = await getArtwork(message.artwork_index);
44 | } else artwork = await getArtwork(parseInt(ctx.command.params['index']));
45 | artwork.tags = await getTagsByNamesAndInsert(Array.from(tags_set));
46 | const exec_result = await modifyArtwork(artwork);
47 | if (exec_result.succeed) return await ctx.resolveWait('作品标签修改成功~');
48 | else return await ctx.resolveWait('修改失败: ' + exec_result.message);
49 | });
50 |
51 | // export default Telegraf.command('tag', async ctx => {
52 | // let waiting_message: Message
53 | // setTimeout(async () => {
54 | // if (waiting_message) await ctx.deleteMessage(waiting_message.message_id)
55 | // }, 10000)
56 | // let command = parseParams(ctx.message.text)
57 | // if (!ctx.message.reply_to_message && !command.params['index']) {
58 | // return await ctx.reply('参数不正确,请回复一条消息或在在参数中指定作品序号!', {
59 | // reply_to_message_id: ctx.message.message_id
60 | // })
61 | // }
62 | // if (!command.target) {
63 | // return await ctx.reply('参数不正确,请在命令中设置标签并用英文逗号隔开!', {
64 | // reply_to_message_id: ctx.message.message_id
65 | // })
66 | // }
67 | // waiting_message = await ctx.reply('正在设置作品标签...', {
68 | // reply_to_message_id: ctx.message.message_id
69 | // })
70 | // let tag_array = command.target.split(/,|,/)
71 | // try {
72 | // let artwork: Artwork
73 | // if (ctx.message.reply_to_message) {
74 | // let reply_to_message = ctx.message.reply_to_message as Message.CommonMessage
75 | // if (!reply_to_message.forward_from_message_id) return await ctx.reply('回复的消息不是有效的频道消息!', {
76 | // reply_to_message_id: ctx.message.message_id
77 | // })
78 | // let message_id: number = reply_to_message.forward_from_message_id
79 | // let message = await getMessage(message_id)
80 | // artwork = await getArtwork(message.artwork_index)
81 | // }
82 | // else {
83 | // artwork = await getArtwork(parseInt(command.params['index']))
84 | // }
85 | // let tags = await getTagsByNamesAndInsert(tag_array)
86 | // artwork.tags = tags
87 | // let exec_result = await modifyArtwork(artwork)
88 | // if (exec_result.succeed) return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '作品标签修改成功~')
89 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '操作失败: ' + exec_result.message)
90 | // }
91 | // catch (err) {
92 | // console.log(err)
93 | // if (err instanceof Error) {
94 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '操作失败: ' + err.message)
95 | // }
96 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '操作失败: 未知错误')
97 | // }
98 |
99 | // })
100 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/ungrant.ts:
--------------------------------------------------------------------------------
1 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
2 | import { removePermissions } from '~/database/operations/admin';
3 |
4 | export default wrapCommand('ungrant', async ctx => {
5 | if (!ctx.command.params['user'] && !ctx.is_reply)
6 | return await ctx.directlyReply(
7 | '命令语法不正确,请回复一条消息或撤销权限的用户ID!'
8 | );
9 | if (!ctx.command.params['user'] && !ctx.reply_to_message?.from)
10 | return await ctx.directlyReply(
11 | '命令语法不正确,请回复一条用户发送的消息!'
12 | );
13 | let user_id = -1;
14 | if (ctx.command.params['user'])
15 | user_id = parseInt(ctx.command.params['user']);
16 | if (ctx.reply_to_message?.from) user_id = ctx.reply_to_message?.from.id;
17 | const succeed = await removePermissions(user_id);
18 | if (succeed)
19 | return await ctx.resolveWait('成功移除用户 ' + user_id + ' 的所有权限');
20 | return await ctx.resolveWait(
21 | '移除用户权限失败,请检查该用户是否具有管理权限'
22 | );
23 | });
24 |
25 | // export default Telegraf.command('ungrant', async ctx => {
26 | // let command = parseParams(ctx.message.text)
27 | // if (!command.params['user'] && !ctx.message.reply_to_message) {
28 | // return await ctx.reply('命令语法不正确,请回复一条消息或撤销权限的用户ID!', {
29 | // reply_to_message_id: ctx.message.message_id
30 | // })
31 | // }
32 | // if (!ctx.message.reply_to_message?.from) return await ctx.reply('命令语法不正确,请回复一条用户发送的消息!', {
33 | // reply_to_message_id: ctx.message.message_id
34 | // })
35 | // let user_id = -1
36 | // if (command.params['user']) user_id = parseInt(command.params['user'])
37 | // if (ctx.message.reply_to_message) user_id = ctx.message.reply_to_message.from.id
38 | // try {
39 | // let succeed = await removePermissions(user_id)
40 | // if (succeed) return await ctx.reply('成功移除用户 ' + user_id + ' 的所有权限', {
41 | // reply_to_message_id: ctx.message.message_id
42 | // })
43 | // return await ctx.reply('移除用户权限失败,请检查该用户是否具有权限', {
44 | // reply_to_message_id: ctx.message.message_id
45 | // })
46 | // }
47 | // catch (err) {
48 | // console.log(err)
49 | // if (err instanceof Error) {
50 | // return await ctx.reply('操作失败: ' + err.message, {
51 | // reply_to_message_id: ctx.message.message_id
52 | // })
53 | // }
54 | // return await ctx.reply('操作失败: 未知错误', {
55 | // reply_to_message_id: ctx.message.message_id
56 | // })
57 | // }
58 | // })
59 |
--------------------------------------------------------------------------------
/src/bot/middlewares/commands/update.ts:
--------------------------------------------------------------------------------
1 | import { MessageOriginChannel } from 'telegraf/typings/core/types/typegram';
2 | import { wrapCommand } from '~/bot/wrappers/command-wrapper';
3 | import { getArtwork } from '~/database/operations/artwork';
4 | import { getMessage } from '~/database/operations/message';
5 | import { modifyArtwork } from '~/services/artwork-service';
6 | import { ArtworkBoolProps, ArtworkStrProps } from '~/types/Artwork';
7 | import { ParamNames } from '~/types/Command';
8 |
9 | const MODIFIABLE_PROPS = ['title', 'desc', 'quality', 'file_name'];
10 |
11 | export default wrapCommand('update', async ctx => {
12 | if (!ctx.command.params['index'] && !ctx.is_reply)
13 | return await ctx.directlyReply('请回复一条消息或指定要更改的作品序号!');
14 | if (
15 | !ctx.command.params['index'] &&
16 | !ctx.reply_to_message?.is_automatic_forward
17 | )
18 | return await ctx.directlyReply('回复的消息不是有效的频道消息!');
19 | await ctx.wait('正在更新作品信息...', true);
20 | let artwork_index = -1;
21 | if (ctx.command.params['index'])
22 | artwork_index = parseInt(ctx.command.params['index']);
23 | if (ctx.reply_to_message) {
24 | const message = await getMessage(
25 | (ctx.reply_to_message.forward_origin as MessageOriginChannel)
26 | .message_id
27 | );
28 | artwork_index = message.artwork_index;
29 | }
30 | const artwork = await getArtwork(artwork_index);
31 | const param_keys: ParamNames[] = Object.keys(ctx.command.params);
32 |
33 | for (const key of param_keys) {
34 | if (key in artwork && MODIFIABLE_PROPS.includes(key)) {
35 | switch (typeof artwork[key as ArtworkStrProps | ArtworkBoolProps]) {
36 | case 'string':
37 | artwork[key as ArtworkStrProps] = ctx.command.params[key];
38 | break;
39 | case 'boolean':
40 | artwork[key as ArtworkBoolProps] = Boolean(
41 | ctx.command.params[key]
42 | );
43 | break;
44 | }
45 | }
46 | }
47 | const result = await modifyArtwork(artwork);
48 | if (result.succeed) return await ctx.resolveWait('作品信息更新成功~');
49 | return await ctx.resolveWait('作品信息更新失败: ' + result.message);
50 | });
51 |
52 | // export default Telegraf.command('update', async ctx => {
53 | // let command = parseParams(ctx.message.text)
54 | // let waiting_message: Message
55 | // setTimeout(async () => {
56 | // if (waiting_message) {
57 | // await ctx.deleteMessage(waiting_message.message_id)
58 | // }
59 | // }, 10000)
60 | // if (!command.params['index'] && !ctx.message.reply_to_message) return await ctx.reply('请回复一条消息或指定要更改的作品序号!', {
61 | // reply_to_message_id: ctx.message.message_id
62 | // })
63 | // waiting_message = await ctx.reply('正在更新作品信息...', {
64 | // reply_to_message_id: ctx.message.message_id
65 | // })
66 | // try {
67 |
68 | // let artwork_index = -1
69 | // if (command.params['index']) artwork_index = parseInt(command.params['index'])
70 | // if (ctx.message.reply_to_message) {
71 | // let reply_to_message = ctx.message.reply_to_message as Message.CommonMessage
72 | // if (!reply_to_message.forward_from_message_id) return await ctx.reply('回复的消息不是有效的频道消息!', {
73 | // reply_to_message_id: ctx.message.message_id
74 | // })
75 |
76 | // let message = await getMessage(reply_to_message.forward_from_message_id)
77 | // artwork_index = message.artwork_index
78 | // }
79 | // let artwork = await getArtwork(artwork_index)
80 | // let param_keys = Object.keys(command.params)
81 | // for (let key of param_keys) {
82 | // console.log(`${key} in artwork: ${key in artwork}`);
83 | // console.log(`Artwork[key]: ${artwork[key]}`);
84 |
85 | // if (key in artwork && typeof artwork[key] == 'string') artwork[key] = command.params[key]
86 | // if (key in artwork && typeof artwork[key] == 'boolean') artwork[key] = Boolean(command.params[key])
87 | // }
88 | // let result = await modifyArtwork(artwork)
89 | // if (result.succeed) return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '作品信息更新成功~')
90 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '作品信息更新失败: ' + result.message)
91 | // }
92 | // catch (err) {
93 | // console.log(err)
94 | // if (err instanceof Error) {
95 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '操作失败: ' + err.message)
96 | // }
97 | // return await ctx.telegram.editMessageText(waiting_message.chat.id, waiting_message.message_id, undefined, '操作失败: 未知错误')
98 | // }
99 | // })
100 |
--------------------------------------------------------------------------------
/src/bot/middlewares/guard.ts:
--------------------------------------------------------------------------------
1 | import config from '~/config';
2 | import { AsyncPredicate } from 'telegraf/typings/composer';
3 | import { Context, Telegraf } from 'telegraf';
4 | import { AdminPermission } from '~/types/Admin';
5 | import { hasPermisson } from '~/database/operations/admin';
6 |
7 | export function genAdminPredicate(
8 | permisson?: AdminPermission
9 | ): AsyncPredicate {
10 | return async function (ctx: Context): Promise {
11 | if (!ctx.from) return false;
12 | if (config.ADMIN_LIST.includes(ctx.from?.id.toString())) return true;
13 | if (!permisson) return false;
14 | const has_permisson = await hasPermisson(ctx.from.id, permisson);
15 | if (!has_permisson && ctx.callbackQuery) {
16 | ctx.answerCbQuery('你没有权限执行此操作');
17 | }
18 | return has_permisson;
19 | };
20 | }
21 |
22 | const adminPredicate: AsyncPredicate = async function (
23 | ctx: Context
24 | ): Promise {
25 | if (ctx.from && config.ADMIN_LIST.includes(ctx.from?.id.toString()))
26 | return true;
27 | // else { if(!flag) await showNonAdminHint(ctx) }
28 | if (ctx.callbackQuery) {
29 | ctx.answerCbQuery('此操作仅限管理员使用');
30 | }
31 | return false;
32 | };
33 |
34 | const NonAdminHandler = Telegraf.fork(async ctx => {
35 | if (ctx.callbackQuery) {
36 | ctx.answerCbQuery('此操作仅限管理员使用');
37 | }
38 | if (ctx.message) {
39 | ctx.reply('此命令仅限管理员使用', {
40 | reply_parameters: {
41 | message_id: ctx.message.message_id,
42 | allow_sending_without_reply: true
43 | }
44 | });
45 | }
46 | });
47 |
48 | export { adminPredicate, NonAdminHandler };
49 |
--------------------------------------------------------------------------------
/src/bot/middlewares/hears/contribute.ts:
--------------------------------------------------------------------------------
1 | import { Telegraf, Markup } from 'telegraf';
2 | import config from '~/config';
3 | import ContributionModel from '~/database/models/CountributionModel';
4 | import getArtworkInfoByUrl from '~/platforms';
5 | import { contributeCaption } from '~/utils/caption';
6 |
7 | export default Telegraf.hears(/#投稿/, async ctx => {
8 | if (
9 | ctx.chat.type == 'private' &&
10 | !config.ADMIN_LIST.includes(ctx.from?.id.toString())
11 | ) {
12 | return await ctx.reply('不能在私聊中使用投稿,请在群里进行投稿');
13 | }
14 |
15 | if (
16 | ctx.message.sender_chat?.type === 'group' ||
17 | ctx.message.sender_chat?.type === 'supergroup'
18 | ) {
19 | return await ctx.reply('抱歉,不可以使用匿名群组身份投稿哦~', {
20 | reply_parameters: {
21 | message_id: ctx.message.message_id,
22 | allow_sending_without_reply: true
23 | }
24 | });
25 | }
26 |
27 | try {
28 | const artworkInfo = await getArtworkInfoByUrl(ctx.message.text);
29 | const replyMessage = await ctx.reply(contributeCaption(artworkInfo), {
30 | reply_parameters: {
31 | message_id: ctx.message.message_id,
32 | allow_sending_without_reply: true
33 | },
34 | link_preview_options: {
35 | is_disabled: false,
36 | url: 'https://pre.someacg.top/' + artworkInfo.post_url,
37 | prefer_large_media: true,
38 | show_above_text: true
39 | },
40 | parse_mode: 'HTML',
41 | ...Markup.inlineKeyboard([
42 | [
43 | Markup.button.callback(
44 | '发到频道',
45 | `publish-${ctx.message.message_id}`
46 | ),
47 | Markup.button.callback(
48 | '删除投稿',
49 | `delete-${ctx.message.message_id}`
50 | )
51 | ],
52 | [
53 | Markup.button.callback(
54 | '发到频道并设为精选',
55 | `publish-${ctx.message.message_id}-q`
56 | )
57 | ]
58 | ])
59 | });
60 |
61 | const isFromChannel = ctx.message.sender_chat?.type === 'channel';
62 |
63 | const contribution = new ContributionModel({
64 | post_url: artworkInfo.post_url,
65 | chat_id: ctx.message.chat.id,
66 | user_id: isFromChannel
67 | ? ctx.message.sender_chat.id
68 | : ctx.message.from.id,
69 | user_tg_username: isFromChannel
70 | ? ctx.message.sender_chat.username
71 | : ctx.message.from.username,
72 | user_name:
73 | isFromChannel && 'title' in ctx.message.sender_chat
74 | ? ctx.message.sender_chat.title
75 | : ctx.message.from.first_name,
76 | message_id: ctx.message.message_id,
77 | reply_message_id: replyMessage.message_id
78 | });
79 |
80 | await contribution.save();
81 | } catch (err) {
82 | if (err instanceof Error) {
83 | ctx.reply(err.message, {
84 | reply_parameters: {
85 | message_id: ctx.message.message_id,
86 | allow_sending_without_reply: true
87 | }
88 | });
89 | return;
90 | }
91 | }
92 | });
93 |
--------------------------------------------------------------------------------
/src/bot/middlewares/inline/index.ts:
--------------------------------------------------------------------------------
1 | import { Markup, Telegraf } from 'telegraf';
2 | import {
3 | getArtworksByTags,
4 | getRandomArtworks
5 | } from '~/database/operations/artwork';
6 | import { pushChannelUrl, randomCaption } from '~/utils/caption';
7 |
8 | export default Telegraf.on('inline_query', async ctx => {
9 | const query = ctx.inlineQuery.query;
10 | const tags: string[] = [];
11 | let artwork_results = [];
12 | if (!query) {
13 | artwork_results = await getRandomArtworks(20);
14 | } else {
15 | if (query.indexOf(' ') == -1) tags.push(query);
16 | else tags.push(...query.split(' '));
17 |
18 | artwork_results = await getArtworksByTags(tags);
19 | }
20 |
21 | if (artwork_results?.length == 0) {
22 | return await ctx.answerInlineQuery([
23 | {
24 | type: 'article',
25 | id: ctx.inlineQuery.id,
26 | title: '没有找到结果呢,试着换个关键词吧~',
27 | input_message_content: {
28 | message_text: '没有找到结果呢,试着换个关键词吧~'
29 | }
30 | }
31 | ]);
32 | } else {
33 | return await ctx.answerInlineQuery(
34 | artwork_results.map((artwork, index) => ({
35 | type: 'photo',
36 | id: ctx.inlineQuery.id + '-' + index,
37 | photo_file_id: artwork.photo_message.file_id,
38 | caption: randomCaption(artwork, tags),
39 | parse_mode: 'HTML',
40 | reply_markup: Markup.inlineKeyboard([
41 | Markup.button.url(
42 | '查看详情',
43 | pushChannelUrl(artwork.photo_message.message_id)
44 | ),
45 | Markup.button.url(
46 | '获取原图',
47 | `https://t.me/${ctx.me}?start=document-${artwork.index}`
48 | )
49 | ]).reply_markup
50 | }))
51 | );
52 | }
53 | });
54 |
--------------------------------------------------------------------------------
/src/bot/modules/push.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { Message } from 'telegraf/typings/core/types/typegram';
3 | import bot from '~/bot';
4 | import config from '~/config';
5 | import { Artwork, ArtworkInfo } from '~/types/Artwork';
6 | import { PushEvent } from '~/types/Event';
7 | import { ChannelMessage } from '~/types/Message';
8 | import { artworkCaption } from '~/utils/caption';
9 | import { resizeFitChannelPhoto } from '~/utils/sharp';
10 |
11 | export async function pushArtwork(
12 | artwork_info: ArtworkInfo,
13 | artwork: Artwork,
14 | event_info: PushEvent
15 | ): Promise<{ photos: ChannelMessage[]; documents: ChannelMessage[] }> {
16 | const caption = artworkCaption(artwork, artwork_info.artist, event_info);
17 |
18 | const thumb_file_paths = event_info.thumb_files.map(file_name =>
19 | path.resolve(config.TEMP_DIR, 'thumbnails', file_name)
20 | );
21 |
22 | const origin_file_paths = event_info.origin_files.map(file_name =>
23 | path.resolve(config.TEMP_DIR, file_name)
24 | );
25 |
26 | const resized_channel_photos = await Promise.all(
27 | origin_file_paths.map(async (file_path, index) => {
28 | return await resizeFitChannelPhoto(
29 | file_path,
30 | thumb_file_paths[index]
31 | );
32 | })
33 | );
34 |
35 | const sendPhotoMessages = (await bot.telegram.sendMediaGroup(
36 | config.PUSH_CHANNEL,
37 | resized_channel_photos.map((file_path, index) => ({
38 | type: 'photo',
39 | media: {
40 | source: file_path
41 | },
42 | caption: index === 0 ? caption : undefined,
43 | parse_mode: 'HTML'
44 | }))
45 | )) as Message.PhotoMessage[];
46 |
47 | const sendDocumentMessages: Message.DocumentMessage[] = [];
48 |
49 | if (event_info.origin_file_modified && event_info.origin_file_id) {
50 | const message = await bot.telegram.sendDocument(
51 | config.PUSH_CHANNEL,
52 | event_info.origin_file_id,
53 | {
54 | caption: event_info.origin_file_modified
55 | ? '* 此原图经过处理'
56 | : undefined,
57 | parse_mode: 'HTML'
58 | }
59 | );
60 |
61 | sendDocumentMessages.push(message);
62 | } else {
63 | const messages = await bot.telegram.sendMediaGroup(
64 | config.PUSH_CHANNEL,
65 | origin_file_paths.map(origin_file_path => ({
66 | type: 'document',
67 | media: {
68 | source: origin_file_path
69 | },
70 | parse_mode: 'HTML'
71 | }))
72 | );
73 |
74 | sendDocumentMessages.push(...(messages as Message.DocumentMessage[]));
75 | }
76 |
77 | const photoMessages: ChannelMessage[] = sendPhotoMessages.map(msg => ({
78 | type: 'photo',
79 | message_id: msg.message_id,
80 | artwork_index: artwork.index,
81 | file_id: msg.photo[0].file_id
82 | // send_time: new Date(msg.date * 1000)
83 | }));
84 |
85 | const documentMessages: ChannelMessage[] = sendDocumentMessages.map(
86 | msg => ({
87 | type: 'document',
88 | message_id: msg.message_id,
89 | artwork_index: artwork.index,
90 | file_id: msg.document.file_id
91 | // send_time: new Date(msg.date * 1000)
92 | })
93 | );
94 |
95 | return {
96 | photos: photoMessages,
97 | documents: documentMessages
98 | };
99 | }
100 |
--------------------------------------------------------------------------------
/src/bot/wrappers/command-wrapper.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-const */
2 | import { Telegraf, NarrowedContext, Context } from 'telegraf';
3 | import {
4 | Message,
5 | ParseMode,
6 | Update
7 | } from 'telegraf/typings/core/types/typegram';
8 | import { CommandEntity } from '~/types/Command';
9 | import { parseParams } from '~/utils/param-parser';
10 | import * as tt from 'telegraf/src/telegram-types';
11 | import logger from '~/utils/logger';
12 |
13 | export function wrapCommand(
14 | command: string,
15 | fn: (ctx: WarpperContext) => Promise
16 | ) {
17 | return Telegraf.command(command, async ctx => {
18 | let _ctx = new WarpperContext(ctx);
19 | try {
20 | await fn(_ctx);
21 | global.currentMongoSession?.commitTransaction().then(() => {
22 | logger.debug('mongoose transaction commited');
23 | global.currentMongoSession?.endSession();
24 | global.currentMongoSession = undefined;
25 | });
26 | } catch (err) {
27 | logger.error(
28 | err,
29 | `error occured when processing ${command} command`
30 | );
31 |
32 | global.currentMongoSession?.abortTransaction().then(() => {
33 | logger.warn('mongoose transaction aborted');
34 | global.currentMongoSession?.endSession();
35 | global.currentMongoSession = undefined;
36 | });
37 |
38 | if (err instanceof Error) {
39 | return await _ctx.resolveWait(
40 | `操作失败: ${err.message}`,
41 | 'HTML'
42 | );
43 | }
44 | return await _ctx.resolveWait('操作失败: 未知原因');
45 | }
46 | });
47 | }
48 |
49 | export class WarpperContext extends Context {
50 | constructor(ctx: NarrowedContext) {
51 | super(ctx.update, ctx.telegram, ctx.botInfo);
52 | this.raw_ctx = ctx;
53 | this.command = parseParams(ctx.message.text, ctx.message.entities);
54 | if (ctx.message.reply_to_message) this.is_reply = true;
55 | else this.is_reply = false;
56 | if (this.is_reply)
57 | this.reply_to_message = ctx.message
58 | .reply_to_message as Message.CommonMessage;
59 | }
60 | raw_ctx: NarrowedContext;
61 | waiting_message?: Message;
62 | command: CommandEntity;
63 | is_reply: boolean;
64 | reply_to_message?: Message.CommonMessage;
65 | autoDelete(timeout?: number) {
66 | if (!timeout) timeout = 20000;
67 | setTimeout(async () => {
68 | if (this.waiting_message) {
69 | try {
70 | await this.deleteMessage(this.waiting_message.message_id);
71 | } catch (e) {
72 | /* empty */
73 | }
74 | }
75 | }, timeout);
76 | }
77 | async directlyReply(message: string, parse_mode?: ParseMode) {
78 | return await this.reply(message, {
79 | reply_parameters:
80 | this.chat.type == 'private'
81 | ? undefined
82 | : {
83 | message_id: this.message.message_id,
84 | allow_sending_without_reply: true
85 | },
86 | parse_mode: parse_mode
87 | });
88 | }
89 | async wait(message: string, auto_delete?: boolean, parse_mode?: ParseMode) {
90 | if (!this.waiting_message)
91 | this.waiting_message = await this.directlyReply(
92 | message,
93 | parse_mode
94 | );
95 | if (auto_delete) this.autoDelete();
96 | }
97 | async resolveWait(message: string, parse_mode?: ParseMode) {
98 | if (this.waiting_message)
99 | await this.telegram.editMessageText(
100 | this.waiting_message.chat.id,
101 | this.waiting_message.message_id,
102 | undefined,
103 | message,
104 | {
105 | parse_mode: parse_mode
106 | }
107 | );
108 | else await this.directlyReply(message, parse_mode);
109 | }
110 | async deleteWaiting() {
111 | if (this.waiting_message)
112 | this.deleteMessage(this.waiting_message.message_id);
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 |
3 | const DOTENV_PATH = process.env.DOTENV_NAME
4 | ? `.env.${process.env.DOTENV_NAME}`
5 | : '.env';
6 |
7 | dotenv.config({
8 | path: DOTENV_PATH
9 | });
10 |
11 | import path from 'path';
12 | import * as env from 'env-var';
13 | import package_info from 'package.json';
14 |
15 | export default {
16 | DB_URL: env.get('DB_URL').required().asString(),
17 | BOT_TOKEN: env.get('BOT_TOKEN').required().asString(),
18 | PORT: process.env.PORT || 3001,
19 | PUSH_CHANNEL: env.get('PUSH_CHANNEL').required().asString(),
20 | BASE_DIR: path.resolve(__dirname, '..'),
21 | TEMP_DIR: env.get('DEV_MODE').asBool()
22 | ? path.resolve(__dirname, '../temp')
23 | : '/tmp',
24 | CLIENT_ID: env.get('CLIENT_ID').asString(),
25 | CLIENT_SECRET: env.get('CLIENT_SECRET').asString(),
26 | ADMIN_LIST: env.get('ADMIN_LIST').required().asArray(),
27 | VERSION: env.get('VERSION').asString() || package_info.version,
28 | DEV_MODE: env.get('DEV_MODE').asBool(),
29 | USE_PROXY: env.get('USE_PROXY').asBool(),
30 | STORAGE_TYPE: env.get('STORAGE_TYPE').required().asString(),
31 | STORAGE_BASE: env.get('STORAGE_BASE').asString(),
32 | B2_ENDPOINT: env.get('B2_ENDPOINT').asString(),
33 | B2_BUCKET: env.get('B2_BUCKET').asString(),
34 | B2_KEY_ID: env.get('B2_KEY_ID').asString(),
35 | B2_KEY: env.get('B2_KEY').asString(),
36 | PIXIV_COOKIE: env.get('PIXIV_COOKIE').asString(),
37 | SP_SITE_ID: env.get('SP_SITE_ID').asString()
38 | };
39 |
--------------------------------------------------------------------------------
/src/database/index.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from 'mongoose';
2 | import config from '~/config';
3 | import logger from '~/utils/logger';
4 |
5 | Mongoose.connect(config.DB_URL, {});
6 |
7 | Mongoose.connection.once('open', function () {
8 | logger.info('Database connected');
9 | });
10 |
11 | Mongoose.connection.once('close', function () {
12 | logger.info('Database disconnected');
13 | });
14 |
15 | export default Mongoose;
16 |
--------------------------------------------------------------------------------
/src/database/models/AdminModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { AdminUser } from '~/types/Admin';
3 |
4 | const adminSchema = new Mongoose.Schema({
5 | user_id: Number,
6 | grant_by: Number,
7 | permissions: [String],
8 | create_time: {
9 | type: Date,
10 | default: new Date()
11 | }
12 | });
13 |
14 | const AdminModel = Mongoose.model('Admin', adminSchema);
15 |
16 | export default AdminModel;
17 |
--------------------------------------------------------------------------------
/src/database/models/ArtistModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { Artist } from '~/types/Artwork';
3 |
4 | const artistSchema = new Mongoose.Schema({
5 | type: String,
6 | uid: Number,
7 | name: String,
8 | username: String,
9 | create_time: {
10 | type: Date,
11 | default: new Date()
12 | }
13 | });
14 |
15 | const artistModel = Mongoose.model('Artist', artistSchema);
16 |
17 | export default artistModel;
18 |
--------------------------------------------------------------------------------
/src/database/models/ArtworkModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { Artwork, ArtworkSource, ImageSize, ArtworkTag } from '~/types/Artwork';
3 |
4 | const artworkSchema = new Mongoose.Schema({
5 | index: Number,
6 | quality: Boolean,
7 | title: String,
8 | desc: String,
9 | file_name: String,
10 | img_thumb: String,
11 | size: new Mongoose.Schema({
12 | width: Number,
13 | height: Number
14 | }),
15 | tags: [
16 | new Mongoose.Schema({
17 | _id: String,
18 | name: String
19 | })
20 | ],
21 | source: new Mongoose.Schema({
22 | type: String,
23 | post_url: String,
24 | picture_index: [Number]
25 | }),
26 | create_time: {
27 | type: Date,
28 | default: new Date()
29 | },
30 | artist_id: Mongoose.Types.ObjectId
31 | });
32 |
33 | // artworkSchema.pre>(
34 | // 'save',
35 | // function (next) {
36 | // // eslint-disable-next-line @typescript-eslint/no-this-alias
37 | // const _this = this;
38 | // const result = ConfigModel.findOneAndUpdate(
39 | // {},
40 | // { $inc: { artwork_count: 1 } },
41 | // {},
42 | // function (err, counter) {
43 | // if (err) return next(err);
44 | // _this.$set({ index: counter.artwork_count + 1 });
45 | // next();
46 | // }
47 | // );
48 | // }
49 | // );
50 |
51 | // 不能改,Artwork 的 index 在创建时就应该是固定的,改了就会出问题
52 | // 所以网站查询的时候也不能依靠 index 了,下次再想想其他方式
53 |
54 | // artworkSchema.pre('deleteOne', function(next) {
55 | // CounterModel.findOneAndUpdate({}, { $inc: { artwork_count: -1 } }, {}, function (err) {
56 | // if (err) return next(err); next()
57 | // })
58 | // })
59 |
60 | const ArtworkModel = Mongoose.model('Artwork', artworkSchema);
61 |
62 | export default ArtworkModel;
63 |
--------------------------------------------------------------------------------
/src/database/models/ConfigModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 |
3 | const ConfigModel = Mongoose.model(
4 | 'Config',
5 | new Mongoose.Schema({
6 | name: String,
7 | value: String
8 | })
9 | );
10 |
11 | export default ConfigModel;
12 |
--------------------------------------------------------------------------------
/src/database/models/CountributionModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { Contribution } from '~/types/Contribution';
3 |
4 | const contributionSchema = new Mongoose.Schema({
5 | post_url: String,
6 | chat_id: Number,
7 | user_id: Number,
8 | user_tg_username: String,
9 | user_name: String,
10 | message_id: Number,
11 | reply_message_id: Number,
12 | create_time: {
13 | type: Date,
14 | default: new Date()
15 | }
16 | });
17 |
18 | const ContributionModel = Mongoose.model('Contribution', contributionSchema);
19 |
20 | export default ContributionModel;
21 |
--------------------------------------------------------------------------------
/src/database/models/FileModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 |
3 | import { File } from '~/types/File';
4 |
5 | const fileSchema = new Mongoose.Schema({
6 | name: String,
7 | file_id: String,
8 | description: String,
9 | create_time: {
10 | type: Date,
11 | default: new Date()
12 | }
13 | });
14 |
15 | const FileModel = Mongoose.model('File', fileSchema);
16 |
17 | export default FileModel;
18 |
--------------------------------------------------------------------------------
/src/database/models/MessageModel.ts:
--------------------------------------------------------------------------------
1 | // @deprecated
2 | import Mongoose from '~/database';
3 | import { ChannelMessage } from '~/types/Message';
4 |
5 | const messageSchema = new Mongoose.Schema({
6 | type: String,
7 | message_id: Number,
8 | artwork_index: Number,
9 | file_id: String,
10 | send_time: {
11 | type: Date,
12 | default: new Date()
13 | }
14 | });
15 |
16 | const MessageModel = Mongoose.model('Message', messageSchema);
17 |
18 | export default MessageModel;
19 |
--------------------------------------------------------------------------------
/src/database/models/PhotoModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { Photo } from '~/types/Photo';
3 |
4 | const photoSchema = new Mongoose.Schema({
5 | artwork_id: Mongoose.Types.ObjectId,
6 | artwork_index: Number,
7 | size: {
8 | width: Number,
9 | height: Number
10 | },
11 | file_size: Number,
12 | file_name: String,
13 | thumb_file_id: String,
14 | document_file_id: String,
15 | thumb_message_id: Number,
16 | document_message_id: Number,
17 | create_time: {
18 | type: Date,
19 | default: new Date()
20 | }
21 | });
22 |
23 | const PhotoModel = Mongoose.model('Photo', photoSchema);
24 |
25 | export default PhotoModel;
26 |
--------------------------------------------------------------------------------
/src/database/models/TagModel.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { ArtworkTag } from '~/types/Artwork';
3 |
4 | const tagSchema = new Mongoose.Schema({
5 | _id: Mongoose.Types.ObjectId,
6 | name: String
7 | });
8 |
9 | const TagModel = Mongoose.model('Tag', tagSchema);
10 |
11 | export default TagModel;
12 |
--------------------------------------------------------------------------------
/src/database/operations/admin.ts:
--------------------------------------------------------------------------------
1 | import AdminModel from '~/database/models/AdminModel';
2 | import { AdminPermission, AdminUser } from '~/types/Admin';
3 |
4 | export async function hasPermisson(
5 | user_id: number,
6 | permisson: AdminPermission
7 | ): Promise {
8 | const document = await AdminModel.findOne({
9 | user_id: user_id
10 | });
11 | if (!document) return false;
12 | if (
13 | document.permissions.includes(AdminPermission.DEFAULT) &&
14 | permisson != AdminPermission.GRANT
15 | )
16 | return true;
17 | return document.permissions.includes(permisson);
18 | }
19 |
20 | export async function hasPermissons(
21 | user_id: number,
22 | permissons: Array
23 | ): Promise {
24 | const document = await AdminModel.findOne({
25 | user_id: user_id
26 | });
27 | if (!document) return false;
28 | let flag = true;
29 | for (const permission of permissons) {
30 | if (!document.permissions.includes(permission)) flag = false;
31 | }
32 | if (document.permissions.includes(AdminPermission.DEFAULT)) return true;
33 | return flag;
34 | }
35 |
36 | export async function grantPermissons(admin: AdminUser): Promise {
37 | let document = await AdminModel.findOne({
38 | user_id: admin.user_id
39 | });
40 | if (document) {
41 | const modified = await AdminModel.updateOne(
42 | {
43 | user_id: admin.user_id
44 | },
45 | admin
46 | );
47 | if (modified.modifiedCount > 0) return true;
48 | return false;
49 | }
50 | document = new AdminModel(admin);
51 | await document.save();
52 | return true;
53 | }
54 |
55 | export async function removePermissions(user_id: number): Promise {
56 | const delete_result = await AdminModel.deleteOne({
57 | user_id: user_id
58 | });
59 | if (delete_result.deletedCount > 0) return true;
60 | return false;
61 | }
62 |
--------------------------------------------------------------------------------
/src/database/operations/artist.ts:
--------------------------------------------------------------------------------
1 | import { Artist } from '~/types/Artwork';
2 | import artistModel from '../models/ArtistModel';
3 |
4 | export async function findOrInsertArtist(artist: Artist) {
5 | const foundArtist = await artistModel.findOne(artist);
6 | if (foundArtist) return foundArtist;
7 |
8 | const artistInstance = new artistModel(artist);
9 | const newArtist = await artistInstance.save();
10 |
11 | return newArtist;
12 | }
13 |
14 | export async function getArtistById(id: string) {
15 | const artist = await artistModel.findById(id);
16 | return artist;
17 | }
18 |
--------------------------------------------------------------------------------
/src/database/operations/artwork.ts:
--------------------------------------------------------------------------------
1 | import ArtworkModel from '~/database/models/ArtworkModel';
2 | import { Artwork, ArtworkSource, ArtworkWithMessages } from '~/types/Artwork';
3 | import { ChannelMessage } from '~/types/Message';
4 | import { getConfig, setConfig } from './config';
5 | import { Config } from '~/types/Config';
6 | import Mongoose from '..';
7 |
8 | interface ArtworkAggregate {
9 | index: number;
10 | title: string;
11 | source: ArtworkSource;
12 | messages: ChannelMessage[];
13 | }
14 |
15 | export async function insertArtwork(artwork: Artwork): Promise<
16 | Artwork & {
17 | _id: Mongoose.Types.ObjectId;
18 | }
19 | > {
20 | let current_count = await getConfig(Config.ARTWORK_COUNT);
21 |
22 | if (!current_count) {
23 | current_count = '0';
24 | }
25 |
26 | let current_count_number = parseInt(current_count);
27 |
28 | current_count_number++;
29 |
30 | await setConfig(Config.ARTWORK_COUNT, current_count_number.toString());
31 |
32 | artwork.index = current_count_number;
33 | const artwork_instance = new ArtworkModel(artwork);
34 |
35 | const document = await artwork_instance.save({
36 | session: global.currentMongoSession
37 | });
38 |
39 | return document;
40 | }
41 |
42 | export async function getArtwork(artwork_index: number): Promise {
43 | const artwork = await ArtworkModel.findOne({
44 | index: artwork_index
45 | });
46 |
47 | if (!artwork) throw new Error('Artwork not found');
48 |
49 | return artwork;
50 | }
51 |
52 | export async function updateArtwork(artwork: Artwork): Promise {
53 | const result = await ArtworkModel.updateOne(
54 | {
55 | index: artwork.index
56 | },
57 | artwork
58 | );
59 | return result.modifiedCount;
60 | }
61 |
62 | export async function deleteArtwork(artwork_index: number): Promise {
63 | const result = await ArtworkModel.deleteOne({
64 | index: artwork_index
65 | });
66 |
67 | return result.deletedCount;
68 | }
69 |
70 | export async function getRandomArtworks(
71 | limit: number
72 | ): Promise {
73 | const results = await ArtworkModel.aggregate([
74 | {
75 | $lookup: {
76 | from: 'messages',
77 | localField: 'index',
78 | foreignField: 'artwork_index',
79 | as: 'messages'
80 | }
81 | },
82 | {
83 | $project: {
84 | index: 1,
85 | title: 1,
86 | source: 1,
87 | 'messages.type': 1,
88 | 'messages.file_id': 1,
89 | 'messages.message_id': 1
90 | }
91 | },
92 | {
93 | $sample: {
94 | size: limit
95 | }
96 | }
97 | ]);
98 |
99 | const artworks: ArtworkWithMessages[] = results.map(result => {
100 | const photo_message = result.messages.filter(
101 | message => message.type === 'photo'
102 | )[0] as ChannelMessage<'photo'>;
103 |
104 | const document_message = result.messages.filter(
105 | message => message.type === 'document'
106 | )[0] as ChannelMessage<'document'>;
107 |
108 | return {
109 | index: result.index,
110 | source: result.source,
111 | title: result.title,
112 | photo_message,
113 | document_message
114 | };
115 | });
116 |
117 | return artworks;
118 | }
119 |
120 | export async function getArtworksByTags(
121 | tags: string[]
122 | ): Promise {
123 | const results = await ArtworkModel.aggregate([
124 | {
125 | $match: {
126 | $and: tags.map(tag => ({ 'tags.name': tag }))
127 | }
128 | },
129 | {
130 | $lookup: {
131 | from: 'messages',
132 | localField: 'index',
133 | foreignField: 'artwork_index',
134 | as: 'messages'
135 | }
136 | },
137 | {
138 | $project: {
139 | index: 1,
140 | title: 1,
141 | source: 1,
142 | 'messages.type': 1,
143 | 'messages.file_id': 1,
144 | 'messages.message_id': 1
145 | }
146 | },
147 | {
148 | $sample: {
149 | size: 20
150 | }
151 | }
152 | ]);
153 |
154 | const artworks: ArtworkWithMessages[] = results.map(result => {
155 | const photo_message = result.messages.filter(
156 | message => message.type === 'photo'
157 | )[0] as ChannelMessage<'photo'>;
158 |
159 | const document_message = result.messages.filter(
160 | message => message.type === 'document'
161 | )[0] as ChannelMessage<'document'>;
162 |
163 | return {
164 | index: result.index,
165 | source: result.source,
166 | title: result.title,
167 | photo_message,
168 | document_message
169 | };
170 | });
171 |
172 | return artworks;
173 | }
174 |
--------------------------------------------------------------------------------
/src/database/operations/config.ts:
--------------------------------------------------------------------------------
1 | import ConfigModel from '../models/ConfigModel';
2 |
3 | export async function getConfig(name: string): Promise {
4 | const result = await ConfigModel.findOne({ name });
5 | return result?.value ?? '';
6 | }
7 |
8 | export async function setConfig(name: string, value: string): Promise {
9 | const result = await ConfigModel.findOne({ name });
10 | if (result) {
11 | result.value = value;
12 | await ConfigModel.updateOne({ name }, result, {
13 | session: global.currentMongoSession
14 | });
15 | } else {
16 | const config = new ConfigModel({
17 | name,
18 | value
19 | });
20 |
21 | await config.save();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/database/operations/contribution.ts:
--------------------------------------------------------------------------------
1 | import ContributionModel from '~/database/models/CountributionModel';
2 | import { Contribution } from '~/types/Contribution';
3 |
4 | export async function addContribution(
5 | contribution: Contribution
6 | ): Promise {
7 | const contribution_model = new ContributionModel(contribution);
8 | const document = await contribution_model.save();
9 | return document;
10 | }
11 |
12 | export async function getContributionById(
13 | message_id: number
14 | ): Promise {
15 | const document = await ContributionModel.findOne({
16 | message_id: message_id
17 | });
18 |
19 | if (!document) {
20 | throw new Error('Contribution not found !');
21 | }
22 |
23 | return document;
24 | }
25 |
26 | export async function deleteContribution(message_id: number): Promise {
27 | const result = await ContributionModel.deleteOne({
28 | message_id: message_id
29 | });
30 | return result.deletedCount;
31 | }
32 |
--------------------------------------------------------------------------------
/src/database/operations/file.ts:
--------------------------------------------------------------------------------
1 | import { File } from '~/types/File';
2 | import FileModel from '../models/FileModel';
3 |
4 | export async function getFileByName(name: string): Promise {
5 | const file = await FileModel.findOne({
6 | name: name
7 | });
8 |
9 | if (!file) throw new Error('指定的文件不存在!');
10 |
11 | return file;
12 | }
13 |
14 | export async function insertFile(file: File): Promise {
15 | const existFile = await FileModel.findOne({
16 | name: file.name
17 | });
18 |
19 | if (existFile) throw new Error('该文件名已被占用。');
20 |
21 | const doc = new FileModel(file);
22 | await doc.save();
23 | }
24 |
--------------------------------------------------------------------------------
/src/database/operations/message.ts:
--------------------------------------------------------------------------------
1 | // @deprecated
2 | import MessageModel from '~/database/models/MessageModel';
3 | import { ChannelMessage, ChannelMessageType } from '~/types/Message';
4 |
5 | export async function insertMessages(messages: ChannelMessage[]) {
6 | if (!messages.length) throw new Error('Empty message array !');
7 |
8 | await MessageModel.insertMany(messages, {
9 | session: global.currentMongoSession
10 | });
11 | }
12 |
13 | export async function getMessage(message_id: number): Promise {
14 | const message = await MessageModel.findOne({
15 | message_id: message_id
16 | });
17 |
18 | if (!message) {
19 | throw new Error('未在数据库中找到关于此消息的数据');
20 | }
21 |
22 | return message;
23 | }
24 |
25 | export async function getMessagesByArtwork(
26 | artwork_index: number
27 | ): Promise> {
28 | const messages = await MessageModel.find({
29 | artwork_index: artwork_index
30 | });
31 |
32 | return messages;
33 | }
34 |
35 | export async function getMessageByArtwork(
36 | artwork_index: number,
37 | message_type: ChannelMessageType
38 | ): Promise {
39 | const messages = await MessageModel.find({
40 | artwork_index: artwork_index,
41 | type: message_type
42 | });
43 |
44 | messages.sort((a, b) => a.message_id - b.message_id);
45 |
46 | if (!messages) {
47 | throw new Error('Channel message not found');
48 | }
49 |
50 | return messages[0];
51 | }
52 |
53 | export async function deleteMessagesByArtwork(artwork_index: number) {
54 | const result = await MessageModel.deleteMany({
55 | artwork_index: artwork_index
56 | });
57 | return result.deletedCount;
58 | }
59 |
--------------------------------------------------------------------------------
/src/database/operations/photo.ts:
--------------------------------------------------------------------------------
1 | import { Photo } from '~/types/Photo';
2 | import PhotoModel from '../models/PhotoModel';
3 |
4 | export const insertPhotos = async (photos: Photo[]) => {
5 | return await PhotoModel.insertMany(photos, {
6 | session: global.currentMongoSession
7 | });
8 | };
9 |
10 | export const getPhotosByArtworkId = async (artwork_id: string) => {
11 | return await PhotoModel.find({ artwork_id: artwork_id });
12 | };
13 |
14 | export const removePhotoByArtworkId = async (artwork_id: string) => {
15 | return await PhotoModel.deleteMany(
16 | { artwork_id: artwork_id },
17 | {
18 | session: global.currentMongoSession
19 | }
20 | );
21 | };
22 |
23 | export const removePhotoByArtworkIndex = async (artwork_index: number) => {
24 | return await PhotoModel.deleteMany(
25 | { artwork_index: artwork_index },
26 | {
27 | session: global.currentMongoSession
28 | }
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/database/operations/tag.ts:
--------------------------------------------------------------------------------
1 | import { ArtworkTag } from '~/types/Artwork';
2 | import Mongoose from '..';
3 | import TagModel from '../models/TagModel';
4 |
5 | export async function insertTag(tag_name: string): Promise {
6 | const tag_instance = new TagModel({
7 | _id: new Mongoose.Types.ObjectId(),
8 | name: tag_name
9 | });
10 |
11 | const tag: ArtworkTag = await tag_instance.save();
12 |
13 | return tag;
14 | }
15 |
16 | export async function getTagById(id: string): Promise {
17 | const tag = await TagModel.findOne({
18 | _id: id
19 | });
20 |
21 | if (tag) return tag;
22 |
23 | throw new Error('Tag id not found');
24 | }
25 |
26 | export async function getTagByName(name: string): Promise {
27 | const tag = await TagModel.findOne({
28 | name: name
29 | });
30 |
31 | if (tag) return tag;
32 |
33 | throw new Error('Tag name not found');
34 | }
35 |
36 | export async function getTagsByNamesAndInsert(
37 | tag_names: Array
38 | ): Promise> {
39 | const tag_array: Array = [];
40 | for (const tag_name of tag_names) {
41 | try {
42 | const found_tag = await getTagByName(tag_name);
43 | tag_array.push(found_tag);
44 | } catch (err) {
45 | const new_tag = await insertTag(tag_name);
46 | tag_array.push(new_tag);
47 | }
48 | }
49 | return tag_array;
50 | }
51 |
--------------------------------------------------------------------------------
/src/platforms/bilibili-api/dynamic.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BiliResponse,
3 | BiliDynamicData,
4 | BiliFingerData
5 | } from '~/types/Bilibili';
6 | import axios, { commonHeaders } from '~/utils/axios';
7 | import { gen_buvid_fp } from './fp';
8 |
9 | import * as crypto from 'crypto';
10 | import { fakeDmCoverImgStr, genExClimbWuzhi } from './utils';
11 |
12 | import { WbiSign } from './sign';
13 | import logger from '~/utils/logger';
14 | import { AxiosError } from 'axios';
15 |
16 | const API_PATH = 'https://api.bilibili.com/x/polymer/web-dynamic/v1/detail';
17 |
18 | export default class DynamicFetcher {
19 | private dynamic_id: string;
20 |
21 | private cookies: string;
22 |
23 | private spm_prefix = '333.1387';
24 |
25 | private uuid: string;
26 |
27 | private retry_count = 0;
28 |
29 | constructor(dynamic_id: string) {
30 | this.dynamic_id = dynamic_id;
31 | this.uuid = crypto.randomUUID();
32 | this.cookies = `_uuid=${this.uuid}`;
33 | }
34 |
35 | headers() {
36 | return {
37 | referer: 'https://t.bilibili.com/',
38 | Cookie: this.cookies
39 | };
40 | }
41 |
42 | async initSpmPrefix() {
43 | let spm_prefix = '';
44 |
45 | const regex1 = //;
46 | const regex2 = /spmId: "([\d.]+)"/;
47 |
48 | try {
49 | const { data } = await axios.get(
50 | 'https://space.bilibili.com/1/dynamic'
51 | );
52 |
53 | if (regex1.test(data)) spm_prefix = data.match(regex1)[1];
54 | else if (regex2.test(data)) spm_prefix = data.match(regex2)[1];
55 | } catch (err) {
56 | logger.warn('initSpmPrefix fetch error: ' + (err as Error).message);
57 |
58 | const axiosError = err as AxiosError;
59 |
60 | const data = axiosError.response?.data as string | undefined;
61 |
62 | if (data) {
63 | if (regex1.test(data)) spm_prefix = data.match(regex1)[1];
64 | else if (regex2.test(data)) spm_prefix = data.match(regex2)[1];
65 | }
66 | }
67 |
68 | if (!spm_prefix)
69 | logger.warn('未能从页面中获取 spm_prefix,使用默认值 333.1387');
70 |
71 | return (this.spm_prefix = spm_prefix || '333.1387');
72 | }
73 |
74 | async submitGateway(): Promise {
75 | const data = genExClimbWuzhi(
76 | this.uuid,
77 | `https://t.bilibili.com/${this.dynamic_id}`,
78 | this.spm_prefix
79 | );
80 |
81 | const payload = JSON.stringify(data);
82 |
83 | // 如果已经生成的有就一直用,初次会 -352 ,后续就可正常使用,且该 cookie 值服务端会自动续期
84 | const buvid_fp = gen_buvid_fp(payload, 31);
85 |
86 | this.cookies += `;buvid_fp=${buvid_fp}`;
87 |
88 | await axios.post(
89 | 'https://api.bilibili.com/x/internal/gaia-gateway/ExClimbWuzhi',
90 | {
91 | payload
92 | },
93 | {
94 | headers: this.headers()
95 | }
96 | );
97 | }
98 |
99 | async detail(): Promise {
100 | await this.initSpmPrefix();
101 |
102 | if (this.cookies.indexOf('buvid_fp') === -1) {
103 | const { data: fingerprint } = await axios.get<
104 | BiliResponse
105 | >('https://api.bilibili.com/x/frontend/finger/spi');
106 |
107 | this.cookies += `;buvid3=${fingerprint.data['b_3']};buvid4=${fingerprint.data['b_4']}`;
108 | }
109 |
110 | await this.submitGateway();
111 |
112 | const baseParams = {
113 | id: this.dynamic_id,
114 | timezone_offset: '-60',
115 | platform: 'web',
116 | gaia_source: 'main_web',
117 | 'x-bili-device-req-json': '{"platform":"web","device":"pc"}',
118 | 'x-bili-web-req-json': `{"spm_id":"${this.spm_prefix}"}`,
119 | features: 'itemOpusStyle',
120 | dm_img_list: '[]',
121 | dm_img_str: '',
122 | dm_cover_img_str: fakeDmCoverImgStr(
123 | 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0XX)), SwiftShader driver)Google Inc. (Google)'
124 | ),
125 | dm_img_inter: '{"ds":[],"wh":[0,0,0],"of":[0,0,0]}'
126 | };
127 |
128 | const signedParams = await WbiSign(baseParams);
129 |
130 | const { data } = await axios.get>(
131 | `${API_PATH}?${signedParams}`,
132 | {
133 | headers: this.headers()
134 | }
135 | );
136 |
137 | if (data.message.includes('352') && this.retry_count < 3) {
138 | this.retry_count++;
139 | return await this.detail();
140 | }
141 |
142 | if (data.code !== 0) {
143 | throw new Error(data.message);
144 | }
145 |
146 | return data.data;
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/platforms/bilibili-api/fp.ts:
--------------------------------------------------------------------------------
1 | class Murmur3 {
2 | static readonly MOD: bigint = 1n << 64n;
3 | private static readonly C1: bigint = 0x87c3_7b91_1142_53d5n;
4 | private static readonly C2: bigint = 0x4cf5_ad43_2745_937fn;
5 | private static readonly C3: bigint = 0x52dc_e729n;
6 | private static readonly C4: bigint = 0x3849_5ab5n;
7 | private static readonly R1: bigint = 27n;
8 | private static readonly R2: bigint = 31n;
9 | private static readonly R3: bigint = 33n;
10 | private static readonly M: bigint = 5n;
11 |
12 | static hash(source: Uint8Array, seed: number): bigint {
13 | let h1 = BigInt(seed);
14 | let h2 = BigInt(seed);
15 | let processed = 0;
16 |
17 | for (let i = 0; i < source.length; i += 16) {
18 | const chunk: Uint8Array = source.slice(i, i + 16);
19 | processed += chunk.length;
20 | if (chunk.length === 16) {
21 | const k1 = BigInt(
22 | chunk
23 | .slice(0, 8)
24 | .reduce(
25 | (acc, val, idx) =>
26 | acc | (BigInt(val) << BigInt(8 * idx)),
27 | 0n
28 | )
29 | );
30 | const k2 = BigInt(
31 | chunk
32 | .slice(8)
33 | .reduce(
34 | (acc, val, idx) =>
35 | acc | (BigInt(val) << BigInt(8 * idx)),
36 | 0n
37 | )
38 | );
39 | h1 ^=
40 | (Murmur3.rotateLeft(
41 | (k1 * Murmur3.C1) % Murmur3.MOD,
42 | Murmur3.R2
43 | ) *
44 | Murmur3.C2) %
45 | Murmur3.MOD;
46 | h1 =
47 | ((Murmur3.rotateLeft(h1, Murmur3.R1) + h2) * Murmur3.M +
48 | Murmur3.C3) %
49 | Murmur3.MOD;
50 | h2 ^=
51 | (Murmur3.rotateLeft(
52 | (k2 * Murmur3.C2) % Murmur3.MOD,
53 | Murmur3.R3
54 | ) *
55 | Murmur3.C1) %
56 | Murmur3.MOD;
57 | h2 =
58 | ((Murmur3.rotateLeft(h2, Murmur3.R2) + h1) * Murmur3.M +
59 | Murmur3.C4) %
60 | Murmur3.MOD;
61 | } else {
62 | let k1 = 0n;
63 | let k2 = 0n;
64 | for (let j = 0; j < chunk.length; j++) {
65 | const byteVal = BigInt(chunk[j]);
66 | if (j < 8) {
67 | k1 |= byteVal << BigInt(8 * j);
68 | } else {
69 | k2 |= byteVal << BigInt(8 * (j - 8));
70 | }
71 | }
72 | k1 =
73 | (Murmur3.rotateLeft(
74 | (k1 * Murmur3.C1) % Murmur3.MOD,
75 | Murmur3.R2
76 | ) *
77 | Murmur3.C2) %
78 | Murmur3.MOD;
79 | h1 ^= k1;
80 | h2 ^=
81 | (Murmur3.rotateLeft(
82 | (k2 * Murmur3.C2) % Murmur3.MOD,
83 | Murmur3.R3
84 | ) *
85 | Murmur3.C1) %
86 | Murmur3.MOD;
87 | }
88 | }
89 |
90 | h1 ^= BigInt(processed);
91 | h2 ^= BigInt(processed);
92 | h1 = (h1 + h2) % Murmur3.MOD;
93 | h2 = (h2 + h1) % Murmur3.MOD;
94 | h1 = Murmur3.fmix64(h1);
95 | h2 = Murmur3.fmix64(h2);
96 | h1 = (h1 + h2) % Murmur3.MOD;
97 | h2 = (h2 + h1) % Murmur3.MOD;
98 |
99 | return (h2 << BigInt(64)) | h1;
100 | }
101 |
102 | private static rotateLeft(x: bigint, k: bigint): bigint {
103 | const index = Number(k);
104 | const binStr: string = x.toString(2).padStart(64, '0');
105 | return BigInt(`0b${binStr.slice(index)}${binStr.slice(0, index)}`);
106 | }
107 |
108 | private static fmix64(k: bigint): bigint {
109 | const C1 = 0xff51_afd7_ed55_8ccdn;
110 | const C2 = 0xc4ce_b9fe_1a85_ec53n;
111 | const R = 33;
112 | let tmp: bigint = k;
113 | tmp ^= tmp >> BigInt(R);
114 | tmp = (tmp * C1) % Murmur3.MOD;
115 | tmp ^= tmp >> BigInt(R);
116 | tmp = (tmp * C2) % Murmur3.MOD;
117 | tmp ^= tmp >> BigInt(R);
118 | return tmp;
119 | }
120 | }
121 |
122 | export function gen_buvid_fp(key: string, seed: number): string {
123 | const source: Uint8Array = new TextEncoder().encode(key);
124 | const m: bigint = Murmur3.hash(source, seed);
125 | return `${(m & (Murmur3.MOD - 1n)).toString(16)}${(m >> 64n).toString(16)}`;
126 | }
127 |
--------------------------------------------------------------------------------
/src/platforms/bilibili-api/sign.ts:
--------------------------------------------------------------------------------
1 | import axios from '~/utils/axios';
2 | import { md5 } from './utils';
3 |
4 | const mixinKeyEncTab = [
5 | 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
6 | 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
7 | 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
8 | 36, 20, 34, 44, 52
9 | ];
10 |
11 | // 对 imgKey 和 subKey 进行字符顺序打乱编码
12 | function getMixinKey(orig: any) {
13 | let temp = '';
14 | mixinKeyEncTab.forEach(n => {
15 | temp += orig[n];
16 | });
17 | return temp.slice(0, 32);
18 | }
19 |
20 | // 为请求参数进行 wbi 签名
21 | export function encWbi(params: any, img_key: any, sub_key: any) {
22 | const mixin_key = getMixinKey(img_key + sub_key),
23 | curr_time = Math.round(Date.now() / 1000),
24 | chr_filter = /[!'()*]/g;
25 | let query: any = [];
26 | Object.assign(params, { wts: curr_time }); // 添加 wts 字段
27 | // 按照 key 重排参数
28 | Object.keys(params)
29 | .sort()
30 | .forEach(key => {
31 | query.push(
32 | `${encodeURIComponent(key)}=${encodeURIComponent(
33 | // 过滤 value 中的 "!'()*" 字符
34 | params[key].toString().replace(chr_filter, '')
35 | )}`
36 | );
37 | });
38 | query = query.join('&');
39 | const wbi_sign = md5(query + mixin_key); // 计算 w_rid
40 | return query + '&w_rid=' + wbi_sign;
41 | }
42 |
43 | // 获取最新的 img_key 和 sub_key
44 | export async function getWbiKeys() {
45 | const resp = await axios({
46 | url: 'https://api.bilibili.com/x/web-interface/nav',
47 | method: 'get',
48 | responseType: 'json'
49 | }),
50 | json_content = resp.data,
51 | img_url = json_content.data.wbi_img.img_url,
52 | sub_url = json_content.data.wbi_img.sub_url;
53 |
54 | return {
55 | img_key: img_url.slice(
56 | img_url.lastIndexOf('/') + 1,
57 | img_url.lastIndexOf('.')
58 | ),
59 | sub_key: sub_url.slice(
60 | sub_url.lastIndexOf('/') + 1,
61 | sub_url.lastIndexOf('.')
62 | )
63 | };
64 | }
65 |
66 | // 签名
67 | export async function WbiSign(params: any) {
68 | const wbi_keys = await getWbiKeys();
69 | return encWbi(params, wbi_keys.img_key, wbi_keys.sub_key);
70 | }
71 |
--------------------------------------------------------------------------------
/src/platforms/bilibili-api/tools.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/BennettChina/hot-news/blob/v3/util/tools.ts
2 |
3 | import { randomBytes } from 'crypto';
4 |
5 | export function random_canvas() {
6 | const rand_png = Uint8Array.from([
7 | ...randomBytes(2),
8 | 0x00,
9 | 0x00,
10 | 0x00,
11 | 0x00,
12 | 73,
13 | 69,
14 | 78,
15 | 68,
16 | 0x00,
17 | 0xe0a0,
18 | 0x00,
19 | 0x2000
20 | ]);
21 | return Buffer.from(rand_png).toString('base64');
22 | }
23 |
24 | export function random_audio() {
25 | const min = 124.04347;
26 | const max = 124.04348;
27 | return Math.random() * (max - min) + min;
28 | }
29 |
30 | export function random_png_end() {
31 | const rand_png = Uint8Array.from([
32 | ...randomBytes(32),
33 | 0x00,
34 | 0x00,
35 | 0x00,
36 | 0x00,
37 | 73,
38 | 69,
39 | 78,
40 | 68,
41 | ...randomBytes(4)
42 | ]);
43 | return Buffer.from(rand_png).toString('base64').slice(-50);
44 | }
45 |
--------------------------------------------------------------------------------
/src/platforms/bilibili-api/utils.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'node:crypto';
2 | import { random_audio, random_png_end, random_canvas } from './tools';
3 |
4 | export function md5(data: any) {
5 | const md5Hash = crypto.createHash('md5');
6 | md5Hash.update(data);
7 | return md5Hash.digest('hex');
8 | }
9 |
10 | const btoa = function (str: string) {
11 | return Buffer.from(str).toString('base64');
12 | };
13 |
14 | export function fakeDmCoverImgStr(str: string) {
15 | const e = new TextEncoder().encode(str).buffer;
16 | const n = new Uint8Array(e);
17 | const r = btoa(String.fromCharCode.apply(null, [...n]));
18 | return r.substring(0, r.length - 2);
19 | }
20 |
21 | export function genExClimbWuzhi(
22 | uuid: string,
23 | post_url: string,
24 | spm_prefix: string
25 | ) {
26 | return {
27 | '3064': 1,
28 | '5062': `${Date.now()}`,
29 | '03bf': `${encodeURIComponent(post_url)}`,
30 | '39c8': `${spm_prefix}.fp.risk`,
31 | '3c43': {
32 | adca: 'Win32',
33 | '13ab': random_canvas(),
34 | bfe9: random_png_end(),
35 | d02f: random_audio()
36 | },
37 | df35: uuid
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/src/platforms/bilibili.ts:
--------------------------------------------------------------------------------
1 | import { ArtworkInfo } from '~/types/Artwork';
2 | // import { getOpusInfo } from './bili-alt-api/opus';
3 | import DynamicFetcher from './bilibili-api/dynamic';
4 |
5 | export default async function getArtworkInfo(
6 | post_url: string,
7 | indexes = [0]
8 | ): Promise {
9 | const url = new URL(post_url);
10 |
11 | const dynamic_id =
12 | url.pathname.indexOf('/') == -1
13 | ? url.pathname
14 | : url.pathname.split('/').pop();
15 |
16 | if (!dynamic_id) {
17 | throw new Error('无效的B站动态链接');
18 | }
19 |
20 | const fetcher = new DynamicFetcher(dynamic_id);
21 |
22 | const dynamic_info = await fetcher.detail();
23 |
24 | let dynamic = dynamic_info.item.modules;
25 |
26 | // For forwarded dynamic, use the original dynamic as artwork source
27 | if (
28 | dynamic_info.item.orig &&
29 | dynamic_info.item.type === 'DYNAMIC_TYPE_FORWARD'
30 | ) {
31 | dynamic = dynamic_info.item.orig.modules;
32 |
33 | // Replace the post_url dynamic id with the original dynamic id
34 | post_url = post_url.replace(dynamic_id, dynamic_info.item.orig.id_str);
35 | }
36 |
37 | if (
38 | !dynamic.module_dynamic.major ||
39 | dynamic.module_dynamic.major.type !== 'MAJOR_TYPE_OPUS'
40 | ) {
41 | throw new Error('B站动态类型似乎不正确');
42 | }
43 |
44 | if (dynamic.module_dynamic.major.opus.pics.length === 0) {
45 | throw new Error('该动态中似乎没有图片');
46 | }
47 |
48 | if (indexes.length === 1 && indexes[0] === -1)
49 | indexes = Array.from(
50 | {
51 | length: dynamic.module_dynamic.major.opus.pics.length
52 | },
53 | (_, i) => i
54 | );
55 |
56 | const photos = dynamic.module_dynamic.major.opus.pics
57 | .filter((_, index) => indexes.includes(index))
58 | .map(item => ({
59 | url_thumb: item.url + '@1024w_1024h.jpg',
60 | url_origin: item.url,
61 | size: {
62 | width: item.width,
63 | height: item.height
64 | }
65 | }));
66 |
67 | return {
68 | source_type: 'bilibili',
69 | post_url: post_url,
70 | title: dynamic.module_dynamic.major.opus.title || undefined,
71 | desc: dynamic.module_dynamic.major.opus.summary?.text || undefined,
72 | artist: {
73 | type: 'bilibili',
74 | uid: dynamic.module_author.mid,
75 | name: dynamic.module_author.name
76 | },
77 | photos
78 | };
79 | }
80 |
--------------------------------------------------------------------------------
/src/platforms/danbooru.ts:
--------------------------------------------------------------------------------
1 | import axios from '~/utils/axios';
2 | import { ArtworkInfo } from '~/types/Artwork';
3 |
4 | export default async function getArtworkInfo(
5 | post_url: string
6 | ): Promise {
7 | const { data: post } = await axios.get(`${post_url}.json`, {
8 | headers: {
9 | 'user-agent': 'curl'
10 | }
11 | });
12 |
13 | const { data: artist } = await axios.get(
14 | `https://danbooru.donmai.us/artists.json?name=${post['tag_string_artist']}`,
15 | {
16 | headers: {
17 | 'user-agent': 'curl'
18 | }
19 | }
20 | );
21 |
22 | return {
23 | source_type: 'danbooru',
24 | post_url: post_url,
25 | artist: {
26 | type: 'danbooru',
27 | name: post['tag_string_artist'],
28 | id: artist[0]['id']
29 | },
30 | raw_tags: post['tag_string'].split(' '),
31 | photos: [
32 | {
33 | url_thumb: post['large_file_url'],
34 | url_origin: post['file_url'],
35 | size: {
36 | width: parseInt(post['image_width']),
37 | height: parseInt(post['image_height'])
38 | }
39 | }
40 | ]
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/src/platforms/index.ts:
--------------------------------------------------------------------------------
1 | import { ArtworkInfo } from '~/types/Artwork';
2 |
3 | export default async function getArtworkInfoByUrl(
4 | url: string,
5 | indexes?: number[]
6 | ): Promise {
7 | const matchPixiv = url.match(
8 | /(https:\/\/)?(www\.)?pixiv\.net\/(((en\/)?(artworks|i)\/(\d{1,9})(\/)?)|(member_illust\.php\?(\S+)?illust_id=(\d{1,9})))/
9 | );
10 | const matchTwitter = url.match(
11 | /(https:\/\/)?(vx|fx|fixup)?(twitter|x|twittpr).com\/(.+)\/status\/(\d+)/
12 | );
13 | const matchDanbooru = url.match(
14 | /(https:\/\/)?danbooru.donmai.us\/(posts|post\/show)\/(\d+)/
15 | );
16 | const matchBiliDynamic = url.match(
17 | /(https:\/\/)?((t.|m.|www.)?bilibili.com(\/opus)?\/(\d+))/
18 | );
19 |
20 | if (!indexes) indexes = [0];
21 |
22 | let module: {
23 | default: (url: string, indexes: number[]) => Promise;
24 | };
25 |
26 | if (matchPixiv) {
27 | module = await import('~/platforms/pixiv');
28 | url = matchPixiv[0];
29 | }
30 | if (matchTwitter) {
31 | module = await import('~/platforms/twitter');
32 | url = matchTwitter[0];
33 | }
34 | if (matchDanbooru) {
35 | module = await import('~/platforms/danbooru');
36 | url = matchDanbooru[0];
37 | }
38 | if (matchBiliDynamic) {
39 | module = await import('~/platforms/bilibili');
40 | url = matchBiliDynamic[0];
41 | }
42 |
43 | if (!module)
44 | throw new Error(
45 | '不支持的链接类型, 目前仅仅支持 Pixiv,Twitter,Danbooru, Bilibili 动态'
46 | );
47 |
48 | return await module.default(url, indexes);
49 | }
50 |
--------------------------------------------------------------------------------
/src/platforms/pixiv.ts:
--------------------------------------------------------------------------------
1 | import { pixivInstance as axios } from '~/utils/axios';
2 | import path from 'path';
3 | import { ArtworkInfo } from '~/types/Artwork';
4 | import { PixivAjaxResp, PixivIllust, PixivIllustPages } from '~/types/Pixiv';
5 |
6 | export default async function getArtworkInfo(
7 | post_url: string,
8 | indexes = [0]
9 | ): Promise {
10 | const pixiv_id = post_url.includes('illust_id=')
11 | ? /illust_id=(\d{7,9})/.exec(post_url)[1]
12 | : path.basename(post_url);
13 | const {
14 | data: { body: illust }
15 | } = await axios.get>(
16 | 'https://www.pixiv.net/ajax/illust/' + pixiv_id
17 | );
18 |
19 | if (indexes.length === 1 && indexes[0] === -1)
20 | indexes = Array.from({ length: illust.pageCount }, (_, i) => i);
21 |
22 | if (indexes[indexes.length - 1] > illust.pageCount - 1)
23 | throw new Error('Picture index out of range');
24 |
25 | const {
26 | data: { body: illust_pages }
27 | } = await axios.get>(
28 | `https://www.pixiv.net/ajax/illust/${pixiv_id}/pages?lang=zh`
29 | );
30 |
31 | const photos = illust_pages
32 | .filter((_, index) => indexes.includes(index))
33 | .map(item => {
34 | return {
35 | url_thumb: item.urls.regular,
36 | url_origin: item.urls.original,
37 | size: {
38 | width: item.width,
39 | height: item.height
40 | }
41 | };
42 | });
43 |
44 | const tags = illust.tags.tags.map(item => {
45 | if (item.tag === 'R-18') item.tag = 'R18';
46 |
47 | item.tag = item.translation?.en ? item.translation.en : item.tag;
48 |
49 | item.tag = item.tag.replace(/\s/g, '_');
50 |
51 | return item.tag;
52 | });
53 |
54 | const illust_desc = illust.description;
55 |
56 | // Remoie all the html tags in the description
57 | // .replace(/<[^>]+>/g, '');
58 |
59 | const artworkInfo: ArtworkInfo = {
60 | source_type: 'pixiv',
61 | post_url: post_url,
62 | title: illust.title,
63 | desc: illust_desc,
64 | raw_tags: tags,
65 | artist: {
66 | type: 'pixiv',
67 | uid: parseInt(illust.userId),
68 | name: illust.userName
69 | },
70 | photos
71 | };
72 |
73 | return artworkInfo;
74 | }
75 |
--------------------------------------------------------------------------------
/src/platforms/twitter-web-api/constants.js:
--------------------------------------------------------------------------------
1 | // https://github.com/zedeus/nitter/issues/919#issuecomment-1619067142
2 | // https://git.sr.ht/~cloutier/bird.makeup/tree/087a8e3e98b642841dde84465c19121fc3b4c6ee/item/src/BirdsiteLive.Twitter/Tools/TwitterAuthenticationInitializer.cs#L36
3 | const tokens = [
4 | 'CjulERsDeqhhjSme66ECg:IQWdVyqFxghAtURHGeGiWAsmCAGmdW3WmbEx6Hck', // iPad
5 | // valid, but endpoints differ
6 | // 'IQKbtAYlXLripLGPWd0HUA:GgDYlkSvaPxGxC4X8liwpUoqKwwr3lCADbz8A7ADU', // iPhone
7 | // '3nVuSoBZnx6U4vzUxf5w:Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys', // Android
8 | // '3rJOl1ODzm9yZy63FACdg:5jPoQ5kQvMJFDYRNE8bQ4rHuds4xJqhvgNJM4awaE8', // Mac
9 | ];
10 |
11 | const graphQLEndpointsPlain = [
12 | '/graphql/oUZZZ8Oddwxs8Cd3iW3UEA/UserByScreenName',
13 | '/graphql/Lxg1V9AiIzzXEiP2c8dRnw/UserByRestId',
14 | '/graphql/3XDB26fBve-MmjHaWTUZxA/TweetDetail',
15 | '/graphql/QqZBEqganhHwmU9QscmIug/UserTweets',
16 | '/graphql/wxoVeDnl0mP7VLhe6mTOdg/UserTweetsAndReplies',
17 | '/graphql/Az0-KW6F-FyYTc2OJmvUhg/UserMedia',
18 | '/graphql/kgZtsNyE46T3JaEf2nF9vw/Likes',
19 | '/graphql/0hWvDhmW8YQ-S_ib3azIrw/TweetResultByRestId'
20 | // these endpoints are not available if authenticated as other clients
21 | // FYI, endpoints for Android: https://gist.github.com/ScamCast/2e40befbd1b61c4a80cda2745d4df1f4
22 | ];
23 |
24 | const graphQLMap = Object.fromEntries(graphQLEndpointsPlain.map((endpoint) => [endpoint.split('/')[3], endpoint]));
25 |
26 | // captured from Twitter web
27 | const featuresMap = {
28 | UserByScreenName: JSON.stringify({
29 | hidden_profile_likes_enabled: false,
30 | responsive_web_graphql_exclude_directive_enabled: true,
31 | verified_phone_label_enabled: false,
32 | subscriptions_verification_info_verified_since_enabled: true,
33 | highlights_tweets_tab_ui_enabled: true,
34 | creator_subscriptions_tweet_preview_api_enabled: true,
35 | responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
36 | responsive_web_graphql_timeline_navigation_enabled: true,
37 | }),
38 | UserByRestId: JSON.stringify({
39 | hidden_profile_likes_enabled: false,
40 | responsive_web_graphql_exclude_directive_enabled: true,
41 | verified_phone_label_enabled: false,
42 | highlights_tweets_tab_ui_enabled: true,
43 | creator_subscriptions_tweet_preview_api_enabled: true,
44 | responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
45 | responsive_web_graphql_timeline_navigation_enabled: true,
46 | }),
47 | UserTweets: JSON.stringify({
48 | rweb_lists_timeline_redesign_enabled: true,
49 | responsive_web_graphql_exclude_directive_enabled: true,
50 | verified_phone_label_enabled: false,
51 | creator_subscriptions_tweet_preview_api_enabled: true,
52 | responsive_web_graphql_timeline_navigation_enabled: true,
53 | responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
54 | tweetypie_unmention_optimization_enabled: true,
55 | responsive_web_edit_tweet_api_enabled: true,
56 | graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
57 | view_counts_everywhere_api_enabled: true,
58 | longform_notetweets_consumption_enabled: true,
59 | responsive_web_twitter_article_tweet_consumption_enabled: false,
60 | tweet_awards_web_tipping_enabled: false,
61 | freedom_of_speech_not_reach_fetch_enabled: true,
62 | standardized_nudges_misinfo: true,
63 | tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
64 | longform_notetweets_rich_text_read_enabled: true,
65 | longform_notetweets_inline_media_enabled: true,
66 | responsive_web_media_download_video_enabled: false,
67 | responsive_web_enhance_cards_enabled: false,
68 | }),
69 | };
70 |
71 | module.exports = {
72 | tokens,
73 | graphQLMap,
74 | featuresMap,
75 | };
--------------------------------------------------------------------------------
/src/platforms/twitter-web-api/fxtwitter.ts:
--------------------------------------------------------------------------------
1 | import { FxTwitterResp } from '~/types/FxTwitter';
2 | import axios from '~/utils/axios';
3 |
4 | const FXTWITTER_API = 'https://api.fxtwitter.com/placeholder/status/';
5 |
6 | export async function getTweetDetails(tweet_id: string) {
7 | const { data } = await axios.get(FXTWITTER_API + tweet_id);
8 | if (data.code !== 200) throw new Error(data.message);
9 | return data.tweet;
10 | }
11 |
--------------------------------------------------------------------------------
/src/platforms/twitter-web-api/tweet.ts:
--------------------------------------------------------------------------------
1 | import api from './twitter-api.js';
2 |
3 | export async function getTweetDetails(status_id: string) {
4 | const tweet = await api.TweetResultByRestId(status_id);
5 | return tweet.data.tweetResult.result;
6 | }
7 |
8 | export async function getUserByUsername(username: string) {
9 | const user = await api.userByScreenName(username);
10 | return user.data.user.result.legacy;
11 | }
12 |
--------------------------------------------------------------------------------
/src/platforms/twitter-web-api/twitter-api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | /* eslint-disable no-undef */
3 | const twitterGot = require('./twitter-got.js');
4 | const { graphQLMap, featuresMap } = require('./constants.js');
5 |
6 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L727-L755
7 | const _params = {
8 | count: 20,
9 | include_profile_interstitial_type: 1,
10 | include_blocking: 1,
11 | include_blocked_by: 1,
12 | include_followed_by: 1,
13 | include_want_retweets: 1,
14 | include_mute_edge: 1,
15 | include_can_dm: 1,
16 | include_can_media_tag: 1,
17 | include_ext_has_nft_avatar: 1,
18 | skip_status: 1,
19 | cards_platform: 'Web-12',
20 | include_cards: 1,
21 | include_ext_alt_text: true,
22 | include_quote_count: true,
23 | include_reply_count: 1,
24 | tweet_mode: 'extended',
25 | include_entities: true,
26 | include_user_entities: true,
27 | include_ext_media_color: true,
28 | include_ext_media_availability: true,
29 | // include_ext_sensitive_media_warning: true, // IDK what it is, maybe disabling it will make NSFW lovers happy?
30 | send_error_codes: true,
31 | simple_quoted_tweet: true,
32 | include_tweet_replies: false,
33 | cursor: undefined,
34 | ext: 'mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,superFollowMetadata',
35 | };
36 |
37 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L756--L770
38 | const _variables = {
39 | count: 20,
40 | includePromotedContent: false,
41 | withSuperFollowsUserFields: true,
42 | withBirdwatchPivots: false,
43 | withDownvotePerspective: false,
44 | withReactionsMetadata: false,
45 | withReactionsPerspective: false,
46 | withSuperFollowsTweetFields: true,
47 | withClientEventToken: false,
48 | withBirdwatchNotes: false,
49 | withVoice: true,
50 | withV2Timeline: false,
51 | __fs_interactive_text: false,
52 | __fs_dont_mention_me_view_api_enabled: false,
53 | };
54 |
55 | // const paginationLegacy = (endpoint, userId, params) =>
56 | // twitterGot('https://api.twitter.com' + endpoint, {
57 | // ..._params,
58 | // ...params,
59 | // userId,
60 | // });
61 |
62 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L1075-L1093
63 | const paginationTweets = async (endpoint, userId, variables, path) => {
64 | const { data } = await twitterGot('https://twitter.com/i/api' + endpoint, {
65 | variables: JSON.stringify({
66 | ..._variables,
67 | ...variables,
68 | userId,
69 | }),
70 | features: featuresMap.UserTweets,
71 | });
72 |
73 | let instructions;
74 | if (!path) {
75 | instructions = data.user.result.timeline.timeline.instructions;
76 | } else {
77 | instructions = data;
78 | path.forEach((p) => (instructions = instructions[p]));
79 | instructions = instructions.instructions;
80 | }
81 |
82 | return instructions.filter((i) => i.type === 'TimelineAddEntries')[0].entries;
83 | };
84 |
85 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L807-L814
86 | const timelineTweets = (userId, params = {}) =>
87 | paginationTweets(graphQLMap.UserTweets, userId, {
88 | ...params,
89 | withQuickPromoteEligibilityTweetFields: true,
90 | });
91 |
92 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L816-L823
93 | const timelineTweetsAndReplies = (userId, params = {}) =>
94 | paginationTweets(graphQLMap.UserTweetsAndReplies, userId, {
95 | ...params,
96 | withCommunity: true,
97 | });
98 |
99 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L825-L831
100 | const timelineMedia = (userId, params = {}) => paginationTweets(graphQLMap.UserMedia, userId, params);
101 |
102 | // this query requires login
103 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L833-L839
104 | const timelineLikes = (userId, params = {}) => paginationTweets(graphQLMap.Likes, userId, params);
105 |
106 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L858-L866
107 | const timelineKeywords = (keywords, params = {}) =>
108 | twitterGot('https://twitter.com/i/api/2/search/adaptive.json', {
109 | ..._params,
110 | ...params,
111 | q: keywords,
112 | tweet_search_mode: 'live',
113 | query_source: 'typed_query',
114 | pc: 1,
115 | });
116 |
117 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L795-L805
118 | const tweetDetail = (userId, params) =>
119 | paginationTweets(
120 | graphQLMap.TweetDetail,
121 | userId,
122 | {
123 | ...params,
124 | with_rux_injections: false,
125 | withCommunity: true,
126 | withQuickPromoteEligibilityTweetFields: false,
127 | withBirdwatchNotes: false,
128 | },
129 | ['threaded_conversation_with_injections']
130 | );
131 |
132 | const TweetResultByRestId = (tweetId) =>
133 | twitterGot(`https://twitter.com/i/api${graphQLMap.TweetResultByRestId}`, {
134 | variables: JSON.stringify({
135 | tweetId: tweetId,
136 | withCommunity: false,
137 | includePromotedContent:false,
138 | withVoice:false
139 | }),
140 | features: featuresMap.UserTweets
141 | })
142 |
143 | function gatherLegacyFromData(entries, filter = 'tweet-') {
144 | const tweets = [];
145 | const filte_entries = [];
146 | entries.forEach((entry) => {
147 | const entryId = entry.entryId;
148 | if (entryId) {
149 | if (filter === 'none') {
150 | if (entryId.startsWith('tweet-')) {
151 | filte_entries.push(entry);
152 | } else if (entryId.startsWith('homeConversation-') || entryId.startsWith('conversationthread-')) {
153 | filte_entries.push(...entry.content.items);
154 | }
155 | } else {
156 | if (entryId.startsWith(filter)) {
157 | filte_entries.push(entry);
158 | }
159 | }
160 | }
161 | });
162 | filte_entries.forEach((entry) => {
163 | if (entry.entryId) {
164 | const content = entry.content || entry.item;
165 | let tweet = content?.itemContent?.tweet_results?.result;
166 | if (tweet && tweet.tweet) {
167 | tweet = tweet.tweet;
168 | }
169 | if (tweet) {
170 | const retweet = tweet.legacy?.retweeted_status_result?.result;
171 | for (const t of [tweet, retweet]) {
172 | if (!t?.legacy) {
173 | continue;
174 | }
175 | t.legacy.user = t.core.user_results.result.legacy;
176 | const quote = t.quoted_status_result?.result;
177 | if (quote) {
178 | t.legacy.quoted_status = quote.legacy;
179 | t.legacy.quoted_status.user = quote.core.user_results.result.legacy;
180 | }
181 | }
182 | const legacy = tweet.legacy;
183 | if (legacy) {
184 | if (retweet) {
185 | legacy.retweeted_status = retweet.legacy;
186 | }
187 | tweets.push(legacy);
188 | }
189 | }
190 | }
191 | });
192 | return tweets;
193 | }
194 |
195 | function pickLegacyByID(id, tweets_dict, users_dict) {
196 | function pickLegacyFromTweet(tweet) {
197 | tweet.user = users_dict[tweet.user_id_str];
198 | if (tweet.retweeted_status_id_str) {
199 | tweet.retweeted_status = pickLegacyFromTweet(tweets_dict[tweet.retweeted_status_id_str]);
200 | }
201 | return tweet;
202 | }
203 |
204 | if (tweets_dict[id]) {
205 | return pickLegacyFromTweet(tweets_dict[id]);
206 | }
207 | }
208 |
209 | function gatherLegacyFromLegacyApiData(data, filter = 'tweet-') {
210 | const tweets_dict = data.globalObjects.tweets;
211 | const users_dict = data.globalObjects.users;
212 | const tweets = [];
213 | data.timeline.instructions[0].addEntries.entries.forEach((entry) => {
214 | if (entry.entryId && entry.entryId.indexOf(filter) !== -1) {
215 | const tweet = pickLegacyByID(entry.content.item.content.tweet.id, tweets_dict, users_dict);
216 | if (tweet) {
217 | tweets.push(tweet);
218 | }
219 | }
220 | });
221 | return tweets;
222 | }
223 |
224 | const getUserTweetsByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweets(id, params));
225 | const getUserTweetsAndRepliesByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweetsAndReplies(id, params));
226 | const getUserMediaByID = async (id, params = {}) => gatherLegacyFromData(await timelineMedia(id, params));
227 | const getUserLikesByID = async (id, params = {}) => gatherLegacyFromData(await timelineLikes(id, params));
228 | const getUserTweetByStatus = async (id, params = {}) => gatherLegacyFromData(await tweetDetail(id, params), 'none');
229 |
230 | const excludeRetweet = function (tweets) {
231 | const excluded = [];
232 | for (const t of tweets) {
233 | if (t.retweeted_status) {
234 | continue;
235 | }
236 | excluded.push(t);
237 | }
238 | return excluded;
239 | };
240 |
241 | const userByScreenName = (screenName) =>
242 | twitterGot(`https://twitter.com/i/api${graphQLMap.UserByScreenName}`, {
243 | variables: `{"screen_name":"${screenName}","withHighlightedLabel":true}`,
244 | features: featuresMap.UserByScreenName,
245 | });
246 | const userByRestId = (restId) =>
247 | twitterGot(`https://twitter.com/i/api${graphQLMap.UserByRestId}`, {
248 | variables: `{"userId":"${restId}","withHighlightedLabel":true}`,
249 | features: featuresMap.UserByRestId,
250 | });
251 | const userByAuto = (id) => {
252 | if (id.startsWith('+')) {
253 | return userByRestId(id.slice(1));
254 | }
255 | return userByScreenName(id);
256 | };
257 | const getUserData = (cache, id) => cache.tryGet(`twitter-userdata-${id}`, () => userByAuto(id));
258 | const getUserID = async (cache, id) => (await getUserData(cache, id)).data.user.result.rest_id;
259 | const getUser = async (cache, id) => (await getUserData(cache, id)).data.user.result.legacy;
260 |
261 | const cacheTryGet = async (cache, _id, params, func) => {
262 | const id = await getUserID(cache, _id);
263 | if (id === undefined) {
264 | throw Error('User not found');
265 | }
266 | const funcName = func.name;
267 | const paramsString = JSON.stringify(params);
268 | return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), 300, false);
269 | };
270 |
271 | const getUserTweets = (cache, id, params = {}) => cacheTryGet(cache, id, params, getUserTweetsByID);
272 | const getUserTweetsAndReplies = (cache, id, params = {}) => cacheTryGet(cache, id, params, getUserTweetsAndRepliesByID);
273 | const getUserMedia = (cache, id, params = {}) => cacheTryGet(cache, id, params, getUserMediaByID);
274 | const getUserLikes = (cache, id, params = {}) => cacheTryGet(cache, id, params, getUserLikesByID);
275 | const getUserTweet = (cache, id, params) => cacheTryGet(cache, id, params, getUserTweetByStatus);
276 |
277 | const getSearch = async (keywords, params = {}) => gatherLegacyFromLegacyApiData(await timelineKeywords(keywords, params), 'sq-I-t-');
278 |
279 | module.exports = {
280 | getUser,
281 | getUserTweets,
282 | getUserTweetsAndReplies,
283 | userByScreenName,
284 | tweetDetail,
285 | getUserMedia,
286 | getUserLikes,
287 | excludeRetweet,
288 | getSearch,
289 | getUserTweet,
290 | TweetResultByRestId
291 | };
--------------------------------------------------------------------------------
/src/platforms/twitter-web-api/twitter-got.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | /* eslint-disable no-undef */
3 | const { CookieJar, Cookie } = require('tough-cookie');
4 | const { promisify } = require('util');
5 | const axios = require('axios');
6 | const constants = require('./constants.js');
7 | // https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L716-L726
8 | const headers = {
9 | authorization: '',
10 | // reference: https://github.com/dangeredwolf/FixTweet/blob/f3082bbb0d69798687481a605f6760b2eb7558e0/src/constants.ts#L23-L25
11 | 'x-guest-token': '',
12 | 'x-twitter-auth-type': '',
13 | 'x-twitter-client-language': 'en',
14 | 'x-twitter-active-user': 'yes',
15 | 'x-csrf-token': '',
16 | 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
17 | Referer: 'https://twitter.com/',
18 | };
19 |
20 | let cookieJar, setCookie, getCookies, tries = 0;
21 |
22 | const cookiedomain = 'twitter.com';
23 | const cookieurl = 'https://twitter.com';
24 |
25 | async function twitterGot(options) {
26 | const response = await axios({
27 | ...options,
28 | headers: {
29 | ...headers,
30 | ...(options.headers || {}),
31 | 'cookie': cookieJar.getCookieStringSync(options.url)
32 | },
33 | });
34 | // 更新csrfToken
35 | for (const c of await getCookies(cookieurl)) {
36 | if (c.key === 'ct0') {
37 | headers['x-csrf-token'] = c.value;
38 | }
39 | }
40 | return response;
41 | }
42 |
43 | async function resetSession() {
44 | cookieJar = new CookieJar();
45 | getCookies = promisify(cookieJar.getCookies.bind(cookieJar));
46 | setCookie = promisify(cookieJar.setCookie.bind(cookieJar));
47 | let response;
48 |
49 | headers.authorization = `Basic ${Buffer.from(constants.tokens[tries++ % constants.tokens.length]).toString('base64')}`;
50 | response = await twitterGot({
51 | url: 'https://api.twitter.com/oauth2/token',
52 | method: 'POST',
53 | params: { grant_type: 'client_credentials' },
54 | });
55 | headers.authorization = `Bearer ${response.data.access_token}`;
56 | // 生成csrf-token
57 | const csrfToken = [...Array(16 * 2)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
58 | await setCookie(new Cookie({ key: 'ct0', value: csrfToken, domain: cookiedomain, secure: false }), cookieurl);
59 | headers['x-csrf-token'] = csrfToken;
60 | headers['x-guest-token'] = '';
61 | // 发起初始化请求
62 | response = await twitterGot({
63 | url: 'https://api.twitter.com/1.1/guest/activate.json',
64 | method: 'POST',
65 | });
66 | // 获取guest-token
67 | // TODO: OAuth2Session, 参见 https://github.com/DIYgod/RSSHub/pull/7739#discussionR655932602
68 | const guestToken = response.data.guest_token;
69 | headers['x-guest-token'] = guestToken;
70 | await setCookie(new Cookie({ key: 'gt', value: guestToken, domain: cookiedomain, secure: false }), cookieurl);
71 | // 发起第二个初始化请求, 获取_twitter_sess
72 | await twitterGot({
73 | url: 'https://twitter.com/i/js_inst',
74 | method: 'GET',
75 | params: { c_name: 'ui_metrics' },
76 | });
77 | return cookieJar;
78 | }
79 |
80 | const initSession = () => cookieJar || resetSession();
81 |
82 | async function twitterRequest(url, params, method) {
83 | await initSession();
84 | // 发起请求
85 | const request = () =>
86 | twitterGot({
87 | url,
88 | method,
89 | params,
90 | });
91 | let response;
92 | try {
93 | response = await request();
94 | } catch (e) {
95 | if (e.response.status === 403) {
96 | await resetSession();
97 | response = await request();
98 | } else {
99 | throw e;
100 | }
101 | }
102 | return response.data;
103 | }
104 |
105 | module.exports = twitterRequest;
--------------------------------------------------------------------------------
/src/platforms/twitter.ts:
--------------------------------------------------------------------------------
1 | import { ArtworkInfo } from '~/types/Artwork';
2 | import { getTweetDetails } from './twitter-web-api/fxtwitter';
3 |
4 | function cleanUrl(url: string) {
5 | // Remove url query parameters
6 | return new URL(url).origin + new URL(url).pathname;
7 | }
8 |
9 | export default async function getArtworkInfo(
10 | post_url: string,
11 | indexes = [0]
12 | ): Promise {
13 | const tweet_url = new URL(post_url);
14 | const url_paths = tweet_url.pathname.split('/');
15 | const tweet = await getTweetDetails(url_paths[3]);
16 |
17 | if (!tweet.media) throw new Error('此推文中没有任何图片');
18 |
19 | if (indexes.length === 1 && indexes[0] === -1)
20 | indexes = Array.from(
21 | { length: tweet.media.photos.length },
22 | (_, i) => i
23 | );
24 |
25 | if (indexes[indexes.length - 1] > tweet.media.photos.length - 1)
26 | throw new Error('图片序号超出范围');
27 |
28 | // Remove t.co Links
29 | const desc = tweet.text.replace(/https:\/\/t.co\/(\w+)/, '');
30 | const photos = tweet.media.photos
31 | .filter((_, index) => indexes.includes(index))
32 | .map(photo => {
33 | return {
34 | url_thumb: cleanUrl(photo.url) + '?name=medium',
35 | url_origin: cleanUrl(photo.url) + '?name=orig',
36 | size: {
37 | width: photo.width,
38 | height: photo.height
39 | }
40 | };
41 | });
42 |
43 | return {
44 | source_type: 'twitter',
45 | post_url: post_url,
46 | desc,
47 | artist: {
48 | type: 'twitter',
49 | name: tweet.author.name,
50 | uid: parseInt(tweet.author.id),
51 | username: tweet.author.screen_name
52 | },
53 | photos
54 | };
55 | }
56 |
--------------------------------------------------------------------------------
/src/services/artwork-service.ts:
--------------------------------------------------------------------------------
1 | import { Artist, Artwork, ArtworkInfo } from '~/types/Artwork';
2 | import downloadFile from '~/utils/download';
3 | import bot from '~/bot';
4 | import config from '~/config';
5 | import path from 'path';
6 | import { ExecResult, PublishEvent, PushEvent } from '~/types/Event';
7 | import { getTagsByNamesAndInsert } from '~/database/operations/tag';
8 | import { pushArtwork } from '~/bot/modules/push';
9 | import {
10 | deleteMessagesByArtwork,
11 | getMessageByArtwork,
12 | getMessagesByArtwork,
13 | insertMessages
14 | } from '~/database/operations/message';
15 | import {
16 | insertArtwork,
17 | updateArtwork,
18 | deleteArtwork
19 | } from '~/database/operations/artwork';
20 | import { artworkCaption } from '~/utils/caption';
21 | import Mongoose from '~/database';
22 | import {
23 | findOrInsertArtist,
24 | getArtistById
25 | } from '~/database/operations/artist';
26 | import { Photo } from '~/types/Photo';
27 | import {
28 | insertPhotos,
29 | removePhotoByArtworkIndex
30 | } from '~/database/operations/photo';
31 | import { getImageSize, getFileSize } from '~/utils/sharp';
32 |
33 | // @ErrCatch 不会用,暂时不用了
34 | export async function publishArtwork(
35 | artworkInfo: ArtworkInfo,
36 | publish_event: PublishEvent
37 | ): Promise {
38 | global.currentMongoSession = await Mongoose.startSession();
39 | global.currentMongoSession.startTransaction();
40 |
41 | // 下载文件
42 | const thumb_files = await Promise.all(
43 | artworkInfo.photos.map(async photo => {
44 | const file_name = await downloadFile(
45 | photo.url_thumb,
46 | path.basename(new URL(photo.url_thumb).pathname),
47 | 'thumbnails'
48 | );
49 | return file_name;
50 | })
51 | );
52 | // 判断是否有文件ID传入
53 | const origin_files = await Promise.all(
54 | artworkInfo.photos.map(async photo => {
55 | const file_name = await downloadFile(
56 | photo.url_origin,
57 | publish_event.origin_file_name
58 | ? publish_event.origin_file_name
59 | : path.basename(new URL(photo.url_origin).pathname)
60 | );
61 | return file_name;
62 | })
63 | );
64 |
65 | const origin_sizes = await Promise.all(
66 | origin_files.map(async (file_name, index) =>
67 | publish_event.origin_file_modified
68 | ? await getImageSize(file_name)
69 | : artworkInfo.photos[index].size
70 | )
71 | );
72 |
73 | const origin_file_sizes = await Promise.all(
74 | origin_files.map(async file_name => await getFileSize(file_name))
75 | );
76 |
77 | // 上传原图文件到云存储
78 | switch (config.STORAGE_TYPE) {
79 | case 'b2':
80 | if (
81 | !config.B2_ENDPOINT ||
82 | !config.B2_KEY_ID ||
83 | !config.B2_KEY ||
84 | !config.B2_BUCKET
85 | ) {
86 | throw new Error('B2 storage configuration is incomplete!');
87 | }
88 | const blackblaze = await import('./storage/blackblaze');
89 | await Promise.all(
90 | thumb_files.map(async file_name => {
91 | await blackblaze.uploadFileB2(file_name, 'thumbnails');
92 | })
93 | );
94 | break;
95 | case 'sharepoint':
96 | if (!config.CLIENT_ID || !config.CLIENT_SECRET) {
97 | throw new Error(
98 | 'SharePoint storage configuration is incomplete!'
99 | );
100 | }
101 | const sharepoint = await import('./storage/upload');
102 | await Promise.all(
103 | origin_files.map(async file_name => {
104 | await sharepoint.uploadOneDrive(file_name);
105 | })
106 | );
107 | break;
108 | default:
109 | throw new Error(`Unsupported storage type: ${config.STORAGE_TYPE}`);
110 | }
111 |
112 | // 获取标签ID
113 | const tags = await getTagsByNamesAndInsert(publish_event.artwork_tags);
114 |
115 | let artist: Artist;
116 |
117 | if (artworkInfo.artist) {
118 | artist = await findOrInsertArtist({
119 | type: artworkInfo.source_type,
120 | uid: artworkInfo.artist.uid,
121 | name: artworkInfo.artist.name,
122 | username: artworkInfo.artist.username
123 | });
124 | }
125 |
126 | const artwork = await insertArtwork({
127 | index: -1,
128 | quality: publish_event.is_quality,
129 | title: artworkInfo.title,
130 | desc: artworkInfo.desc,
131 | tags: tags,
132 | source: {
133 | type: artworkInfo.source_type,
134 | post_url: artworkInfo.post_url,
135 | picture_index: publish_event.picture_index
136 | },
137 | artist_id: artist.id,
138 | create_time: new Date(),
139 |
140 | // @deprecated, @TODO should be removed in the future version
141 | file_name: origin_files[0],
142 | size: origin_sizes[0]
143 | });
144 |
145 | const push_event: PushEvent = {
146 | thumb_files,
147 | origin_files,
148 | contribution: publish_event.contribution,
149 | origin_file_modified: publish_event.origin_file_modified,
150 | origin_file_id: publish_event.origin_file_id
151 | };
152 | // 推送作品到频道
153 | const pushMessages = await pushArtwork(artworkInfo, artwork, push_event);
154 | // 将频道的消息存入数据库
155 |
156 | await insertMessages([...pushMessages.photos, ...pushMessages.documents]);
157 |
158 | const photos: Photo[] = artworkInfo.photos.map((_, index) => ({
159 | artwork_id: artwork._id,
160 | artwork_index: artwork.index,
161 | size: origin_sizes[index],
162 | file_name: origin_files[index],
163 | file_size: origin_file_sizes[index],
164 | thumb_file_id: pushMessages.photos[index].file_id,
165 | document_file_id: pushMessages.documents[index].file_id,
166 | thumb_message_id: pushMessages.photos[index].message_id,
167 | document_message_id: pushMessages.documents[index].message_id,
168 | create_time: new Date()
169 | }));
170 |
171 | await insertPhotos(photos);
172 |
173 | return {
174 | succeed: true,
175 | message: '作品成功发布啦~'
176 | };
177 | }
178 |
179 | export async function modifyArtwork(artwork: Artwork): Promise {
180 | global.currentMongoSession = await Mongoose.startSession();
181 | global.currentMongoSession.startTransaction();
182 |
183 | const count = await updateArtwork(artwork);
184 | if (count < 1) {
185 | return {
186 | succeed: false,
187 | message: 'Artwork 数据库更新失败'
188 | };
189 | }
190 | const photo_message = await getMessageByArtwork(artwork.index, 'photo');
191 | const artist = await getArtistById(artwork.artist_id.toString());
192 |
193 | await bot.telegram.editMessageCaption(
194 | config.PUSH_CHANNEL,
195 | photo_message.message_id,
196 | undefined,
197 | artworkCaption(artwork, artist),
198 | {
199 | parse_mode: 'HTML'
200 | }
201 | );
202 | return {
203 | succeed: true,
204 | message: '作品信息修改成功'
205 | };
206 | }
207 |
208 | export async function delArtwork(artwork_index: number): Promise {
209 | global.currentMongoSession = await Mongoose.startSession();
210 | global.currentMongoSession.startTransaction();
211 |
212 | const artwork_delete_count = await deleteArtwork(artwork_index);
213 | if (artwork_delete_count < 1) {
214 | return {
215 | succeed: false,
216 | message: 'Artwork 数据库删除失败'
217 | };
218 | }
219 |
220 | const messages = await getMessagesByArtwork(artwork_index);
221 |
222 | for (const message of messages) {
223 | await bot.telegram.deleteMessage(
224 | config.PUSH_CHANNEL,
225 | message.message_id
226 | );
227 | }
228 |
229 | const message_delete_count = await deleteMessagesByArtwork(artwork_index);
230 |
231 | await removePhotoByArtworkIndex(artwork_index);
232 |
233 | if (message_delete_count < 1) {
234 | return {
235 | succeed: false,
236 | message: 'Artwork 数据库删除失败'
237 | };
238 | }
239 | return {
240 | succeed: true,
241 | message: '作品删除成功'
242 | };
243 | }
244 |
--------------------------------------------------------------------------------
/src/services/graph/auth.ts:
--------------------------------------------------------------------------------
1 | import axios from '~/utils/axios';
2 | import config from '~/config';
3 | import logger from '~/utils/logger';
4 | import { getConfig, setConfig } from '~/database/operations/config';
5 | import { Config } from '~/types/Config';
6 |
7 | const AUTH_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
8 |
9 | export default async function getAccessToken(): Promise {
10 | let stored_refresh_token = await getConfig(Config.REFRESH_TOKEN);
11 |
12 | if (!stored_refresh_token) {
13 | logger.warn(
14 | 'No stored refresh token found, use process.env.REFRESH_TOKEN instead'
15 | );
16 | stored_refresh_token = process.env.REFRESH_TOKEN;
17 | }
18 |
19 | const body = new URLSearchParams();
20 | body.append('client_id', config.CLIENT_ID);
21 | body.append('client_secret', config.CLIENT_SECRET);
22 | body.append('refresh_token', stored_refresh_token);
23 | body.append('redirect_uri', 'http://localhost');
24 | body.append('grant_type', 'refresh_token');
25 |
26 | const response = await axios.post(AUTH_URL, body, {
27 | headers: {
28 | 'Content-Type': 'application/x-www-form-urlencoded'
29 | }
30 | });
31 |
32 | const { access_token, refresh_token } = response.data;
33 |
34 | if (!access_token) {
35 | logger.error('Failed to get onedrive access token');
36 | throw new Error('Failed to get onedrive access token');
37 | }
38 |
39 | await setConfig(Config.REFRESH_TOKEN, refresh_token);
40 | return response.data.access_token;
41 | }
42 |
--------------------------------------------------------------------------------
/src/services/storage/blackblaze.ts:
--------------------------------------------------------------------------------
1 | import AWS from 'aws-sdk';
2 | import fs from 'fs';
3 | import path from 'path';
4 | import config from '~/config';
5 | import logger from '~/utils/logger';
6 |
7 | const credentials = new AWS.Credentials({
8 | accessKeyId: config.B2_KEY_ID,
9 | secretAccessKey: config.B2_KEY
10 | });
11 |
12 | AWS.config.credentials = credentials;
13 |
14 | const endpoint = new AWS.Endpoint(config.B2_ENDPOINT);
15 |
16 | const s3 = new AWS.S3({
17 | endpoint: endpoint,
18 | credentials: credentials
19 | });
20 |
21 | export async function uploadFileB2(file_name: string, sub_dir = '') {
22 | let extname = path.extname(file_name).substring(1);
23 |
24 | if (extname == 'jpg') extname = 'jpeg';
25 |
26 | const key = config.STORAGE_BASE
27 | ? `${config.STORAGE_BASE}/${file_name}`
28 | : file_name;
29 |
30 | s3.putObject(
31 | {
32 | Bucket: config.B2_BUCKET,
33 | Key: key,
34 | Body: fs.createReadStream(
35 | path.resolve(config.TEMP_DIR, sub_dir, file_name)
36 | ),
37 | ContentType: 'image/' + path.extname(file_name).substring(1)
38 | },
39 | err => {
40 | if (err) {
41 | logger.error(
42 | err,
43 | `Failed to upload ${file_name} to S3 storage`
44 | );
45 | } else {
46 | logger.info(`Uploaded ${file_name} to S3 storage`);
47 | }
48 | }
49 | );
50 | }
51 |
52 | export async function isB2FileExist(file_path: string): Promise {
53 | return new Promise(resolve => {
54 | s3.getObject(
55 | {
56 | Bucket: config.B2_BUCKET,
57 | Key: file_path
58 | },
59 | err => {
60 | if (err) return resolve(false);
61 | return resolve(true);
62 | }
63 | );
64 | });
65 | }
66 |
--------------------------------------------------------------------------------
/src/services/storage/upload.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 | import axios from '~/utils/axios';
4 | import getAccessToken from '../graph/auth';
5 | import config from '~/config';
6 | import logger from '~/utils/logger';
7 |
8 | const SITE_PATH = config.SP_SITE_ID ? `/sites/${config.SP_SITE_ID}` : '/me';
9 | const UPLOAD_BASE = config.STORAGE_BASE ? `/${config.STORAGE_BASE}` : '';
10 |
11 | export async function uploadOneDrive(file_name: string): Promise {
12 | const access_token = await getAccessToken();
13 | const file_path = path.resolve(config.TEMP_DIR, file_name);
14 | const uploadSession = await axios.post(
15 | `https://graph.microsoft.com/v1.0${SITE_PATH}/drive/root:${UPLOAD_BASE}/${file_name}:/createUploadSession`,
16 | undefined,
17 | { headers: { Authorization: `Bearer ${access_token}` } }
18 | );
19 | const uploadUrl = uploadSession.data.uploadUrl as string;
20 | if (!uploadUrl) {
21 | logger.error(
22 | uploadSession.data,
23 | 'Failed to get onedrive upload session'
24 | );
25 | throw new Error('Failed to get onedrive upload session');
26 | }
27 |
28 | const filestream = fs.createReadStream(file_path);
29 | const fileLength = fs.readFileSync(file_path).length;
30 |
31 | const uploadRequest = await axios.put(uploadUrl, filestream, {
32 | headers: {
33 | 'Content-Length': fileLength.toString(),
34 | 'Content-Range': `bytes 0-${fileLength - 1}/${fileLength}`,
35 | Authorization: `Bearer ${access_token}`
36 | },
37 | maxContentLength: 52428890,
38 | maxBodyLength: 52428890
39 | });
40 | if (uploadRequest.status === 200 || uploadRequest.status === 201) {
41 | logger.info(`Uploaded ${file_name} to OneDrive`);
42 | } else {
43 | logger.error(`Failed to upload ${file_name} to OneDrive`);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/tests/bilibili.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import DynamicFetcher from '../platforms/bilibili-api/dynamic';
3 | import downloadFile from '../utils/download';
4 |
5 | describe('Bilibili API Test', () => {
6 | test('Dynamic API Test', async () => {
7 | const fetcher = new DynamicFetcher('956651175804928018');
8 | const dynamic = await fetcher.detail();
9 | expect(dynamic.item.modules.module_author.mid).toBe('12727107');
10 | });
11 |
12 | test('Image Download Test', async () => {
13 | const file = await downloadFile(
14 | 'https://i0.hdslb.com/bfs/new_dyn/bfd08349371cb139ad72ef50cb66a72924894709.jpg'
15 | );
16 | expect(file).toBeTruthy();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/tests/twitter.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import {
3 | getTweetDetails,
4 | getUserByUsername
5 | } from '../platforms/twitter-web-api/tweet';
6 |
7 | describe('Twitter Web API Test', () => {
8 | test('Get Tweet Details', async () => {
9 | const tweet = await getTweetDetails('1614089157323952132');
10 | expect(tweet.legacy.user_id_str).toBe('829606036948475904');
11 | });
12 |
13 | test('Get User By Username', async () => {
14 | const user = await getUserByUsername('majotabi_PR');
15 | expect(user.screen_name).toBe('majotabi_PR');
16 | });
17 |
18 | test('Get Tweet Media', async () => {
19 | const tweet = await getTweetDetails('1653719799011360769');
20 | expect(tweet.legacy.extended_entities?.media[0].type).toBe('photo');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/types/Admin.d.ts:
--------------------------------------------------------------------------------
1 | export const enum AdminPermission {
2 | DEFAULT = 'DEFAULT',
3 | PUBLISH = 'PUBLISH',
4 | TAG = 'TAG',
5 | UPDATE = 'UPDATE',
6 | DELETE = 'DELETE',
7 | GRANT = 'GRANT'
8 | }
9 |
10 | export type AdminUser = {
11 | user_id: number;
12 | grant_by: number;
13 | permissions: Array;
14 | create_time?: Date;
15 | };
16 |
--------------------------------------------------------------------------------
/src/types/Artwork.d.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 | import { ChannelMessage } from './Message';
3 |
4 | type ArtworkSourceType = 'pixiv' | 'twitter' | 'danbooru' | 'bilibili';
5 |
6 | export type ArtworkSource = {
7 | type: ArtworkSourceType;
8 | post_url: string;
9 | picture_index: number[];
10 | };
11 |
12 | export type ArtworkTag = {
13 | _id: string;
14 | name: string;
15 | };
16 |
17 | export type ImageSize = {
18 | width: number;
19 | height: number;
20 | };
21 |
22 | export type Artwork = {
23 | index: number;
24 | quality: boolean;
25 | title?: string;
26 | desc?: string;
27 | file_name?: string;
28 | img_thumb?: string;
29 | size?: ImageSize;
30 | tags: Array;
31 | source: ArtworkSource;
32 | create_time?: Date;
33 | artist_id?: Mongoose.Types.ObjectId;
34 | };
35 |
36 | export type ArtworkStrProps = 'title' | 'desc' | 'file_name' | 'img_thumb';
37 | export type ArtworkBoolProps = 'quality';
38 |
39 | export type ArtworkInfo = {
40 | source_type: ArtworkSourceType;
41 | post_url: string;
42 | title?: string;
43 | desc?: string;
44 | photos: {
45 | url_thumb: string;
46 | url_origin: string;
47 | size: ImageSize;
48 | }[];
49 | raw_tags?: string[];
50 | artist: Artist;
51 | };
52 |
53 | export type ArtworkWithFileId = Pick & {
54 | photo_file_id: string;
55 | document_file_id: string;
56 | photo_message_id: number;
57 | };
58 |
59 | export type ArtworkWithMessages = Partial & {
60 | photo_message: ChannelMessage<'photo'>;
61 | document_message: ChannelMessage<'document'>;
62 | };
63 |
64 | export type Artist = {
65 | id?: Mongoose.Types.ObjectId;
66 | type: ArtworkSourceType;
67 | uid?: number;
68 | name: string;
69 | username?: string;
70 | create_time?: Date;
71 | };
72 |
--------------------------------------------------------------------------------
/src/types/Bilibili.d.ts:
--------------------------------------------------------------------------------
1 | export type BiliResponse = {
2 | code: number;
3 | message: string;
4 | ttl?: number;
5 | data: T;
6 | };
7 |
8 | export type BiliFingerData = {
9 | b_3: string;
10 | b_4: string;
11 | };
12 |
13 | export type BiliDynamicModule = {
14 | module_author: {
15 | name: string;
16 | jump_url: string;
17 | mid: number;
18 | };
19 | module_dynamic: {
20 | major?: {
21 | type: string; // Should be 'MAJOR_TYPE_OPUS',
22 | opus?: {
23 | pics: BiliDynamicPic[];
24 | summary?: {
25 | text: string;
26 | };
27 | title: string | null;
28 | };
29 | };
30 | };
31 | };
32 |
33 | export type BiliDynamicData = {
34 | item: {
35 | modules: BiliDynamicModule;
36 | id_str: string;
37 | orig?: {
38 | id_str: string;
39 | modules: BiliDynamicModule;
40 | };
41 | type: string;
42 | };
43 | };
44 |
45 | export type BiliDynamicPic = {
46 | height: number;
47 | width: number;
48 | sizes: number; // float
49 | url: string;
50 | };
51 |
--------------------------------------------------------------------------------
/src/types/Command.d.ts:
--------------------------------------------------------------------------------
1 | // export type CommandParam = {
2 | // key: string,
3 | // value: string
4 | // }
5 |
6 | export type ParamNames =
7 | | 'user'
8 | | 'index'
9 | | 'index'
10 | | 'tags'
11 | | 'contribute_from'
12 | | 'desc'
13 | | 'quality'
14 | | string;
15 |
16 | export type CommandParams = {
17 | [P in ParamNames]?: string;
18 | };
19 |
20 | export type CommandEntity = {
21 | name: string;
22 | target?: string;
23 | params?: CommandParams;
24 | urls?: string[];
25 | hashtags?: string[];
26 | };
27 |
--------------------------------------------------------------------------------
/src/types/Config.d.ts:
--------------------------------------------------------------------------------
1 | export const enum Config {
2 | ARTWORK_COUNT = 'artwork_count',
3 | REFRESH_TOKEN = 'refresh_token'
4 | }
5 |
--------------------------------------------------------------------------------
/src/types/Contribution.d.ts:
--------------------------------------------------------------------------------
1 | export type Contribution = {
2 | post_url: string;
3 | chat_id: number;
4 | user_id: number;
5 | user_tg_username: string;
6 | user_name: string;
7 | message_id: number;
8 | reply_message_id: number;
9 | create_time: Date;
10 | };
11 |
--------------------------------------------------------------------------------
/src/types/Event.d.ts:
--------------------------------------------------------------------------------
1 | import { Contribution } from './Contribution';
2 |
3 | export type PushEvent = {
4 | thumb_files: string[];
5 | origin_files: string[];
6 | contribution?: Contribution;
7 | origin_file_modified: boolean;
8 | origin_file_id?: string;
9 | };
10 |
11 | export type PublishEvent = {
12 | is_quality: boolean;
13 | picture_index: number[];
14 | artwork_tags: Array;
15 | origin_file_name?: string;
16 | origin_file_modified: boolean;
17 | origin_file_id?: string;
18 | contribution?: Contribution;
19 | };
20 |
21 | export type ExecResult = {
22 | succeed: boolean;
23 | message: string;
24 | };
25 |
--------------------------------------------------------------------------------
/src/types/File.d.ts:
--------------------------------------------------------------------------------
1 | export type File = {
2 | name: string;
3 | file_id: string;
4 | description?: string;
5 | create_time: Date;
6 | };
7 |
--------------------------------------------------------------------------------
/src/types/FxTwitter.d.ts:
--------------------------------------------------------------------------------
1 | export type FxTwitterResp = {
2 | code: number;
3 | message: string;
4 | tweet: FxTwitterTweet;
5 | };
6 |
7 | export type FxTwitterTweet = {
8 | url: string;
9 | id: string;
10 | text: string;
11 | author: FxTwitterUser;
12 | media?: {
13 | photos?: FxTwitterMediaPhotos[];
14 | };
15 | };
16 |
17 | export type FxTwitterUser = {
18 | name: string;
19 | screen_name: string;
20 | id: string;
21 | };
22 |
23 | export type FxTwitterMediaPhotos = {
24 | url: string;
25 | width: number;
26 | height: number;
27 | type: string;
28 | };
29 |
--------------------------------------------------------------------------------
/src/types/Message.d.ts:
--------------------------------------------------------------------------------
1 | export type ChannelMessageType = 'photo' | 'document' | 'text';
2 |
3 | export type ChannelMessage = {
4 | type: T;
5 | message_id: number;
6 | artwork_index: number;
7 | file_id: string;
8 | send_time?: Date;
9 | };
10 |
--------------------------------------------------------------------------------
/src/types/Photo.d.ts:
--------------------------------------------------------------------------------
1 | import Mongoose from '~/database';
2 |
3 | export type Photo = {
4 | artwork_id: Mongoose.Types.ObjectId;
5 | artwork_index: number;
6 | size: {
7 | width: number;
8 | height: number;
9 | };
10 | file_size: number;
11 | file_name: string;
12 | thumb_name?: string;
13 | thumb_file_id: string;
14 | document_file_id: string;
15 | thumb_message_id: number;
16 | document_message_id: number;
17 | create_time: Date;
18 | };
19 |
--------------------------------------------------------------------------------
/src/types/Pixiv.d.ts:
--------------------------------------------------------------------------------
1 | export type PixivAjaxResp = {
2 | error: boolean;
3 | message: string;
4 | body: T;
5 | };
6 |
7 | export type PixivIllust = {
8 | illustId: string;
9 | illustTitle: string;
10 | illustComment: string;
11 | id: string;
12 | title: string;
13 | description: string;
14 | illustType: number;
15 | sl: number;
16 | urls: {
17 | mini: string;
18 | thumb: string;
19 | small: string;
20 | regular: string;
21 | original: string;
22 | };
23 | tags: {
24 | authorId: string;
25 | tags: [
26 | {
27 | tag: string;
28 | locked: boolean;
29 | translation?: {
30 | en: string;
31 | };
32 | }
33 | ];
34 | };
35 | alt: string;
36 | userId: string;
37 | userName: string;
38 | userAccount: string;
39 | pageCount: number;
40 | width: number;
41 | height: number;
42 | };
43 |
44 | export type PixivIllustPages = [
45 | {
46 | urls: {
47 | mini: string;
48 | thumb: string;
49 | small: string;
50 | regular: string;
51 | original: string;
52 | };
53 | width: number;
54 | height: number;
55 | }
56 | ];
57 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import { ClientSession } from 'mongoose';
2 |
3 | declare global {
4 | // eslint-disable-next-line no-var
5 | var currentMongoSession: ClientSession | undefined;
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/axios.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosResponse } from 'axios';
2 | import config from '~/config';
3 | import { PixivAjaxResp } from '~/types/Pixiv';
4 | import logger from './logger';
5 |
6 | export const commonHeaders = {
7 | 'accept-language':
8 | 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja-JP;q=0.6,ja;q=0.5',
9 | 'user-agent':
10 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64;) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.61 Chrome/126.0.6478.61 Not/A)Brand/8 Safari/537.36'
11 | };
12 |
13 | const defaultInstance = axios.create({
14 | headers: commonHeaders
15 | });
16 |
17 | const pixivInstance = axios.create({
18 | headers: {
19 | cookie: config.PIXIV_COOKIE,
20 | ...commonHeaders
21 | }
22 | });
23 |
24 | defaultInstance.interceptors.request.use(req => {
25 | logger.debug(`requesting ${req.url}`);
26 | logger.debug(`request headers: ${JSON.stringify(req.headers)}`);
27 | logger.debug(`request params: ${JSON.stringify(req.params)}`);
28 | logger.debug(`request data: ${JSON.stringify(req.data)}`);
29 |
30 | return req;
31 | });
32 |
33 | defaultInstance.interceptors.response.use((response: AxiosResponse) => {
34 | logger.debug(`response status: ${response.status}`);
35 | logger.debug(`response body: ${JSON.stringify(response.data)}`);
36 |
37 | return response;
38 | });
39 |
40 | pixivInstance.interceptors.response.use(
41 | (response: AxiosResponse>) => {
42 | if (response.status != 200) {
43 | if (response.data.error && response.data.message) {
44 | throw new Error(response.data.message);
45 | } else {
46 | throw new Error(
47 | `pixiv ajax request failed with status code ${response.status}`
48 | );
49 | }
50 | }
51 | return response;
52 | }
53 | );
54 |
55 | export default defaultInstance;
56 |
57 | export { pixivInstance };
58 |
--------------------------------------------------------------------------------
/src/utils/caption.ts:
--------------------------------------------------------------------------------
1 | import config from '~/config';
2 | import {
3 | Artist,
4 | Artwork,
5 | ArtworkInfo,
6 | ArtworkWithMessages
7 | } from '~/types/Artwork';
8 | import { PushEvent } from '~/types/Event';
9 |
10 | const MAX_CAPTION_LENGTH = 1024;
11 |
12 | function replaceHtmlBrackets(text: string) {
13 | return text
14 | .replace(/<(?![/abs])/g, '<')
15 | .replace(/(?/g, '>');
16 | }
17 |
18 | function replaceHtmlBr(text: string) {
19 | return text.replace(/
/g, '\n');
20 | }
21 |
22 | function escapeHtmlTags(text: string) {
23 | return replaceHtmlBrackets(replaceHtmlBr(text));
24 | }
25 | /**
26 | * Cut off description text and remove unclosed tags
27 | *
28 | * @param {string} text The text should escaped the tag
29 | * @param {number} length
30 | * @return {*}
31 | */
32 | function cutDescription(description: string, totalLength: number) {
33 | const unclosedTagRegex = /<([a-zA-Z]+)(?:(?!<\/\1>).)*$/g;
34 |
35 | const expect_slice =
36 | MAX_CAPTION_LENGTH - totalLength + description.length - 3;
37 |
38 | let desc_cut = description.slice(0, expect_slice);
39 |
40 | desc_cut = desc_cut.replace(unclosedTagRegex, '');
41 |
42 | if (desc_cut[desc_cut.length - 1] === '<') {
43 | desc_cut = desc_cut.slice(0, desc_cut.length - 1);
44 | }
45 |
46 | return desc_cut + '...';
47 | }
48 |
49 | function genArtistUrl(artist: Artist) {
50 | switch (artist.type) {
51 | case 'pixiv':
52 | return 'https://www.pixiv.net/users/' + artist.uid;
53 | case 'twitter':
54 | return 'https://twitter.com/' + artist.username;
55 | case 'danbooru':
56 | return 'https://danbooru.donmai.us/artists/' + artist.id;
57 | case 'bilibili':
58 | return 'https://space.bilibili.com/' + artist.uid + '/dynamic';
59 | }
60 | }
61 |
62 | export function pushChannelUrl(message_id: number) {
63 | const channel_username = config.PUSH_CHANNEL.slice(1);
64 |
65 | return `https://t.me/${channel_username}/${message_id}`;
66 | }
67 |
68 | export function artworkCaption(
69 | artwork: Artwork,
70 | artist: Artist,
71 | event_info?: PushEvent
72 | ) {
73 | let caption = '';
74 | if (artwork.title)
75 | caption += `${replaceHtmlBrackets(artwork.title)} \n\n`;
76 |
77 | caption += `来源: ${artwork.source.post_url}\n`;
78 | caption += `画师: `;
79 | caption += `${artist.name}\n`;
80 | if (event_info?.contribution) {
81 | const user_link =
82 | event_info.contribution.user_id > 0
83 | ? `tg://user?id=${event_info.contribution.user_id}`
84 | : `https://t.me/${event_info.contribution.user_tg_username}?profile`;
85 | caption += `投稿 by ${event_info.contribution.user_name}\n`;
86 | }
87 |
88 | if (artwork.desc)
89 | caption += `\n${escapeHtmlTags(
90 | artwork.desc
91 | )}
\n`;
92 |
93 | caption += '\n';
94 |
95 | if (artwork.quality) caption += '#精选 ';
96 |
97 | for (const tag of artwork.tags) {
98 | caption += `#${tag.name} `;
99 | }
100 |
101 | // When the caption length is longer than 1024, cut off the description
102 | if (caption.length > MAX_CAPTION_LENGTH) {
103 | artwork.desc = cutDescription(
104 | escapeHtmlTags(artwork.desc),
105 | caption.length
106 | );
107 | caption = artworkCaption(artwork, artist, event_info);
108 | }
109 |
110 | return caption;
111 | }
112 |
113 | export function infoCmdCaption(artwork_info: ArtworkInfo) {
114 | let caption = '图片下载成功!\n\n';
115 | if (artwork_info.title)
116 | caption += `${replaceHtmlBrackets(artwork_info.title)}\n`;
117 | if (artwork_info.artist) {
118 | caption += `画师: `;
119 | caption += `${
120 | artwork_info.artist.name
121 | }\n`;
122 | }
123 | caption += `尺寸: `;
124 |
125 | caption += artwork_info.photos
126 | .map(photo => `${photo.size.width}x${photo.size.height}`)
127 | .join('/');
128 | if (artwork_info.desc)
129 | caption += `${escapeHtmlTags(
130 | artwork_info.desc
131 | )}
\n`;
132 | if (artwork_info.raw_tags && artwork_info.raw_tags.length > 0) {
133 | caption += '\n';
134 | caption += '';
135 | caption += artwork_info.raw_tags.map(str => `#${str}`).join(' ');
136 | caption += '
';
137 | }
138 |
139 | // When the caption length is longer than 1024, cut off the description
140 | if (caption.length > MAX_CAPTION_LENGTH) {
141 | artwork_info.desc = cutDescription(
142 | escapeHtmlTags(artwork_info.desc),
143 | caption.length
144 | );
145 | caption = infoCmdCaption(artwork_info);
146 | }
147 |
148 | return caption;
149 | }
150 |
151 | export function contributeCaption(artwork_info: ArtworkInfo) {
152 | let caption = '感谢投稿 ! 正在召唤 @Revincx_Rua \n\n';
153 | caption += `链接: ${artwork_info.post_url}\n`;
154 | caption += `尺寸: ${artwork_info.photos[0].size.width}x${artwork_info.photos[0].size.height}\n\n`;
155 | if (artwork_info.raw_tags && artwork_info.raw_tags.length > 0) {
156 | caption += '';
157 | caption += artwork_info.raw_tags.map(str => `#${str}`).join(' ');
158 | caption += '
';
159 | caption += '\n';
160 | }
161 |
162 | return caption;
163 | }
164 |
165 | export function randomCaption(
166 | artwork: Artwork | ArtworkWithMessages,
167 | tags?: string[]
168 | ) {
169 | let caption = '';
170 | if (artwork.title && artwork.source.post_url)
171 | caption += `${replaceHtmlBrackets(
172 | artwork.title
173 | )}\n\n`;
174 |
175 | caption += `这是你要的`;
176 |
177 | if (tags && tags.length > 0) {
178 | caption += ' ';
179 | caption += tags.map(str => `#${str}`).join(' ');
180 | caption += ' ';
181 | }
182 |
183 | caption += '壁纸~';
184 |
185 | return caption;
186 | }
187 |
--------------------------------------------------------------------------------
/src/utils/decorators.ts:
--------------------------------------------------------------------------------
1 | // import { ExecResult } from "~/types/Event"
2 |
3 | // export function ErrCatch(target: Function): ExecResult
4 | // {
5 | // try{
6 | // target()
7 | // }
8 | // catch (err) {
9 | // if(err instanceof Error)
10 | // {
11 | // console.log(err.message)
12 | // return {
13 | // succeed: false,
14 | // message: err.message
15 | // }
16 | // }
17 | // return {
18 | // succeed: false,
19 | // message: "未知原因"
20 | // }
21 | // }
22 | // }
23 |
--------------------------------------------------------------------------------
/src/utils/download.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 | import axios from 'axios';
4 | import config from '~/config';
5 | import logger from './logger';
6 |
7 | export default async function downloadFile(
8 | url: string,
9 | file_name?: string,
10 | sub_dir = ''
11 | ): Promise {
12 | file_name = file_name ? file_name : path.basename(url);
13 | logger.info('Start download file ' + file_name);
14 | const file_path = path.resolve(config.TEMP_DIR, sub_dir, file_name);
15 |
16 | if (sub_dir && !fs.existsSync(path.resolve(config.TEMP_DIR, sub_dir))) {
17 | fs.mkdirSync(path.resolve(config.TEMP_DIR, sub_dir), {
18 | recursive: true
19 | });
20 | }
21 |
22 | const headers = {};
23 |
24 | if (url.includes('pximg.net')) {
25 | Object.assign(headers, {
26 | referer: 'https://www.pixiv.net/'
27 | });
28 | }
29 |
30 | if (url.includes('hdslb.com')) {
31 | Object.assign(headers, {
32 | referer: 'https://www.bilibili.com/'
33 | });
34 | }
35 |
36 | try {
37 | logger.debug('Downloading file from URL: ' + url);
38 |
39 | const response = await axios.get(url, {
40 | responseType: 'arraybuffer',
41 | headers
42 | });
43 |
44 | fs.writeFileSync(file_path, response.data);
45 |
46 | logger.info('File ' + file_name + ' downloaded');
47 | } catch (e) {
48 | logger.error('Download file ' + file_name + ' failed: ' + e);
49 | throw new Error('无法下载 ' + file_name);
50 | }
51 |
52 | // 用 Stream 经常会出现这边写完那边读不到的问题,很奇怪
53 | // let stream = fs.createWriteStream(file_path)
54 | // stream.write(response.data)
55 |
56 | return file_name;
57 | }
58 |
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import pino from 'pino';
2 |
3 | export default pino({
4 | transport: {
5 | target: 'pino-pretty',
6 | options: {
7 | colorize: process.env.DEV_MODE
8 | }
9 | },
10 | level: process.env.LOG_LEVEL || 'info'
11 | });
12 |
--------------------------------------------------------------------------------
/src/utils/param-parser.ts:
--------------------------------------------------------------------------------
1 | import { CommandEntity } from '~/types/Command';
2 | import logger from './logger';
3 | import { MessageEntity } from 'telegraf/typings/core/types/typegram';
4 |
5 | export function parseParams(
6 | command: string,
7 | entities?: MessageEntity[]
8 | ): CommandEntity {
9 | logger.debug(`parsing command: ${command}`);
10 |
11 | command = command.trim();
12 |
13 | const str_array = command.split(' ');
14 |
15 | const cmd_entity: CommandEntity = {
16 | name:
17 | str_array[0].indexOf('@') == -1
18 | ? str_array[0].slice(1)
19 | : str_array[0].slice(1, str_array[0].indexOf('@')),
20 | params: {}
21 | };
22 |
23 | if (str_array.length == 1) return cmd_entity;
24 |
25 | // exprimental: parse from entities
26 |
27 | if (entities?.length > 0) {
28 | cmd_entity.urls = entities
29 | .filter(entity => entity.type === 'url')
30 | .map(entity =>
31 | command.substring(entity.offset, entity.offset + entity.length)
32 | );
33 |
34 | cmd_entity.hashtags = entities
35 | .filter(entity => entity.type === 'hashtag')
36 | .map(entity =>
37 | command.substring(
38 | entity.offset + 1,
39 | entity.offset + entity.length
40 | )
41 | );
42 | }
43 |
44 | for (let i = 1; i < str_array.length; i++) {
45 | if (i == str_array.length - 1) {
46 | // This part is url, skip param parse
47 | if (cmd_entity.urls?.indexOf(str_array[i]) > -1) {
48 | cmd_entity.target = str_array[i];
49 | break;
50 | }
51 | // This part is hashtag, skip param parse
52 | if (cmd_entity.hashtags?.indexOf(str_array[i].slice(1)) > -1) break;
53 |
54 | // last part without equal sign, it's target
55 | if (str_array[i].indexOf('=') == -1) {
56 | cmd_entity.target = str_array[i];
57 | break;
58 | }
59 | }
60 |
61 | if (str_array[i].indexOf('=') > -1) {
62 | // parse params
63 | const param_pair = str_array[i].split('=');
64 | const param_name = param_pair.shift();
65 |
66 | if (param_name)
67 | cmd_entity.params[param_name] = param_pair.join('=');
68 | }
69 | }
70 |
71 | return cmd_entity;
72 | }
73 |
74 | export function semiIntArray(str: string): number[] {
75 | return str.search(',') == -1
76 | ? [parseInt(str)]
77 | : str
78 | .split(/,|,/)
79 | .map(item => parseInt(item))
80 | .sort((a, b) => a - b);
81 | }
82 |
--------------------------------------------------------------------------------
/src/utils/sharp.ts:
--------------------------------------------------------------------------------
1 | import sharp from 'sharp';
2 | import path from 'path';
3 | import config from '~/config';
4 | import fs from 'fs/promises';
5 | import type { ImageSize } from '~/types/Artwork';
6 |
7 | export async function getImageSize(file_name: string) {
8 | const file_path = path.resolve(config.TEMP_DIR, file_name);
9 |
10 | const { width, height } = await sharp(file_path).metadata();
11 |
12 | return { width, height } as ImageSize;
13 | }
14 |
15 | export async function getFileSize(file_name: string) {
16 | const file_path = path.resolve(config.TEMP_DIR, file_name);
17 | const stats = await fs.stat(file_path);
18 |
19 | return stats.size;
20 | }
21 |
22 | export async function resizeFitChannelPhoto(
23 | origin_file_path: string,
24 | thumb_file_path: string
25 | ) {
26 | const file_name = path.basename(origin_file_path);
27 | // First, if the file is png, convert it to jpeg with max size 10MB, then resize at max 2560x2560
28 | const output_name = `channel_${file_name}`;
29 | const output_path = path.resolve(config.TEMP_DIR, output_name);
30 | const output = await sharp(origin_file_path)
31 | .jpeg({ quality: 98 })
32 | .resize({ width: 2560, height: 2560, fit: 'inside' })
33 | .toFile(output_path);
34 |
35 | if (output.size < 10 * 1024 * 1024) return output_path;
36 |
37 | // Process again with quality 80
38 | const output_quality_80 = await sharp(output_path)
39 | .jpeg({ quality: 80 })
40 | .toFile(output_path);
41 |
42 | if (output_quality_80.size < 10 * 1024 * 1024) return output_path;
43 |
44 | // Give up and use thumbnail file
45 | return thumb_file_path;
46 | }
47 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 | /* Basic Options */
5 | // "incremental": true, /* Enable incremental compilation */
6 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
7 | "module": "CommonJS",
8 | // "moduleResolution": "node10",
9 | /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
10 | // "lib": [
11 | // "ES2015",
12 | // "DOM"
13 | // ] /* Specify library files to be included in the compilation. */,
14 | "allowJs": true, /* Allow javascript files to be compiled. */
15 | // "checkJs": true, /* Report errors in .js files. */
16 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
17 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
18 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
19 | // "sourceMap": true, /* Generates corresponding '.map' file. */
20 | // "outFile": "./", /* Concatenate and emit output to single file. */
21 | "outDir": "./dist", /* Redirect output structure to the directory. */
22 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
23 | // "composite": true, /* Enable project compilation */
24 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
25 | "resolveJsonModule": true,
26 | "removeComments": true /* Do not emit comments to output. */,
27 | // "noEmit": true, /* Do not emit outputs. */
28 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
31 | /* Strict Type-Checking Options */
32 | "strict": true /* Enable all strict type-checking options. */,
33 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
34 | "strictNullChecks": false /* Enable strict null checks. */,
35 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
36 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
37 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
40 | /* Additional Checks */
41 | // "noUnusedLocals": true, /* Report errors on unused locals. */
42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
45 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
46 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
47 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
48 | /* Module Resolution Options */
49 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
50 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */,
51 | "paths": {
52 | "~/*": ["src/*"]
53 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
54 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
55 | // "typeRoots": [], /* List of folders to include type definitions from. */
56 | // "types": [], /* Type declaration files to be included in compilation. */
57 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
58 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
59 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
60 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
61 | /* Source Map Options */
62 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
63 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
64 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
65 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
66 | /* Experimental Options */
67 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
68 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
69 | /* Advanced Options */
70 | "skipLibCheck": true /* Skip type checking of declaration files. */,
71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
72 | // "ignoreDeprecations": "5.0",
73 | "typeRoots": [
74 | "node_modules/@types",
75 | "src/types"
76 | ]
77 | },
78 | "include": [
79 | "src/**/*",
80 | "test/**/*",
81 | "docs/**/*"
82 | ],
83 | "exclude": [
84 | "node_modules",
85 | "**/*.spec.ts",
86 | "**/*.ts.bak"
87 | ]
88 | }
89 |
--------------------------------------------------------------------------------