├── .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 | SomeACG 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 | --------------------------------------------------------------------------------