├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── config.example.ts ├── ecosystem.config.js ├── global.d.ts ├── nodemon.json ├── package.json ├── patches └── oicq+2.3.1.patch ├── pnpm-lock.yaml ├── readme.md ├── scripts └── check-config.js ├── src ├── client.ts ├── constants │ └── env.ts ├── handlers │ ├── group │ │ ├── index.ts │ │ ├── tasks │ │ │ ├── handleCommand.ts │ │ │ ├── handleMention.ts │ │ │ ├── handleMuti.ts │ │ │ └── handleSingle.ts │ │ └── types.ts │ └── shared │ │ ├── commands │ │ └── tool.ts │ │ └── repeater.ts ├── index.ts ├── modules │ ├── _novelai │ │ ├── api.ts │ │ ├── ban.ts │ │ ├── index.ts │ │ └── logger.ts │ ├── bilibili │ │ ├── index.ts │ │ └── types │ │ │ ├── live.ts │ │ │ ├── room.ts │ │ │ └── user.ts │ ├── github │ │ ├── constants │ │ │ └── bot.ts │ │ ├── index.ts │ │ └── types │ │ │ ├── check-run.ts │ │ │ ├── issue.ts │ │ │ ├── pull-request.ts │ │ │ ├── push.ts │ │ │ └── workflow.ts │ ├── health-check │ │ └── index.ts │ ├── index.ts │ ├── mx-space │ │ ├── api-client.ts │ │ ├── api │ │ │ └── hitokoto.ts │ │ ├── event-handler.ts │ │ ├── index.ts │ │ ├── message.ts │ │ ├── socket.ts │ │ ├── store │ │ │ ├── aggregate.ts │ │ │ └── user.ts │ │ ├── types.ts │ │ ├── types │ │ │ └── mx-socket-types.ts │ │ └── utils │ │ │ └── fetch-image.ts │ └── openai │ │ ├── index.ts │ │ └── test.ts ├── registries │ ├── command.ts │ └── mention.ts └── utils │ ├── helper.ts │ ├── logger.ts │ ├── message.ts │ ├── plugin.ts │ ├── queue.ts │ └── time.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@innei/eslint-config-ts') 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [innei] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://afdian.net/@Innei'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js Build CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Cache pnpm modules 27 | uses: actions/cache@v3 28 | env: 29 | cache-name: cache-pnpm-modules 30 | with: 31 | path: ~/.pnpm-store 32 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 33 | restore-keys: | 34 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 35 | - uses: pnpm/action-setup@v2.2.1 36 | with: 37 | version: latest 38 | run_install: false 39 | - name: Install Dependencies 40 | run: | 41 | pnpm i --no-optional 42 | - name: Build project 43 | run: | 44 | npm run build 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Release 7 | 8 | jobs: 9 | build: 10 | name: Upload Release Asset 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | outputs: 16 | release_url: ${{ steps.create_release.outputs.upload_url }} 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: 16.x 24 | - name: Start MongoDB 25 | uses: supercharge/mongodb-github-action@1.7.0 26 | with: 27 | mongodb-version: 4.4 28 | - name: Start Redis 29 | uses: supercharge/redis-github-action@1.4.0 30 | with: 31 | redis-version: 6 32 | - name: Cache pnpm modules 33 | uses: actions/cache@v3 34 | env: 35 | cache-name: cache-pnpm-modules 36 | with: 37 | path: ~/.pnpm-store 38 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 41 | - uses: pnpm/action-setup@v2.2.1 42 | with: 43 | version: latest 44 | run_install: true 45 | 46 | - name: Build project 47 | run: | 48 | pnpm run build 49 | - name: Create Release 50 | id: create_release 51 | uses: actions/create-release@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | tag_name: ${{ github.ref }} 56 | release_name: Release ${{ github.ref }} 57 | draft: false 58 | prerelease: false 59 | - name: Upload Release Asset 60 | id: upload-release-asset 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./build 67 | asset_name: release-${{ matrix.os }}.zip 68 | asset_content_type: application/zip 69 | # deploy: 70 | # name: Deploy To Remote Server 71 | # runs-on: ubuntu-latest 72 | # needs: [build] 73 | # steps: 74 | # - name: Exec deploy script with SSH 75 | # uses: appleboy/ssh-action@master 76 | # env: 77 | # JWTSECRET: ${{ secrets.JWTSECRET }} 78 | # with: 79 | # command_timeout: 10m 80 | # host: ${{ secrets.HOST }} 81 | # username: ${{ secrets.USER }} 82 | # password: ${{ secrets.PASSWORD }} 83 | # envs: JWTSECRET 84 | # script_stop: true 85 | # script: | 86 | # whoami 87 | # cd 88 | # source ~/.zshrc 89 | # cd mx 90 | # ls -a 91 | # node server-deploy.js --jwtSecret=$JWTSECRET 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/data 2 | config.ts 3 | 4 | dist 5 | build 6 | node_modules 7 | 8 | formula.txt 9 | data 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # https://zenn.dev/haxibami/scraps/083718c1beec04 2 | strict-peer-dependencies=false 3 | 4 | registry=https://registry.npmjs.org 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@innei/prettier') 2 | -------------------------------------------------------------------------------- /config.example.ts: -------------------------------------------------------------------------------- 1 | export const botConfig = { 2 | groupIds: [615052447, 615052525], 3 | 4 | ownerId: 1003521738, 5 | 6 | uid: 926284623, 7 | password: '', 8 | 9 | githubHook: { 10 | secret: '', 11 | port: 7777, 12 | mentionId: 1003521738, 13 | watchGroupIds: [ 14 | 615052525, 15 | // 615052525 16 | ], 17 | }, 18 | 19 | bilibili: { 20 | live: { 21 | id: 1434499, 22 | atAll: true, 23 | }, 24 | watchGroupIds: [ 25 | 615052525, 26 | // 615052525 27 | ], 28 | }, 29 | 30 | errorNotify: { 31 | groupId: 615052525, 32 | }, 33 | 34 | mxSpace: { 35 | apiEndpoint: 'https://api.innei.ren/v2', 36 | gateway: 'https://api.innei.ren/system', 37 | token: '', 38 | 39 | watchGroupIds: [ 40 | 615052525, 41 | // 615052525 42 | ], 43 | }, 44 | novelai: { 45 | token: '', 46 | }, 47 | chatgpt: { 48 | token: '', 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'mx-bot', 3 | // script: './build/index.js', 4 | script: 'npm run prod', 5 | autorestart: true, 6 | } 7 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import { Consola } from 'consola' 2 | 3 | export {} 4 | declare global { 5 | export const consola: Consola 6 | 7 | interface globalThis { 8 | [key: string]: any 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src", "./config.ts"], 3 | "ignore": ["src/**/*.test.ts"], 4 | "ext": "ts,mjs,js,json,graphql", 5 | "exec": "tsx src/index.ts", 6 | "legacyWatch": true 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imx-bot", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "prepare": "node ./scripts/check-config.js", 7 | "dev": "NODE_ENV=development nodemon", 8 | "build": "tsc", 9 | "bundle": "esbuild src/index.ts --platform=node --bundle --minify --outfile=build/index.js", 10 | "prod": "NODE_ENV=production TS_NODE_BASEURL=./dist node -r tsconfig-paths/register ./dist/src/index.js", 11 | "d": "npm run rebuild-start", 12 | "rebuild-start": "git pull && npm run build && pm2 reload ecosystem.config.js --update-env" 13 | }, 14 | "dependencies": { 15 | "@innei/next-async": "0.3.0", 16 | "@mx-space/api-client": "1.4.3", 17 | "@types/cron": "2.0.1", 18 | "@types/lodash": "4.14.195", 19 | "@types/yargs": "17.0.24", 20 | "axios": "1.4.0", 21 | "chalk": "4.1.2", 22 | "consola": "2.15.3", 23 | "cron": "2.3.1", 24 | "dayjs": "1.11.9", 25 | "github-webhook-handler": "1.0.0", 26 | "icqq": "0.4.7", 27 | "isomorphic-fetch": "^3.0.0", 28 | "lodash": "4.17.21", 29 | "openai": "^3.3.0", 30 | "randomcolor": "0.6.2", 31 | "remove-markdown": "0.5.0", 32 | "socket.io-client": "4.7.1", 33 | "yargs": "17.7.2" 34 | }, 35 | "devDependencies": { 36 | "@innei/eslint-config-ts": "0.10.3", 37 | "@innei/prettier": "0.10.3", 38 | "@types/node": "20.3.3", 39 | "@types/randomcolor": "0.5.7", 40 | "nodemon": "2.0.22", 41 | "tsconfig-paths": "4.2.0", 42 | "tsx": "^3.12.7", 43 | "typescript": "5.1.6" 44 | }, 45 | "resolutions": { 46 | "axios": "0.27.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /patches/oicq+2.3.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/core/device.js b/lib/core/device.js 2 | index ffadc63..57da54d 100644 3 | --- a/lib/core/device.js 4 | +++ b/lib/core/device.js 5 | @@ -34,7 +34,7 @@ function generateImei(uin) { 6 | return imei + calcSP(imei); 7 | } 8 | /** 生成短设备信息 */ 9 | -function generateShortDevice(uin) { 10 | +function generateShortDeviceDeprecated(uin) { 11 | const hash = (0, constants_1.md5)(String(uin)); 12 | const hex = hash.toString("hex"); 13 | return { 14 | @@ -56,6 +56,45 @@ function generateShortDevice(uin) { 15 | "--end--": "修改后可能需要重新验证设备", 16 | }; 17 | } 18 | +/** 生成随机设备信息 */ 19 | +function generateShortDevice(uin) { 20 | + function randomString(length) { 21 | + const pool = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 22 | + let result = ""; 23 | + 24 | + for (let i = 0; i < length; i++) { 25 | + result += pool.charAt(Math.floor(Math.random() * pool.length)); 26 | + } 27 | + 28 | + return result; 29 | + } 30 | + 31 | + function randomInt(boundary) { 32 | + return Math.floor(Math.random() * boundary); 33 | + } 34 | + 35 | + return { 36 | + "--begin--": "该设备由账号作为seed固定生成,账号不变则永远相同", 37 | + "--notice--": "The code that generated this file was patched by Adachi-BOT", 38 | + product: `ILPP-${randomString(5).toUpperCase()}`, 39 | + device: `${randomString(5).toUpperCase()}`, 40 | + board: `${randomString(5).toUpperCase()}`, 41 | + brand: `${randomString(4).toUpperCase()}`, 42 | + model: `ILPP ${randomString(4).toUpperCase()}`, 43 | + wifi_ssid: `HUAWEI-${randomString(7)}`, 44 | + bootloader: "U-boot", 45 | + android_id: `IL.${randomInt(10000000)}.${randomInt(10000)}`, 46 | + boot_id: `${randomString(8)}-${randomString(4)}-${randomString(4)}-${randomString(4)}-${randomString(12)}`, 47 | + proc_version: `Linux version 5.10.101-android12-${randomString(8)}`, 48 | + mac_address: `2D:${randomString(2).toUpperCase()}:${randomString(2).toUpperCase()}:${randomString( 49 | + 2 50 | + ).toUpperCase()}:${randomString(2).toUpperCase()}:${randomString(2).toUpperCase()}`, 51 | + ip_address: `192.168.${randomInt(255)}.${randomInt(255)}`, 52 | + imei: `86${randomInt(10000000000000)}`, 53 | + incremental: `${randomString(10).toUpperCase()}`, 54 | + "--end--": "修改后可能需要重新验证设备。", 55 | + }; 56 | +} 57 | exports.generateShortDevice = generateShortDevice; 58 | /** 生成完整设备信息 */ 59 | function generateFullDevice(d) { 60 | @@ -104,16 +143,19 @@ var Platform; 61 | Platform[Platform["iPad"] = 5] = "iPad"; 62 | })(Platform = exports.Platform || (exports.Platform = {})); 63 | const mobile = { 64 | + // XXX Here update device informations based on mirai 65 | + // https://github.com/mamoe/mirai/blob/c9d1d386b16e886ad63eb51e6baf7270fcf2fa7b/mirai-core/src/commonMain/kotlin/utils/MiraiProtocolInternal.kt 66 | id: "com.tencent.mobileqq", 67 | - name: "A8.8.80.7400", 68 | - version: "8.8.80.7400", 69 | - ver: "8.8.80", 70 | - sign: Buffer.from([166, 183, 69, 191, 36, 162, 194, 119, 82, 119, 22, 246, 243, 110, 182, 141]), 71 | + name: "A8.9.50.f5a7d351", 72 | + version: "8.9.50.10650", 73 | + ver: "8.9.50", 74 | + sign: Buffer.from([0xA6, 0xB7, 0x45, 0xBF, 0x24, 0xA2, 0xC2, 0x77, 0x52, 0x77, 0x16, 0xF6, 0xF3, 0x6E, 0xB6, 0x8D]), 75 | - buildtime: 1640921786, 76 | + buildtime: 1676531414, 77 | appid: 16, 78 | - subid: 537113159, 79 | - bitmap: 184024956, 80 | - sigmap: 34869472, 81 | + subid: 537155551, 82 | + bitmap: 150470524, 83 | + sigmap: 16724722, 84 | + subsigmap: 0x10400, 85 | - sdkver: "6.0.0.2494", 86 | + sdkver: "6.0.0.2535", 87 | display: "Android", 88 | }; -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # imx-bot 2 | 3 | 一个自用的 QQ 机器人,提供以下功能。持续更新中... 4 | 5 | - GitHub Webhook 推送 6 | - [Mix Space](https://github.com/mx-space) 事件推送 7 | - 早安晚安 8 | - 欢迎新人 9 | - 错误通知 10 | - 工具 (IP Query, Base64, MD5) 11 | - 复读机 12 | - 第三方 NovelAI 绘图 13 | 14 | ## 示例 15 | 16 | **交互式** 17 | 18 | ``` 19 | > /tool —md5 "111:" 20 | 21 | < md5 结果: 5b1c3849efa2d27d3f1b9c63f1fb3a62 22 | ``` 23 | 24 | ``` 25 | > /tool —base64 MQ== -d 26 | < base64 解码结果: 1 27 | 28 | > /tool —base64 1 29 | < base64 编码结果: MQ== 30 | ``` 31 | 32 | ``` 33 | > /tool —ip 110.143.148.18 34 | 35 | < IP: 110.143.148.18 36 | 城市: 澳大利亚 - 澳大利亚 37 | ISP: N/A 38 | 组织: N/A 39 | 范围: 110.140.0.0 - 110.143.255.255 40 | ``` 41 | 42 | ``` 43 | > /mx_stat 44 | 45 | < 来自 Mx Space 的状态信息: 46 | 47 | 当前有文章 99 篇,生活记录 99 篇,评论 334 条,友链 43 条,说说 27 条,速记 21 条。 48 | 未读评论 0 条,友链申请 0 条。 49 | 今日访问 197 次,最高在线 8 人,总计在线 234 人。 50 | 调用次数 1984607 次,当前在线 3 人。 51 | ``` 52 | 53 | **通知式** 54 | 55 | ``` 56 | > 晚安,早点睡哦! 57 | 58 | 若不爱你,死生无地。若不爱你,青魂可离。 59 | 60 | ``` 61 | 62 | ``` 63 | > mx-server 发布了一个新版本 v3.24.5,前往查看: 64 | https://github.com/mx-space/mx-server/releases/tag/v3.24.5 65 | ``` 66 | 67 | ``` 68 | > Innei 向 mx-server 提交了一个更改 69 | 70 | release: v3.24.5 71 | ``` 72 | 73 | ``` 74 | > @Innei mx-server CI 挂了!!!! 75 | 查看原因: https://github.com/mx-space/mx-server/runs/6054082406?check_suite_focus=true 76 | ``` 77 | 78 | ``` 79 | > Innei 发布了新生活观察日记: 没有尽头的日子 80 | 心情: 悲哀 天气: 多云 81 | 不知不觉已经来上海两个月了,开始实习也已经一个半月了。 82 | 83 | 突然觉得时间过得好快,但是又过的好慢。自从小区被封,也快一个月了。我都不知道我也熬到了现在。但是未来的日子仍然是个未知数,没人知道什么时候能解封,小区天天有人被确诊,拉走一车一车的小阳人,每天醒来就是重置 14 天,就是连「开端」都不敢这么拍吧。隔天进行一次核酸,也不知道下一个阳的会不会是自己,每天生活在这样的环境下,又要担心被拉走又要好好... 84 | 85 | 前往阅读: https://innei.ren/notes/114 86 | ``` 87 | 88 | ``` 89 | > Apr 20, 2022, 10:15:27 PM GMT+8 90 | [TypeError] The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received type number (1) 91 | TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received type number (1) 92 | at new NodeError (node:internal/errors:371:5) 93 | at Function.from (node:buffer:322:9) 94 | at toolCommand (/Users/xiaoxun/github/innei-repo/app/dist/src/handlers/shared/commands/tool.js:33:28) 95 | at handleCommandMessage (/Users/xiaoxun/github/innei-repo/app/dist/src/handlers/shared/command.js:22:61) 96 | at processTicksAndRejections (node:internal/process/task_queues:96:5) 97 | at async handleSingleMessage (/Users/xiaoxun/github/innei-repo/app/dist/src/handlers/group/single.js:13:32) 98 | at async groupMessageHandler (/Users/xiaoxun/github/innei-repo/app/dist/src/handlers/group/index.js:11:16) 99 | at async Client. (/Users/xiaoxun/github/innei-repo/app/dist/src/client.js:18:16) 100 | ``` 101 | -------------------------------------------------------------------------------- /scripts/check-config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const exist = fs.existsSync(path.join(__dirname, '../config.ts')) 4 | if (!exist) { 5 | fs.cpSync( 6 | path.join(__dirname, '../config.example.ts'), 7 | path.join(__dirname, '../config.ts'), 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { createClient, Platform } from 'icqq' 3 | import { botConfig } from '../config' 4 | import { groupMessageHandler } from './handlers/group' 5 | 6 | const client = createClient({ 7 | log_level: process.env.NODE_ENV === 'development' ? 'debug' : 'warn', 8 | platform: Platform.old_Android, 9 | data_dir: path.join(process.cwd(), 'data'), 10 | }) 11 | 12 | client.on('system.online', () => console.log('Logged in!')) 13 | client.on('message.group', async (e) => { 14 | const { group_id } = e 15 | 16 | if (botConfig.groupIds.includes(group_id)) { 17 | return await groupMessageHandler(e) 18 | } 19 | }) 20 | 21 | client.on('system.login.slider', (e) => { 22 | console.log(`输入滑块地址获取的 ticket 后继续。\n滑块地址: ${e.url}`) 23 | process.stdin.once('data', (data) => { 24 | client.submitSlider(data.toString().trim()) 25 | }) 26 | }) 27 | client.on('system.login.qrcode', (e) => { 28 | console.log('扫码完成后回车继续: ') 29 | process.stdin.once('data', () => { 30 | client.login() 31 | }) 32 | }) 33 | client.on('system.login.device', (e) => { 34 | console.log('请选择验证方式:(1:短信验证 其他:扫码验证)') 35 | process.stdin.once('data', (data) => { 36 | if (data.toString().trim() === '1') { 37 | client.sendSmsCode() 38 | console.log('请输入手机收到的短信验证码:') 39 | process.stdin.once('data', (res) => { 40 | client.submitSmsCode(res.toString().trim()) 41 | }) 42 | } else { 43 | console.log(`扫码完成后回车继续:${e.url}`) 44 | process.stdin.once('data', () => { 45 | client.login() 46 | }) 47 | } 48 | }) 49 | }) 50 | client.login(botConfig.uid, botConfig.password) 51 | 52 | process.on('uncaughtException', (err) => { 53 | console.error(err) 54 | client.sendGroupMsg( 55 | botConfig.errorNotify.groupId, 56 | `${formatNow()}\n[${err.name || 'ERROR'}] ${err.message}\n${err.stack}`, 57 | ) 58 | }) 59 | process.on('unhandledRejection', (err) => { 60 | console.error(err) 61 | if (err instanceof Error) { 62 | client.sendGroupMsg( 63 | botConfig.errorNotify.groupId, 64 | `${formatNow()}\n[${err.name || 'ERROR'}] ${err.message}\n${err.stack}`, 65 | ) 66 | } else if (typeof err === 'string') { 67 | client.sendGroupMsg( 68 | botConfig.errorNotify.groupId, 69 | `${formatNow()}\n[ERROR] ${err}`, 70 | ) 71 | } 72 | }) 73 | 74 | export { client } 75 | 76 | function formatNow() { 77 | return Intl.DateTimeFormat(undefined, { 78 | timeStyle: 'long', 79 | dateStyle: 'medium', 80 | }).format(new Date()) 81 | } 82 | -------------------------------------------------------------------------------- /src/constants/env.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV === 'development' 2 | 3 | export const userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 imx` 4 | -------------------------------------------------------------------------------- /src/handlers/group/index.ts: -------------------------------------------------------------------------------- 1 | import type { GroupMessageEvent } from 'icqq' 2 | import { Co } from '@innei/next-async' 3 | import { commandMessageRoutine } from './tasks/handleCommand' 4 | import { mentionRoutine } from './tasks/handleMention' 5 | import { multiMessageElemRoutine } from './tasks/handleMuti' 6 | import { groupSingleTextMessageAction } from './tasks/handleSingle' 7 | 8 | export const groupMessageHandler = async (e: GroupMessageEvent) => { 9 | consola.debug(e.message) 10 | const coTask = new Co( 11 | {}, 12 | { 13 | automaticNext: false, 14 | catchAbortError: true, 15 | }, 16 | ) 17 | coTask.use( 18 | groupSingleTextMessageAction, 19 | mentionRoutine, 20 | multiMessageElemRoutine, 21 | commandMessageRoutine, 22 | ) 23 | await coTask.start(e) 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /src/handlers/group/tasks/handleCommand.ts: -------------------------------------------------------------------------------- 1 | import PKG from 'package.json' 2 | 3 | import { isDev } from '~/constants/env' 4 | import { toolCommand } from '~/handlers/shared/commands/tool' 5 | import { commandRegistry } from '~/registries/command' 6 | import { createNamespaceLogger } from '~/utils/logger' 7 | import { checkIsSendable } from '~/utils/message' 8 | 9 | import type { GroupCoRoutine } from '../types' 10 | 11 | const logger = createNamespaceLogger('commander') 12 | export const commandMessageRoutine: GroupCoRoutine = async function (event) { 13 | if (!event.commandMessage || !event.commandName) { 14 | this.next() 15 | return 16 | } 17 | 18 | const { commandName, commandParsedArgs: args, shouldQuote = false } = event 19 | 20 | logger.debug('commandName: ', commandName) 21 | 22 | switch (commandName) { 23 | case 'tool': { 24 | event.reply(await toolCommand(args), shouldQuote) 25 | break 26 | } 27 | case 'ping': 28 | event.reply('pong', shouldQuote) 29 | break 30 | case 'version': 31 | if (isDev) { 32 | event.reply(`v${PKG.version} (dev)`, shouldQuote) 33 | break 34 | } 35 | event.reply( 36 | `imx-bot: v${PKG.version || process.env.npm_package_version}` + 37 | '\n' + 38 | `author: Innei\nframework: oicq-bot`, 39 | shouldQuote, 40 | ) 41 | break 42 | case 'uptime': { 43 | const T = performance.now() 44 | const M = 24 * 60 * 60 * 1000 45 | const a = T / M 46 | const A = Math.floor(a) 47 | const b = (a - A) * 24 48 | const B = Math.floor(b) 49 | const c = (b - B) * 60 50 | const C = Math.floor((b - B) * 60) 51 | const D = Math.floor((c - C) * 60) 52 | 53 | const message = `已运行: ${A}天${B}小时${C}分${D}秒` 54 | 55 | event.reply(message, shouldQuote) 56 | break 57 | } 58 | } 59 | 60 | // handle command registry 61 | 62 | const handler = commandRegistry.getHandler(commandName) 63 | 64 | if (handler) { 65 | const result = await handler(event) 66 | const isSendable = checkIsSendable(result) 67 | 68 | if (isSendable) { 69 | event.reply(result as any, shouldQuote) 70 | } 71 | 72 | this.abort() 73 | } 74 | 75 | const wildcardHandlerList = commandRegistry.handlerList 76 | for await (const handler of wildcardHandlerList) { 77 | const result = await handler(event, this.abort) 78 | const isSendable = checkIsSendable(result) 79 | 80 | if (isSendable && result) { 81 | event.reply(result, shouldQuote) 82 | 83 | // 终止 84 | this.abort() 85 | } 86 | } 87 | 88 | // 没有匹配到命令也终止 89 | // this.abort() 90 | } 91 | -------------------------------------------------------------------------------- /src/handlers/group/tasks/handleMention.ts: -------------------------------------------------------------------------------- 1 | import { botConfig } from 'config' 2 | 3 | import { mentionRegistry } from '~/registries/mention' 4 | import { praseCommandMessage } from '~/utils/message' 5 | 6 | import type { GroupCoRoutine } from '../types' 7 | 8 | // [ 9 | // { type: 'at', qq: 926284623, text: '@金色离婚证' }, 10 | // { type: 'text', text: ' ' } 11 | // ] 12 | 13 | export const mentionRoutine: GroupCoRoutine = async function (event) { 14 | const messages = event.message 15 | 16 | const mentionMessageIndex = messages.findIndex((message) => { 17 | return message.type === 'at' && message.qq === botConfig.uid 18 | }) 19 | 20 | const isMentionMessage = mentionMessageIndex !== -1 21 | 22 | if (!isMentionMessage) { 23 | this.next() 24 | return 25 | } 26 | // const mentionMessageElem = messages[mentionMessageIndex] 27 | const afterMentionMessageElem = messages[mentionMessageIndex + 1] 28 | 29 | if (afterMentionMessageElem) { 30 | const isText = afterMentionMessageElem.type == 'text' 31 | 32 | if (isText) { 33 | const result = await praseCommandMessage( 34 | afterMentionMessageElem.text, 35 | afterMentionMessageElem, 36 | ) 37 | 38 | Object.assign(event, result) 39 | event.commandMessage = afterMentionMessageElem 40 | event.shouldQuote = true 41 | 42 | await this.next() 43 | 44 | mentionRegistry.runWaterfall(event) 45 | this.abort() 46 | return 47 | } 48 | } else { 49 | event.reply('没事别艾特我!!') 50 | } 51 | 52 | this.abort() 53 | } 54 | -------------------------------------------------------------------------------- /src/handlers/group/tasks/handleMuti.ts: -------------------------------------------------------------------------------- 1 | import type { TextElem } from 'icqq' 2 | import { praseCommandMessage } from '~/utils/message' 3 | import type { GroupCoRoutine } from '../types' 4 | 5 | export const multiMessageElemRoutine: GroupCoRoutine = async function (event) { 6 | const { message } = event 7 | 8 | event.message.forEach((elem) => { 9 | ;(elem as any).messageElems = event.message 10 | }) 11 | if (message.length <= 1) { 12 | return this.next() 13 | } 14 | const hasCommand = message.findIndex( 15 | (elem) => elem.type === 'text' && elem.text.startsWith('/'), 16 | ) 17 | if (hasCommand !== -1) { 18 | const textElem = message[hasCommand] 19 | const result = praseCommandMessage( 20 | (textElem as any).text, 21 | textElem as TextElem, 22 | ) 23 | Object.assign(event, result) 24 | event.commandMessage = textElem as TextElem 25 | this.next() 26 | return 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/handlers/group/tasks/handleSingle.ts: -------------------------------------------------------------------------------- 1 | import { isMessageRepeater } from '~/handlers/shared/repeater' 2 | import { praseCommandMessage } from '~/utils/message' 3 | 4 | import type { GroupCoRoutine } from '../types' 5 | 6 | export const groupSingleTextMessageAction: GroupCoRoutine = async function ( 7 | event, 8 | ) { 9 | const messages = event.message 10 | if (messages.length !== 1) { 11 | this.next() 12 | return 13 | } 14 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 15 | const message = messages[0]! 16 | if (message.type === 'text') { 17 | const text = message.text.trim() 18 | 19 | const isCommand = text.startsWith('/') 20 | if (isCommand) { 21 | const result = await praseCommandMessage(text, message) 22 | Object.assign(event, result) 23 | event.commandMessage = message 24 | 25 | this.next() 26 | return 27 | } 28 | const isRepeater = await isMessageRepeater(event.group_id.toString(), event) 29 | if (isRepeater === true) { 30 | return event.reply(text) 31 | } else if (isRepeater === 'break') { 32 | return event.reply('打断复读!!!!') 33 | } 34 | this.abort() 35 | } 36 | this.next() 37 | } 38 | -------------------------------------------------------------------------------- /src/handlers/group/types.ts: -------------------------------------------------------------------------------- 1 | import type { GroupMessageEvent, MessageElem } from 'icqq' 2 | import type { CoCallerAction } from '@innei/next-async' 3 | 4 | export type GroupCoRoutine = ( 5 | this: CoCallerAction, 6 | message: GroupMessageEvent, 7 | ) => void 8 | 9 | declare module 'icqq' { 10 | export interface GroupMessageEvent { 11 | commandName?: string 12 | commandArgs?: string 13 | commandParsedArgs?: any 14 | commandMessage?: TextElem 15 | 16 | shouldQuote?: boolean 17 | } 18 | 19 | interface TextElem { 20 | commandName?: string 21 | commandArgs?: string 22 | commandParsedArgs?: any 23 | messageElems?: MessageElem[] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/handlers/shared/commands/tool.ts: -------------------------------------------------------------------------------- 1 | import type { Sendable } from 'icqq' 2 | import { getIpInfo } from '~/utils/helper' 3 | import { createNamespaceLogger } from '~/utils/logger' 4 | 5 | // 优先级 ip - base64 6 | 7 | const logger = createNamespaceLogger('tool') 8 | export const toolCommand = async (args: any): Promise => { 9 | logger.debug(args) 10 | 11 | if (args.ip) { 12 | const ip = args.ip 13 | const ipInfo = await getIpInfo(ip) 14 | 15 | if (ipInfo === 'error') { 16 | return { 17 | type: 'text', 18 | text: `${ip} 不是一个有效的 IP 地址`, 19 | } 20 | } 21 | 22 | return { 23 | type: 'text', 24 | text: `IP: ${ipInfo.ip}\n城市: ${ 25 | [ipInfo.countryName, ipInfo.regionName, ipInfo.cityName] 26 | .filter(Boolean) 27 | .join(' - ') || 'N/A' 28 | }\nISP: ${ipInfo.ispDomain || 'N/A'}\n组织: ${ 29 | ipInfo.ownerDomain || 'N/A' 30 | }${ 31 | 'range' in ipInfo 32 | ? `\n范围: ${ 33 | ipInfo.range ? Object.values(ipInfo.range).join(' - ') : 'N/A' 34 | }` 35 | : '' 36 | }`, 37 | } 38 | } 39 | 40 | if (args.base64 || args.base) { 41 | const base64 = args.base64 || args.base 42 | 43 | if (base64 === true) { 44 | return { 45 | type: 'text', 46 | text: `base64 参数不能为 true`, 47 | } 48 | } 49 | const decode = args.d 50 | let value = '' 51 | if (!decode) { 52 | value = Buffer.from(base64.toString()).toString('base64') 53 | } else { 54 | value = Buffer.from(base64.toString(), 'base64').toString() 55 | } 56 | 57 | return { 58 | type: 'text', 59 | 60 | text: `base64 ${decode ? '解码' : '编码'}结果: ${value}`, 61 | } 62 | } 63 | 64 | if (args.md5) { 65 | const toMd5Str = args.md5.toString() 66 | 67 | if (toMd5Str.length > 10e6) { 68 | return { 69 | type: 'text', 70 | text: `md5 参数长度不能超过 10MB`, 71 | } 72 | } 73 | 74 | return { 75 | type: 'text', 76 | text: `${toMd5Str}\n\nmd5 结果: ${require('crypto') 77 | .createHash('md5') 78 | .update(toMd5Str) 79 | .digest('hex')}`, 80 | } 81 | } 82 | 83 | return { 84 | type: 'text', 85 | text: '无效指令', 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/handlers/shared/repeater.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from 'icqq' 2 | import { createNamespaceLogger } from '~/utils/logger' 3 | 4 | const idToMessageQueue = new Map() 5 | 6 | const count = 3 7 | 8 | const breakRepeatCount = 12 9 | 10 | const logger = createNamespaceLogger('repeater') 11 | export const isMessageRepeater = async (id: string, message: Message) => { 12 | const stringifyMessage = message.toString() 13 | 14 | logger.debug(`check message: ${stringifyMessage}`) 15 | 16 | if (idToMessageQueue.has(id)) { 17 | const messageQueue = idToMessageQueue.get(id)! 18 | 19 | const lastestMessageInQueue = messageQueue.at(-1) 20 | 21 | if (lastestMessageInQueue === stringifyMessage) { 22 | messageQueue.push(stringifyMessage) 23 | } else { 24 | messageQueue.length = 0 25 | messageQueue.push(stringifyMessage) 26 | } 27 | 28 | if (messageQueue.length == count) { 29 | messageQueue.length = count + 1 30 | 31 | logger.log(`repeated message: ${stringifyMessage}`) 32 | return true 33 | } else if (messageQueue.length > count) { 34 | if (messageQueue.length - count == breakRepeatCount) { 35 | messageQueue.length = 0 36 | return 'break' 37 | } 38 | } 39 | 40 | idToMessageQueue.set(id, [...messageQueue]) 41 | } else { 42 | idToMessageQueue.set(id, [stringifyMessage]) 43 | } 44 | 45 | return false 46 | } 47 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { registerModules } from './modules' 2 | import { registerLogger } from './utils/logger' 3 | import { hook } from './utils/plugin' 4 | 5 | async function bootstrap() { 6 | registerLogger() 7 | 8 | const { client } = await import('./client') 9 | client.login() 10 | 11 | await registerModules() 12 | await hook.runAsyncWaterfall(client) 13 | } 14 | bootstrap() 15 | -------------------------------------------------------------------------------- /src/modules/_novelai/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { botConfig } from 'config' 3 | import { URLSearchParams } from 'url' 4 | 5 | import { userAgent } from '~/constants/env' 6 | import { sleep } from '~/utils/helper' 7 | import { AsyncQueue } from '~/utils/queue' 8 | 9 | import { disallowedTags } from './ban' 10 | import { novelAiLogger } from './logger' 11 | 12 | const endpoint = 'http://91.217.139.190:5010' 13 | 14 | const token = botConfig.novelai.token 15 | 16 | export const aiRequestQueue = new AsyncQueue(1) 17 | 18 | interface NovelAIParams { 19 | tagText: string 20 | shape: 'Portrait' | 'Landscape' | 'Square' 21 | scale: number | string 22 | seed?: number | string 23 | } 24 | export const getApiImage = async ( 25 | params: Partial, 26 | ): Promise< 27 | | string 28 | | { 29 | buffer: ArrayBuffer 30 | seed: string | undefined 31 | tags: string 32 | } 33 | > => { 34 | const { seed, tagText, scale, shape = 'Portrait' } = params 35 | 36 | if (!tagText) { 37 | return '' 38 | } 39 | const tagSet = new Set(tagText.split(',').map((t) => t.trim())) 40 | 41 | const jointTag = [...tagSet.values()] 42 | .join(',') 43 | .replace( 44 | new RegExp(`(${disallowedTags.map((tag) => `${tag}|`).join('')})`, 'ig'), 45 | '', 46 | ) 47 | const nextParams: any = { 48 | token, 49 | tags: jointTag, 50 | // r18, 51 | shape, 52 | scale, 53 | seed, 54 | } 55 | if (seed == -1) { 56 | delete nextParams.seed 57 | } 58 | 59 | for (const key in nextParams) { 60 | if (typeof nextParams[key] === 'undefined') { 61 | delete nextParams[key] 62 | } 63 | } 64 | 65 | const search = new URLSearchParams(Object.entries(nextParams)).toString() 66 | 67 | const request = aiRequestQueue.enqueue(() => { 68 | return axios 69 | .get(`${endpoint}/got_image?${search}`, { 70 | timeout: 60 * 1000, 71 | headers: { 72 | 'user-agent': userAgent, 73 | }, 74 | responseType: 'arraybuffer', 75 | }) 76 | .then((res) => { 77 | novelAiLogger.debug( 78 | `get image from novelAI: ${res.status}, seed: ${res.headers['seed']}`, 79 | ) 80 | return { 81 | buffer: res.data as ArrayBuffer, 82 | seed: res.headers['seed'], 83 | tags: jointTag, 84 | } 85 | }) 86 | .catch((er: any) => { 87 | novelAiLogger.debug(er.message) 88 | return '生成失败' 89 | }) 90 | }) 91 | 92 | // 等待 10 秒 93 | aiRequestQueue.enqueue(() => sleep(10 * 1000)) 94 | return request 95 | } 96 | 97 | export interface Image2ImageParams { 98 | noise: number | string 99 | strength: number | string 100 | 101 | image: Buffer 102 | } 103 | export const getImage2Image = async ( 104 | params: Partial & Image2ImageParams>, 105 | ) => { 106 | const { tagText = '', image } = params 107 | 108 | if (!tagText) { 109 | return '' 110 | } 111 | 112 | if (!image) { 113 | return '' 114 | } 115 | 116 | const tagSet = new Set(tagText.split(',').map((t) => t.trim())) 117 | 118 | const jointTag = [...tagSet.values()] 119 | .join(',') 120 | .replace( 121 | new RegExp(`(${disallowedTags.map((tag) => `${tag}|`).join('')})`, 'ig'), 122 | '', 123 | ) 124 | 125 | const nextParams: any = { 126 | ...params, 127 | tags: jointTag, 128 | token, 129 | } 130 | delete nextParams.tagText 131 | delete nextParams.image 132 | 133 | for (const key in nextParams) { 134 | if (typeof nextParams[key] === 'undefined') { 135 | delete nextParams[key] 136 | } 137 | } 138 | 139 | const search = new URLSearchParams(Object.entries(nextParams)).toString() 140 | 141 | return aiRequestQueue.enqueue(() => 142 | axios 143 | .post(`${endpoint}/got_image2image?${search}`, image.toString('base64'), { 144 | timeout: 60 * 1000, 145 | headers: { 146 | 'user-agent': userAgent, 147 | 'Content-Type': 'application/x-www-form-urlencoded', 148 | }, 149 | responseType: 'arraybuffer', 150 | }) 151 | .then((res) => { 152 | novelAiLogger.debug( 153 | `get image from novelAI: ${res.status}, seed: ${res.headers['seed']}`, 154 | ) 155 | return { 156 | buffer: res.data as ArrayBuffer, 157 | seed: res.headers['seed'], 158 | tags: jointTag, 159 | } 160 | }) 161 | .catch((er: any) => { 162 | novelAiLogger.debug(er.message) 163 | return '生成失败' 164 | }), 165 | ) 166 | } 167 | -------------------------------------------------------------------------------- /src/modules/_novelai/ban.ts: -------------------------------------------------------------------------------- 1 | export const disallowedTags = [ 2 | 'underwear', 3 | 'swimsuit', 4 | 'pantsu', 5 | 'skirt_lift', 6 | 'breasts', 7 | 'thighhighs', 8 | 'stockings', 9 | 'pantyhose', 10 | 'bikini', 11 | 'garter', 12 | 'garterbelt', 13 | 'highheels', 14 | 'lingerie', 15 | 'maid', 16 | 'nurse', 17 | 'swimsuit', 18 | 'underwear', 19 | 'yandere', 20 | 'yuri', 21 | 'cum_inside', 22 | 'cum_outside', 23 | 'peeing', 24 | 'nipple_tweak', 25 | 'slave', 26 | 'bondage', 27 | 'nipple_torture', 28 | 'femdom', 29 | 'reverse_cowgirl', 30 | 'girl_on_top', 31 | 'cowgirl_position', 32 | 'nyotaimori', 33 | 'pegging', 34 | 'milk_squirt', 35 | 'ass/oshiri/butt', 36 | 'spitroast', 37 | 'rape', 38 | 'rape', 39 | 'forced_orgasm', 40 | 'frogtie', 41 | 'ball gag', 42 | 'hogtie', 43 | 'anal_fisting', 44 | 'fisting', 45 | 'artificial_vagina', 46 | 'fanny packing', 47 | 'transformation', 48 | 'paizuri', 49 | 'nipple_piercing', 50 | 'pee', 51 | 'triple_penetration', 52 | 'ejaculation', 53 | 'cum', 54 | 'cum_in_pussy', 55 | 'cum on body', 56 | 'cum_on_food', 57 | 'cum_on_hair', 58 | 'cum_on_breast', 59 | 'body_writing', 60 | 'deepthroat', 61 | 'rope', 62 | 'bdsm', 63 | 'wetlabia', 64 | 'anal_fingering', 65 | 'bound_arms', 66 | 'fingering', 67 | 'bound_wrists', 68 | 'ass_grab', 69 | 'bestiality', 70 | 'double_dildo', 71 | 'futanari', 72 | 'double_penetration', 73 | 'double_anal', 74 | 'double_vaginal', 75 | 'underwater_sex', 76 | 'mind_control', 77 | 'handjob', 78 | 'shaved_pussy', 79 | 'cunnilingus', 80 | 'egg_vibrator', 81 | 'double_handjob', 82 | 'x-ray/cross-section/internal_cumshot', 83 | 'thigh_sex', 84 | 'buttjob', 85 | 'breast_feeding', 86 | 'faceless_male', 87 | 'breast_pump/milking_machine', 88 | 'breast_sucking/nipple_suck', 89 | 'bathing', 90 | 'tally', 91 | 'sex', 92 | 'sex machine', 93 | 'grinding', 94 | 'molestation', 95 | 'twincest', 96 | 'humiliation', 97 | 'suspension', 98 | 'blood', 99 | 'facial', 100 | 'multiple_insertions', 101 | 'tentacles_under_clothes', 102 | 'vibrator under clothes', 103 | 'covered_erect_nipples', 104 | 'penetration', 105 | 'overflow', 106 | 'semen on labia', 107 | 'vaginal juice', 108 | 'cleave_gag', 109 | 'panty_gag', 110 | 'spread_ass', 111 | 'fingering', 112 | 'handjob', 113 | 'fruit_insertion', 114 | 'standing sex', 115 | 'vibrator', 116 | 'vibrator_in_thighhighs', 117 | 'extreme_content', 118 | 'breast_grab', 119 | 'cervix', 120 | 'breast_hold', 121 | 'masturbation', 122 | 'footjob', 123 | 'facesitting', 124 | 'string_panties', 125 | 'bustier', 126 | 'bra', 127 | 'nipples', 128 | 'inverted_nipples', 129 | 'areola', 130 | 'puffy_nipples', 131 | 'erect_nipples', 132 | 'small_nipples', 133 | 'small_breasts', 134 | 'genderswap', 135 | 'breasts', 136 | 'chest', 137 | 'mole_on_breast', 138 | 'hair_between_eyes', 139 | 'armpit', 140 | 'armpit_hair', 141 | 'heterochromia', 142 | 'clitoris', 143 | 'pussy', 144 | 'vaginal', 145 | 'penis', 146 | 'pubic_hair', 147 | 'cyborg', 148 | 'quadruple amputee', 149 | ] 150 | -------------------------------------------------------------------------------- /src/modules/_novelai/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { appendFile, readFile } from 'fs/promises' 3 | import path from 'path' 4 | import axios from 'axios' 5 | import { botConfig } from 'config' 6 | import type { 7 | Client, 8 | GroupMessageEvent, 9 | ImageElem, 10 | MessageEvent, 11 | TextElem, 12 | } from 'icqq' 13 | import { sample } from 'lodash' 14 | import { userAgent } from '~/constants/env' 15 | import { commandRegistry } from '~/registries/command' 16 | import { aiRequestQueue, getApiImage, getImage2Image } from './api' 17 | 18 | const command2Shape: Record = { 19 | ai_sfw_l: 'Landscape', 20 | ai_sfw_p: 'Portrait', 21 | ai_sfw_s: 'Square', 22 | } 23 | 24 | class NovelAiStatic { 25 | private enabled = true 26 | 27 | /** 28 | * 炼金任务 29 | */ 30 | private hasLongTask = false 31 | 32 | private formulaFilePath = path.join(String(process.cwd()), 'formula.txt') 33 | 34 | // 配方格式:备注 | 配方 35 | // 如:favor|8k wallpaper,loli,genshin 36 | private async readFormula() { 37 | const content = await readFile(this.formulaFilePath, { 38 | encoding: 'utf-8', 39 | }).catch(() => {}) 40 | 41 | if (!content) { 42 | return [] 43 | } 44 | 45 | return content 46 | .split('\n') 47 | .map((line) => { 48 | return line.split('|') 49 | }) 50 | .filter((line) => line[0] && line[1]) 51 | } 52 | 53 | private async saveFormula( 54 | message: TextElem, 55 | event: GroupMessageEvent, 56 | abort: () => void, 57 | ) { 58 | if (!event.source) { 59 | return '无法获取来源信息' 60 | } 61 | const quotedMessage = event.source.message 62 | if (!quotedMessage) { 63 | return '引用消息为空' 64 | } 65 | 66 | const tagsText = quotedMessage 67 | .toString() 68 | .replace('[图片] tags:', '') 69 | .split('seed: ')[0] 70 | const remark = message.commandArgs 71 | 72 | await appendFile( 73 | this.formulaFilePath, 74 | `${remark || 'formula'}|${`${tagsText}\n`}`, 75 | { 76 | encoding: 'utf-8', 77 | }, 78 | ) 79 | 80 | await event.reply('保存成功', true) 81 | 82 | abort() 83 | } 84 | 85 | private async useFormula( 86 | message: TextElem, 87 | event: MessageEvent, 88 | abort: () => void, 89 | ) { 90 | const formulaIndex = parseInt(message.commandArgs?.trim() || '0') 91 | if (Number.isNaN(formulaIndex)) { 92 | return '无效的配方编号' 93 | } 94 | 95 | const afterIndex = 96 | message.commandArgs?.slice(String(formulaIndex).length) || '' 97 | const postfixAiArgs = afterIndex.replace(/^&/, '') 98 | const matrix = await this.readFormula() 99 | 100 | const formula = matrix[formulaIndex - 1] 101 | 102 | const [, nextLineArgs = postfixAiArgs] = 103 | message.commandArgs?.split('\n') || [] 104 | 105 | if (formula) { 106 | event.reply(`使用配方:${formula.toString()}`) 107 | return await this.draw( 108 | { 109 | ...message, 110 | commandArgs: `${formula[1]}\n${nextLineArgs}`, 111 | }, 112 | event, 113 | abort, 114 | ) 115 | } 116 | 117 | return '无效的配方' 118 | } 119 | 120 | private getDrawMessage() { 121 | if (aiRequestQueue.waitingcount > 0) { 122 | return `AI 绘图:排队中,前方还有 ${aiRequestQueue.waitingcount} 个请求` 123 | } 124 | return sample(['在画了在画了...', '少女鉴赏中...'])! 125 | } 126 | private async draw( 127 | message: TextElem, 128 | event: MessageEvent, 129 | abort: () => void, 130 | ) { 131 | const args = message.commandArgs 132 | if (!args) { 133 | return abort() 134 | } 135 | 136 | if (this.hasLongTask) { 137 | return 'AI 绘图:当前正在炼金,请稍后再试' 138 | } 139 | 140 | // /ai_sfw_l masterpiece,best quality,extremely detailed CG unity 8k wallpaper, (((loli))), looking at viewer, white short hair, solo, white knee high, white socks, dynamic_angle, white jk, (cute), ((kindergarten)), red eyes, (hoodie), ((:3)),(lift by self),(underwear),((cute)),sea,genshin impact,shark 141 | // seed=68846426&scale=22&count=10 142 | const [tagText, params = ''] = args.split('\n') 143 | const [realTagText, paramsPostfix = ''] = tagText.split('&') 144 | const paramsObject = new URLSearchParams(params || paramsPostfix) 145 | const count = +(paramsObject.get('count') || 1) 146 | 147 | if (count == 1) event.reply(this.getDrawMessage(), true) 148 | try { 149 | for (let i = 0; i < Math.min(count ? +count || 1 : 1, 10); i++) { 150 | if (count > 1) { 151 | this.hasLongTask = true 152 | event.reply(`开始炼金,第 ${i + 1} 张/共 ${count} 张`) 153 | } 154 | 155 | const bufferOrText = await getApiImage({ 156 | tagText: realTagText, 157 | shape: command2Shape[message.commandName!] || 'Portrait', 158 | seed: paramsObject.get('seed') || undefined, 159 | scale: paramsObject.get('scale') || undefined, 160 | }) 161 | 162 | if (typeof bufferOrText == 'string') { 163 | this.hasLongTask = false 164 | return bufferOrText || '出错了' 165 | } 166 | 167 | if (this.enabled) { 168 | await event.reply( 169 | [ 170 | { 171 | type: 'image', 172 | file: Buffer.from(bufferOrText.buffer), 173 | }, 174 | { 175 | type: 'text', 176 | text: `\ntags: ${bufferOrText.tags}\n\nseed: ${ 177 | bufferOrText.seed 178 | }, scale: ${paramsObject.get('scale') || `11.0`}`, 179 | }, 180 | ], 181 | true, 182 | ) 183 | } 184 | } 185 | } finally { 186 | this.hasLongTask = false 187 | } 188 | 189 | return abort() 190 | } 191 | 192 | private async image2image( 193 | message: TextElem, 194 | event: MessageEvent, 195 | abort: Function, 196 | ) { 197 | const list = message.messageElems 198 | 199 | if (!list) { 200 | return abort() 201 | } 202 | 203 | const args = message.commandArgs 204 | if (!args) { 205 | return abort() 206 | } 207 | 208 | const imageElem = list.find( 209 | (item) => item.type == 'image' && item.file, 210 | ) as ImageElem 211 | if (!imageElem) { 212 | return abort() 213 | } 214 | 215 | const file = imageElem.file 216 | let buffer: Buffer 217 | if (typeof file == 'string') { 218 | const url = imageElem.url! 219 | 220 | buffer = await axios 221 | .get(url, { 222 | headers: { 223 | 'user-agent': userAgent, 224 | }, 225 | responseType: 'arraybuffer', 226 | }) 227 | .then((data) => { 228 | return data.data 229 | }) 230 | } else { 231 | buffer = file as Buffer 232 | } 233 | 234 | const [tagText, params = ''] = args.split('\n') 235 | const [realTagText, paramsPostfix = ''] = tagText.split('&') 236 | const paramsObject = new URLSearchParams(params || paramsPostfix) 237 | 238 | event.reply(this.getDrawMessage(), true) 239 | 240 | const resultImage = await getImage2Image({ 241 | image: buffer, 242 | tagText: realTagText, 243 | noise: paramsObject.get('noise') || undefined, 244 | shape: command2Shape[message.commandName!] || 'Portrait', 245 | strength: paramsObject.get('strength') || undefined, 246 | }) 247 | 248 | if (typeof resultImage == 'string') { 249 | return resultImage 250 | } 251 | 252 | event.reply( 253 | [ 254 | { 255 | type: 'image', 256 | file: Buffer.from(resultImage.buffer), 257 | }, 258 | { 259 | type: 'text', 260 | text: `\ntags: ${resultImage.tags}\n\nseed: ${ 261 | resultImage.seed 262 | }, scale: ${paramsObject.get('scale') || `11.0`}`, 263 | }, 264 | ], 265 | true, 266 | ) 267 | } 268 | 269 | private async sendStatus() { 270 | return `AI 绘图:${this.enabled ? '开启' : '关闭'}` 271 | } 272 | 273 | async setup() { 274 | commandRegistry.register( 275 | 'ai_sfw_toggle', 276 | async (event): Promise => { 277 | const isOwner = event.sender.user_id === botConfig.ownerId 278 | if (isOwner) { 279 | this.enabled = !this.enabled 280 | return `AI 绘图:已${this.enabled ? '开启' : '关闭'}` 281 | } 282 | 283 | return '你不是我的主人,暂无权限操作' 284 | }, 285 | ) 286 | 287 | commandRegistry.registerWildcard(async (event, abort) => { 288 | const command = event.commandName 289 | 290 | if (!this.enabled) { 291 | return 292 | } 293 | const message = event.commandMessage! 294 | const isOwner = event.sender.user_id === botConfig.ownerId 295 | 296 | switch (command) { 297 | case 'ai_sfw_l': 298 | case 'ai_sfw_p': 299 | case 'ai_sfw_s': 300 | case 'draw': 301 | await this.draw(message, event, abort) 302 | return 303 | 304 | case 'ai_sfw_status': 305 | case 'ai_status': 306 | return this.sendStatus() 307 | case 'ai_save': 308 | if (isOwner) { 309 | return this.saveFormula(message, event as GroupMessageEvent, abort) 310 | } else { 311 | event.reply('你不是我的主人,无法使用此命令', true) 312 | return abort() 313 | } 314 | 315 | case 'ai_img2img': 316 | case 'ai_image2image': { 317 | return this.image2image(message, event, abort) 318 | } 319 | case 'ai_formula': 320 | return this.readFormula().then((arr) => { 321 | return arr 322 | .map( 323 | ([comment, formula], index) => 324 | `${index + 1}, ${comment}:${formula}`, 325 | ) 326 | .join('\n') 327 | }) 328 | case 'ai_sfw_formula': 329 | case 'ai_use': 330 | return this.useFormula(message, event as GroupMessageEvent, abort) 331 | // case '' 332 | case 'ai_help': 333 | return ( 334 | `AI 绘图:${this.enabled ? '开启' : '关闭'}\n\n` + 335 | `ai_sfw_l: 横屏\nai_sfw_p: 竖屏\nai_sfw_s: 正方形\nai_sfw_toggle: 开关\nai_sfw_status: 状态\nai_help: 帮助\n\n参数:\nseed: 随机种子\nscale: CFG 倍数\ncount: 生成数量\n\n例子:\n/ai_sfw_l masterpiece,best quality,extremely detailed CG unity 8k wallpaper` + 336 | `\n/ai_sfw_l masterpiece,best quality,extremely detailed CG unity 8k wallpaper&seed=68846426&scale=22&count=10\n` + 337 | `使用配方: /ai_sfw_formula {{index}}\n\n` + 338 | `保存配方: /ai_save \n\n` + 339 | `查看配方: /ai_formula` 340 | ) 341 | default: 342 | return 343 | } 344 | }) 345 | } 346 | } 347 | 348 | const novelAi = new NovelAiStatic() 349 | 350 | export const register = (_ctx: Client) => { 351 | novelAi.setup() 352 | return novelAi 353 | } 354 | -------------------------------------------------------------------------------- /src/modules/_novelai/logger.ts: -------------------------------------------------------------------------------- 1 | import { createNamespaceLogger } from '~/utils/logger' 2 | 3 | export const novelAiLogger = createNamespaceLogger('novelai') 4 | -------------------------------------------------------------------------------- /src/modules/bilibili/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { botConfig } from 'config' 3 | import { CronJob } from 'cron' 4 | import type { Client } from 'icqq' 5 | import { userAgent } from '~/constants/env' 6 | import type { BLRoom } from './types/room' 7 | import type { BLUser } from './types/user' 8 | 9 | const headers = { 10 | referer: `https://link.bilibili.com/p/center/index?visit_id=22ast2mb9zhc`, 11 | 'User-Agent': userAgent, 12 | } 13 | 14 | export const register = (client: Client) => { 15 | let playStatus = false 16 | const config = botConfig.bilibili 17 | 18 | const liveId = config.live.id 19 | const work = async () => { 20 | const res = await axios 21 | .get( 22 | `https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${liveId}&protocol=0,1&format=0,1,2&codec=0,1&qn=0&platform=web&ptype=8&dolby=5`, 23 | { 24 | headers, 25 | }, 26 | ) 27 | .catch((err) => null) 28 | 29 | if (!res?.data) { 30 | return 31 | } 32 | 33 | if (res?.data?.data.playurl_info) { 34 | if (playStatus) { 35 | return 36 | } 37 | 38 | const userInfo = await axios 39 | .get( 40 | `https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid=${liveId}`, 41 | { 42 | headers, 43 | }, 44 | ) 45 | .catch((err) => null) 46 | 47 | if (!userInfo?.data) { 48 | return 49 | } 50 | 51 | const info = (userInfo.data as BLUser).data.info 52 | 53 | const bgBuffer = await axios 54 | .get( 55 | ` https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?room_ids=${liveId}&req_biz=link-center`, 56 | { 57 | headers, 58 | }, 59 | ) 60 | .then((res) => { 61 | return (res.data as BLRoom).data.by_room_ids[liveId].cover 62 | }) 63 | .catch((err) => null) 64 | .then((url) => { 65 | if (url) { 66 | return axios 67 | .get(url, { 68 | headers, 69 | responseType: 'arraybuffer', 70 | }) 71 | .then((res) => res.data as Buffer) 72 | .catch((err) => null) 73 | } 74 | return null 75 | }) 76 | .then((buffer) => { 77 | return buffer 78 | }) 79 | 80 | await Promise.all( 81 | config.watchGroupIds.map(async (groupId) => { 82 | await client.sendGroupMsg(groupId, [ 83 | bgBuffer 84 | ? { type: 'image', file: bgBuffer } 85 | : { 86 | type: 'text', 87 | text: '', 88 | }, 89 | config.live.atAll 90 | ? { type: 'at', qq: 'all', text: ' ' } 91 | : { type: 'text', text: '' }, 92 | { 93 | type: 'text', 94 | text: `${info.uname}(${info.uid}) 开播了\n\n前往直播间: https://live.bilibili.com/${liveId}`, 95 | }, 96 | ]) 97 | }), 98 | ) 99 | 100 | playStatus = true 101 | } else { 102 | playStatus = false 103 | } 104 | } 105 | const job = new CronJob('*/1 * * * *', work) 106 | job.start() 107 | work() 108 | } 109 | -------------------------------------------------------------------------------- /src/modules/bilibili/types/live.ts: -------------------------------------------------------------------------------- 1 | export interface BLLive { 2 | code: number 3 | message: string 4 | ttl: number 5 | data: Data 6 | } 7 | interface Data { 8 | room_id: number 9 | short_id: number 10 | uid: number 11 | is_hidden: boolean 12 | is_locked: boolean 13 | is_portrait: boolean 14 | live_status: number 15 | hidden_till: number 16 | lock_till: number 17 | encrypted: boolean 18 | pwd_verified: boolean 19 | live_time: number 20 | room_shield: number 21 | all_special_types: any[] 22 | playurl_info: Playurl_info 23 | } 24 | export interface Playurl_info { 25 | conf_json: string 26 | playurl: Playurl 27 | } 28 | interface Playurl { 29 | cid: number 30 | g_qn_desc: GQnDescItem[] 31 | stream: StreamItem[] 32 | p2p_data: P2p_data 33 | dolby_qn: null 34 | } 35 | interface GQnDescItem { 36 | qn: number 37 | desc: string 38 | hdr_desc: string 39 | } 40 | interface StreamItem { 41 | protocol_name: string 42 | format: FormatItem[] 43 | } 44 | interface FormatItem { 45 | format_name: string 46 | codec: CodecItem[] 47 | } 48 | interface CodecItem { 49 | codec_name: string 50 | current_qn: number 51 | accept_qn: number[] 52 | base_url: string 53 | url_info: UrlInfoItem[] 54 | hdr_qn: null 55 | dolby_type: number 56 | } 57 | interface UrlInfoItem { 58 | host: string 59 | extra: string 60 | stream_ttl: number 61 | } 62 | interface P2p_data { 63 | p2p: boolean 64 | p2p_type: number 65 | m_p2p: boolean 66 | m_servers: null 67 | } 68 | -------------------------------------------------------------------------------- /src/modules/bilibili/types/room.ts: -------------------------------------------------------------------------------- 1 | export interface BLRoom { 2 | code: number 3 | message: string 4 | ttl: number 5 | data: Data 6 | } 7 | interface Data { 8 | by_uids: {} 9 | by_room_ids: By_room_ids 10 | } 11 | 12 | interface By_room_ids { 13 | [key: number]: RoomInfo 14 | } 15 | export interface RoomInfo { 16 | room_id: number 17 | uid: number 18 | area_id: number 19 | live_status: number 20 | live_url: string 21 | parent_area_id: number 22 | title: string 23 | parent_area_name: string 24 | area_name: string 25 | live_time: string 26 | description: string 27 | tags: string 28 | attention: number 29 | online: number 30 | short_id: number 31 | uname: string 32 | cover: string 33 | background: string 34 | join_slide: number 35 | live_id: number 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/bilibili/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface BLUser { 2 | code: number 3 | msg: string 4 | message: string 5 | data: Data 6 | } 7 | interface Data { 8 | info: UserInfo 9 | level: Level 10 | san: number 11 | } 12 | export interface UserInfo { 13 | uid: number 14 | uname: string 15 | face: string 16 | rank: string 17 | platform_user_level: number 18 | mobile_verify: number 19 | identification: number 20 | official_verify: Official_verify 21 | vip_type: number 22 | gender: number 23 | } 24 | interface Official_verify { 25 | type: number 26 | desc: string 27 | role: number 28 | } 29 | interface Level { 30 | uid: number 31 | cost: number 32 | rcost: number 33 | user_score: string 34 | vip: number 35 | vip_time: string 36 | svip: number 37 | svip_time: string 38 | update_time: string 39 | master_level: Master_level 40 | user_level: number 41 | color: number 42 | anchor_score: number 43 | } 44 | interface Master_level { 45 | level: number 46 | color: number 47 | current: number[] 48 | next: number[] 49 | anchor_score: number 50 | upgrade_score: number 51 | master_level_color: number 52 | sort: string 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/github/constants/bot.ts: -------------------------------------------------------------------------------- 1 | export const botList = ['renovate[bot]'] 2 | -------------------------------------------------------------------------------- /src/modules/github/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { botConfig } from 'config' 3 | import createHandler from 'github-webhook-handler' 4 | import type { Client, Sendable } from 'icqq' 5 | import { botList } from './constants/bot' 6 | import type { CheckRun } from './types/check-run' 7 | import type { IssueEvent } from './types/issue' 8 | import type { PullRequestPayload } from './types/pull-request' 9 | import type { PushEvent } from './types/push' 10 | 11 | export const register = (client: Client) => { 12 | // see: https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads 13 | const handler = createHandler({ 14 | path: '/webhook', 15 | secret: botConfig.githubHook.secret, 16 | }) 17 | 18 | http 19 | .createServer((req, res) => { 20 | handler(req, res, (err) => { 21 | res.statusCode = 404 22 | res.end('no such location') 23 | }) 24 | }) 25 | .listen(botConfig.githubHook.port) 26 | 27 | handler.on('error', (err) => { 28 | console.error('Error:', err.message) 29 | }) 30 | 31 | handler.on('push', async (event) => { 32 | const { 33 | pusher: { name: pusherName }, 34 | repository, 35 | ref, 36 | commits, 37 | } = event.payload as PushEvent 38 | 39 | if ( 40 | (pusherName as string).endsWith('[bot]') || 41 | botList.includes(pusherName) 42 | ) { 43 | return 44 | } 45 | 46 | const isPushToMain = 47 | ref === 'refs/heads/main' || ref === 'refs/heads/master' 48 | if (Array.isArray(commits)) { 49 | if (!commits.length) { 50 | return 51 | } 52 | const commitMessages = [] as string[] 53 | const commitAuthors = [] as string[] 54 | 55 | commits.forEach((commit) => { 56 | commitMessages.push(commit.message.split('\n')[0]) 57 | 58 | commitAuthors.push(commit.author.name) 59 | }) 60 | if (commits.length == 1) { 61 | const commit = commits[0] 62 | await sendMessage( 63 | `${pusherName}${ 64 | commit.author?.name && commit.author?.name !== pusherName 65 | ? ` & ${commit.author?.name}` 66 | : '' 67 | } 向 ${repository.full_name} ${ 68 | !isPushToMain ? `的 ${ref.replace('refs/heads/', '')} 分支` : '' 69 | }提交了一个更改\n\n${commit.message}${ 70 | isPushToMain ? '' : `\n\n查看提交更改内容: ${commit.url}` 71 | }`, 72 | ) 73 | } else { 74 | const isUniquePusher = new Set(commitAuthors).size === 1 75 | await sendMessage( 76 | `${ 77 | isUniquePusher ? commitAuthors[0] : `${commitAuthors[0]} 等多人` 78 | } 向 ${repository.full_name} 提交了多个更改\n\n${commitMessages.join( 79 | '\n', 80 | )}`, 81 | ) 82 | } 83 | } else { 84 | const { message } = commits 85 | 86 | if (typeof message === 'undefined') { 87 | return 88 | } 89 | 90 | await sendMessage( 91 | `${pusherName} 向 ${repository.full_name} 提交了一个更改\n\n${message}` + 92 | `\n\n查看提交更改内容:${commits.url}`, 93 | ) 94 | } 95 | }) 96 | 97 | handler.on('issues', async (event) => { 98 | if (event.payload.action !== 'opened') { 99 | return 100 | } 101 | 102 | const payload = event.payload as IssueEvent 103 | 104 | if ( 105 | payload.sender.login.endsWith('[bot]') || 106 | botList.includes(payload.sender.login) 107 | ) { 108 | return 109 | } 110 | 111 | await sendMessage( 112 | `${payload.sender.login} 向 ${payload.repository.name} 发布了一个 Issue「#${event.payload.issue.number} - ${payload.issue.title}` + 113 | '\n' + 114 | `前往处理:${payload.issue.html_url}`, 115 | ) 116 | }) 117 | 118 | handler.on('release', async (event) => { 119 | const { payload } = event 120 | const { 121 | action, 122 | repository: { full_name: name }, 123 | release: { tag_name }, 124 | } = payload 125 | if (action !== 'released') { 126 | return 127 | } 128 | await sendMessage( 129 | `${name} 发布了一个新版本 ${tag_name},前往查看:\n${payload.release.html_url}`, 130 | ) 131 | }) 132 | 133 | handler.on('check_run', async (event) => { 134 | const { payload } = event 135 | const { 136 | check_run: { 137 | conclusion, 138 | status, 139 | html_url, 140 | check_suite: { head_branch }, 141 | }, 142 | 143 | repository: { full_name: name }, 144 | } = payload as CheckRun 145 | 146 | if (!['master', 'main'].includes(head_branch)) { 147 | return 148 | } 149 | 150 | if (status !== 'completed') { 151 | return 152 | } 153 | 154 | if (conclusion && ['failure', 'timed_out'].includes(conclusion)) { 155 | await sendMessage([ 156 | { 157 | type: 'at', 158 | qq: botConfig.githubHook.mentionId, 159 | }, 160 | { 161 | type: 'text', 162 | text: ` ${name} CI 挂了!!!!\n查看原因:${html_url}`, 163 | }, 164 | ]) 165 | } 166 | }) 167 | 168 | handler.on('ping', async () => {}) 169 | 170 | handler.on('pull_request', async ({ payload }) => { 171 | const { action } = payload as PullRequestPayload 172 | 173 | if (action !== 'opened') { 174 | return 175 | } 176 | 177 | const { 178 | pull_request: { 179 | html_url, 180 | title, 181 | body, 182 | head: { label: headLabel }, 183 | 184 | user: { login: userName }, 185 | base: { 186 | repo: { full_name: repoName }, 187 | label: baseLabel, 188 | }, 189 | }, 190 | } = payload as PullRequestPayload 191 | 192 | if (userName.endsWith('[bot]') || botList.includes(userName)) { 193 | return 194 | } 195 | await sendMessage( 196 | `${userName} 向 ${repoName} 提交了一个 Pull Request\n\n${title}\n\n` + 197 | `${baseLabel} <-- ${headLabel}\n\n` + 198 | `${body ? `${body}\n\n` : ''}前往处理:${html_url}`, 199 | ) 200 | }) 201 | 202 | async function sendMessage(message: Sendable) { 203 | const tasks = botConfig.githubHook.watchGroupIds.map((id) => { 204 | client.sendGroupMsg(id, message) 205 | }) 206 | 207 | await Promise.all(tasks) 208 | } 209 | 210 | client.on('message.group', async (ev) => { 211 | if (botConfig.githubHook.watchGroupIds.includes(ev.group_id)) { 212 | if ( 213 | ev.sender.user_id === client.uin || 214 | (await client.getGroupInfo(ev.group_id)).owner_id === ev.sender.user_id 215 | ) { 216 | return 217 | } 218 | 219 | const { message } = ev 220 | if (message.length === 1 && message[0].type === 'text') { 221 | const firstLine = message[0].text.split('\r')[0] 222 | 223 | const banRegexp = [/^.*? 向 .*? 提交了一个更改$/] 224 | 225 | if (banRegexp.some((regexp) => regexp.test(firstLine))) { 226 | await ev.recall() 227 | } 228 | } 229 | } 230 | }) 231 | } 232 | -------------------------------------------------------------------------------- /src/modules/github/types/check-run.ts: -------------------------------------------------------------------------------- 1 | export interface CheckRun { 2 | action: string 3 | check_run: Check_run 4 | repository: Repository 5 | sender: Sender 6 | } 7 | interface Check_run { 8 | id: number 9 | node_id: string 10 | head_sha: string 11 | external_id: string 12 | url: string 13 | html_url: string 14 | details_url: string 15 | status: string 16 | conclusion: null | string 17 | started_at: string 18 | completed_at: null 19 | output: Output 20 | name: string 21 | check_suite: Check_suite 22 | app: App 23 | pull_requests: PullRequestsItem[] 24 | deployment: Deployment 25 | } 26 | interface Output { 27 | title: null 28 | summary: null 29 | text: null 30 | annotations_count: number 31 | annotations_url: string 32 | } 33 | interface Check_suite { 34 | id: number 35 | node_id: string 36 | head_branch: string 37 | head_sha: string 38 | status: string 39 | conclusion: null 40 | url: string 41 | before: string 42 | after: string 43 | pull_requests: PullRequestsItem[] 44 | app: App 45 | created_at: string 46 | updated_at: string 47 | } 48 | interface PullRequestsItem { 49 | url: string 50 | id: number 51 | number: number 52 | head: Head 53 | base: Base 54 | } 55 | interface Head { 56 | ref: string 57 | sha: string 58 | repo: Repo 59 | } 60 | interface Repo { 61 | id: number 62 | url: string 63 | name: string 64 | } 65 | interface Base { 66 | ref: string 67 | sha: string 68 | repo: Repo 69 | } 70 | interface App { 71 | id: number 72 | node_id: string 73 | owner: Owner 74 | name: string 75 | description: string 76 | external_url: string 77 | html_url: string 78 | created_at: string 79 | updated_at: string 80 | permissions: Permissions 81 | events: any[] 82 | } 83 | interface Owner { 84 | login: string 85 | id: number 86 | node_id: string 87 | avatar_url: string 88 | gravatar_id: string 89 | url: string 90 | html_url: string 91 | followers_url: string 92 | following_url: string 93 | gists_url: string 94 | starred_url: string 95 | subscriptions_url: string 96 | organizations_url: string 97 | repos_url: string 98 | events_url: string 99 | received_events_url: string 100 | type: string 101 | site_admin: boolean 102 | } 103 | interface Permissions { 104 | administration: string 105 | checks: string 106 | contents: string 107 | deployments: string 108 | issues: string 109 | members: string 110 | metadata: string 111 | organization_administration: string 112 | organization_hooks: string 113 | organization_plan: string 114 | organization_projects: string 115 | organization_user_blocking: string 116 | pages: string 117 | pull_requests: string 118 | repository_hooks: string 119 | repository_projects: string 120 | statuses: string 121 | team_discussions: string 122 | vulnerability_alerts: string 123 | } 124 | interface Deployment { 125 | url: string 126 | id: number 127 | node_id: string 128 | task: string 129 | original_environment: string 130 | environment: string 131 | description: null 132 | created_at: string 133 | updated_at: string 134 | statuses_url: string 135 | repository_url: string 136 | } 137 | interface Repository { 138 | id: number 139 | node_id: string 140 | name: string 141 | full_name: string 142 | private: boolean 143 | owner: Owner 144 | html_url: string 145 | description: null 146 | fork: boolean 147 | url: string 148 | forks_url: string 149 | keys_url: string 150 | collaborators_url: string 151 | teams_url: string 152 | hooks_url: string 153 | issue_events_url: string 154 | events_url: string 155 | assignees_url: string 156 | branches_url: string 157 | tags_url: string 158 | blobs_url: string 159 | git_tags_url: string 160 | git_refs_url: string 161 | trees_url: string 162 | statuses_url: string 163 | languages_url: string 164 | stargazers_url: string 165 | contributors_url: string 166 | subscribers_url: string 167 | subscription_url: string 168 | commits_url: string 169 | git_commits_url: string 170 | comments_url: string 171 | issue_comment_url: string 172 | contents_url: string 173 | compare_url: string 174 | merges_url: string 175 | archive_url: string 176 | downloads_url: string 177 | issues_url: string 178 | pulls_url: string 179 | milestones_url: string 180 | notifications_url: string 181 | labels_url: string 182 | releases_url: string 183 | deployments_url: string 184 | created_at: string 185 | updated_at: string 186 | pushed_at: string 187 | git_url: string 188 | ssh_url: string 189 | clone_url: string 190 | svn_url: string 191 | homepage: null 192 | size: number 193 | stargazers_count: number 194 | watchers_count: number 195 | language: string 196 | has_issues: boolean 197 | has_projects: boolean 198 | has_downloads: boolean 199 | has_wiki: boolean 200 | has_pages: boolean 201 | forks_count: number 202 | mirror_url: null 203 | archived: boolean 204 | disabled: boolean 205 | open_issues_count: number 206 | license: null 207 | forks: number 208 | open_issues: number 209 | watchers: number 210 | default_branch: string 211 | } 212 | interface Sender { 213 | login: string 214 | id: number 215 | node_id: string 216 | avatar_url: string 217 | gravatar_id: string 218 | url: string 219 | html_url: string 220 | followers_url: string 221 | following_url: string 222 | gists_url: string 223 | starred_url: string 224 | subscriptions_url: string 225 | organizations_url: string 226 | repos_url: string 227 | events_url: string 228 | received_events_url: string 229 | type: string 230 | site_admin: boolean 231 | } 232 | -------------------------------------------------------------------------------- /src/modules/github/types/issue.ts: -------------------------------------------------------------------------------- 1 | export interface IssueEvent { 2 | action: string 3 | issue: Issue 4 | changes: Changes 5 | repository: Repository 6 | sender: Sender 7 | } 8 | interface Issue { 9 | url: string 10 | repository_url: string 11 | labels_url: string 12 | comments_url: string 13 | events_url: string 14 | html_url: string 15 | id: number 16 | node_id: string 17 | number: number 18 | title: string 19 | user: User 20 | labels: LabelsItem[] 21 | state: string 22 | locked: boolean 23 | assignee: Assignee 24 | assignees: AssigneesItem[] 25 | milestone: Milestone 26 | comments: number 27 | created_at: string 28 | updated_at: string 29 | closed_at: null 30 | author_association: string 31 | body: string 32 | } 33 | interface User { 34 | login: string 35 | id: number 36 | node_id: string 37 | avatar_url: string 38 | gravatar_id: string 39 | url: string 40 | html_url: string 41 | followers_url: string 42 | following_url: string 43 | gists_url: string 44 | starred_url: string 45 | subscriptions_url: string 46 | organizations_url: string 47 | repos_url: string 48 | events_url: string 49 | received_events_url: string 50 | type: string 51 | site_admin: boolean 52 | } 53 | interface LabelsItem { 54 | id: number 55 | node_id: string 56 | url: string 57 | name: string 58 | color: string 59 | default: boolean 60 | } 61 | interface Assignee { 62 | login: string 63 | id: number 64 | node_id: string 65 | avatar_url: string 66 | gravatar_id: string 67 | url: string 68 | html_url: string 69 | followers_url: string 70 | following_url: string 71 | gists_url: string 72 | starred_url: string 73 | subscriptions_url: string 74 | organizations_url: string 75 | repos_url: string 76 | events_url: string 77 | received_events_url: string 78 | type: string 79 | site_admin: boolean 80 | } 81 | interface AssigneesItem { 82 | login: string 83 | id: number 84 | node_id: string 85 | avatar_url: string 86 | gravatar_id: string 87 | url: string 88 | html_url: string 89 | followers_url: string 90 | following_url: string 91 | gists_url: string 92 | starred_url: string 93 | subscriptions_url: string 94 | organizations_url: string 95 | repos_url: string 96 | events_url: string 97 | received_events_url: string 98 | type: string 99 | site_admin: boolean 100 | } 101 | interface Milestone { 102 | url: string 103 | html_url: string 104 | labels_url: string 105 | id: number 106 | node_id: string 107 | number: number 108 | title: string 109 | description: string 110 | creator: Creator 111 | open_issues: number 112 | closed_issues: number 113 | state: string 114 | created_at: string 115 | updated_at: string 116 | due_on: string 117 | closed_at: string 118 | } 119 | interface Creator { 120 | login: string 121 | id: number 122 | node_id: string 123 | avatar_url: string 124 | gravatar_id: string 125 | url: string 126 | html_url: string 127 | followers_url: string 128 | following_url: string 129 | gists_url: string 130 | starred_url: string 131 | subscriptions_url: string 132 | organizations_url: string 133 | repos_url: string 134 | events_url: string 135 | received_events_url: string 136 | type: string 137 | site_admin: boolean 138 | } 139 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 140 | interface Changes {} 141 | interface Repository { 142 | id: number 143 | node_id: string 144 | name: string 145 | full_name: string 146 | private: boolean 147 | owner: Owner 148 | html_url: string 149 | description: null 150 | fork: boolean 151 | url: string 152 | forks_url: string 153 | keys_url: string 154 | collaborators_url: string 155 | teams_url: string 156 | hooks_url: string 157 | issue_events_url: string 158 | events_url: string 159 | assignees_url: string 160 | branches_url: string 161 | tags_url: string 162 | blobs_url: string 163 | git_tags_url: string 164 | git_refs_url: string 165 | trees_url: string 166 | statuses_url: string 167 | languages_url: string 168 | stargazers_url: string 169 | contributors_url: string 170 | subscribers_url: string 171 | subscription_url: string 172 | commits_url: string 173 | git_commits_url: string 174 | comments_url: string 175 | issue_comment_url: string 176 | contents_url: string 177 | compare_url: string 178 | merges_url: string 179 | archive_url: string 180 | downloads_url: string 181 | issues_url: string 182 | pulls_url: string 183 | milestones_url: string 184 | notifications_url: string 185 | labels_url: string 186 | releases_url: string 187 | deployments_url: string 188 | created_at: string 189 | updated_at: string 190 | pushed_at: string 191 | git_url: string 192 | ssh_url: string 193 | clone_url: string 194 | svn_url: string 195 | homepage: null 196 | size: number 197 | stargazers_count: number 198 | watchers_count: number 199 | language: null 200 | has_issues: boolean 201 | has_projects: boolean 202 | has_downloads: boolean 203 | has_wiki: boolean 204 | has_pages: boolean 205 | forks_count: number 206 | mirror_url: null 207 | archived: boolean 208 | disabled: boolean 209 | open_issues_count: number 210 | license: null 211 | forks: number 212 | open_issues: number 213 | watchers: number 214 | default_branch: string 215 | } 216 | interface Owner { 217 | login: string 218 | id: number 219 | node_id: string 220 | avatar_url: string 221 | gravatar_id: string 222 | url: string 223 | html_url: string 224 | followers_url: string 225 | following_url: string 226 | gists_url: string 227 | starred_url: string 228 | subscriptions_url: string 229 | organizations_url: string 230 | repos_url: string 231 | events_url: string 232 | received_events_url: string 233 | type: string 234 | site_admin: boolean 235 | } 236 | interface Sender { 237 | login: string 238 | id: number 239 | node_id: string 240 | avatar_url: string 241 | gravatar_id: string 242 | url: string 243 | html_url: string 244 | followers_url: string 245 | following_url: string 246 | gists_url: string 247 | starred_url: string 248 | subscriptions_url: string 249 | organizations_url: string 250 | repos_url: string 251 | events_url: string 252 | received_events_url: string 253 | type: string 254 | site_admin: boolean 255 | } 256 | -------------------------------------------------------------------------------- /src/modules/github/types/pull-request.ts: -------------------------------------------------------------------------------- 1 | export interface PullRequestPayload { 2 | action: string 3 | number: number 4 | pull_request: Pull_request 5 | repository: Repository 6 | sender: Sender 7 | } 8 | interface Pull_request { 9 | url: string 10 | id: number 11 | node_id: string 12 | html_url: string 13 | diff_url: string 14 | patch_url: string 15 | issue_url: string 16 | number: number 17 | state: string 18 | locked: boolean 19 | title: string 20 | user: User 21 | body: string 22 | created_at: string 23 | updated_at: string 24 | closed_at: null 25 | merged_at: null 26 | merge_commit_sha: null 27 | assignee: null 28 | assignees: any[] 29 | requested_reviewers: any[] 30 | requested_teams: any[] 31 | labels: any[] 32 | milestone: null 33 | commits_url: string 34 | review_comments_url: string 35 | review_comment_url: string 36 | comments_url: string 37 | statuses_url: string 38 | head: Head 39 | base: Base 40 | _links: _links 41 | author_association: string 42 | draft: boolean 43 | merged: boolean 44 | mergeable: null 45 | rebaseable: null 46 | mergeable_state: string 47 | merged_by: null 48 | comments: number 49 | review_comments: number 50 | maintainer_can_modify: boolean 51 | commits: number 52 | additions: number 53 | deletions: number 54 | changed_files: number 55 | } 56 | interface User { 57 | login: string 58 | id: number 59 | node_id: string 60 | avatar_url: string 61 | gravatar_id: string 62 | url: string 63 | html_url: string 64 | followers_url: string 65 | following_url: string 66 | gists_url: string 67 | starred_url: string 68 | subscriptions_url: string 69 | organizations_url: string 70 | repos_url: string 71 | events_url: string 72 | received_events_url: string 73 | type: string 74 | site_admin: boolean 75 | } 76 | interface Head { 77 | label: string 78 | ref: string 79 | sha: string 80 | user: User 81 | repo: Repo 82 | } 83 | interface Repo { 84 | id: number 85 | node_id: string 86 | name: string 87 | full_name: string 88 | private: boolean 89 | owner: Owner 90 | html_url: string 91 | description: null 92 | fork: boolean 93 | url: string 94 | forks_url: string 95 | keys_url: string 96 | collaborators_url: string 97 | teams_url: string 98 | hooks_url: string 99 | issue_events_url: string 100 | events_url: string 101 | assignees_url: string 102 | branches_url: string 103 | tags_url: string 104 | blobs_url: string 105 | git_tags_url: string 106 | git_refs_url: string 107 | trees_url: string 108 | statuses_url: string 109 | languages_url: string 110 | stargazers_url: string 111 | contributors_url: string 112 | subscribers_url: string 113 | subscription_url: string 114 | commits_url: string 115 | git_commits_url: string 116 | comments_url: string 117 | issue_comment_url: string 118 | contents_url: string 119 | compare_url: string 120 | merges_url: string 121 | archive_url: string 122 | downloads_url: string 123 | issues_url: string 124 | pulls_url: string 125 | milestones_url: string 126 | notifications_url: string 127 | labels_url: string 128 | releases_url: string 129 | deployments_url: string 130 | created_at: string 131 | updated_at: string 132 | pushed_at: string 133 | git_url: string 134 | ssh_url: string 135 | clone_url: string 136 | svn_url: string 137 | homepage: null 138 | size: number 139 | stargazers_count: number 140 | watchers_count: number 141 | language: null 142 | has_issues: boolean 143 | has_projects: boolean 144 | has_downloads: boolean 145 | has_wiki: boolean 146 | has_pages: boolean 147 | forks_count: number 148 | mirror_url: null 149 | archived: boolean 150 | disabled: boolean 151 | open_issues_count: number 152 | license: null 153 | forks: number 154 | open_issues: number 155 | watchers: number 156 | default_branch: string 157 | allow_squash_merge: boolean 158 | allow_merge_commit: boolean 159 | allow_rebase_merge: boolean 160 | delete_branch_on_merge: boolean 161 | } 162 | interface Owner { 163 | login: string 164 | id: number 165 | node_id: string 166 | avatar_url: string 167 | gravatar_id: string 168 | url: string 169 | html_url: string 170 | followers_url: string 171 | following_url: string 172 | gists_url: string 173 | starred_url: string 174 | subscriptions_url: string 175 | organizations_url: string 176 | repos_url: string 177 | events_url: string 178 | received_events_url: string 179 | type: string 180 | site_admin: boolean 181 | } 182 | interface Base { 183 | label: string 184 | ref: string 185 | sha: string 186 | user: User 187 | repo: Repo 188 | } 189 | interface _links { 190 | self: Self 191 | html: Html 192 | issue: Issue 193 | comments: Comments 194 | review_comments: Review_comments 195 | review_comment: Review_comment 196 | commits: Commits 197 | statuses: Statuses 198 | } 199 | interface Self { 200 | href: string 201 | } 202 | interface Html { 203 | href: string 204 | } 205 | interface Issue { 206 | href: string 207 | } 208 | interface Comments { 209 | href: string 210 | } 211 | interface Review_comments { 212 | href: string 213 | } 214 | interface Review_comment { 215 | href: string 216 | } 217 | interface Commits { 218 | href: string 219 | } 220 | interface Statuses { 221 | href: string 222 | } 223 | interface Repository { 224 | id: number 225 | node_id: string 226 | name: string 227 | full_name: string 228 | private: boolean 229 | owner: Owner 230 | html_url: string 231 | description: null 232 | fork: boolean 233 | url: string 234 | forks_url: string 235 | keys_url: string 236 | collaborators_url: string 237 | teams_url: string 238 | hooks_url: string 239 | issue_events_url: string 240 | events_url: string 241 | assignees_url: string 242 | branches_url: string 243 | tags_url: string 244 | blobs_url: string 245 | git_tags_url: string 246 | git_refs_url: string 247 | trees_url: string 248 | statuses_url: string 249 | languages_url: string 250 | stargazers_url: string 251 | contributors_url: string 252 | subscribers_url: string 253 | subscription_url: string 254 | commits_url: string 255 | git_commits_url: string 256 | comments_url: string 257 | issue_comment_url: string 258 | contents_url: string 259 | compare_url: string 260 | merges_url: string 261 | archive_url: string 262 | downloads_url: string 263 | issues_url: string 264 | pulls_url: string 265 | milestones_url: string 266 | notifications_url: string 267 | labels_url: string 268 | releases_url: string 269 | deployments_url: string 270 | created_at: string 271 | updated_at: string 272 | pushed_at: string 273 | git_url: string 274 | ssh_url: string 275 | clone_url: string 276 | svn_url: string 277 | homepage: null 278 | size: number 279 | stargazers_count: number 280 | watchers_count: number 281 | language: null 282 | has_issues: boolean 283 | has_projects: boolean 284 | has_downloads: boolean 285 | has_wiki: boolean 286 | has_pages: boolean 287 | forks_count: number 288 | mirror_url: null 289 | archived: boolean 290 | disabled: boolean 291 | open_issues_count: number 292 | license: null 293 | forks: number 294 | open_issues: number 295 | watchers: number 296 | default_branch: string 297 | } 298 | interface Sender { 299 | login: string 300 | id: number 301 | node_id: string 302 | avatar_url: string 303 | gravatar_id: string 304 | url: string 305 | html_url: string 306 | followers_url: string 307 | following_url: string 308 | gists_url: string 309 | starred_url: string 310 | subscriptions_url: string 311 | organizations_url: string 312 | repos_url: string 313 | events_url: string 314 | received_events_url: string 315 | type: string 316 | site_admin: boolean 317 | } 318 | -------------------------------------------------------------------------------- /src/modules/github/types/push.ts: -------------------------------------------------------------------------------- 1 | type CommitType = { 2 | id: string 3 | tree_id: string 4 | distinct: boolean 5 | message: string 6 | timestamp: string 7 | url: string 8 | author: { 9 | name: string 10 | email: string 11 | username: string 12 | } 13 | committer: { 14 | name: string 15 | email: string 16 | username: string 17 | } 18 | added: string[] 19 | removed: any[] 20 | modified: any[] 21 | } 22 | 23 | export interface PushEvent { 24 | ref: string 25 | before: string 26 | after: string 27 | created: boolean 28 | deleted: boolean 29 | forced: boolean 30 | base_ref: string 31 | compare: string 32 | commits: CommitType[] | CommitType 33 | head_commit: Head_commit 34 | repository: Repository 35 | pusher: Pusher 36 | sender: Sender 37 | } 38 | interface Head_commit { 39 | id: string 40 | tree_id: string 41 | distinct: boolean 42 | message: string 43 | timestamp: string 44 | url: string 45 | author: Author 46 | committer: Committer 47 | added: string[] 48 | removed: any[] 49 | modified: any[] 50 | } 51 | interface Author { 52 | name: string 53 | email: string 54 | username: string 55 | } 56 | interface Committer { 57 | name: string 58 | email: string 59 | username: string 60 | } 61 | interface Repository { 62 | id: number 63 | node_id: string 64 | name: string 65 | full_name: string 66 | private: boolean 67 | owner: Owner 68 | html_url: string 69 | description: null 70 | fork: boolean 71 | url: string 72 | forks_url: string 73 | keys_url: string 74 | collaborators_url: string 75 | teams_url: string 76 | hooks_url: string 77 | issue_events_url: string 78 | events_url: string 79 | assignees_url: string 80 | branches_url: string 81 | tags_url: string 82 | blobs_url: string 83 | git_tags_url: string 84 | git_refs_url: string 85 | trees_url: string 86 | statuses_url: string 87 | languages_url: string 88 | stargazers_url: string 89 | contributors_url: string 90 | subscribers_url: string 91 | subscription_url: string 92 | commits_url: string 93 | git_commits_url: string 94 | comments_url: string 95 | issue_comment_url: string 96 | contents_url: string 97 | compare_url: string 98 | merges_url: string 99 | archive_url: string 100 | downloads_url: string 101 | issues_url: string 102 | pulls_url: string 103 | milestones_url: string 104 | notifications_url: string 105 | labels_url: string 106 | releases_url: string 107 | deployments_url: string 108 | created_at: number 109 | updated_at: string 110 | pushed_at: number 111 | git_url: string 112 | ssh_url: string 113 | clone_url: string 114 | svn_url: string 115 | homepage: null 116 | size: number 117 | stargazers_count: number 118 | watchers_count: number 119 | language: string 120 | has_issues: boolean 121 | has_projects: boolean 122 | has_downloads: boolean 123 | has_wiki: boolean 124 | has_pages: boolean 125 | forks_count: number 126 | mirror_url: null 127 | archived: boolean 128 | disabled: boolean 129 | open_issues_count: number 130 | license: null 131 | forks: number 132 | open_issues: number 133 | watchers: number 134 | default_branch: string 135 | stargazers: number 136 | master_branch: string 137 | } 138 | interface Owner { 139 | name: string 140 | email: string 141 | login: string 142 | id: number 143 | node_id: string 144 | avatar_url: string 145 | gravatar_id: string 146 | url: string 147 | html_url: string 148 | followers_url: string 149 | following_url: string 150 | gists_url: string 151 | starred_url: string 152 | subscriptions_url: string 153 | organizations_url: string 154 | repos_url: string 155 | events_url: string 156 | received_events_url: string 157 | type: string 158 | site_admin: boolean 159 | } 160 | interface Pusher { 161 | name: string 162 | email: string 163 | } 164 | interface Sender { 165 | login: string 166 | id: number 167 | node_id: string 168 | avatar_url: string 169 | gravatar_id: string 170 | url: string 171 | html_url: string 172 | followers_url: string 173 | following_url: string 174 | gists_url: string 175 | starred_url: string 176 | subscriptions_url: string 177 | organizations_url: string 178 | repos_url: string 179 | events_url: string 180 | received_events_url: string 181 | type: string 182 | site_admin: boolean 183 | } 184 | -------------------------------------------------------------------------------- /src/modules/github/types/workflow.ts: -------------------------------------------------------------------------------- 1 | export interface WorkflowEvent { 2 | action: string 3 | organization: Organization 4 | repository: Repository 5 | sender: Sender 6 | workflow: Workflow 7 | workflow_run: Workflow_run 8 | } 9 | interface Organization { 10 | avatar_url: string 11 | description: string 12 | events_url: string 13 | hooks_url: string 14 | id: number 15 | issues_url: string 16 | login: string 17 | members_url: string 18 | node_id: string 19 | public_members_url: string 20 | repos_url: string 21 | url: string 22 | } 23 | interface Repository { 24 | archive_url: string 25 | archived?: boolean 26 | assignees_url: string 27 | blobs_url: string 28 | branches_url: string 29 | clone_url?: string 30 | collaborators_url: string 31 | comments_url: string 32 | commits_url: string 33 | compare_url: string 34 | contents_url: string 35 | contributors_url: string 36 | created_at?: string 37 | default_branch?: string 38 | deployments_url: string 39 | description: null 40 | disabled?: boolean 41 | downloads_url: string 42 | events_url: string 43 | fork: boolean 44 | forks?: number 45 | forks_count?: number 46 | forks_url: string 47 | full_name: string 48 | git_commits_url: string 49 | git_refs_url: string 50 | git_tags_url: string 51 | git_url?: string 52 | has_downloads?: boolean 53 | has_issues?: boolean 54 | has_pages?: boolean 55 | has_projects?: boolean 56 | has_wiki?: boolean 57 | homepage?: null 58 | hooks_url: string 59 | html_url: string 60 | id: number 61 | issue_comment_url: string 62 | issue_events_url: string 63 | issues_url: string 64 | keys_url: string 65 | labels_url: string 66 | language?: null 67 | languages_url: string 68 | license?: null 69 | merges_url: string 70 | milestones_url: string 71 | mirror_url?: null 72 | name: string 73 | node_id: string 74 | notifications_url: string 75 | open_issues?: number 76 | open_issues_count?: number 77 | owner: Owner 78 | private: boolean 79 | pulls_url: string 80 | pushed_at?: string 81 | releases_url: string 82 | size?: number 83 | ssh_url?: string 84 | stargazers_count?: number 85 | stargazers_url: string 86 | statuses_url: string 87 | subscribers_url: string 88 | subscription_url: string 89 | svn_url?: string 90 | tags_url: string 91 | teams_url: string 92 | trees_url: string 93 | updated_at?: string 94 | url: string 95 | watchers?: number 96 | watchers_count?: number 97 | } 98 | interface Owner { 99 | avatar_url: string 100 | events_url: string 101 | followers_url: string 102 | following_url: string 103 | gists_url: string 104 | gravatar_id: string 105 | html_url: string 106 | id: number 107 | login: string 108 | node_id: string 109 | organizations_url: string 110 | received_events_url: string 111 | repos_url: string 112 | site_admin: boolean 113 | starred_url: string 114 | subscriptions_url: string 115 | type: string 116 | url: string 117 | } 118 | interface Sender { 119 | avatar_url: string 120 | events_url: string 121 | followers_url: string 122 | following_url: string 123 | gists_url: string 124 | gravatar_id: string 125 | html_url: string 126 | id: number 127 | login: string 128 | node_id: string 129 | organizations_url: string 130 | received_events_url: string 131 | repos_url: string 132 | site_admin: boolean 133 | starred_url: string 134 | subscriptions_url: string 135 | type: string 136 | url: string 137 | } 138 | interface Workflow { 139 | badge_url: string 140 | created_at: string 141 | html_url: string 142 | id: number 143 | name: string 144 | node_id: string 145 | path: string 146 | state: string 147 | updated_at: string 148 | url: string 149 | } 150 | interface Workflow_run { 151 | artifacts_url: string 152 | cancel_url: string 153 | check_suite_id: number 154 | check_suite_node_id: string 155 | check_suite_url: string 156 | conclusion: null 157 | created_at: string 158 | event: string 159 | head_branch: string 160 | head_commit: Head_commit 161 | head_repository: Head_repository 162 | head_sha: string 163 | html_url: string 164 | id: number 165 | jobs_url: string 166 | logs_url: string 167 | name: string 168 | node_id: string 169 | previous_attempt_url: null 170 | pull_requests: any[] 171 | repository: Repository 172 | rerun_url: string 173 | run_attempt: number 174 | run_number: number 175 | run_started_at: string 176 | status: string 177 | updated_at: string 178 | url: string 179 | workflow_id: number 180 | workflow_url: string 181 | } 182 | interface Head_commit { 183 | author: Author 184 | committer: Committer 185 | id: string 186 | message: string 187 | timestamp: string 188 | tree_id: string 189 | } 190 | interface Author { 191 | email: string 192 | name: string 193 | } 194 | interface Committer { 195 | email: string 196 | name: string 197 | } 198 | interface Head_repository { 199 | archive_url: string 200 | assignees_url: string 201 | blobs_url: string 202 | branches_url: string 203 | collaborators_url: string 204 | comments_url: string 205 | commits_url: string 206 | compare_url: string 207 | contents_url: string 208 | contributors_url: string 209 | deployments_url: string 210 | description: null 211 | downloads_url: string 212 | events_url: string 213 | fork: boolean 214 | forks_url: string 215 | full_name: string 216 | git_commits_url: string 217 | git_refs_url: string 218 | git_tags_url: string 219 | hooks_url: string 220 | html_url: string 221 | id: number 222 | issue_comment_url: string 223 | issue_events_url: string 224 | issues_url: string 225 | keys_url: string 226 | labels_url: string 227 | languages_url: string 228 | merges_url: string 229 | milestones_url: string 230 | name: string 231 | node_id: string 232 | notifications_url: string 233 | owner: Owner 234 | private: boolean 235 | pulls_url: string 236 | releases_url: string 237 | stargazers_url: string 238 | statuses_url: string 239 | subscribers_url: string 240 | subscription_url: string 241 | tags_url: string 242 | teams_url: string 243 | trees_url: string 244 | url: string 245 | } 246 | -------------------------------------------------------------------------------- /src/modules/health-check/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'icqq' 2 | import { commandRegistry } from '~/registries/command' 3 | 4 | class HealthCheckStatic { 5 | checkFnList = [() => 'UP!'] as Array<() => string | Promise> 6 | registerHealthCheck(checkFn: () => string | Promise) { 7 | this.checkFnList.push(checkFn) 8 | 9 | return () => { 10 | const idx = this.checkFnList.findIndex((fn) => fn === checkFn) 11 | return idx > -1 && this.checkFnList.splice(idx, 1) 12 | } 13 | } 14 | call() { 15 | return Promise.all(this.checkFnList.map((fn) => fn())) 16 | } 17 | 18 | async setup() { 19 | commandRegistry.register('health', async () => { 20 | return (await this.call().then((result) => { 21 | return result.join('\n') 22 | })) as string 23 | }) 24 | } 25 | } 26 | 27 | export const healthCheck = new HealthCheckStatic() 28 | 29 | export const register = (ctx: Client) => { 30 | healthCheck.setup() 31 | return healthCheck 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { readdir } from 'fs/promises' 3 | import { resolve } from 'path' 4 | 5 | import { createNamespaceLogger } from '~/utils/logger' 6 | import { hook } from '~/utils/plugin' 7 | 8 | const logger = createNamespaceLogger('module-loader') 9 | export const registerModules = async () => { 10 | const modules = await readdir(resolve(__dirname)) 11 | 12 | modules.forEach((moduleName) => { 13 | // 跳过禁用的组件 14 | if (moduleName.startsWith('_')) return 15 | 16 | const modulePath = resolve(__dirname, moduleName) 17 | 18 | if (!fs.statSync(modulePath).isDirectory()) { 19 | return 20 | } 21 | 22 | logger.log(`register module: ${moduleName}`) 23 | try { 24 | const { register } = require(modulePath) 25 | hook.register(register) 26 | } catch (err) { 27 | logger.error(`register module: ${moduleName} failed`) 28 | consola.error(err) 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/mx-space/api-client.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios' 2 | import chalk from 'chalk' 3 | import { botConfig } from 'config' 4 | 5 | import { allControllers, createClient } from '@mx-space/api-client' 6 | import { axiosAdaptor } from '@mx-space/api-client/dist/adaptors/axios' 7 | 8 | import { userAgent } from '~/constants/env' 9 | import { createNamespaceLogger } from '~/utils/logger' 10 | 11 | const logger = createNamespaceLogger('mx-space-api') 12 | 13 | // const prettyStringify = (data: any) => { 14 | // return JSON.stringify(data, null, 2) 15 | // } 16 | 17 | declare module 'axios' { 18 | interface AxiosRequestConfig { 19 | __requestStartedAt?: number 20 | __requestEndedAt?: number 21 | __requestDuration?: number 22 | } 23 | } 24 | 25 | axiosAdaptor.default.interceptors.request.use((req) => { 26 | // req.__requestStartedAt = performance.now() 27 | 28 | // logger.debug( 29 | // `HTTP Request: [${req.method?.toUpperCase()}] ${req.baseURL || ''}${ 30 | // req.url 31 | // } 32 | // params: ${prettyStringify(req.params)} 33 | // data: ${prettyStringify(req.data)}`, 34 | // ) 35 | 36 | req.headers = { 37 | ...req.headers, 38 | 'user-agent': userAgent, 39 | authorization: botConfig.mxSpace.token, 40 | 'x-request-id': Math.random().toString(36).slice(2), 41 | } as any 42 | 43 | return req 44 | }) 45 | axiosAdaptor.default.interceptors.response.use( 46 | (res: AxiosResponse) => { 47 | // const endAt = performance.now() 48 | // res.config.__requestEndedAt = endAt 49 | // res.config.__requestDuration = 50 | // // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 51 | // res.config?.__requestStartedAt ?? endAt - res.config!.__requestStartedAt! 52 | // logger.debug( 53 | // `HTTP Response ${`${res.config.baseURL || ''}${ 54 | // res.config.url 55 | // }`} +${res.config.__requestDuration.toFixed(2)}ms: `, 56 | // res.data, 57 | // ) 58 | return res 59 | }, 60 | (err) => { 61 | const res = err.response 62 | 63 | const error = Promise.reject(err) 64 | if (!res) { 65 | return error 66 | } 67 | logger.error( 68 | chalk.red( 69 | `HTTP Response Failed ${`${res.config.baseURL || ''}${ 70 | res.config.url 71 | }`}`, 72 | ), 73 | ) 74 | 75 | return error 76 | }, 77 | ) 78 | const apiClient = createClient(axiosAdaptor)(botConfig.mxSpace?.apiEndpoint, { 79 | controllers: allControllers, 80 | }) 81 | 82 | export { apiClient } 83 | -------------------------------------------------------------------------------- /src/modules/mx-space/api/hitokoto.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const fetchHitokoto = async () => { 4 | return (await axios.get('https://v1.hitokoto.cn/', { timeout: 2000 })).data 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/mx-space/event-handler.ts: -------------------------------------------------------------------------------- 1 | import { botConfig } from 'config' 2 | import dayjs from 'dayjs' 3 | import type { Client, Sendable } from 'icqq' 4 | import rmd from 'remove-markdown' 5 | import type { 6 | CommentModel, 7 | LinkModel, 8 | NoteModel, 9 | PageModel, 10 | PostModel, 11 | RecentlyModel, 12 | SayModel, 13 | } from '@mx-space/api-client' 14 | import { LinkState } from '@mx-space/api-client' 15 | import { isDev } from '~/constants/env' 16 | import { createNamespaceLogger } from '~/utils/logger' 17 | import { getShortDateTime, relativeTimeFromNow } from '~/utils/time' 18 | import { apiClient } from './api-client' 19 | import { aggregateStore } from './store/aggregate' 20 | import { userStore } from './store/user' 21 | import { 22 | MxSocketEventTypes, 23 | MxSystemEventBusEvents, 24 | } from './types/mx-socket-types' 25 | import { fetchImageBuffer } from './utils/fetch-image' 26 | 27 | const logger = createNamespaceLogger('mx-event') 28 | export const handleEvent = 29 | (client: Client) => 30 | async ( 31 | type: MxSocketEventTypes | MxSystemEventBusEvents, 32 | payload: any, 33 | code?: number, 34 | ) => { 35 | logger.debug(type, payload) 36 | 37 | const user = userStore.user! 38 | 39 | const { 40 | url: { webUrl }, 41 | seo: { title: siteTitle }, 42 | } = aggregateStore.aggregate! 43 | 44 | const sendToGuild = async (message: Sendable) => { 45 | const { watchGroupIds } = botConfig.mxSpace 46 | 47 | return await Promise.all( 48 | watchGroupIds.map((id) => { 49 | return client.sendGroupMsg(id, message) 50 | }), 51 | ) 52 | } 53 | 54 | switch (type) { 55 | case MxSocketEventTypes.POST_CREATE: { 56 | // case MxSocketEventTypes.POST_UPDATE: 57 | const isNew = type === MxSocketEventTypes.POST_CREATE 58 | const publishDescription = isNew ? '发布了新文章' : '更新了文章' 59 | const { title, text, category, id, slug, summary } = 60 | payload as PostModel 61 | 62 | if (!category) { 63 | logger.error(`category not found, post id: ${id}`) 64 | return 65 | } 66 | const simplePreview = getSimplePreview(text) 67 | const message = `${ 68 | user.name 69 | } ${publishDescription}: ${title}\n\n${simplePreview}\n\n${ 70 | summary ? `${summary}\n\n` : '' 71 | }前往阅读:${webUrl}/posts/${category.slug}/${slug}` 72 | await sendToGuild(message) 73 | 74 | return 75 | } 76 | 77 | case MxSocketEventTypes.NOTE_CREATE: { 78 | // case MxSocketEventTypes.NOTE_UPDATE: { 79 | const isNew = type === MxSocketEventTypes.NOTE_CREATE 80 | const publishDescription = isNew ? '发布了新生活观察日记' : '更新了日记' 81 | const { title, text, nid, mood, weather, images, hide, password } = 82 | payload as NoteModel 83 | const isSecret = checkNoteIsSecret(payload as NoteModel) 84 | 85 | if (hide || password || isSecret) { 86 | return 87 | } 88 | const simplePreview = getSimplePreview(text) 89 | 90 | const status = [mood ? `心情: ${mood}` : ''] 91 | .concat(weather ? `天气: ${weather}` : '') 92 | .filter(Boolean) 93 | .join('\t') 94 | const message = `${user.name} ${publishDescription}: ${title}\n${ 95 | status ? `\n${status}\n\n` : '\n' 96 | }${simplePreview}\n\n前往阅读:${webUrl}/notes/${nid}` 97 | await sendToGuild(message) 98 | 99 | if (Array.isArray(images) && images.length > 0) { 100 | const imageBuffer = await fetchImageBuffer(images[0].src) 101 | await sendToGuild({ 102 | type: 'image', 103 | file: imageBuffer, 104 | }) 105 | } 106 | 107 | return 108 | } 109 | 110 | case MxSocketEventTypes.LINK_APPLY: { 111 | const { avatar, name, url, description, state } = payload as LinkModel 112 | if (state !== LinkState.Audit) { 113 | return 114 | } 115 | const avatarBuffer: Buffer | string = await fetchImageBuffer(avatar) 116 | 117 | const message = 118 | `有新的友链申请了耶!\n` + `${name}\n${url}\n\n` + `${description}` 119 | const sendable: Sendable = [] 120 | 121 | if (avatarBuffer) { 122 | sendable.push({ 123 | type: 'image', 124 | file: avatarBuffer, 125 | }) 126 | } 127 | 128 | sendable.push({ 129 | type: 'text', 130 | text: avatarBuffer ? `\n${message}` : message, 131 | }) 132 | await sendToGuild(sendable) 133 | return 134 | } 135 | 136 | case MxSocketEventTypes.COMMENT_CREATE: { 137 | const { author, text, refType, parent, id, isWhispers } = 138 | payload as CommentModel 139 | if (isWhispers) { 140 | await sendToGuild(`「${siteTitle}」嘘,有人说了一句悄悄话。`) 141 | return 142 | } 143 | 144 | const parentIsWhispers = (() => { 145 | const walk: (parent: any) => boolean = (parent) => { 146 | if (!parent || typeof parent == 'string') { 147 | return false 148 | } 149 | return parent.isWhispers || walk(parent?.parent) 150 | } 151 | 152 | return walk(parent) 153 | })() 154 | if (parentIsWhispers) { 155 | return 156 | } 157 | 158 | const refId = payload.ref?.id || payload.ref?._id || payload.ref 159 | let refModel: PostModel | NoteModel | PageModel | null = null 160 | 161 | switch (refType) { 162 | case 'Post': { 163 | refModel = await apiClient.post.getPost(refId) 164 | break 165 | } 166 | case 'Note': { 167 | refModel = await apiClient.note.getNoteById(refId as string) 168 | 169 | break 170 | } 171 | case 'Page': { 172 | refModel = await apiClient.page.getById(refId) 173 | break 174 | } 175 | } 176 | 177 | if (!refModel) { 178 | return 179 | } 180 | const isMaster = author === user.name || author === user.username 181 | let message: string 182 | if (isMaster && !parent) { 183 | message = `${author} 在「${ 184 | refModel.title 185 | }」发表之后的 ${relativeTimeFromNow(refModel.created)}又说:${text}` 186 | } else { 187 | message = `${author} 在「${refModel.title}」发表了评论:${text}` 188 | } 189 | 190 | const uri = (() => { 191 | switch (refType) { 192 | case 'Post': { 193 | return `/posts/${(refModel as PostModel).category.slug}/${ 194 | (refModel as PostModel).slug 195 | }` 196 | } 197 | case 'Note': { 198 | return `/notes/${(refModel as NoteModel).nid}` 199 | } 200 | case 'Page': { 201 | return `/${(refModel as PageModel).slug}` 202 | } 203 | } 204 | })() 205 | 206 | if (uri) { 207 | message += `\n\n查看评论:${webUrl}${uri}#comments-${id}` 208 | } 209 | 210 | sendToGuild(message) 211 | return 212 | } 213 | 214 | case MxSocketEventTypes.PAGE_UPDATED: { 215 | const { title, slug } = payload as PageModel 216 | const message = `${user.name} 更新了页面「${title}」\n\n前往查看:${webUrl}/${slug}` 217 | await sendToGuild(message) 218 | return 219 | } 220 | 221 | case MxSocketEventTypes.SAY_CREATE: { 222 | const { author, source, text } = payload as SayModel 223 | 224 | const message = 225 | `${user.name} 发布一条说说:\n` + 226 | `${text}\n${source || author ? `来自: ${source || author}` : ''}` 227 | await sendToGuild(message) 228 | 229 | return 230 | } 231 | case MxSocketEventTypes.RECENTLY_CREATE: { 232 | const { content } = payload as RecentlyModel 233 | 234 | const message = `${user.name} 发布一条动态说:\n${content}` 235 | await sendToGuild(message) 236 | 237 | return 238 | } 239 | 240 | case MxSystemEventBusEvents.SystemException: { 241 | const { message, stack } = payload as Error 242 | const messageWithStack = `来自 Mix Space 的系统异常:${getShortDateTime( 243 | new Date(), 244 | )}\n${message}\n\n${stack}` 245 | await sendToGuild(messageWithStack) 246 | return 247 | } 248 | default: { 249 | if (isDev) { 250 | console.log(payload) 251 | } 252 | } 253 | } 254 | } 255 | 256 | const getSimplePreview = (text: string) => { 257 | const _text = rmd(text) as string 258 | return _text.length > 200 ? `${_text.slice(0, 200)}...` : _text 259 | } 260 | 261 | function checkNoteIsSecret(note: NoteModel) { 262 | if (!note.secret) { 263 | return false 264 | } 265 | const isSecret = dayjs(note.secret).isAfter(new Date()) 266 | 267 | return isSecret 268 | } 269 | -------------------------------------------------------------------------------- /src/modules/mx-space/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { botConfig } from 'config' 3 | import { CronJob } from 'cron' 4 | import type { Client } from 'icqq' 5 | import { sample } from 'lodash' 6 | import { createNamespaceLogger } from '~/utils/logger' 7 | import { healthCheck } from '../health-check' 8 | import { fetchHitokoto } from './api/hitokoto' 9 | import { listenMessage } from './message' 10 | import mxSocket from './socket' 11 | import { aggregateStore } from './store/aggregate' 12 | import { userStore } from './store/user' 13 | import type { MxContext } from './types' 14 | 15 | const logger = createNamespaceLogger('mx-space') 16 | export const register = async (client: Client) => { 17 | logger.info('module loading...') 18 | 19 | const initData = async () => { 20 | const [user, aggregateData] = await Promise.all([ 21 | userStore.fetchUser(), 22 | aggregateStore.fetch(), 23 | ]) 24 | userStore.setUser(user) 25 | aggregateStore.setData(aggregateData) 26 | } 27 | 28 | try { 29 | await initData() 30 | } catch (err) { 31 | consola.error(err) 32 | process.exit(-1) 33 | } 34 | 35 | const socket = mxSocket(client) 36 | socket.connect() 37 | 38 | logger.info('module loaded!') 39 | 40 | const ctx: MxContext = { 41 | socket, 42 | client, 43 | aggregationData: aggregateStore.aggregate!, 44 | 45 | refreshData: initData, 46 | } 47 | 48 | listenMessage(ctx) 49 | 50 | client.on('notice.group.increase', async (e) => { 51 | if (!botConfig.mxSpace.watchGroupIds.includes(e.group_id)) { 52 | return 53 | } 54 | 55 | const { hitokoto } = await fetchHitokoto() 56 | 57 | client.sendGroupMsg(e.group_id, [ 58 | { type: 'text', text: `欢迎新大佬 ` }, 59 | { type: 'at', qq: e.user_id }, 60 | { type: 'text', text: `(${e.user_id})!\n${hitokoto || ''}` }, 61 | ]) 62 | }) 63 | 64 | const sayGoodMorning = new CronJob('0 0 6 * * *', async () => { 65 | const { hitokoto } = await fetchHitokoto() 66 | const greeting = sample([ 67 | '新的一天也要加油哦', 68 | '今天也要元气满满哦!', 69 | '今天也是充满希望的一天', 70 | ]) 71 | const tasks = botConfig.mxSpace.watchGroupIds.map((id) => 72 | client.sendGroupMsg(id, `早上好!${greeting}\n\n${hitokoto || ''}`), 73 | ) 74 | 75 | await Promise.all(tasks) 76 | }) 77 | 78 | const sayGoodEvening = new CronJob('0 0 22 * * *', async () => { 79 | const { hitokoto } = await fetchHitokoto() 80 | const tasks = botConfig.mxSpace.watchGroupIds.map((id) => 81 | client.sendGroupMsg(id, `晚安,早点睡哦!\n\n${hitokoto || ''}`), 82 | ) 83 | 84 | await Promise.all(tasks) 85 | }) 86 | 87 | sayGoodMorning.start() 88 | sayGoodEvening.start() 89 | 90 | healthCheck.registerHealthCheck(() => { 91 | return `Mx Socket connected: ${ 92 | socket.connected ? 'connected' : 'disconnected' 93 | }` 94 | }) 95 | 96 | return { 97 | socket, 98 | job: { 99 | sayGoodMorning, 100 | sayGoodEvening, 101 | }, 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/modules/mx-space/message.ts: -------------------------------------------------------------------------------- 1 | import rmd from 'remove-markdown' 2 | 3 | import type { NoteModel } from '@mx-space/api-client' 4 | 5 | import { commandRegistry } from '~/registries/command' 6 | 7 | import { apiClient } from './api-client' 8 | import { fetchHitokoto } from './api/hitokoto' 9 | import type { MxContext } from './types' 10 | 11 | export const listenMessage = async (ctx: MxContext) => { 12 | commandRegistry.registerWildcard(async (event) => { 13 | const commandName = event.commandName! 14 | const caller = commandMap[commandName] 15 | if (caller) { 16 | const commandSplit = event.commandMessage!.text.split(' ') 17 | const afterArgs = commandSplit.slice(1) 18 | const { 19 | aggregationData: { 20 | seo: { title }, 21 | }, 22 | } = ctx 23 | const prefix = `来自${title ? `「${title}」` : ' Mix Space '}的 ` 24 | 25 | return prefix + (await caller(ctx, afterArgs)) 26 | } 27 | }) 28 | } 29 | 30 | const commandMap: Record< 31 | string, 32 | (ctx: MxContext, args: string[]) => string | Promise 33 | > = { 34 | rss: async (ctx) => { 35 | const data = await apiClient.aggregate.getTop(5) 36 | 37 | const aggregate = ctx.aggregationData 38 | const { 39 | url: { webUrl }, 40 | } = aggregate 41 | const posts = data.posts 42 | .map( 43 | (post) => 44 | `${post.title}\n${webUrl}/posts/${post.category.slug}/${post.slug}`, 45 | ) 46 | .join('\n') 47 | const notes = data.notes 48 | .map((note) => `${note.title}\n${webUrl}/notes/${note.nid}`) 49 | .join('\n') 50 | return 'RSS 列表:' + '\n' + `博文:\n${posts}\n\n生活记录:\n${notes}` 51 | }, 52 | mx_stat: async () => { 53 | const data = await apiClient.aggregate.getStat() 54 | 55 | const { 56 | callTime, 57 | posts, 58 | notes, 59 | linkApply, 60 | recently, 61 | says, 62 | todayIpAccessCount, 63 | todayMaxOnline, 64 | todayOnlineTotal, 65 | unreadComments, 66 | comments, 67 | links, 68 | online, 69 | } = data 70 | return ( 71 | '状态信息:' + 72 | '\n\n' + 73 | `当前有文章 ${posts} 篇,生活记录 ${notes} 篇,评论 ${comments} 条,友链 ${links} 条,说说 ${says} 条,速记 ${recently} 条。` + 74 | '\n' + 75 | `未读评论 ${unreadComments} 条,友链申请 ${linkApply} 条。` + 76 | '\n' + 77 | `今日访问 ${todayIpAccessCount} 次,最高在线 ${todayMaxOnline} 人,总计在线 ${todayOnlineTotal} 人。` + 78 | '\n' + 79 | `调用次数 ${callTime} 次,当前在线 ${online} 人。` 80 | ) 81 | }, 82 | 83 | mx_version: async () => { 84 | const data = await apiClient.proxy.info.get() 85 | return `版本信息:\n\n${JSON.stringify(data, null, 2)}` 86 | }, 87 | 88 | mx_health: async (ctx) => { 89 | const socketConnected = ctx.socket.connected 90 | const data = ctx.aggregationData 91 | return ( 92 | `健康检查:\n\n` + 93 | `Socket 连接状态:${socketConnected ? '正常' : '异常'}` + 94 | `\n\n` + 95 | `聚合数据获取:${JSON.stringify(data, null, 2)}` 96 | ) 97 | }, 98 | 99 | mx_refresh_data: async (ctx) => { 100 | await ctx.refreshData() 101 | return '提示:\n\n刷新成功' 102 | }, 103 | 104 | mx_note: async (ctx, args) => { 105 | const [_nid] = args 106 | const nid = parseInt(_nid, 10) 107 | if (!nid) { 108 | return '日记:\n\n请指定生活记录 ID' 109 | } 110 | const { 111 | aggregationData: { 112 | url: { webUrl }, 113 | }, 114 | } = ctx 115 | try { 116 | const _data = await apiClient.note.getNoteById( 117 | nid as any, 118 | undefined, 119 | true, 120 | ) 121 | // TODO remmove this 122 | // @ts-ignore 123 | const data: NoteModel = _data.data || _data 124 | 125 | if (data.password || data.hide) { 126 | return '日记:\n\n该日记已被隐藏或者已加密' 127 | } 128 | return `日记:\n\n${data.title}\n\n${rmd( 129 | data.text, 130 | )}\n\n前往阅读:${webUrl}/notes/${data.nid}` 131 | // @ts-ignore 132 | } catch (err: RequestError) { 133 | return `日记获取错误:\n\n${err.message}` 134 | } 135 | }, 136 | 137 | hitokoto: async () => { 138 | const { hitokoto } = await fetchHitokoto() 139 | return `一言:\n\n${hitokoto}` 140 | }, 141 | } 142 | -------------------------------------------------------------------------------- /src/modules/mx-space/socket.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'icqq' 2 | import { io } from 'socket.io-client' 3 | import { simpleCamelcaseKeys } from '@mx-space/api-client' 4 | import type { MxSocketEventTypes } from '~/modules/mx-space/types/mx-socket-types' 5 | import { botConfig } from '../../../config' 6 | import { createNamespaceLogger } from '../../utils/logger' 7 | import { handleEvent } from './event-handler' 8 | 9 | const logger = createNamespaceLogger('mx-socket') 10 | 11 | // eslint-disable-next-line import/no-default-export 12 | export default (client: Client) => { 13 | const mxSocket = io(botConfig.mxSpace?.gateway, { 14 | transports: ['websocket'], 15 | timeout: 10000, 16 | forceNew: true, 17 | query: { 18 | token: botConfig.mxSpace.token, 19 | }, 20 | 21 | autoConnect: false, 22 | }) 23 | 24 | mxSocket.io.on('error', () => { 25 | logger.error('Socket 连接异常') 26 | }) 27 | mxSocket.io.on('reconnect', () => { 28 | logger.info('Socket 重连成功') 29 | }) 30 | mxSocket.io.on('reconnect_attempt', () => { 31 | logger.info('Socket 重连中') 32 | }) 33 | mxSocket.io.on('reconnect_failed', () => { 34 | logger.info('Socket 重连失败') 35 | }) 36 | 37 | mxSocket.on('disconnect', () => { 38 | const tryReconnect = () => { 39 | if (mxSocket.connected === false) { 40 | mxSocket.io.connect() 41 | } else { 42 | timer = clearInterval(timer) 43 | } 44 | } 45 | let timer: any = setInterval(tryReconnect, 2000) 46 | }) 47 | 48 | mxSocket.on( 49 | 'message', 50 | (payload: string | Record<'type' | 'data' | 'code', any>) => { 51 | if (typeof payload !== 'string') { 52 | return handleEvent(client)( 53 | payload.type, 54 | simpleCamelcaseKeys(payload.data), 55 | payload.code, 56 | ) 57 | } 58 | const { data, type, code } = JSON.parse(payload) as { 59 | data: any 60 | type: MxSocketEventTypes 61 | code?: number 62 | } 63 | handleEvent(client)(type, simpleCamelcaseKeys(data), code) 64 | }, 65 | ) 66 | 67 | return mxSocket 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/mx-space/store/aggregate.ts: -------------------------------------------------------------------------------- 1 | import type { AggregateRoot } from '@mx-space/api-client' 2 | 3 | import { apiClient } from '../api-client' 4 | 5 | class AggregateStore { 6 | public aggregate: AggregateRoot | null = null 7 | 8 | setData(data: AggregateRoot) { 9 | this.aggregate = { ...data } 10 | } 11 | 12 | async fetch() { 13 | const data = await apiClient.aggregate.getAggregateData() 14 | return data 15 | } 16 | } 17 | 18 | export const aggregateStore = new AggregateStore() 19 | -------------------------------------------------------------------------------- /src/modules/mx-space/store/user.ts: -------------------------------------------------------------------------------- 1 | import type { UserModel } from '@mx-space/api-client' 2 | 3 | import { apiClient } from '../api-client' 4 | 5 | class UserStore { 6 | public user: UserModel | null = null 7 | 8 | setUser(user: UserModel) { 9 | this.user = { ...user } 10 | } 11 | 12 | async fetchUser() { 13 | const user = await apiClient.user.getMasterInfo() 14 | return user 15 | } 16 | } 17 | 18 | export const userStore = new UserStore() 19 | -------------------------------------------------------------------------------- /src/modules/mx-space/types.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'icqq' 2 | import type { Socket } from 'socket.io-client' 3 | import type { AggregateRoot } from '@mx-space/api-client' 4 | 5 | export type MxContext = { 6 | socket: Socket 7 | client: Client 8 | 9 | aggregationData: AggregateRoot 10 | 11 | refreshData: () => Promise 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/mx-space/types/mx-socket-types.ts: -------------------------------------------------------------------------------- 1 | export enum MxSocketEventTypes { 2 | GATEWAY_CONNECT = 'GATEWAY_CONNECT', 3 | GATEWAY_DISCONNECT = 'GATEWAY_DISCONNECT', 4 | 5 | VISITOR_ONLINE = 'VISITOR_ONLINE', 6 | VISITOR_OFFLINE = 'VISITOR_OFFLINE', 7 | 8 | AUTH_FAILED = 'AUTH_FAILED', 9 | 10 | COMMENT_CREATE = 'COMMENT_CREATE', 11 | 12 | POST_CREATE = 'POST_CREATE', 13 | POST_UPDATE = 'POST_UPDATE', 14 | POST_DELETE = 'POST_DELETE', 15 | 16 | NOTE_CREATE = 'NOTE_CREATE', 17 | NOTE_UPDATE = 'NOTE_UPDATE', 18 | NOTE_DELETE = 'NOTE_DELETE', 19 | 20 | PAGE_UPDATED = 'PAGE_UPDATED', 21 | 22 | SAY_CREATE = 'SAY_CREATE', 23 | SAY_DELETE = 'SAY_DELETE', 24 | SAY_UPDATE = 'SAY_UPDATE', 25 | 26 | RECENTLY_CREATE = 'RECENTLY_CREATE', 27 | RECENTLY_DElETE = 'RECENTLY_DElETE', 28 | 29 | LINK_APPLY = 'LINK_APPLY', 30 | 31 | DANMAKU_CREATE = 'DANMAKU_CREATE', 32 | // util 33 | CONTENT_REFRESH = 'CONTENT_REFRESH', // 内容更新或重置 页面需要重载 34 | // for admin 35 | IMAGE_REFRESH = 'IMAGE_REFRESH', 36 | IMAGE_FETCH = 'IMAGE_FETCH', 37 | 38 | ADMIN_NOTIFICATION = 'ADMIN_NOTIFICATION', 39 | STDOUT = 'STDOUT', 40 | 41 | PTY = 'pty', 42 | 43 | PTY_MESSAGE = 'pty_message', 44 | } 45 | 46 | export enum MxSystemEventBusEvents { 47 | EmailInit = 'email.init', 48 | PushSearch = 'search.push', 49 | TokenExpired = 'token.expired', 50 | 51 | CleanAggregateCache = 'cache.aggregate', 52 | SystemException = 'system.exception', 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/mx-space/utils/fetch-image.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const fetchImageBuffer = async (src: string) => { 4 | const buffer: Buffer | string = await axios 5 | .get(src, { responseType: 'arraybuffer', timeout: 4000 }) 6 | .then((data) => data.data) 7 | .then((arr) => { 8 | return Buffer.from(arr) 9 | }) 10 | .catch(() => { 11 | return '' 12 | }) 13 | 14 | return buffer 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/openai/index.ts: -------------------------------------------------------------------------------- 1 | import { botConfig } from 'config' 2 | import type { GroupMessageEvent } from 'icqq' 3 | import { Configuration, OpenAIApi } from 'openai' 4 | import { commandRegistry } from '~/registries/command' 5 | import { mentionRegistry } from '~/registries/mention' 6 | 7 | export const register = () => { 8 | const configuration = new Configuration({ 9 | apiKey: botConfig.chatgpt.token, 10 | }) 11 | const openai = new OpenAIApi(configuration) 12 | 13 | const userId2ConversationIdMap = new Map() 14 | const conversationId2MessageIdMap = new Map() 15 | 16 | async function handle(event: GroupMessageEvent) { 17 | const userId = event.sender.user_id 18 | const conversationId = userId2ConversationIdMap.get(userId) 19 | const parentMessageId = conversationId 20 | ? conversationId2MessageIdMap.get(conversationId) 21 | : undefined 22 | 23 | const plainTextMessage = event.message.reduce((acc, cur) => { 24 | if (cur.type === 'text') { 25 | acc += cur.text 26 | } 27 | return acc 28 | }, '') 29 | 30 | consola.debug(`Q: ${plainTextMessage}`) 31 | const reply = await openai.createCompletion({ 32 | model: 'gpt-4-0314', 33 | 34 | temperature: 0.6, 35 | }) 36 | 37 | const ans = reply.data.choices[0].text 38 | 39 | // if (!conversationId && reply.conversationId) { 40 | // userId2ConversationIdMap.set(userId, reply.conversationId) 41 | // conversationId2MessageIdMap.set(reply.conversationId, reply.id) 42 | // } 43 | if (ans) event.reply(ans, true) 44 | } 45 | commandRegistry.register('ask', handle) 46 | commandRegistry.register('chat', (event: GroupMessageEvent) => { 47 | if (event.message.length === 1 && event.message[0].type === 'text') { 48 | const isReset = event.message[0].text.trim() === 'reset' 49 | const userId = event.sender.user_id 50 | if (isReset) { 51 | const conversationId = userId2ConversationIdMap.get(userId) 52 | userId2ConversationIdMap.delete(userId) 53 | conversationId && conversationId2MessageIdMap.delete(conversationId) 54 | event.reply('ChatGPT: 已重置上下文', true) 55 | return 56 | } 57 | } 58 | 59 | handle(event) 60 | }) 61 | 62 | mentionRegistry.register(async (event, abort) => { 63 | await handle(event) 64 | abort() 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/modules/openai/test.ts: -------------------------------------------------------------------------------- 1 | import { botConfig } from 'config' 2 | import { Configuration, OpenAIApi } from 'openai' 3 | 4 | const configuration = new Configuration({ 5 | apiKey: botConfig.chatgpt.token, 6 | }) 7 | const openai = new OpenAIApi(configuration) 8 | -------------------------------------------------------------------------------- /src/registries/command.ts: -------------------------------------------------------------------------------- 1 | import type { GroupMessageEvent, Sendable } from 'icqq' 2 | 3 | type CommandReturnValue = 4 | | Promise 5 | | void 6 | | undefined 7 | | Promise 8 | | string 9 | | Promise 10 | | Sendable 11 | | Promise 12 | class CommandRegistry { 13 | private readonly commandMap = new Map< 14 | string, 15 | (event: Event) => CommandReturnValue 16 | >() 17 | 18 | private readonly wildcardCommandHandlerList = [] as (( 19 | event: Event, 20 | abort: () => void, 21 | ) => Promise)[] 22 | 23 | register(command: string, handler: (event: Event) => CommandReturnValue) { 24 | if (this.commandMap.has(command)) { 25 | throw new Error(`Command ${command} already registered`) 26 | } 27 | this.commandMap.set(command, handler) 28 | 29 | return () => { 30 | this.removeCommand(command) 31 | } 32 | } 33 | 34 | registerWildcard( 35 | handler: (event: Event, abort: () => void) => Promise, 36 | ) { 37 | this.wildcardCommandHandlerList.push(handler) 38 | 39 | return () => { 40 | const idx = this.wildcardCommandHandlerList.findIndex( 41 | (fn) => fn === handler, 42 | ) 43 | return idx > -1 && this.wildcardCommandHandlerList.splice(idx, 1) 44 | } 45 | } 46 | 47 | getHandler(command: string) { 48 | return this.commandMap.get(command) 49 | } 50 | 51 | get handlerList() { 52 | return [...this.wildcardCommandHandlerList] 53 | } 54 | 55 | removeCommand(command: string) { 56 | this.commandMap.delete(command) 57 | } 58 | } 59 | 60 | export const commandRegistry = new CommandRegistry() 61 | -------------------------------------------------------------------------------- /src/registries/mention.ts: -------------------------------------------------------------------------------- 1 | import type { GroupMessageEvent, Sendable } from 'icqq' 2 | import { checkIsSendable } from '~/utils/message' 3 | 4 | type MessageReturnValue = 5 | | Promise 6 | | void 7 | | undefined 8 | | Promise 9 | | string 10 | | Promise 11 | | Sendable 12 | | Promise 13 | class MentionRegistry { 14 | private handlerList = [] as (( 15 | event: GroupMessageEvent, 16 | abort: () => void, 17 | ) => MessageReturnValue)[] 18 | register( 19 | handler: ( 20 | event: GroupMessageEvent, 21 | abort: () => void, 22 | ) => MessageReturnValue, 23 | ) { 24 | this.handlerList.push(handler) 25 | return () => { 26 | const idx = this.handlerList.findIndex((fn) => fn === handler) 27 | return idx > -1 && this.handlerList.splice(idx, 1) 28 | } 29 | } 30 | 31 | async runWaterfall(event: GroupMessageEvent) { 32 | const abort = () => { 33 | throw new AbortError() 34 | } 35 | for await (const handler of this.handlerList) { 36 | try { 37 | const result = await handler(event, abort) 38 | if (result) { 39 | if (checkIsSendable(result)) { 40 | return event.reply(result as any) 41 | } 42 | return result 43 | } 44 | } catch (err) { 45 | if (err instanceof AbortError) { 46 | break 47 | } else { 48 | throw err 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | class AbortError extends Error {} 56 | 57 | export const mentionRegistry = new MentionRegistry() 58 | -------------------------------------------------------------------------------- /src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { isIPv4, isIPv6 } from 'net' 3 | 4 | import { simpleCamelcaseKeys as camelcaseKeys } from '@mx-space/api-client' 5 | 6 | export const getIpInfo = async (ip: string) => { 7 | const isV4 = isIPv4(ip) 8 | const isV6 = isIPv6(ip) 9 | if (!isV4 && !isV6) { 10 | return 'error' as const 11 | } 12 | 13 | if (isV4) { 14 | const { data } = await axios.get(`https://api.i-meto.com/ip/v1/qqwry/${ip}`) 15 | return camelcaseKeys(data) as IpType 16 | } else { 17 | const { data } = (await axios.get(`http://ip-api.com/json/${ip}`)) as any 18 | 19 | return { 20 | cityName: data.city, 21 | countryName: data.country, 22 | ip: data.query, 23 | ispDomain: data.as, 24 | ownerDomain: data.org, 25 | regionName: data.region_name, 26 | } 27 | } 28 | } 29 | 30 | export interface IpType { 31 | ip: string 32 | countryName: string 33 | regionName: string 34 | cityName: string 35 | ownerDomain: string 36 | ispDomain: string 37 | range?: { 38 | from: string 39 | to: string 40 | } 41 | } 42 | 43 | export const sleep = (ms: number) => { 44 | return new Promise((resolve) => setTimeout(resolve, ms)) 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import type { FancyReporterOptions } from 'consola' 3 | import { Consola, FancyReporter, LogLevel } from 'consola' 4 | import rc from 'randomcolor' 5 | 6 | import { isDev } from '~/constants/env' 7 | 8 | import { getShortTime } from './time' 9 | 10 | const logger = new Consola({ 11 | reporters: [new FancyReporter()], 12 | level: isDev ? LogLevel.Verbose : LogLevel.Info, 13 | }) 14 | export const registerLogger = () => { 15 | logger.wrapAll() 16 | ;(global as any).consola = logger 17 | } 18 | export { logger as consola } 19 | class NameSpaceReporter extends FancyReporter { 20 | private color: string 21 | constructor(public namespace: string, options?: FancyReporterOptions) { 22 | super(options) 23 | 24 | this.color = rc({ 25 | format: 'hex', 26 | seed: namespace, 27 | luminosity: 'light', 28 | }) 29 | } 30 | protected formatDate() { 31 | return '' 32 | } 33 | protected formatLogObj(): string { 34 | const prefix = `${chalk.hex(this.color)(this.namespace)}: ` 35 | return `${chalk.yellow( 36 | getShortTime(new Date()), 37 | // @ts-ignore 38 | // eslint-disable-next-line prefer-rest-params 39 | )} ${prefix}${super.formatLogObj.apply(this, arguments)}`.trimEnd() 40 | } 41 | } 42 | 43 | export const createNamespaceLogger = (namespace: string): Consola => { 44 | const logger = new Consola({ 45 | reporters: [new NameSpaceReporter(namespace)], 46 | level: isDev ? LogLevel.Verbose : LogLevel.Info, 47 | }) 48 | 49 | return logger 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/message.ts: -------------------------------------------------------------------------------- 1 | import type { TextElem } from 'icqq' 2 | import yargs from 'yargs' 3 | 4 | export const praseCommandMessage = async ( 5 | messageText: string, 6 | messageEl?: TextElem, 7 | ) => { 8 | // replace mac qq auto replace `--` to ch `—` 9 | const args = await yargs.parse(messageText.replace(/—/g, '--'), {}) 10 | const commandName = args._[0] 11 | 12 | const result = { 13 | commandName: String(commandName).slice(1).replaceAll('-', '_'), 14 | commandParsedArgs: args, 15 | commandArgs: messageText.split(' ')[1], 16 | } 17 | 18 | if (messageEl) { 19 | messageEl.commandName = result.commandName 20 | messageEl.commandParsedArgs = result.commandParsedArgs 21 | 22 | messageEl.commandArgs = result.commandArgs 23 | } 24 | return result 25 | } 26 | 27 | export function checkIsSendable(obj: any) { 28 | if (!obj) { 29 | return false 30 | } 31 | return typeof obj === 'string' || (typeof obj === 'object' && 'type' in obj) 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from 'icqq' 2 | 3 | class Plugin { 4 | private _plugins: ((client: Client, ...args: any[]) => any)[] 5 | constructor() { 6 | this._plugins = [] 7 | } 8 | 9 | register(plugin) { 10 | this._plugins.push(plugin) 11 | } 12 | 13 | getPlugins() { 14 | return this._plugins.concat() 15 | } 16 | 17 | runAsyncWaterfall(client: Client, ...args) { 18 | let current = Promise.resolve() 19 | return Promise.all( 20 | this._plugins.map((plugin) => { 21 | current = current.then(() => plugin(client, ...args)) 22 | return current 23 | }), 24 | ) 25 | } 26 | } 27 | 28 | export const hook = new Plugin() 29 | -------------------------------------------------------------------------------- /src/utils/queue.ts: -------------------------------------------------------------------------------- 1 | type PromiseCaller = () => Promise 2 | type ResolverCB = (args: T | PromiseLike) => void 3 | 4 | class QueueItem { 5 | constructor( 6 | protected caller: PromiseCaller, 7 | protected resolve: ResolverCB, 8 | protected reject: (reason: any) => void, 9 | protected complete: () => void, 10 | ) {} 11 | 12 | run() { 13 | this.caller() 14 | .then((val) => { 15 | this.resolve(val) 16 | }) 17 | .catch((err) => { 18 | this.reject(err) 19 | }) 20 | .then(() => { 21 | this.complete() 22 | }) 23 | } 24 | } 25 | 26 | export class AsyncQueue { 27 | protected waitingList: QueueItem[] = [] 28 | protected running = 0 29 | 30 | constructor(public concurrency: number) {} 31 | 32 | enqueue(caller: () => Promise): Promise { 33 | const ret = new Promise((resolve, reject) => { 34 | const item = new QueueItem(caller, resolve, reject, () => { 35 | this.running-- 36 | this.process() 37 | }) 38 | this.waitingList.push(item) 39 | }) 40 | 41 | this.process() 42 | 43 | return ret 44 | } 45 | 46 | get waitingcount() { 47 | return this.waitingList.length 48 | } 49 | 50 | protected process() { 51 | while (this.running < this.concurrency && this.waitingList.length > 0) { 52 | this.running++ 53 | this.waitingList.shift()?.run() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | /** Get Time, format `12:00:00` */ 2 | export const getShortTime = (date: Date) => { 3 | return Intl.DateTimeFormat('en-US', { 4 | timeStyle: 'medium', 5 | hour12: false, 6 | }).format(date) 7 | } 8 | 9 | export const getShortDate = (date: Date) => { 10 | return Intl.DateTimeFormat('en-US', { 11 | dateStyle: 'short', 12 | }) 13 | .format(date) 14 | .replace(/\//g, '-') 15 | } 16 | /** 2-12-22, 21:31:42 */ 17 | export const getShortDateTime = (date: Date) => { 18 | return Intl.DateTimeFormat('en-US', { 19 | dateStyle: 'short', 20 | timeStyle: 'medium', 21 | hour12: false, 22 | }) 23 | .format(date) 24 | .replace(/\//g, '-') 25 | } 26 | 27 | export const relativeTimeFromNow = ( 28 | time: Date | string, 29 | current = new Date(), 30 | ) => { 31 | time = new Date(time) 32 | const msPerMinute = 60 * 1000 33 | const msPerHour = msPerMinute * 60 34 | const msPerDay = msPerHour * 24 35 | const msPerMonth = msPerDay * 30 36 | const msPerYear = msPerDay * 365 37 | 38 | const elapsed = +current - +time 39 | 40 | if (elapsed < msPerMinute) { 41 | const gap = Math.ceil(elapsed / 1000) 42 | return gap <= 0 ? '刚刚' : `${gap} 秒` 43 | } else if (elapsed < msPerHour) { 44 | return `${Math.round(elapsed / msPerMinute)} 分钟` 45 | } else if (elapsed < msPerDay) { 46 | return `${Math.round(elapsed / msPerHour)} 小时` 47 | } else if (elapsed < msPerMonth) { 48 | return `${Math.round(elapsed / msPerDay)} 天` 49 | } else if (elapsed < msPerYear) { 50 | return `${Math.round(elapsed / msPerMonth)} 个月` 51 | } else { 52 | return `${Math.round(elapsed / msPerYear)} 年` 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "esModuleInterop": true, 5 | "moduleResolution": "node", 6 | "target": "ES2021", 7 | "resolveJsonModule": true, 8 | "incremental": true, 9 | "outDir": "dist", 10 | "skipLibCheck": true, 11 | "noImplicitAny": false, 12 | "strict": true, 13 | "declaration": false, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~": ["./src"], 17 | "~/*": ["./src/*"], 18 | "config": ["./config.ts"] 19 | } 20 | }, 21 | "exclude": ["./config.example.ts"] 22 | } 23 | --------------------------------------------------------------------------------