├── res └── i18n │ ├── ja.json │ ├── zh-TW.json │ └── en-US.json ├── src ├── classes │ ├── deepai │ │ └── index.d.ts │ ├── custom.ts │ ├── ApplicationCommandOptionData.ts │ └── ApplicationCommand.ts ├── types │ ├── api │ │ ├── saucenao │ │ │ ├── Base.ts │ │ │ ├── HGame.ts │ │ │ ├── HMagazines.ts │ │ │ ├── EHentai.ts │ │ │ ├── Artstation.ts │ │ │ ├── Pixiv.ts │ │ │ ├── Seiga.ts │ │ │ ├── Anime.ts │ │ │ ├── HAnime.ts │ │ │ ├── Twitter.ts │ │ │ ├── Pawoo.ts │ │ │ ├── Moebooru.ts │ │ │ └── Mangadex.ts │ │ ├── Scam.ts │ │ ├── Imgur.ts │ │ ├── FXTwitter.ts │ │ ├── DeviantArt.ts │ │ ├── Konachan.ts │ │ ├── Danbooru.ts │ │ ├── Saucenao.ts │ │ ├── Sankaku.ts │ │ └── Yandere.ts │ ├── Localizer.ts │ ├── Zod.ts │ └── StealthModule.ts ├── localizers │ ├── data │ │ ├── _Fields.ts │ │ ├── APIEmbed.ts │ │ └── ActionRowData.ts │ ├── MessageOptions.ts │ └── InteractionReplyOptions.ts ├── modules │ ├── index.ts │ ├── scam.ts │ ├── moebooru.ts │ ├── twitter.ts │ └── pixiv.ts ├── commands │ ├── misc │ │ ├── ping.ts │ │ ├── delete.ts │ │ ├── invite.ts │ │ ├── ask.ts │ │ └── slap.ts │ ├── setting │ │ ├── forgetme.ts │ │ ├── ignoreme.ts │ │ └── language.ts │ ├── index.ts │ ├── tool │ │ ├── roll.ts │ │ ├── dice.ts │ │ ├── choice.ts │ │ ├── avatar.ts │ │ ├── currency.ts │ │ └── sauce.ts │ ├── _lib.ts │ └── fun │ │ └── mine.ts ├── Reporting.ts ├── prototype.ts ├── utils.ts ├── Localizations.ts └── index.ts ├── bun.lockb ├── .env.example ├── .vscode ├── settings.json ├── i18n-ally-custom-framework.yml └── launch.json ├── prisma └── schema.prisma ├── .gitignore ├── .eslintrc.js ├── package.json ├── tsconfig.json ├── README.md └── PRIVACY.md /res/i18n/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /src/classes/deepai/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "deepai"; -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hker9527/bckbot/HEAD/bun.lockb -------------------------------------------------------------------------------- /src/classes/custom.ts: -------------------------------------------------------------------------------- 1 | export abstract class Custom { 2 | public abstract toAPI(): T; 3 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TOKEN= 2 | deepai= 3 | error_chid= 4 | saucenao_key= 5 | safebrowsing_key= 6 | pixiv_refresh_token= 7 | imgur_id= 8 | imgur_secret= 9 | exchangerate_key= -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": [ 3 | "res/i18n", 4 | "bin/modules/i18n", 5 | "src/modules/i18n" 6 | ], 7 | "i18n-ally.keystyle": "nested" 8 | } -------------------------------------------------------------------------------- /src/types/api/saucenao/Base.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoBase = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()) 6 | })); 7 | -------------------------------------------------------------------------------- /.vscode/i18n-ally-custom-framework.yml: -------------------------------------------------------------------------------- 1 | languageIds: 2 | - typescript 3 | 4 | usageMatchRegex: 5 | - "!\\.getString\\([.\\W]*?[\"]({key}.+?)[\"]," 6 | - "key: ['\"`]({key}.+?)['\"`]" 7 | 8 | monopoly: false 9 | -------------------------------------------------------------------------------- /src/types/Localizer.ts: -------------------------------------------------------------------------------- 1 | export type L = { 2 | [K in keyof T]: K extends U ? LocalizerItem : T[K]; 3 | }; 4 | 5 | export type LocalizerItem = string | { 6 | key: string, 7 | data?: Record 8 | }; 9 | -------------------------------------------------------------------------------- /src/types/api/saucenao/HGame.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoHGame = new Zod(z.object({ 5 | title: z.string(), 6 | company: z.string(), 7 | getchu_id: z.string() 8 | })); 9 | -------------------------------------------------------------------------------- /src/types/api/saucenao/HMagazines.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoHMagazines = new Zod(z.object({ 5 | title: z.string(), 6 | part: z.string(), 7 | date: z.string() 8 | })); 9 | -------------------------------------------------------------------------------- /src/types/api/saucenao/EHentai.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoEHentai = new Zod(z.object({ 5 | source: z.string(), 6 | creator: z.array(z.string()), 7 | eng_name: z.string(), 8 | jp_name: z.string() 9 | })); 10 | -------------------------------------------------------------------------------- /src/types/api/saucenao/Artstation.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoArtstation = new Zod(z.object({ 5 | title: z.string(), 6 | as_project: z.string(), 7 | author_name: z.string(), 8 | author_url: z.string() 9 | })); 10 | -------------------------------------------------------------------------------- /src/types/api/saucenao/Pixiv.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoPixiv = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | title: z.string(), 7 | pixiv_id: z.number(), 8 | member_name: z.string(), 9 | member_id: z.number() 10 | })); 11 | -------------------------------------------------------------------------------- /src/types/api/saucenao/Seiga.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoSeiga = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | title: z.string(), 7 | seiga_id: z.number(), 8 | member_name: z.string(), 9 | member_id: z.number() 10 | })); 11 | -------------------------------------------------------------------------------- /src/localizers/data/_Fields.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizerItem } from "@type/Localizer"; 2 | import type { LActionRowData } from "./ActionRowData"; 3 | import type { LAPIEmbed } from "./APIEmbed"; 4 | 5 | export interface LocalizableMessageFields { 6 | components?: LActionRowData[], 7 | content?: LocalizerItem, 8 | embeds?: LAPIEmbed[] 9 | }; -------------------------------------------------------------------------------- /src/types/api/saucenao/Anime.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoAnime = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | source: z.string(), 7 | anidb_aid: z.number(), 8 | part: z.string(), 9 | year: z.string(), 10 | est_time: z.string() 11 | })); -------------------------------------------------------------------------------- /src/types/api/saucenao/HAnime.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoHAnime = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | source: z.string(), 7 | anidb_aid: z.number(), 8 | part: z.string(), 9 | year: z.string(), 10 | est_time: z.string() 11 | })); 12 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import type { StealthModule } from "@type/StealthModule"; 2 | import { moebooru } from "./moebooru"; 3 | import { pixiv } from "./pixiv"; 4 | import { scam } from "./scam"; 5 | import { twitter } from "./twitter"; 6 | 7 | export const modules: StealthModule[] = [ 8 | moebooru, 9 | pixiv, 10 | scam, 11 | twitter 12 | ] -------------------------------------------------------------------------------- /src/types/api/saucenao/Twitter.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoTwitter = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | created_at: z.string(), 7 | tweet_id: z.string(), 8 | twitter_user_id: z.string(), 9 | twitter_user_handle: z.string() 10 | })); 11 | -------------------------------------------------------------------------------- /src/types/api/Scam.ts: -------------------------------------------------------------------------------- 1 | export interface APIScam { 2 | matches?: ({ 3 | threatType: string; 4 | platformType: string; 5 | threat: { 6 | url: string 7 | }; 8 | threatEntryMetadata?: { 9 | entries: ({ 10 | key: string, 11 | value: string 12 | })[] 13 | }, 14 | cacheDuration: string; 15 | threatEntryType: string; 16 | })[]; 17 | } -------------------------------------------------------------------------------- /src/types/Zod.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from "zod"; 2 | 3 | export class Zod { 4 | public z: ZodType; 5 | 6 | public constructor(z: ZodType) { 7 | this.z = z; 8 | } 9 | 10 | public check(o: unknown, safe = true): o is T { 11 | if (safe) { 12 | return this.z.safeParse(o).success; 13 | } 14 | 15 | this.z.parse(o); 16 | return true; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/types/api/saucenao/Pawoo.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoPawoo = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | created_at: z.string(), 7 | pawoo_id: z.number(), 8 | pawoo_user_acct: z.string(), 9 | pawoo_user_username: z.string(), 10 | pawoo_user_display_name: z.string() 11 | })); 12 | -------------------------------------------------------------------------------- /src/types/api/Imgur.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Zod } from "@type/Zod"; 3 | 4 | const DataSchema = z.object({ 5 | id: z.string() 6 | }); 7 | 8 | const ImgurSchema = z.object({ 9 | data: DataSchema, 10 | success: z.literal(true), 11 | status: z.literal(200) 12 | }); 13 | export const ZAPIImgur = new Zod(ImgurSchema); 14 | 15 | export type APIImgur = z.infer; 16 | -------------------------------------------------------------------------------- /src/commands/misc/ping.ts: -------------------------------------------------------------------------------- 1 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | 4 | class Command extends SlashApplicationCommand { 5 | public async onCommand(): Promise { 6 | return { 7 | content: "uwu" 8 | }; 9 | } 10 | } 11 | 12 | export const ping = new Command({ 13 | name: "ping" 14 | }); -------------------------------------------------------------------------------- /src/commands/misc/delete.ts: -------------------------------------------------------------------------------- 1 | import { MessageContextMenuCommand } from "@class/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | 4 | class Command extends MessageContextMenuCommand { 5 | public async onContextMenu(): Promise { 6 | // This is only used to register the command, and the handler is in index.ts 7 | return {}; 8 | } 9 | } 10 | 11 | export const _delete = new Command({ 12 | name: "delete" 13 | }); -------------------------------------------------------------------------------- /src/types/api/saucenao/Moebooru.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoMoebooru = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | danbooru_id: z.number().optional(), 7 | gelbooru_id: z.number().optional(), 8 | konachan_id: z.number().optional(), 9 | yandere_id: z.number().optional(), 10 | sankaku_id: z.number().optional(), 11 | creator: z.string(), 12 | material: z.string(), 13 | characters: z.string(), 14 | source: z.string() 15 | })); 16 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = "file:./db.sqlite" 8 | } 9 | 10 | model PixivCache { 11 | id String @id 12 | type String 13 | hash String 14 | time DateTime @default(now()) 15 | } 16 | 17 | model Language { 18 | id String @id 19 | type String 20 | language String 21 | override Boolean 22 | } 23 | 24 | model Ignore { 25 | id String @id 26 | type String 27 | } 28 | -------------------------------------------------------------------------------- /src/types/api/saucenao/Mangadex.ts: -------------------------------------------------------------------------------- 1 | import { Zod } from "@type/Zod"; 2 | import { z } from "zod"; 3 | 4 | export const ZAPISaucenaoMangadex = new Zod(z.object({ 5 | ext_urls: z.array(z.string().url()), 6 | md_id: z.number().optional(), // "https://mangadex.org/chapter/{{id}}" 7 | mu_id: z.number().optional(), // "https://www.mangaupdates.com/series.html?id={{id}}" 8 | mal_id: z.number().optional(), // "https://myanimelist.net/manga/{{id}}/" 9 | source: z.string(), 10 | part: z.string(), 11 | artist: z.string(), 12 | author: z.string() 13 | })); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | bin 39 | bin/**/* 40 | 41 | # ignore yarn.lock 42 | yarn.lock 43 | 44 | *.tsbuildinfo 45 | 46 | prisma/migrations 47 | prisma/db.sqlite -------------------------------------------------------------------------------- /src/types/StealthModule.ts: -------------------------------------------------------------------------------- 1 | import type { LBaseMessageOptions } from "@localizer/MessageOptions" 2 | import type { Message } from "discord.js" 3 | 4 | export interface StealthModuleActionArgument { 5 | message: Message, 6 | matches?: RegExpMatchArray 7 | }; 8 | 9 | export interface StealthModule { 10 | name: string, 11 | event: "messageCreate" | "messageDelete" | "messageUpdate", 12 | pattern?: RegExp, 13 | action: (obj: StealthModuleActionArgument) => Promise, 17 | onTimeout?: (message: Message) => Promise 18 | }; -------------------------------------------------------------------------------- /src/commands/misc/invite.ts: -------------------------------------------------------------------------------- 1 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | import type { ChatInputCommandInteraction } from "discord.js"; 4 | 5 | class Command extends SlashApplicationCommand { 6 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 7 | return { 8 | content: `` 9 | }; 10 | } 11 | } 12 | 13 | export const invite = new Command({ 14 | name: "invite" 15 | }); -------------------------------------------------------------------------------- /src/Reporting.ts: -------------------------------------------------------------------------------- 1 | const flag = Bun.env.NODE_ENV !== "production"; 2 | 3 | const getPrefix = () => { 4 | const error = new Error(); 5 | const stack = error.stack!.split("\n"); 6 | const path = stack[3].trim().split(" ").pop(); 7 | 8 | // Replace base path 9 | return `[${path!.replace(process.cwd() + "/", "").replace(/\(|\)/g, "")}]`; 10 | }; 11 | 12 | export const report = (string: string) => { 13 | console.log(`${getPrefix()} ${string}`); 14 | }; 15 | 16 | export const debug = (tag: string, e: unknown) => { 17 | if (flag) console.debug(`${getPrefix()} [${tag}] ${e}`); 18 | }; 19 | 20 | export const error = (tag: string, e: unknown) => { 21 | console.error(`${getPrefix()} [${tag}] ${e}`); 22 | }; -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: [ 5 | "typescript", 6 | "@typescript-eslint" 7 | ], 8 | extends: [ 9 | "eslint-config-alloy/typescript" 10 | ], 11 | rules: { 12 | "comma-dangle": ["error", "never"], 13 | "indent": ["error", "tab", { "SwitchCase": 1 }], 14 | "func-style": ["error", "expression"], 15 | "@typescript-eslint/consistent-type-assertions": "off", 16 | "@typescript-eslint/member-ordering": "off", 17 | "no-case-declarations": "off", 18 | "no-undef": "off", 19 | "no-unused-vars": "off", 20 | "no-var": "error", 21 | "@typescript-eslint/no-unused-vars": "error", 22 | "eqeqeq": "error", 23 | "quotes": ["error", "double"], 24 | "yoda": "error" 25 | } 26 | } -------------------------------------------------------------------------------- /src/commands/setting/forgetme.ts: -------------------------------------------------------------------------------- 1 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | import { PrismaClient } from "@prisma/client"; 4 | import type { ChatInputCommandInteraction } from "discord.js"; 5 | 6 | const client = new PrismaClient(); 7 | 8 | class Command extends SlashApplicationCommand { 9 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 10 | await client.language.deleteMany({ 11 | where: { 12 | id: interaction.user.id, 13 | type: "u" 14 | } 15 | }); 16 | 17 | return { 18 | content: { 19 | key: "forgetme.success" 20 | } 21 | }; 22 | } 23 | } 24 | 25 | export const forgetme = new Command({ 26 | name: "forgetme" 27 | }); -------------------------------------------------------------------------------- /src/prototype.ts: -------------------------------------------------------------------------------- 1 | export const injectPrototype = () => { }; // Turn this file into a module 2 | 3 | declare global { 4 | interface Number { 5 | inRange: (a: number, b: number) => boolean; 6 | } 7 | 8 | interface Array { 9 | unique: () => Array; 10 | } 11 | 12 | interface BigInt { 13 | toJSON: () => string; 14 | } 15 | } 16 | 17 | Number.prototype.inRange = function (a: number, b: number) { 18 | return this.valueOf() > a && this.valueOf() < b; 19 | }; 20 | 21 | // Prevent being read from loops 22 | // For example: 23 | // for (let key in array) { ... } 24 | 25 | Object.defineProperty(Array.prototype, "unique", { 26 | enumerable: false, 27 | writable: true 28 | }); 29 | 30 | Array.prototype.unique = function () { 31 | return [...new Set(this)]; 32 | }; 33 | 34 | BigInt.prototype.toJSON = function () { 35 | return this.toString(); 36 | } -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseApplicationCommand } from "@class/ApplicationCommand"; 2 | import type { ApplicationCommandType } from "discord.js"; 3 | 4 | import { mine } from "./fun/mine"; 5 | import { ask } from "./misc/ask"; 6 | import { _delete } from "./misc/delete"; 7 | import { invite } from "./misc/invite"; 8 | import { ping } from "./misc/ping"; 9 | import { slap } from "./misc/slap"; 10 | import { avatar } from "./tool/avatar"; 11 | import { choice } from "./tool/choice"; 12 | import { currency } from "./tool/currency"; 13 | import { dice } from "./tool/dice"; 14 | import { roll } from "./tool/roll"; 15 | import { sauce } from "./tool/sauce"; 16 | import { forgetme } from "./setting/forgetme"; 17 | import { ignoreme } from "./setting/ignoreme"; 18 | import { language } from "./setting/language"; 19 | 20 | export const commands: BaseApplicationCommand[] = [ 21 | mine, 22 | ask, 23 | _delete, 24 | invite, 25 | ping, 26 | slap, 27 | avatar, 28 | choice, 29 | currency, 30 | dice, 31 | roll, 32 | sauce, 33 | forgetme, 34 | ignoreme, 35 | language 36 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bckbot", 3 | "version": "3.6.1", 4 | "author": "hker9527", 5 | "main": "src/index.ts", 6 | "dependencies": { 7 | "@book000/pixivts": "^0.21.7", 8 | "@prisma/client": "^5.1.1", 9 | "assert-ts": "^0.3.4", 10 | "decimal.js": "^10.4.2", 11 | "discord.js": "^14.12.1", 12 | "html-to-text": "^9.0.5", 13 | "i18next": "^23.4.4", 14 | "linkifyjs": "^4.0.2", 15 | "node-emoji": "^2.1.0", 16 | "prisma": "^5.1.1", 17 | "tslog": "^4.9.2", 18 | "underscore": "^1.13.6", 19 | "zod": "^3.19.1" 20 | }, 21 | "devDependencies": { 22 | "@types/html-to-text": "^9.0.1", 23 | "@types/linkifyjs": "^2.1.4", 24 | "@types/node-emoji": "^2.1.0", 25 | "@types/underscore": "^1.11.14", 26 | "bun-types": "latest", 27 | "eslint": "^8.54.0", 28 | "eslint-config-alloy": "^5.1.1", 29 | "eslint-plugin-typescript": "^0.14.0" 30 | }, 31 | "overrides": { 32 | "@types/node": "npm:bun-types@latest" 33 | }, 34 | "license": "MIT", 35 | "scripts": { 36 | "start": "bun run src/index.ts", 37 | "dev": "bun run --watch src/index.ts" 38 | } 39 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext" 5 | ], 6 | "module": "esnext", 7 | "target": "esnext", 8 | "moduleResolution": "bundler", 9 | "moduleDetection": "force", 10 | "allowImportingTsExtensions": true, 11 | "noEmit": true, 12 | "composite": true, 13 | "strict": true, 14 | "downlevelIteration": true, 15 | "skipLibCheck": true, 16 | "jsx": "react-jsx", 17 | "allowSyntheticDefaultImports": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "allowJs": true, 20 | "types": [ 21 | "bun-types" // add Bun global 22 | ], 23 | "noImplicitAny": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@localizer/*": [ 27 | "src/localizers/*" 28 | ], 29 | "@command/*": [ 30 | "src/commands/*" 31 | ], 32 | "@module/*": [ 33 | "src/modules/*" 34 | ], 35 | "@type/*": [ 36 | "src/types/*" 37 | ], 38 | "@class/*": [ 39 | "src/classes/*" 40 | ], 41 | "@res/*": [ 42 | "res/*" 43 | ], 44 | "@app/*": [ 45 | "src/*" 46 | ], 47 | "@root/*": [ 48 | "/*" 49 | ] 50 | }, 51 | "esModuleInterop": true 52 | } 53 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { Decimal } from "decimal.js"; 3 | 4 | export const round = (number: number, precision = 2) => { 5 | return parseFloat(new Decimal(number).toFixed(precision)); 6 | }; 7 | 8 | export const random = (low: number, high: number) => { 9 | if (low === high) return low; 10 | return (Math.random() * (high - low + 1) + low) | 0; 11 | }; 12 | 13 | export const arr2obj = (a1: (string | number)[], a2: T[]): Record => { 14 | assert(a1.length === a2.length, `Array length mismatch: ${a1.length} !== ${a2.length}`); 15 | const out: Record = {}; 16 | for (let i = 0; i < a1.length; i++) { 17 | out[a1[i]] = a2[i]; 18 | } 19 | return out; 20 | }; 21 | 22 | export const enumStringKeys = (e: T) => { 23 | return Object.keys(e).filter(value => isNaN(Number(value))) as (keyof T)[]; 24 | }; 25 | 26 | // Use k, m, ... suffixes for numbers 27 | export const num2str = (num: number) => { 28 | if (num < 1000) return num.toString(); 29 | const exp = Math.floor(Math.log(num) / Math.log(1000)); 30 | return `${(num / Math.pow(1000, exp)).toFixed(1)}${"kMGTPE"[exp - 1]}`; 31 | }; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bckbot 2 | 3 | > A discord bot powered by discord.js 4 | 5 | ## Functionalities 6 | 7 | * Context menu support 8 | * Reverse image search 9 | * Better pixiv preview (Multi image support) 10 | * Currency conversion 11 | * Scam URL detection 12 | * Utilities, e.g. choices, magicball 13 | * i18n (Supported languages: English, Traditional Chinese. More coming!) 14 | 15 | ## Live demo 16 | 17 | [Here](https://discordapp.com/oauth2/authorize?&client_id=342373857555906562&scope=bot%20applications.commands&permissions=523328) 18 | 19 | ## How to run 20 | 21 | Check `.env.example` to find out what tokens are required. Then, just run 22 | 23 | ```bash 24 | yarn 25 | yarn start 26 | ``` 27 | 28 | ## Main libraries 29 | 30 | * discord.js 31 | * TypeScript 32 | * ESLint 33 | 34 | ## APIs 35 | 36 | | Name | Purposes | 37 | | ---- | -------- | 38 | |[pixiv](https://www.pixiv.net/en/)|Fetch images from pixiv| 39 | |[saucenao](https://saucenao.com/)|Reverse image search| 40 | |[exchangerate.host](https://exchangerate.host/)|Currency conversion| 41 | |[Google Safebrowsing](https://safebrowsing.google.com/)|Detect malicious URLs| 42 | |[FXTwitter](https://github.com/FixTweet/FixTweet)|Generate embeds from Twitter links| -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "bun", 9 | "internalConsoleOptions": "neverOpen", 10 | "request": "launch", 11 | "name": "Debug File", 12 | "program": "src/index.ts", 13 | "cwd": "${workspaceFolder}", 14 | "stopOnEntry": false, 15 | "watchMode": false 16 | }, 17 | { 18 | "type": "bun", 19 | "internalConsoleOptions": "neverOpen", 20 | "request": "launch", 21 | "name": "Run File", 22 | "program": "src/index.ts", 23 | "cwd": "${workspaceFolder}", 24 | "noDebug": true, 25 | "watchMode": false 26 | }, 27 | { 28 | "type": "bun", 29 | "internalConsoleOptions": "neverOpen", 30 | "request": "attach", 31 | "name": "Attach Bun", 32 | "url": "ws://localhost:6499/", 33 | "stopOnEntry": false 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /src/commands/tool/roll.ts: -------------------------------------------------------------------------------- 1 | import { random } from "@app/utils"; 2 | 3 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 4 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 5 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 6 | import type { ChatInputCommandInteraction } from "discord.js"; 7 | 8 | class Command extends SlashApplicationCommand { 9 | public options: LApplicationCommandOptionData[] = [ 10 | { 11 | name: "upper", 12 | type: "Integer" 13 | }, 14 | { 15 | name: "lower", 16 | type: "Integer" 17 | } 18 | ]; 19 | 20 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 21 | const lower = interaction.options.getInteger("lower") ?? 0; 22 | const upper = interaction.options.getInteger("upper") ?? (lower + 100); 23 | 24 | if (lower > upper) { 25 | return { 26 | content: { 27 | key: "roll.invalidRange", 28 | data: { lower, upper } 29 | } 30 | }; 31 | } 32 | 33 | return { 34 | content: { 35 | key: "roll.roll", 36 | data: { 37 | points: random(lower, upper) 38 | } 39 | } 40 | }; 41 | } 42 | }; 43 | 44 | export const roll = new Command({ 45 | name: "roll" 46 | }); -------------------------------------------------------------------------------- /src/localizers/MessageOptions.ts: -------------------------------------------------------------------------------- 1 | import { Localizer } from "@app/Localizations"; 2 | import type { LocaleString } from "discord-api-types/v9"; 3 | import type { BaseMessageOptions } from "discord.js"; 4 | import { LActionRowDataLocalizer } from "./data/ActionRowData"; 5 | import { LocalizableAPIEmbedAdapter } from "./data/APIEmbed"; 6 | import type { LocalizableMessageFields } from "./data/_Fields"; 7 | 8 | export type LBaseMessageOptions = LocalizableMessageFields & Omit; 9 | 10 | export class LocalizableBaseMessageOptionsAdapter { 11 | private data: LBaseMessageOptions; 12 | 13 | public constructor(data: LBaseMessageOptions) { 14 | this.data = data; 15 | } 16 | 17 | public build(locale: LocaleString): BaseMessageOptions { 18 | const { components, content, embeds, ...x } = this.data; 19 | 20 | const options: BaseMessageOptions = { ...x }; 21 | 22 | if (components) { 23 | options.components = components.map(component => new LActionRowDataLocalizer(component).localize(locale)); 24 | } 25 | 26 | if (content) { 27 | options.content = Localizer(content, locale); 28 | } 29 | 30 | if (embeds) { 31 | options.embeds = embeds?.map(embed => new LocalizableAPIEmbedAdapter(embed).build(locale)); 32 | } 33 | 34 | return options; 35 | } 36 | } -------------------------------------------------------------------------------- /src/types/api/FXTwitter.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Zod } from "@type/Zod"; 3 | 4 | const BaseTweetSchema = z.object({ 5 | url: z.string(), 6 | text: z.string(), 7 | author: z.object({ 8 | name: z.string(), 9 | screen_name: z.string(), 10 | avatar_url: z.string(), 11 | url: z.string() 12 | }), 13 | replies: z.number(), 14 | retweets: z.number(), 15 | likes: z.number(), 16 | created_timestamp: z.number(), 17 | possibly_sensitive: z.boolean().optional(), 18 | views: z.number().nullable(), 19 | replying_to: z.string().nullable(), 20 | replying_to_status: z.string().nullable(), 21 | color: z.null(), 22 | media: z.object({ 23 | all: z.array(z.object({ 24 | type: z.string(), 25 | url: z.string() 26 | })).optional(), 27 | photos: z.array(z.object({ 28 | url: z.string() 29 | })).optional(), 30 | videos: z.array(z.object({ 31 | thumbnail_url: z.string() 32 | })).optional() 33 | }).optional() 34 | }); 35 | 36 | const TweetSchema = BaseTweetSchema.extend({ 37 | quote: BaseTweetSchema.optional() 38 | }); 39 | 40 | const FxTwitterSchema = z.object({ 41 | code: z.number(), 42 | message: z.string(), 43 | tweet: TweetSchema.nullable() 44 | }); 45 | 46 | export const ZAPIFXTwitter = new Zod(FxTwitterSchema); 47 | 48 | export type APIFXTwitter = z.infer; -------------------------------------------------------------------------------- /src/localizers/InteractionReplyOptions.ts: -------------------------------------------------------------------------------- 1 | import { Localizer } from "@app/Localizations"; 2 | import type { LocaleString } from "discord-api-types/v9"; 3 | import type { InteractionReplyOptions } from "discord.js"; 4 | import { LActionRowDataLocalizer } from "./data/ActionRowData"; 5 | import { LocalizableAPIEmbedAdapter } from "./data/APIEmbed"; 6 | import type { LocalizableMessageFields } from "./data/_Fields"; 7 | 8 | export type LInteractionReplyOptions = LocalizableMessageFields & Omit; 9 | 10 | export class LocalizableInteractionReplyOptionsAdapter { 11 | private data: LInteractionReplyOptions; 12 | 13 | public constructor(data: LInteractionReplyOptions) { 14 | this.data = data; 15 | } 16 | 17 | public build(locale: LocaleString): InteractionReplyOptions { 18 | const { components, content, embeds, ...x } = this.data; 19 | 20 | const options: InteractionReplyOptions = { ...x }; 21 | 22 | if (components) { 23 | options.components = components.map(component => new LActionRowDataLocalizer(component).localize(locale)); 24 | } 25 | 26 | if (content) { 27 | options.content = Localizer(content, locale); 28 | } 29 | 30 | if (embeds) { 31 | options.embeds = embeds.map(e => new LocalizableAPIEmbedAdapter(e).build(locale)); 32 | } 33 | 34 | return options; 35 | } 36 | }; -------------------------------------------------------------------------------- /src/commands/misc/ask.ts: -------------------------------------------------------------------------------- 1 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | import type { ChatInputCommandInteraction } from "discord.js"; 4 | 5 | import { random } from "@app/utils"; 6 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 7 | 8 | const map = { 9 | Good: 0x28a745, 10 | Fair: 0xffc107, 11 | Bad: 0xdc3545 12 | }; 13 | 14 | class Command extends SlashApplicationCommand { 15 | protected _defer = true; 16 | 17 | public options: LApplicationCommandOptionData[] = [ 18 | { 19 | name: "question", 20 | type: "String", 21 | required: true 22 | } 23 | ]; 24 | 25 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 26 | const result = random(0, 19); 27 | const type = result < 10 ? "Good" : (result < 15 ? "Fair" : "Bad"); 28 | 29 | await Bun.sleep(random(1000, 3000)); 30 | 31 | return { 32 | embeds: [{ 33 | color: map[type], 34 | footer: { 35 | text: { 36 | key: `🤔\t$t(ask.answer${result})` 37 | } 38 | }, 39 | author: { 40 | name: `${interaction.options.getString("question", true)}` 41 | } 42 | }] 43 | }; 44 | } 45 | } 46 | 47 | export const ask = new Command({ 48 | name: "ask" 49 | }); 50 | -------------------------------------------------------------------------------- /src/commands/_lib.ts: -------------------------------------------------------------------------------- 1 | import type { Collection, Message } from "discord.js"; 2 | 3 | export const findImagesFromMessage = (message: Message) => { 4 | // Attachment shows first, then embeds. 5 | return [ 6 | ...message.attachments.map(attachment => attachment.url), 7 | ...message.embeds.filter(embed => embed.thumbnail || embed.image).map(embed => embed.thumbnail?.url ?? embed.image?.url ?? embed.url) 8 | ] as string[]; 9 | } 10 | 11 | export const findImageFromMessages = (index: number, msgs: Collection) => { 12 | // Precedence: URL > Embeds > Attachments 13 | 14 | let i = 0; 15 | let url: string | null = null; 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | for (const [_, msg] of msgs.filter(a => a.author.id !== a.client.user!.id)) { 19 | for (const embed of msg.embeds.reverse()) { 20 | if (i === index) { 21 | if (embed.thumbnail) { 22 | url = embed.thumbnail.url; 23 | break; 24 | } else if (embed.image) { 25 | url = embed.image.url; 26 | break; 27 | } 28 | } else { 29 | i++; 30 | } 31 | } 32 | 33 | if (url) return url; 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 36 | for (const [_, attachment] of msg.attachments) { 37 | if (attachment.width && attachment.width > 0) { 38 | if (i === index) { 39 | url = attachment.url; 40 | break; 41 | } else { 42 | i++; 43 | } 44 | } 45 | } 46 | 47 | if (url) return url; 48 | } 49 | 50 | return null; 51 | }; -------------------------------------------------------------------------------- /src/commands/tool/dice.ts: -------------------------------------------------------------------------------- 1 | import { random } from "@app/utils"; 2 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 3 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 4 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 5 | import type { ChatInputCommandInteraction } from "discord.js"; 6 | 7 | class Command extends SlashApplicationCommand { 8 | public options: LApplicationCommandOptionData[] = [ 9 | { 10 | name: "n", 11 | type: "Integer", 12 | required: true, 13 | minValue: 1 14 | }, 15 | { 16 | name: "faces", 17 | type: "Integer", 18 | required: true, 19 | minValue: 1, 20 | maxValue: 0x198964 21 | }, 22 | { 23 | name: "offset", 24 | type: "Integer" 25 | } 26 | ]; 27 | 28 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 29 | const n = interaction.options.getInteger("n", true); 30 | const faces = interaction.options.getInteger("faces", true); 31 | const offset = interaction.options.getInteger("offset") ?? 0; 32 | 33 | let result = 0; 34 | for (let i = 0; i < n; i++) { 35 | result += random(1, faces); 36 | } 37 | result += offset; 38 | 39 | return { 40 | content: { 41 | key: "dice.roll", 42 | data: { 43 | n, 44 | faces, 45 | offset: offset > 0 ? `+${offset}` : offset === 0 ? "" : offset, 46 | result 47 | } 48 | } 49 | }; 50 | } 51 | }; 52 | 53 | export const dice = new Command({ 54 | name: "dice" 55 | }); -------------------------------------------------------------------------------- /src/types/api/DeviantArt.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | // 3 | // To change quicktype's target language, run command: 4 | // 5 | // "Set quicktype target language" 6 | 7 | export interface APIDeviantArt { 8 | version: string; 9 | type: string; 10 | title: string; 11 | category: string; 12 | url: string; 13 | author_name: string; 14 | author_url: string; 15 | provider_name: string; 16 | provider_url: string; 17 | safety: string; 18 | pubdate: Date; 19 | community: Community; 20 | rating: string; 21 | copyright: Copyright; 22 | width: number; 23 | height: number; 24 | imagetype: string; 25 | thumbnail_url: string; 26 | thumbnail_width: number; 27 | thumbnail_height: number; 28 | thumbnail_url_150: string; 29 | thumbnail_url_200h: string; 30 | thumbnail_width_200h: number; 31 | thumbnail_height_200h: number; 32 | } 33 | 34 | export interface Community { 35 | statistics: Statistics; 36 | } 37 | 38 | export interface Statistics { 39 | _attributes: StatisticsAttributes; 40 | } 41 | 42 | export interface StatisticsAttributes { 43 | views: number; 44 | favorites: number; 45 | comments: number; 46 | downloads: number; 47 | } 48 | 49 | export interface Copyright { 50 | _attributes: CopyrightAttributes; 51 | } 52 | 53 | export interface CopyrightAttributes { 54 | url: string; 55 | year: string; 56 | entity: string; 57 | } 58 | -------------------------------------------------------------------------------- /src/types/api/Konachan.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | // 3 | // To change quicktype's target language, run command: 4 | // 5 | // "Set quicktype target language" 6 | 7 | export type APIKonachan = KonachanPost[]; 8 | 9 | interface KonachanPost { 10 | id: number; 11 | tags: string; 12 | created_at: number; 13 | creator_id: number; 14 | author: string; 15 | change: number; 16 | source: string | null; 17 | score: number; 18 | md5: string; 19 | file_size: number; 20 | file_url: string; 21 | is_shown_in_index: boolean; 22 | preview_url: string; 23 | preview_width: number; 24 | preview_height: number; 25 | actual_preview_width: number; 26 | actual_preview_height: number; 27 | sample_url: string; 28 | sample_width: number; 29 | sample_height: number; 30 | sample_file_size: number; 31 | jpeg_url: string; 32 | jpeg_width: number; 33 | jpeg_height: number; 34 | jpeg_file_size: number; 35 | rating: Rating; 36 | has_children: boolean; 37 | parent_id: number | null; 38 | status: Status; 39 | width: number; 40 | height: number; 41 | is_held: boolean; 42 | frames_pending_string: string; 43 | frames_pending: any[]; 44 | frames_string: string; 45 | frames: any[]; 46 | flag_detail?: null; 47 | } 48 | 49 | enum Rating { 50 | Explicit = "e", 51 | Questionable = "q", 52 | Safe = "s", 53 | } 54 | 55 | enum Status { 56 | Active = "active", 57 | Pending = "pending", 58 | } 59 | -------------------------------------------------------------------------------- /src/commands/tool/choice.ts: -------------------------------------------------------------------------------- 1 | import { random, round } from "@app/utils"; 2 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 3 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 4 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 5 | import type { ChatInputCommandInteraction } from "discord.js"; 6 | 7 | export const shuffleArray = (array: any[]) => { 8 | for (let i = array.length - 1; i > 0; i--) { 9 | let j = random(0, i); 10 | [array[i], array[j]] = [array[j], array[i]]; 11 | } 12 | }; 13 | 14 | class Command extends SlashApplicationCommand { 15 | public options: LApplicationCommandOptionData[] = [ 16 | { 17 | name: "choices", 18 | type: "String", 19 | required: true 20 | } 21 | ]; 22 | 23 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 24 | const argv = interaction.options.getString("choices", true).split(" ").filter((v: string, i: number, a: string[]) => a.indexOf(v) === i); 25 | 26 | if (argv.length < 2) { 27 | return { 28 | content: { 29 | key: "choice.notEnoughChoices" 30 | } 31 | }; 32 | } 33 | 34 | const last = argv.pop()!; 35 | shuffleArray(argv); 36 | 37 | let pMax = 1; 38 | interface Option { 39 | name: string, 40 | p: number; 41 | } 42 | 43 | const o: Option[] = []; 44 | 45 | for (const i in argv) { 46 | const p = random(0, pMax * 100000) / 100000; 47 | o.push({ name: argv[i], p }); 48 | pMax = pMax - p; 49 | } 50 | o.push({ name: last, p: pMax }); 51 | 52 | return { 53 | content: { 54 | key: "choice.result", 55 | data: { 56 | result: o.sort((a: Option, b: Option) => { 57 | return b.p - a.p; 58 | }).map((a: Option) => { 59 | return a.name + " (" + round(a.p * 100, 3) + "%)"; 60 | }).join(" ") 61 | } 62 | } 63 | }; 64 | } 65 | }; 66 | 67 | export const choice = new Command({ 68 | name: "choice" 69 | }); -------------------------------------------------------------------------------- /src/modules/scam.ts: -------------------------------------------------------------------------------- 1 | import { getString } from "@app/Localizations"; 2 | import type { StealthModule, StealthModuleActionArgument } from "@type/StealthModule"; 3 | import type { APIScam } from "@type/api/Scam"; 4 | import { Locale } from "discord.js"; 5 | import { find } from "linkifyjs"; 6 | 7 | export const scam: StealthModule = { 8 | name: "scam", 9 | event: "messageCreate", 10 | action: async (obj: StealthModuleActionArgument) => { 11 | const urls = find(obj.message.content).filter(result => result.type === "url").map(result => result.href); 12 | if (urls.length) { 13 | const body = { 14 | "client": { 15 | "clientId": "bckbot", 16 | "clientVersion": "1.0.0" 17 | }, 18 | "threatInfo": { 19 | "threatTypes": ["THREAT_TYPE_UNSPECIFIED", "MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"], 20 | "platformTypes": ["ALL_PLATFORMS"], 21 | "threatEntryTypes": ["URL", "EXECUTABLE"], 22 | "threatEntries": urls.map(url => { return { url }; }) 23 | } 24 | }; 25 | 26 | const response = await fetch(`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${Bun.env.safebrowsing_key}`, { 27 | method: "POST", 28 | headers: { 29 | "Accept": "application/json", 30 | "Content-Type": "application/json" 31 | }, 32 | body: JSON.stringify(body) 33 | }); 34 | 35 | const json = await response.json() as APIScam; 36 | try { 37 | if (json.matches) { 38 | const txts = []; 39 | for (const match of json.matches) { 40 | txts.push(getString("scam.scam", obj.message.guild?.preferredLocale ?? Locale.EnglishUS, { 41 | link: match.threat.url, 42 | threatType: match.threatType, 43 | platformType: match.platformType, 44 | entryType: match.threatEntryType 45 | })); 46 | } 47 | return { 48 | type: "reply", 49 | result: { 50 | content: txts.join("\n") 51 | } 52 | } 53 | } 54 | } catch (e) { 55 | console.error(e); 56 | } 57 | } 58 | return false; 59 | } 60 | }; -------------------------------------------------------------------------------- /src/localizers/data/APIEmbed.ts: -------------------------------------------------------------------------------- 1 | import { Localizer } from "@app/Localizations"; 2 | import type { L, LocalizerItem } from "../../types/Localizer"; 3 | import type { APIEmbed, APIEmbedProvider, EmbedAuthorData, EmbedField, EmbedFooterData, LocaleString } from "discord.js"; 4 | 5 | type LEmbedAuthorData = L; 6 | interface LEmbedField extends Omit, "inline"> { 7 | inline?: boolean 8 | }; 9 | type LEmbedFooterData = L; 10 | type LAPIEmbedProvider = L; 11 | 12 | export interface LAPIEmbed extends Omit, "author" | "fields" | "footer" | "provider"> { 13 | author?: LEmbedAuthorData; 14 | fields?: LEmbedField[]; 15 | footer?: LEmbedFooterData; 16 | provider?: LAPIEmbedProvider; 17 | title?: LocalizerItem; 18 | }; 19 | 20 | export class LocalizableAPIEmbedAdapter { 21 | private data: LAPIEmbed; 22 | 23 | public constructor(data: LAPIEmbed) { 24 | this.data = data; 25 | } 26 | 27 | public build(locale: LocaleString): APIEmbed { 28 | const { author, description, fields, footer, provider, title, ...x } = this.data; 29 | 30 | const embed: APIEmbed = x; 31 | 32 | if (author) { 33 | const { name, ...y } = author; 34 | embed.author = { 35 | name: Localizer(name, locale), 36 | ...y 37 | }; 38 | } 39 | 40 | if (description) { 41 | embed.description = Localizer(description, locale); 42 | } 43 | 44 | if (fields) { 45 | embed.fields = fields.map(({ name, value, ...x }) => ({ 46 | name: Localizer(name, locale), 47 | value: Localizer(value, locale), 48 | inline: x.inline ?? false 49 | })); 50 | } 51 | 52 | if (footer) { 53 | const { text, ...y } = footer; 54 | embed.footer = { 55 | text: Localizer(text, locale), 56 | ...y 57 | }; 58 | } 59 | 60 | if (provider) { 61 | const { name, ...x } = provider; 62 | embed.provider = { 63 | name: name ? Localizer(name, locale) : undefined, 64 | ...x 65 | }; 66 | } 67 | 68 | if (title) { 69 | embed.title = Localizer(title, locale); 70 | } 71 | 72 | return embed; 73 | } 74 | } -------------------------------------------------------------------------------- /src/commands/setting/ignoreme.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@app/Reporting"; 2 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 3 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 4 | import { PrismaClient } from "@prisma/client"; 5 | import assert from "assert-ts"; 6 | import type { ButtonInteraction, ChatInputCommandInteraction } from "discord.js"; 7 | 8 | const client = new PrismaClient(); 9 | 10 | const setUserIgnore = async (id: string, ignore: boolean) => { 11 | try { 12 | if (ignore) { 13 | await client.ignore.create({ 14 | data: { 15 | id, 16 | type: "u" 17 | } 18 | }); 19 | } else { 20 | const result = await client.ignore.deleteMany({ 21 | where: { 22 | id, 23 | type: "u" 24 | } 25 | }); 26 | 27 | assert(result.count === 1, "Expected to delete one row"); 28 | } 29 | 30 | return true; 31 | } catch (e) { 32 | error("ignoreme.setUserIgnore", e); 33 | return false; 34 | } 35 | }; 36 | 37 | class Command extends SlashApplicationCommand { 38 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 39 | const item = await client.ignore.findFirst({ 40 | where: { 41 | id: interaction.user.id, 42 | type: "u" 43 | } 44 | }); 45 | 46 | return { 47 | content: { 48 | key: item ? "ignoreme.noticeWarning" : "ignoreme.ignoreWarning" 49 | }, 50 | components: [ 51 | { 52 | type: "ActionRow", 53 | components: [ 54 | { 55 | customId: item ? "noticeme" : "ignoreme", 56 | type: "Button", 57 | style: "Success", 58 | emoji: "✅" 59 | } 60 | ] 61 | } 62 | ], 63 | ephemeral: true 64 | } 65 | } 66 | 67 | public async onButton(interaction: ButtonInteraction): Promise { 68 | await interaction.deferUpdate(); 69 | await setUserIgnore(interaction.user.id, interaction.customId === "ignoreme"); 70 | 71 | return { 72 | content: { 73 | key: "ignoreme.success" 74 | } 75 | }; 76 | } 77 | } 78 | 79 | export const ignoreme = new Command({ 80 | name: "ignoreme" 81 | }); -------------------------------------------------------------------------------- /src/commands/misc/slap.ts: -------------------------------------------------------------------------------- 1 | import { arr2obj, random } from "@app/utils"; 2 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 3 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 4 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 5 | import assert from "assert-ts"; 6 | import Decimal from "decimal.js"; 7 | import type { ChatInputCommandInteraction } from "discord.js"; 8 | import { random as emoji } from "node-emoji"; 9 | 10 | const urandom = (object: Record) => { 11 | const opt = Object.keys(object); 12 | 13 | if (opt.length === 1) { 14 | return opt[0]; 15 | } else { 16 | const rand = Math.random(); 17 | let sumProb = new Decimal(0); 18 | for (const prob of Object.values(object)) { 19 | sumProb = sumProb.add(new Decimal(prob)); 20 | } 21 | assert(sumProb.toString() === "1", `sumProb != 1, got ${sumProb}`); 22 | 23 | sumProb.minus(object[opt[opt.length - 1]]); 24 | 25 | for (let i = opt.length - 1; i > 0; i--) { 26 | if (sumProb.lessThan(rand)) { 27 | return opt[i]; 28 | } else { 29 | sumProb = sumProb.minus(object[opt[i - 1]]); 30 | } 31 | } 32 | 33 | return opt.shift()!; 34 | } 35 | }; 36 | 37 | 38 | class Command extends SlashApplicationCommand { 39 | public options: LApplicationCommandOptionData[] = [ 40 | { 41 | name: "victim", 42 | type: "String", 43 | required: true 44 | }, 45 | { 46 | name: "tool", 47 | type: "String" 48 | } 49 | ]; 50 | 51 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 52 | return { 53 | content: { 54 | key: "slap.slap", 55 | data: { 56 | slapper: interaction.member!.toString(), 57 | victim: interaction.options.getString("victim", true), 58 | // TODO: Fix emoji that cannot display (message.react?) 59 | tool: (interaction.options.getString("tool") ?? emoji().emoji), 60 | damage: urandom( 61 | arr2obj( 62 | [ 63 | random(50, 100), 64 | random(100, 300), 65 | random(300, 600), 66 | random(600, 1000) 67 | ], 68 | [0.1, 0.6, 0.2, 0.1] 69 | ) 70 | ) 71 | } 72 | } 73 | } 74 | } 75 | }; 76 | 77 | export const slap = new Command({ 78 | name: "slap" 79 | }); 80 | -------------------------------------------------------------------------------- /src/types/api/Danbooru.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | // 3 | // To change quicktype's target language, run command: 4 | // 5 | // "Set quicktype target language" 6 | 7 | export type APIDanbooru = DanbooruPost[]; 8 | 9 | interface DanbooruPost { 10 | id: number; 11 | created_at: string; 12 | updated_at: string; 13 | up_score: number; 14 | down_score: number; 15 | score: number; 16 | source: string | null; 17 | md5: string; 18 | rating: Rating; 19 | is_note_locked: boolean; 20 | is_rating_locked: boolean; 21 | is_status_locked: boolean; 22 | is_pending: boolean; 23 | is_flagged: boolean; 24 | is_deleted: boolean; 25 | uploader_id: number; 26 | approver_id: null; 27 | pool_string: string; 28 | last_noted_at: null | string; 29 | last_comment_bumped_at: null; 30 | fav_count: number; 31 | tag_string: string; 32 | tag_count: number; 33 | tag_count_general: number; 34 | tag_count_artist: number; 35 | tag_count_character: number; 36 | tag_count_copyright: number; 37 | file_ext?: string; 38 | file_size: number; 39 | image_width: number; 40 | image_height: number; 41 | parent_id: null; 42 | has_children: boolean; 43 | is_banned: boolean; 44 | pixiv_id: number | null; 45 | last_commented_at: null | string; 46 | has_active_children: boolean; 47 | bit_flags: number; 48 | tag_count_meta: number; 49 | has_large: boolean | null; 50 | has_visible_children: boolean; 51 | tag_string_general: string; 52 | tag_string_character: string; 53 | tag_string_copyright: string; 54 | tag_string_artist: string; 55 | tag_string_meta: string; 56 | file_url?: string; 57 | large_file_url?: string; 58 | preview_file_url?: string; 59 | } 60 | 61 | enum Rating { 62 | Explicit = "e", 63 | Questionable = "q", 64 | Safe = "s", 65 | } 66 | -------------------------------------------------------------------------------- /src/types/api/Saucenao.ts: -------------------------------------------------------------------------------- 1 | // Generated by ts-to-zod 2 | import { Zod } from "@type/Zod"; 3 | import { z } from "zod"; 4 | import { ZAPISaucenaoAnime } from "./saucenao/Anime"; 5 | import { ZAPISaucenaoArtstation } from "./saucenao/Artstation"; 6 | import { ZAPISaucenaoEHentai } from "./saucenao/EHentai"; 7 | import { ZAPISaucenaoHAnime } from "./saucenao/HAnime"; 8 | import { ZAPISaucenaoHGame } from "./saucenao/HGame"; 9 | import { ZAPISaucenaoHMagazines } from "./saucenao/HMagazines"; 10 | import { ZAPISaucenaoMangadex } from "./saucenao/Mangadex"; 11 | import { ZAPISaucenaoMoebooru } from "./saucenao/Moebooru"; 12 | import { ZAPISaucenaoPawoo } from "./saucenao/Pawoo"; 13 | import { ZAPISaucenaoPixiv } from "./saucenao/Pixiv"; 14 | import { ZAPISaucenaoSeiga } from "./saucenao/Seiga"; 15 | import { ZAPISaucenaoTwitter } from "./saucenao/Twitter"; 16 | 17 | const indexSchema = z.object({ 18 | status: z.number(), 19 | parent_id: z.number(), 20 | id: z.number(), 21 | results: z.number() 22 | }); 23 | 24 | const resultHeaderSchema = z.object({ 25 | similarity: z.string(), 26 | thumbnail: z.string(), 27 | index_id: z.number(), 28 | index_name: z.string(), 29 | dupes: z.number() 30 | }); 31 | 32 | const headerSchema = z.object({ 33 | user_id: z.string(), 34 | account_type: z.string(), 35 | short_limit: z.string(), 36 | long_limit: z.string(), 37 | long_remaining: z.number(), 38 | short_remaining: z.number(), 39 | status: z.number(), 40 | results_requested: z.number(), 41 | index: z.record(indexSchema), 42 | search_depth: z.string(), 43 | minimum_similarity: z.number(), 44 | query_image_display: z.string(), 45 | query_image: z.string(), 46 | results_returned: z.number(), 47 | message: z.string().optional() 48 | }); 49 | 50 | const resultSchema = z.object({ 51 | header: resultHeaderSchema, 52 | data: z.union([ 53 | ZAPISaucenaoAnime.z, 54 | ZAPISaucenaoArtstation.z, 55 | ZAPISaucenaoEHentai.z, 56 | ZAPISaucenaoHAnime.z, 57 | ZAPISaucenaoHGame.z, 58 | ZAPISaucenaoHMagazines.z, 59 | ZAPISaucenaoMangadex.z, 60 | ZAPISaucenaoMoebooru.z, 61 | ZAPISaucenaoPawoo.z, 62 | ZAPISaucenaoPixiv.z, 63 | ZAPISaucenaoSeiga.z, 64 | ZAPISaucenaoTwitter.z 65 | ]) 66 | }); 67 | 68 | export const ZAPISaucenao = new Zod(z.object({ 69 | header: headerSchema, 70 | results: z.array(resultSchema) 71 | })); 72 | 73 | export type APISaucenao = z.infer; -------------------------------------------------------------------------------- /src/types/api/Sankaku.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | // 3 | // To change quicktype's target language, run command: 4 | // 5 | // "Set quicktype target language" 6 | 7 | export interface APISankaku { 8 | meta: any, 9 | data: SankakuPost[] 10 | } 11 | 12 | interface SankakuPost { 13 | id: number; 14 | rating: Rating; 15 | status: string; 16 | author: Author; 17 | sample_url: null | string; 18 | sample_width: number; 19 | sample_height: number; 20 | preview_url: null | string; 21 | preview_width: number | null; 22 | preview_height: number | null; 23 | file_url: null | string; 24 | width: number; 25 | height: number; 26 | file_size: number; 27 | file_type: string; 28 | created_at: CreatedAt; 29 | has_children: boolean; 30 | has_comments: boolean; 31 | has_notes: boolean; 32 | is_favorited: boolean; 33 | user_vote: null; 34 | md5: string; 35 | parent_id: number | null; 36 | change: number; 37 | fav_count: number; 38 | recommended_posts: number; 39 | recommended_score: number; 40 | vote_count: number; 41 | total_score: number; 42 | comment_count: null; 43 | source: null | string; 44 | in_visible_pool: boolean; 45 | is_premium: boolean; 46 | is_rating_locked: boolean; 47 | redirect_to_signup: boolean; 48 | sequence: null; 49 | tags: Tag[]; 50 | } 51 | 52 | interface Author { 53 | id: number; 54 | name: string; 55 | avatar: string; 56 | avatar_rating: Rating; 57 | } 58 | 59 | enum Rating { 60 | Explicit = "e", 61 | Questionable = "q", 62 | Safe = "s", 63 | } 64 | 65 | interface CreatedAt { 66 | json_class: "Time"; 67 | s: number; 68 | n: number; 69 | } 70 | 71 | interface Tag { 72 | id: number; 73 | name_en: string; 74 | name_ja: null | string; 75 | type: number; 76 | count: number; 77 | post_count: number; 78 | pool_count: number; 79 | locale: string; 80 | rating: Rating | null; 81 | name: string; 82 | } -------------------------------------------------------------------------------- /src/types/api/Yandere.ts: -------------------------------------------------------------------------------- 1 | // Generated by https://quicktype.io 2 | // 3 | // To change quicktype's target language, run command: 4 | // 5 | // "Set quicktype target language" 6 | 7 | export type APIYandere = YanderePost[]; 8 | 9 | interface YanderePost { 10 | id: number; 11 | tags: string; 12 | created_at: number; 13 | updated_at: number; 14 | creator_id: number; 15 | approver_id: null; 16 | author: string; 17 | change: number; 18 | source: string | null; 19 | score: number; 20 | md5: string; 21 | file_size: number; 22 | file_ext: string; 23 | file_url: string; 24 | is_shown_in_index: boolean; 25 | preview_url: string; 26 | preview_width: number; 27 | preview_height: number; 28 | actual_preview_width: number; 29 | actual_preview_height: number; 30 | sample_url: string; 31 | sample_width: number; 32 | sample_height: number; 33 | sample_file_size: number; 34 | jpeg_url: string; 35 | jpeg_width: number; 36 | jpeg_height: number; 37 | jpeg_file_size: number; 38 | rating: Rating; 39 | is_rating_locked: boolean; 40 | has_children: boolean; 41 | parent_id: number | null; 42 | status: Status; 43 | is_pending: boolean; 44 | width: number; 45 | height: number; 46 | is_held: boolean; 47 | frames_pending_string: string; 48 | frames_pending: any[]; 49 | frames_string: string; 50 | frames: any[]; 51 | is_note_locked: boolean; 52 | last_noted_at: number; 53 | last_commented_at: number; 54 | flag_detail?: FlagDetail; 55 | } 56 | 57 | interface FlagDetail { 58 | post_id: number; 59 | reason: string; 60 | created_at: string; 61 | user_id: null; 62 | flagged_by: string; 63 | } 64 | 65 | enum Rating { 66 | Explicit = "e", 67 | Questionable = "q", 68 | Safe = "s", 69 | } 70 | 71 | enum Status { 72 | Active = "active", 73 | Pending = "pending", 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/tool/avatar.ts: -------------------------------------------------------------------------------- 1 | import { UserContextMenuCommand } from "@class/ApplicationCommand"; 2 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 3 | import type { ImageURLOptions, UserContextMenuCommandInteraction } from "discord.js"; 4 | 5 | const bestOptions: ImageURLOptions = { 6 | extension: "png", 7 | size: 4096 8 | }; 9 | 10 | class Command extends UserContextMenuCommand { 11 | public async onContextMenu(interaction: UserContextMenuCommandInteraction): Promise { 12 | const user = await interaction.targetUser.fetch(); 13 | const member = interaction.guild?.members.cache.get(user.id); 14 | 15 | const userAvatarURL = user.displayAvatarURL(bestOptions); 16 | const guildAvatarURL = member && "avatarURL" in member ? member.avatarURL(bestOptions) : null; 17 | const bannerURL = user.bannerURL(bestOptions); 18 | 19 | return { 20 | embeds: [{ 21 | fields: [ 22 | { 23 | name: { 24 | key: "avatar.user" 25 | }, 26 | value: `<@${user.id}> (${user.id})` 27 | }, 28 | { 29 | name: { 30 | key: "avatar.userAvatar" 31 | }, 32 | value: { 33 | key: "avatar.link", 34 | data: { 35 | link: userAvatarURL 36 | } 37 | } 38 | }, 39 | { 40 | name: { 41 | key: "avatar.guildAvatar" 42 | }, 43 | value: guildAvatarURL ? { 44 | key: "avatar.link", 45 | data: { 46 | link: guildAvatarURL 47 | } 48 | } : { 49 | key: "avatar.none" 50 | } 51 | }, 52 | { 53 | name: { 54 | key: "avatar.banner" 55 | }, 56 | value: bannerURL ? { 57 | key: "avatar.link", 58 | data: { 59 | link: bannerURL 60 | } 61 | } : { 62 | key: "avatar.none" 63 | } 64 | } 65 | ], 66 | author: { 67 | name: `${user.username}` 68 | }, 69 | color: 0x8dd272, 70 | image: { 71 | url: userAvatarURL 72 | }, 73 | url: "https://discord.com" 74 | }, 75 | { 76 | image: { 77 | url: guildAvatarURL || "" 78 | }, 79 | url: "https://discord.com" 80 | }, 81 | { 82 | image: { 83 | url: bannerURL || "" 84 | }, 85 | url: "https://discord.com" 86 | }].filter((embed) => embed.image.url !== ""), 87 | ephemeral: true 88 | }; 89 | } 90 | } 91 | 92 | export const avatar = new Command({ 93 | name: "avatar" 94 | }); -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # BCKBOT PRIVACY POLICY 2 | 3 | ## Introduction 4 | 5 | This privacy policy ("Policy") describes how [bckbot](https://github.com/hker9527/bckbot) ("The Bot") collects, uses, and discloses information collected from users ("Them") of the Application. 6 | 7 | ## Information Collection 8 | 9 | The Bot collects and stores information from users of the Application in order to provide functionality to its users: 10 | 11 | - Their Discord ID: For identifying a specific user 12 | - Their Discord locale (language): For providing localized responses 13 | 14 | If they choose to opt-out of data collection, they can do so by using the chat command `/ignoreme`. 15 | 16 | ## Use of Information 17 | 18 | The Bot uses the information collected in the preceding section for the following purposes: 19 | 20 | - To provide The Bot to Them 21 | - To maintain the functionality of The Bot 22 | 23 | ## Data Retention 24 | 25 | The Bot retains the collected information, including user information, for as long as necessary to provide The Bot to Them. Users can request deletion of their information through the existing methods or by using the chat command `/forgetme`. 26 | 27 | ## Information Transfer 28 | 29 | The Bot does NOT transfer information, including user information, to third parties. Their data will not be sold or shared without the user's consent. 30 | 31 | ## Disclosure of Information 32 | 33 | The Bot may disclose information that it collects about users, including personal information, if required to do so by law or in the good faith belief that such action is necessary to: 34 | 35 | - Comply with a legal obligation 36 | - Protect and defend the rights or property of The Bot 37 | - Prevent or investigate possible wrongdoing in connection with the Application 38 | - Protect the personal safety of users of the Application or the public 39 | - Protect against legal liability 40 | 41 | ## Security of Information 42 | 43 | The Bot takes commercially reasonable steps to ensure the security of the information it holds. The data is stored locally on a secure server, and access to the server is only possible by the author of The Bot, using the SSH public key authentication method. 44 | 45 | ## Changes to this Policy 46 | 47 | The Bot reserves the right to change this Policy at any time. The Bot will notify users of material changes to the Policy by updating The Bot's profile description. Users are responsible for regularly reviewing this Policy. 48 | 49 | ## Contact Us 50 | 51 | If you have any questions about this Policy, please contact the author at either: 52 | 53 | - On Discord/GitHub: hker9527 54 | - By email: bckps7336@gmail.com -------------------------------------------------------------------------------- /src/Localizations.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizerItem } from "@type/Localizer"; 2 | import type { LocaleString, LocalizationMap } from "discord-api-types/v10"; 3 | import { readdirSync } from "fs"; 4 | import i18next from "i18next"; 5 | import { error } from "./Reporting"; 6 | 7 | const resources: Partial> }>> = {}; 8 | 9 | for (const file of readdirSync("./res/i18n/")) { 10 | const res = await Bun.file(`./res/i18n/${file}`).json() as Record>; 11 | const locale = file.split(".")[0] as LocaleString; 12 | resources[locale] = { translation: res }; 13 | } 14 | 15 | i18next.init({ 16 | resources 17 | }); 18 | 19 | export const getString = (key: string, locale: LocaleString, options?: Record) => { 20 | while (!i18next.isInitialized); 21 | 22 | // Locale fallback 23 | switch (locale) { 24 | case "en-GB": 25 | locale = "en-US"; 26 | break; 27 | case "zh-CN": 28 | locale = "zh-TW"; 29 | break; 30 | default: 31 | if (!key.includes("$t") && !i18next.exists(key, { lng: locale })) { 32 | locale = "en-US"; 33 | } 34 | break; 35 | } 36 | 37 | if (!key.includes("$t") && !i18next.exists(key, { lng: locale })) { 38 | error("getString", `${key} @ ${locale} does not exist!`); 39 | } 40 | 41 | return i18next.t(key, { 42 | interpolation: { 43 | escapeValue: false, 44 | skipOnVariables: false 45 | }, 46 | lng: locale, 47 | ...options 48 | }); 49 | }; 50 | 51 | export const Localizer = (localizable: LocalizerItem, locale: LocaleString) => { 52 | if (typeof localizable === "string") { 53 | return localizable; 54 | } 55 | 56 | return getString(localizable.key, locale, localizable.data); 57 | } 58 | 59 | const getLocalizationMap = (key: string) => { 60 | const map: LocalizationMap = {}; 61 | 62 | for (const _locale in resources) { 63 | const locale = _locale as LocaleString; 64 | if (locale === "en-US" || !i18next.exists(key, { lng: locale })) continue; 65 | map[locale] = getString(key, locale); 66 | } 67 | 68 | return map; 69 | }; 70 | 71 | export const getName = (commandName: string, optionName?: string) => { 72 | return { 73 | name: getString(`${commandName}.${optionName ? `_${optionName}` : ""}_name`, "en-US"), 74 | nameLocalizations: getLocalizationMap(`${commandName}.${optionName ? `_${optionName}` : ""}_name`) 75 | }; 76 | }; 77 | 78 | export const getDescription = (commandName: string, optionName?: string) => { 79 | return { 80 | description: getString(`${commandName}.${optionName ? `_${optionName}` : ""}_description`, "en-US"), 81 | descriptionLocalizations: getLocalizationMap(`${commandName}.${optionName ? `_${optionName}` : ""}_description`) 82 | }; 83 | }; -------------------------------------------------------------------------------- /src/commands/setting/language.ts: -------------------------------------------------------------------------------- 1 | import { SlashApplicationCommand } from "@app/classes/ApplicationCommand"; 2 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 3 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 4 | import { PrismaClient } from "@prisma/client"; 5 | import type { ChatInputCommandInteraction } from "discord.js"; 6 | 7 | const client = new PrismaClient(); 8 | 9 | class Command extends SlashApplicationCommand { 10 | public options: LApplicationCommandOptionData[] = [ 11 | { 12 | name: "language", 13 | type: "String", 14 | choices: [ 15 | { 16 | name: "default", 17 | value: "default" 18 | }, 19 | { 20 | name: "english", 21 | value: "en-US" 22 | }, 23 | { 24 | name: "tchinese", 25 | value: "zh-TW" 26 | } 27 | ] 28 | } 29 | ]; 30 | 31 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 32 | const language = interaction.options.getString("language"); 33 | if (language) { 34 | switch (language) { 35 | case "default": 36 | await client.language.deleteMany({ 37 | where: { 38 | id: interaction.user.id, 39 | type: "u" 40 | } 41 | }); 42 | 43 | return { 44 | content: { 45 | key: "language.resetSuccess" 46 | }, 47 | ephemeral: true 48 | }; 49 | default: 50 | await client.language.upsert({ 51 | where: { 52 | id: interaction.user.id, 53 | type: "u" 54 | }, 55 | create: { 56 | id: interaction.user.id, 57 | type: "u", 58 | language, 59 | override: true 60 | }, 61 | update: { 62 | language, 63 | override: true 64 | } 65 | }); 66 | 67 | return { 68 | content: { 69 | key: "language.setSuccess", 70 | data: { 71 | language: `$t(language.${language})` 72 | } 73 | }, 74 | ephemeral: true 75 | }; 76 | } 77 | } else { 78 | const languageItem = await client.language.findFirst({ 79 | where: { 80 | id: interaction.user.id, 81 | type: "u" 82 | } 83 | }); 84 | if (languageItem) { 85 | return { 86 | content: { 87 | key: "language.current", 88 | data: { 89 | language: `$t(language.${languageItem.language})`, 90 | override: languageItem.override ? "🔒" : "🔓" 91 | } 92 | }, 93 | ephemeral: true 94 | } 95 | } 96 | 97 | return { 98 | content: { 99 | key: "language.notFound" 100 | }, 101 | ephemeral: true 102 | } 103 | } 104 | } 105 | } 106 | 107 | export const language = new Command({ 108 | name: "language" 109 | }); -------------------------------------------------------------------------------- /src/commands/fun/mine.ts: -------------------------------------------------------------------------------- 1 | import { random } from "@app/utils"; 2 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 3 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 4 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 5 | import type { ChatInputCommandInteraction } from "discord.js"; 6 | import { sample } from "underscore"; 7 | 8 | const numberSymbols = " 123456789".split(""); 9 | const bombSymbol = "X"; 10 | 11 | class Command extends SlashApplicationCommand { 12 | public options: LApplicationCommandOptionData[] = [ 13 | { 14 | name: "h", 15 | type: "Integer", 16 | minValue: 3, 17 | maxValue: 14 18 | }, 19 | { 20 | name: "w", 21 | type: "Integer", 22 | minValue: 3, 23 | maxValue: 14 24 | }, 25 | { 26 | name: "n", 27 | type: "Integer", 28 | minValue: 1, 29 | maxValue: 191 30 | } 31 | ]; 32 | 33 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 34 | const _h = random(3, 15); 35 | const _w = random(3, 15); 36 | 37 | let h = interaction.options.getInteger("h") ?? _h; 38 | let w = interaction.options.getInteger("w") ?? _w; 39 | let mineCount = interaction.options.getInteger("n") ?? Math.max(1, h * w / random(5, 10) | 0); 40 | 41 | if (mineCount > (h * w - 5)) mineCount = Math.max(1, h * w / random(5, 10) | 0); 42 | 43 | const field = [...new Array(h)].map(() => [...new Array(w)].map(() => numberSymbols[0])); 44 | 45 | const mineLocations = []; 46 | 47 | // 4 corners are always safe 48 | let avail = [...new Array(h * w)].map((a, i) => i).filter(a => [0, w - 1, w * (h - 1), w * h - 1].indexOf(a) === -1); 49 | 50 | // Put mines into the field 51 | for (let i = 0; i < mineCount; i++) { 52 | const ran = sample(avail)!; 53 | 54 | mineLocations.push(ran); 55 | delete avail[avail.indexOf(ran)]; 56 | 57 | avail = avail.filter(a => !!a); 58 | } 59 | 60 | // Populate the radar 61 | for (let mineLocation of mineLocations) { 62 | let [x, y] = [mineLocation % w, (mineLocation / w) | 0]; 63 | field[y][x] = bombSymbol; 64 | 65 | // Filter is used to prevent out-of-bound array access 66 | for (let _m of [ 67 | [x - 1, y - 1], [x - 1, y], [x - 1, y + 1], 68 | [x, y - 1], [x, y], [x, y + 1], 69 | [x + 1, y - 1], [x + 1, y], [x + 1, y + 1] 70 | ].filter(a => a[0] < w && a[0] > -1 && a[1] < h && a[1] > -1)) { 71 | // Add the surroundings of a bomb by 1 72 | if (field[_m[1]][_m[0]] !== bombSymbol) { 73 | field[_m[1]][_m[0]] = numberSymbols[numberSymbols.indexOf(field[_m[1]][_m[0]]) + 1]; 74 | } 75 | } 76 | } 77 | 78 | return { 79 | content: { 80 | key: "mine.mine", 81 | data: { 82 | h, w, mineCount, 83 | mineField: field.map((a, i) => a.map((b, j) => 84 | // Show the 4 corners 85 | i === 0 && j === 0 86 | || i === 0 && j === w - 1 87 | || i === h - 1 && j === 0 88 | || i === h - 1 && j === w - 1 ? b : `||${b}||` 89 | ).join("")).join("\n") 90 | } 91 | } 92 | }; 93 | } 94 | }; 95 | 96 | export const mine = new Command({ 97 | name: "mine" 98 | }); -------------------------------------------------------------------------------- /src/localizers/data/ActionRowData.ts: -------------------------------------------------------------------------------- 1 | import { Localizer } from "@app/Localizations"; 2 | import type { L } from "@type/Localizer"; 3 | import type { LocaleString } from "discord-api-types/v9"; 4 | import type { ActionRowData, InteractionButtonComponentData, LinkButtonComponentData, MessageActionRowComponentData, SelectMenuComponentOptionData, StringSelectMenuComponentData } from "discord.js"; 5 | import { ButtonStyle, ComponentType } from "discord.js"; 6 | 7 | interface LBaseComponentData { 8 | type: keyof typeof ComponentType; 9 | }; 10 | 11 | interface LInteractionButtonComponentData extends LBaseComponentData, Omit, "type" | "style"> { 12 | type: "Button"; // Upstream problem bruh 13 | style: Exclude; 14 | }; 15 | 16 | interface LLinkButtonComponentData extends LBaseComponentData, Omit, "type" | "style"> { 17 | type: "Button"; // Upstream problem bruh 18 | style: "Link"; 19 | }; 20 | 21 | interface LStringSelectMenuComponentData extends Omit, "type" | "options"> { 22 | type: "StringSelect", 23 | options: L[] 24 | }; 25 | 26 | export type LActionRowComponentData = ( 27 | | LInteractionButtonComponentData 28 | | LLinkButtonComponentData 29 | | LStringSelectMenuComponentData 30 | ); 31 | 32 | export interface LActionRowData { 33 | type: "ActionRow"; 34 | components: LActionRowComponentData[]; 35 | }; 36 | 37 | export class LActionRowDataLocalizer { 38 | private data: LActionRowData; 39 | 40 | public constructor(data: LActionRowData) { 41 | this.data = data; 42 | } 43 | 44 | public localize(locale: LocaleString): ActionRowData { 45 | const actionRow: ActionRowData = { 46 | type: ComponentType.ActionRow, 47 | components: this.data.components.map(component => { 48 | switch (component.type) { 49 | case "Button": { 50 | if (component.style === "Link") { 51 | const { type, style, label, ...y } = component; 52 | return { 53 | type: ComponentType[type], 54 | style: ButtonStyle[style], 55 | label: label ? Localizer(label, locale) : undefined, 56 | ...y 57 | }; 58 | } 59 | 60 | const { type, style, label, ...y } = component; 61 | return { 62 | type: ComponentType[type], 63 | style: ButtonStyle[style], 64 | label: label ? Localizer(label, locale) : undefined, 65 | ...y 66 | }; 67 | } 68 | case "StringSelect": { 69 | const { type, options, placeholder, ...y } = component; 70 | 71 | return { 72 | type: ComponentType[type], 73 | options: options.map(option => { 74 | const { label, description, ...z } = option; 75 | return { 76 | label: Localizer(label, locale), 77 | description: description ? Localizer(description, locale) : undefined, 78 | ...z 79 | }; 80 | }), 81 | placeholder: placeholder ? Localizer(placeholder, locale) : undefined, 82 | ...y 83 | } 84 | } 85 | } 86 | }) 87 | }; 88 | 89 | return actionRow; 90 | } 91 | } -------------------------------------------------------------------------------- /src/classes/ApplicationCommandOptionData.ts: -------------------------------------------------------------------------------- 1 | import { getName, getDescription } from "@app/Localizations"; 2 | import type { BaseApplicationCommandOptionsData, ApplicationCommandOptionChoiceData, ApplicationCommandOptionData, ApplicationCommandNumericOptionData, ApplicationCommandStringOptionData } from "discord.js"; 3 | import { ApplicationCommandOptionType } from "discord.js"; 4 | import { Custom } from "./custom"; 5 | 6 | type LocalizableOption = Omit & { 7 | type: keyof typeof ApplicationCommandOptionType 8 | }; 9 | 10 | type LApplicationCommandOptionChoiceData = Omit, "nameLocalizations">; 11 | 12 | interface LApplicationCommandChoicesData extends LocalizableOption { 13 | choices?: LApplicationCommandOptionChoiceData[] 14 | } 15 | 16 | interface LApplicationCommandStringOptionData extends LApplicationCommandChoicesData, Pick { 17 | type: "String"; 18 | } 19 | 20 | interface LApplicationCommandNumericOptionData extends LApplicationCommandChoicesData, Pick { 21 | type: "Number" | "Integer"; 22 | } 23 | 24 | export type LApplicationCommandOptionData = 25 | | LApplicationCommandStringOptionData 26 | | LApplicationCommandNumericOptionData; 27 | 28 | export class ApplicationCommandOption extends Custom { 29 | private commandName: string; 30 | private option: LApplicationCommandOptionData; 31 | 32 | public constructor(commandName: string, option: LApplicationCommandOptionData) { 33 | super(); 34 | 35 | this.commandName = commandName; 36 | this.option = option; 37 | } 38 | 39 | public toAPI(): ApplicationCommandOptionData { 40 | let _ret: BaseApplicationCommandOptionsData & { 41 | type: ApplicationCommandOptionType 42 | } = { 43 | ...getName(this.commandName, this.option.name), 44 | ...getDescription(this.commandName, this.option.name), 45 | required: "required" in this.option ? this.option.required : undefined, 46 | type: ApplicationCommandOptionType[this.option.type] 47 | }; 48 | 49 | let choices: ApplicationCommandOptionChoiceData[] | undefined; 50 | let options: any; 51 | 52 | if (this.option.choices) { 53 | choices = this.option.choices.map(choice => ({ 54 | ...getName(this.commandName, choice.name), 55 | value: choice.value 56 | })); 57 | } 58 | 59 | // if ("options" in this.option && this.option.options) { 60 | // options = Object.entries(this.option.options).map(([key, value]) => 61 | // new ApplicationCommandOption(this.commandName, key, value).toAPI() 62 | // ); 63 | // } 64 | 65 | switch (this.option.type) { 66 | case "Number": 67 | case "Integer": 68 | return { 69 | ..._ret, 70 | choices, 71 | options 72 | } as ApplicationCommandNumericOptionData; 73 | case "String": 74 | return { 75 | ..._ret, 76 | choices, 77 | options 78 | } as ApplicationCommandStringOptionData; 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/commands/tool/currency.ts: -------------------------------------------------------------------------------- 1 | import { arr2obj, round } from "@app/utils"; 2 | import { SlashApplicationCommand } from "@class/ApplicationCommand"; 3 | import type { LApplicationCommandOptionData } from "@class/ApplicationCommandOptionData"; 4 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 5 | import { Zod } from "@type/Zod"; 6 | import assert from "assert-ts"; 7 | import type { ChatInputCommandInteraction } from "discord.js"; 8 | import { z } from "zod"; 9 | import { Logger } from "tslog"; 10 | 11 | const logger = new Logger({ 12 | name: "currency", 13 | minLevel: Bun.env.DEV === "true" ? 0 : 3 14 | }); 15 | 16 | const currencies = [ 17 | "TWD", "HKD", "JPY", "USD", "EUR" 18 | ]; 19 | 20 | let lastUpdated = new Date(0); 21 | let quotes: Record = {}; 22 | 23 | const getQuote = (source: string, target: string, amount: number) => { 24 | if (source === target) { 25 | return 1; 26 | } 27 | 28 | return round(quotes[source + target] * amount, 3); 29 | }; 30 | 31 | const worker = async () => { 32 | const sublogger = logger.getSubLogger({ 33 | name: "worker" 34 | }); 35 | 36 | try { 37 | if (Bun.env.DEV === "true") { 38 | logger.debug("Using mock data"); 39 | quotes = { 40 | "TWDHKD": 0.25, 41 | "TWDJPY": 3.5, 42 | "TWDUSD": 0.035, 43 | "TWDEUR": 0.03, 44 | "HKDTWD": 4, 45 | "HKDJPY": 14, 46 | "HKDUSD": 0.14, 47 | "HKDEUR": 0.12, 48 | "JPYTWD": 0.285, 49 | "JPYHKD": 0.071, 50 | "JPYUSD": 0.009, 51 | "JPYEUR": 0.008, 52 | "USDTWD": 28.5, 53 | "USDHKD": 7, 54 | "USDJPY": 110, 55 | "USDEUR": 0.85, 56 | "EURTWD": 33.5, 57 | "EURHKD": 8.5, 58 | "EURJPY": 130, 59 | "EURUSD": 1.18 60 | }; 61 | 62 | return true; 63 | } 64 | 65 | quotes = {}; 66 | 67 | for (let i = 0; i < currencies.length - 1; i++) { 68 | const currency = currencies[i]; 69 | const otherCurrencies = currencies.filter(c => c !== currency); 70 | 71 | const response = await fetch(`http://api.exchangerate.host/live?access_key=${Bun.env.exchangerate_key}&source=${currency}¤cies=${currencies.join(",")}`) 72 | .then(res => res.json()); 73 | 74 | sublogger.trace(response); 75 | 76 | const Z = new Zod(z.object({ 77 | success: z.literal(true), 78 | source: z.literal(currency), 79 | quotes: z.object(arr2obj(otherCurrencies.map(oc => currency + oc), otherCurrencies.map(_ => z.number()))) 80 | })); 81 | assert(Z.check(response)); 82 | 83 | quotes = { 84 | ...quotes, 85 | ...response.quotes 86 | }; 87 | } 88 | 89 | lastUpdated = new Date(); 90 | 91 | return true; 92 | } catch (e) { 93 | sublogger.error(e); 94 | return false; 95 | } 96 | } 97 | 98 | worker(); 99 | setInterval(worker, 86400 * 2 * 1000); 100 | 101 | class Command extends SlashApplicationCommand { 102 | public options: LApplicationCommandOptionData[] = [ 103 | { 104 | name: "source", 105 | type: "String", 106 | required: true, 107 | choices: currencies.map(currency => ({ 108 | name: currency, 109 | value: currency 110 | })) 111 | }, 112 | { 113 | name: "amount", 114 | type: "Number", 115 | minValue: 1 116 | }, 117 | { 118 | name: "target", 119 | type: "String", 120 | choices: currencies.map(currency => ({ 121 | name: currency, 122 | value: currency 123 | })) 124 | } 125 | ]; 126 | 127 | public async onCommand(interaction: ChatInputCommandInteraction): Promise { 128 | const source = interaction.options.getString("source", true); 129 | const amount = interaction.options.getNumber("amount") ?? 1; 130 | const target = interaction.options.getString("target") ?? null; 131 | 132 | return { 133 | embeds: [{ 134 | title: "Convert", 135 | fields: [ 136 | { 137 | name: { 138 | key: `currency._${source}_name` 139 | }, 140 | value: amount.toString() 141 | }, 142 | { 143 | name: target ? { 144 | key: `currency._${target}_name` 145 | } : "All", 146 | value: target ? 147 | getQuote(source, target, amount).toString() : 148 | currencies.map(currency => `${currency}: ${getQuote(source, currency, amount)}`).join("\n") 149 | } 150 | ], 151 | footer: { 152 | text: "Updated at" 153 | }, 154 | timestamp: lastUpdated.toISOString() 155 | }] 156 | }; 157 | } 158 | }; 159 | 160 | export const currency = new Command({ 161 | name: "currency" 162 | }); -------------------------------------------------------------------------------- /src/modules/moebooru.ts: -------------------------------------------------------------------------------- 1 | import type { LAPIEmbed } from "@localizer/data/APIEmbed"; 2 | import type { APIDanbooru } from "@type/api/Danbooru"; 3 | import type { APIKonachan } from "@type/api/Konachan"; 4 | import type { APISankaku } from "@type/api/Sankaku"; 5 | import type { APIYandere } from "@type/api/Yandere"; 6 | import type { StealthModule } from "@type/StealthModule"; 7 | 8 | export enum ApiPortal { 9 | // eslint-disable-next-line no-unused-vars 10 | kon = "https://konachan.com/post.json", 11 | // eslint-disable-next-line no-unused-vars 12 | yan = "https://yande.re/post.json", 13 | // eslint-disable-next-line no-unused-vars 14 | dan = "https://danbooru.donmai.us/posts.json", 15 | // eslint-disable-next-line no-unused-vars 16 | san = "https://capi-v2.sankakucomplex.com/posts/keyset" 17 | } 18 | 19 | export interface ImageObject { 20 | id: string, 21 | rating: "s" | "q" | "e", 22 | source: string | null, 23 | file_url: string | null, 24 | created_at: Date, 25 | width: number, 26 | height: number; 27 | } 28 | 29 | // TODO: Rewrite as class 30 | export const fetchList = async (provider: keyof typeof ApiPortal, tags: string[] = [], nsfw = false): Promise => { 31 | const res = await fetch(`${ApiPortal[provider]}?tags=${tags.filter(tag => { return !tag.includes("rating") || nsfw; }).join("+")}${nsfw ? "" : "+rating:s"}&limit=20`) 32 | .then(res => res.json()); 33 | 34 | switch (provider) { 35 | case "kon": { 36 | const result = res as APIKonachan; 37 | return result.map(a => { 38 | return { 39 | id: `${a.id}`, 40 | rating: a.rating, 41 | source: a.source, 42 | file_url: a.file_url, 43 | created_at: new Date(a.created_at * 1000), 44 | width: a.width, 45 | height: a.height 46 | }; 47 | }); 48 | } 49 | case "yan": { 50 | const result = res as APIYandere; 51 | return result.map(a => { 52 | return { 53 | id: `${a.id}`, 54 | rating: a.rating, 55 | source: a.source, 56 | file_url: a.file_url, 57 | created_at: new Date(a.created_at * 1000), 58 | width: a.width, 59 | height: a.height 60 | }; 61 | }); 62 | } 63 | case "dan": { 64 | const result = res as APIDanbooru; 65 | return result.map(a => { 66 | return { 67 | id: `${a.id}`, 68 | rating: a.rating, 69 | source: a.source, 70 | file_url: a.large_file_url ?? a.file_url ?? a.preview_file_url ?? null, 71 | created_at: new Date(a.created_at), 72 | width: a.image_width, 73 | height: a.image_height 74 | }; 75 | }); 76 | } 77 | case "san": { 78 | const result = res as APISankaku; 79 | return result.data.map(a => { 80 | return { 81 | id: `${a.id}`, 82 | rating: a.rating, 83 | source: a.source, 84 | file_url: a.file_url ?? a.sample_url ?? a.preview_url ?? null, 85 | created_at: new Date(a.created_at.s * 1000), 86 | width: a.width, 87 | height: a.height 88 | }; 89 | }); 90 | } 91 | } 92 | }; 93 | 94 | export const genEmbed = (provider: keyof typeof ApiPortal, imageObject: ImageObject, showImage = false, nsfw = false) => { 95 | const embed: LAPIEmbed = { 96 | author: { 97 | name: { 98 | key: "moebooru.searchResult" 99 | }, 100 | iconURL: `https://cdn4.iconfinder.com/data/icons/alphabet-3/500/ABC_alphabet_letter_font_graphic_language_text_${provider[0].toUpperCase()}-64.png` 101 | }, 102 | color: ({ 103 | s: 0x7df28b, 104 | q: 0xe4ea69, 105 | e: 0xd37a52 106 | })[imageObject.rating], 107 | fields: [{ 108 | name: "Post", 109 | value: `[${imageObject.id}](${({ 110 | kon: `https://konachan.com/post/show/${imageObject.id}`, 111 | yan: `https://yande.re/post/show/${imageObject.id}`, 112 | dan: `https://danbooru.donmai.us/posts/${imageObject.id}`, 113 | san: `https://sankaku.app/post/show/${imageObject.id}` 114 | })[provider]})`, 115 | inline: true 116 | }, { 117 | name: { 118 | key: "moebooru.dimensions" 119 | }, 120 | value: `${imageObject.width} x ${imageObject.height}`, 121 | inline: true 122 | }, { 123 | name: { 124 | key: "moebooru.sourceHeader" 125 | }, 126 | value: (imageObject.source?.length ? imageObject.source : undefined) ?? "(未知)" 127 | }], 128 | timestamp: imageObject.created_at.toISOString() 129 | }; 130 | 131 | if (showImage && (imageObject.rating !== "s" || nsfw) && imageObject.file_url) { 132 | // embed.setImage(imageObject.file_url); 133 | embed.image = { 134 | url: imageObject.file_url 135 | }; 136 | } 137 | return embed; 138 | }; 139 | 140 | export const moebooru: StealthModule = { 141 | name: "moebooru", 142 | event: "messageCreate", 143 | action: async () => { 144 | // TODO: Generate embed like pixiv 145 | return false; 146 | } 147 | }; 148 | -------------------------------------------------------------------------------- /src/classes/ApplicationCommand.ts: -------------------------------------------------------------------------------- 1 | import { getDescription, getName } from "@app/Localizations"; 2 | 3 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 4 | import type { LBaseMessageOptions } from "@localizer/MessageOptions"; 5 | import type { ApplicationCommandDataResolvable, ButtonInteraction, ChatInputCommandInteraction, Message, MessageComponentInteraction, MessageContextMenuCommandInteraction, StringSelectMenuInteraction, UserContextMenuCommandInteraction } from "discord.js"; 6 | import { ApplicationCommandType } from "discord.js"; 7 | import type { LApplicationCommandOptionData } from "./ApplicationCommandOptionData"; 8 | import { ApplicationCommandOption } from "./ApplicationCommandOptionData"; 9 | import { Custom } from "./custom"; 10 | 11 | export abstract class BaseApplicationCommand extends Custom { 12 | protected _type: T; 13 | protected _name: string; 14 | protected _nsfw: boolean; 15 | protected _defer: boolean; 16 | 17 | public constructor(argv: { 18 | type: T, 19 | name: string, 20 | nsfw?: boolean, 21 | defer?: boolean 22 | }) { 23 | super(); 24 | 25 | this._type = argv.type; 26 | this._name = argv.name; 27 | this._nsfw = argv.nsfw ?? false; 28 | this._defer = argv.defer ?? false; 29 | } 30 | 31 | // Getters 32 | public get type(): T { 33 | return this._type; 34 | } 35 | 36 | public get name(): string { 37 | return this._name; 38 | } 39 | 40 | public get nsfw(): boolean { 41 | return this._nsfw; 42 | } 43 | 44 | public get defer(): boolean { 45 | return this._defer; 46 | } 47 | 48 | public toAPI(): ApplicationCommandDataResolvable { 49 | if (this.isSlashApplicationCommand()) { 50 | return { 51 | type: this._type, 52 | ...getName(this._name), 53 | ...getDescription(this._name), 54 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 55 | options: Object.entries(this.options ?? {}).map(([_, lApplicationCommandChoicesData]) => { 56 | return new ApplicationCommandOption(this._name, lApplicationCommandChoicesData).toAPI(); 57 | }) 58 | }; 59 | } else if (this.isMessageContextMenuCommand()) { 60 | return { 61 | type: this._type, 62 | ...getName(this._name) 63 | } 64 | } else if (this.isUserContextMenuCommand()) { 65 | return { 66 | type: this._type, 67 | ...getName(this._name) 68 | } 69 | } 70 | 71 | throw new Error("Invalid application command type"); 72 | } 73 | 74 | // Type guards 75 | public isSlashApplicationCommand(): this is SlashApplicationCommand { 76 | return this._type === ApplicationCommandType.ChatInput; 77 | } 78 | 79 | public isMessageContextMenuCommand(): this is MessageContextMenuCommand { 80 | return this._type === ApplicationCommandType.Message; 81 | } 82 | 83 | public isUserContextMenuCommand(): this is UserContextMenuCommand { 84 | return this._type === ApplicationCommandType.User; 85 | } 86 | 87 | // Events 88 | public async onButton?(interaction: ButtonInteraction): Promise; 89 | public async onMessageComponent?(interaction: MessageComponentInteraction): Promise; 90 | public async onSelectMenu?(interaction: StringSelectMenuInteraction): Promise; 91 | public async onTimeout?(message: Message): Promise; 92 | } 93 | 94 | export abstract class SlashApplicationCommand extends BaseApplicationCommand { 95 | public options?: LApplicationCommandOptionData[]; 96 | 97 | public constructor(argv: { 98 | name: string, 99 | nsfw?: boolean, 100 | defer?: boolean, 101 | }) { 102 | super({ 103 | type: ApplicationCommandType.ChatInput, 104 | ...argv 105 | }); 106 | } 107 | 108 | public abstract onCommand(interaction: ChatInputCommandInteraction): Promise; 109 | } 110 | 111 | export abstract class MessageContextMenuCommand extends BaseApplicationCommand { 112 | public constructor(argv: { 113 | name: string, 114 | nsfw?: boolean, 115 | defer?: boolean, 116 | }) { 117 | super({ 118 | type: ApplicationCommandType.Message, 119 | ...argv 120 | }); 121 | } 122 | 123 | public abstract onContextMenu(interaction: MessageContextMenuCommandInteraction): Promise; 124 | } 125 | 126 | export abstract class UserContextMenuCommand extends BaseApplicationCommand { 127 | public constructor(argv: { 128 | name: string, 129 | nsfw?: boolean, 130 | defer?: boolean, 131 | }) { 132 | super({ 133 | type: ApplicationCommandType.User, 134 | ...argv 135 | }); 136 | } 137 | 138 | public abstract onContextMenu(interaction: UserContextMenuCommandInteraction): Promise; 139 | } -------------------------------------------------------------------------------- /res/i18n/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "ask": { 3 | "_description": "神祕的魔法球", 4 | "_name": "問", 5 | "_question_description": "你的問題", 6 | "_question_name": "問題", 7 | "answer0": "這是必然。", 8 | "answer1": "肯定是的。", 9 | "answer10": "回覆攏統,再試試。", 10 | "answer11": "待會再問。", 11 | "answer12": "最好現在不告訴你。", 12 | "answer13": "現在無法預測。", 13 | "answer14": "專心再問一遍。", 14 | "answer15": "想的美。", 15 | "answer16": "我的回覆是「不」。", 16 | "answer17": "我的來源說「不」。", 17 | "answer18": "前景不太好。", 18 | "answer19": "很可疑。", 19 | "answer2": "不用懷疑。", 20 | "answer3": "毫無疑問。", 21 | "answer4": "你能依靠它。", 22 | "answer5": "如我所見,是的。", 23 | "answer6": "很有可能。", 24 | "answer7": "外表很好。", 25 | "answer8": "是的。", 26 | "answer9": "種種跡象指出「是的」。" 27 | }, 28 | "avatar": { 29 | "_name": "用戶圖像", 30 | "user": "用戶", 31 | "userAvatar": "用戶頭像", 32 | "guildAvatar": "伺服器頭像", 33 | "banner": "橫幅", 34 | "link": "✅ [連結]({{link}})", 35 | "none": "❌ 無" 36 | }, 37 | "choice": { 38 | "_choices_description": "用空格分開", 39 | "_choices_name": "選項", 40 | "_description": "解決你的選擇困難症", 41 | "_name": "挑一個", 42 | "notEnoughChoices": "提供的選擇不足!", 43 | "result": "結果: {{result}}" 44 | }, 45 | "currency": { 46 | "_EUR_name": "歐元", 47 | "_HKD_name": "港幣", 48 | "_JPY_name": "日圓", 49 | "_TWD_name": "台幣", 50 | "_USD_name": "美元", 51 | "_amount_description": "預設為1", 52 | "_amount_name": "金額", 53 | "_description": "轉換貨幣", 54 | "_name": "轉換", 55 | "_source_description": "來源貨幣", 56 | "_source_name": "來源", 57 | "_target_description": "預設為全部", 58 | "_target_name": "目標" 59 | }, 60 | "delete": { 61 | "_name": "刪除機器人訊息", 62 | "deleted": "已刪除機器人訊息。", 63 | "deletingOthersMessage": "你只能刪除你自己的互動。", 64 | "notMyMessage": "你不能刪除其他人的訊息。" 65 | }, 66 | "dice": { 67 | "_description": "擲骰子", 68 | "_faces_description": "骰子多少面", 69 | "_faces_name": "面", 70 | "_n_description": "多少顆骰子", 71 | "_n_name": "顆", 72 | "_name": "擲骰子", 73 | "_offset_description": "加減多少", 74 | "_offset_name": "偏移量", 75 | "offset": "加上$t(dice.offsetDescription)`{{offset}}`", 76 | "roll": "{{n}}d{{faces}}{{offset}} = {{result}}" 77 | }, 78 | "forgetme": { 79 | "_description": "忘記你的資料", 80 | "_name": "forgetme", 81 | "success": "你的資料已被刪除。", 82 | "failure": "你沒有任何資料。" 83 | }, 84 | "ignoreme": { 85 | "_description": "控制機器人看不見你的訊息。", 86 | "_name": "ignoreme", 87 | "ignoreWarning": "你將無法使用任何自動功能,包括:\n```\n - Pixiv/Twitter圖片嵌入\n - 偵測有害鏈接\n```\n你可以再次使用這個指令來取消。\n你確定嗎?(點擊取消來取消)", 88 | "noticeWarning": "你將可以再次使用自動功能。\n你確定嗎?(點擊下方刪除來取消)", 89 | "success": "操作成功。", 90 | "failure": "操作失敗。" 91 | }, 92 | "index": { 93 | "delete": "刪除", 94 | "error": "出包了……請等待修復!" 95 | }, 96 | "invite": { 97 | "_description": "獲取這個機械人的邀請鏈接", 98 | "_name": "邀請鏈接" 99 | }, 100 | "mine": { 101 | "_description": "手機或許會比較難玩,所以推薦使用電腦", 102 | "_h_description": "地雷陣的高度", 103 | "_h_name": "高", 104 | "_n_description": "多少顆地雷", 105 | "_n_name": "數", 106 | "_name": "踩地雷", 107 | "_w_description": "地雷陣的寬度", 108 | "_w_name": "寬", 109 | "mine": "{{h}}x{{w}},**__{{mineCount}}__**顆地雷。\n{{mineField}}" 110 | }, 111 | "moebooru": { 112 | "dimensions": "大小", 113 | "searchResult": "搜尋結果", 114 | "sourceHeader": "來源" 115 | }, 116 | "language": { 117 | "_name": "更改語言", 118 | "_description": "設定機器人發送訊息的語言。", 119 | "_language_name": "語言", 120 | "_language_description": "你想要設置的語言。", 121 | "_default_name": "預設", 122 | "_default_description": "重置為預設", 123 | "_english_name": "$t(language.en-US)", 124 | "_tchinese_name": "$t(language.zh-TW)", 125 | "en-US": "美式英文", 126 | "zh-TW": "正體中文", 127 | "setSuccess": "成功設置語言為{{language}}。", 128 | "resetSuccess": "成功重設語言。", 129 | "current": "目前的語言:{{language}} {{override}}", 130 | "notFound": "找不到你的資料。也許你在忽略名單上?" 131 | }, 132 | "ping": { 133 | "_description": "敲敲門", 134 | "_name": "測試" 135 | }, 136 | "pixiv": { 137 | "descriptionHeader": "說明:", 138 | "descriptionPlaceholder": "(無)", 139 | "imageNotFound": "找不到編號為{{id}}的圖片", 140 | "sauceContent": "[ID: {{illust_id}}](https://www.pixiv.net/en/artworks/{{illust_id}})\t[作者: {{author}}](https://www.pixiv.net/en/users/{{author_id}})", 141 | "sauceHeader": "圖源:", 142 | "titlePlaceholder": "Pixiv圖片" 143 | }, 144 | "roll": { 145 | "_description": "預設爲0-100", 146 | "_lower_description": "下限", 147 | "_lower_name": "下限", 148 | "_name": "隨機數字", 149 | "_upper_description": "上限", 150 | "_upper_name": "上限", 151 | "invalidRange": "無效範圍 {{lower}} - {{upper}}!", 152 | "roll": "`{{points}}` 點。" 153 | }, 154 | "sauce": { 155 | "_name": "抓圖源", 156 | "another": "另一個圖源?", 157 | "confidenceLevel": "信心值:{{similarity}}%", 158 | "error": "發生錯誤:{{error}}", 159 | "invalidUrl": "找不到可用圖片!", 160 | "name": "抓圖源", 161 | "noSauce": "找不到圖源!", 162 | "postNotFound": "找不到{{url}}的圖源!", 163 | "unknown": "不懂這是蝦米碗糕...\n```json\n{{json}}```", 164 | "whichImage": "想搜尋哪一張圖片?" 165 | }, 166 | "scam": { 167 | "ALL_PLATFORMS": "所有平台", 168 | "ANDROID": "安卓", 169 | "ANY_PLATFORM": "任何平台", 170 | "CHROME": "Chrome", 171 | "EXECUTABLE": "可執行檔", 172 | "IOS": "iOS", 173 | "LINUX": "Linux", 174 | "MALWARE": "有害軟體", 175 | "OSX": "OSX", 176 | "PLATFORM_TYPE_UNSPECIFIED": "未指定", 177 | "POTENTIALLY_HARMFUL_APPLICATION": "可能有害的應用程式", 178 | "SOCIAL_ENGINEERING": "社交工程", 179 | "THREAT_ENTRY_TYPE_UNSPECIFIED": "未指定", 180 | "THREAT_TYPE_UNSPECIFIED": "未指定", 181 | "UNWANTED_SOFTWARE": "不需要的軟體", 182 | "URL": "網址", 183 | "WINDOWS": "Windows", 184 | "scam": "🚨偵測到潛在有害鏈接 `{{link}}` !\n風險類別:`{{threatType}}`\n影響平台:`{{platformType}}`" 185 | }, 186 | "slap": { 187 | "_description": "賞他一巴掌!", 188 | "_name": "扇", 189 | "_tool_description": "用什麼扇他", 190 | "_tool_name": "工具", 191 | "_victim_description": "要扇誰", 192 | "_victim_name": "受害者", 193 | "slap": "{{slapper}} 使用 {{tool}} 攻擊 {{victim}},造成了 {{damage}} 點傷害。" 194 | }, 195 | "twitter": { 196 | "originalTweetButton": "原始推文" 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/modules/twitter.ts: -------------------------------------------------------------------------------- 1 | import { num2str } from "@app/utils"; 2 | import type { LAPIEmbed } from "@localizer/data/APIEmbed"; 3 | import type { LActionRowData } from "@localizer/data/ActionRowData"; 4 | import type { StealthModule } from "@type/StealthModule"; 5 | import { ZAPIFXTwitter } from "@type/api/FXTwitter"; 6 | import { find } from "linkifyjs"; 7 | import { Logger } from "tslog"; 8 | 9 | const logger = new Logger({ 10 | name: "twitter", 11 | minLevel: Bun.env.NODE_ENV === "production" ? 3 : 0 12 | }); 13 | 14 | export const fetchTweet = async (url: URL) => { 15 | const sublogger = logger.getSubLogger({ 16 | name: "fetchTweet" 17 | }); 18 | 19 | let response: string | null = null; 20 | 21 | try { 22 | const [, author, , statusId] = url.pathname.split("/"); 23 | 24 | response = await (await fetch(`https://api.fxtwitter.com/${author}/status/${statusId}`)).text(); 25 | 26 | const json = JSON.parse(response); 27 | sublogger.trace(json); 28 | 29 | if (ZAPIFXTwitter.check(json, false)) { 30 | return json.tweet; 31 | } 32 | } catch (e) { 33 | logger.error(e); 34 | sublogger.trace(response); 35 | 36 | return null; 37 | } 38 | }; 39 | 40 | export const twitter: StealthModule = { 41 | name: "twitter", 42 | event: "messageCreate", 43 | action: async (obj) => { 44 | const url = find(obj.message.content) 45 | .filter(result => result.type === "url") 46 | .map(result => new URL(result.href)) 47 | .filter(url => 48 | [ 49 | "vxtwitter.com", 50 | "fixvx.com", 51 | "fxtwitter.com", 52 | "fixupx.com", 53 | "twittpr.com", 54 | "twitter.com", 55 | "x.com" 56 | ].some(domain => url.hostname.endsWith(domain)) 57 | )[0]; 58 | 59 | if (!url) { 60 | return false; 61 | } 62 | 63 | logger.debug("Found twitter link", url.href); 64 | 65 | const vanilla = /^(www\.)?(twitter|x)\.com$/.test(url.hostname); 66 | 67 | // Fetch response in background 68 | const [hasImageEmbed, json] = await Promise.all([ 69 | new Promise(async (resolve) => { 70 | // Wait for embed to populate 71 | await Bun.sleep(2000); 72 | 73 | // Fetch newest version message (Reload embeds) 74 | obj.message = await obj.message.channel.messages.fetch(obj.message.id); 75 | 76 | resolve(obj.message.embeds.some((embed) => (embed.image?.width ?? 0) > 0 && (embed.image?.height ?? 0) > 0)); 77 | }), 78 | fetchTweet(url) 79 | ]); 80 | 81 | logger.debug("hasImageEmbed", hasImageEmbed); 82 | 83 | if (!json) { 84 | logger.debug("Failed to get tweet data"); 85 | return false; 86 | } 87 | 88 | const images = json.media?.photos ?? []; 89 | const videos = json.media?.videos ?? []; 90 | 91 | logger.debug("images", images.length); 92 | logger.debug("videos", videos.length); 93 | 94 | const imageUrls = [ 95 | ...images.map((image) => image.url), 96 | ...videos.map((video) => video.thumbnail_url) 97 | ]; 98 | 99 | if (vanilla) { 100 | if (!json.quote) { 101 | if (hasImageEmbed && images.length === 1) { 102 | logger.debug("Rejected: Vanilla twitter with only 1 image"); 103 | return false; 104 | } 105 | if (images.length === 0) { 106 | logger.debug("Rejected: Vanilla twitter with no images and quotes"); 107 | return false; 108 | } 109 | } 110 | } 111 | 112 | if (images.length < 2 && !vanilla) { 113 | logger.debug("Rejected: Fixup sites with less than 2 images"); 114 | return false; 115 | } 116 | 117 | if (json.possibly_sensitive && "nsfw" in obj.message.channel && !obj.message.channel.nsfw) { 118 | logger.debug("Rejected: NSFW tweet in SFW channel"); 119 | return false; 120 | } 121 | 122 | const components = [ 123 | { 124 | type: "ActionRow", 125 | components: [ 126 | { 127 | type: "Button", 128 | label: { 129 | key: "twitter.originalTweetButton" 130 | }, 131 | style: "Link", 132 | url: json.url 133 | } 134 | ] 135 | } 136 | ] as LActionRowData[]; 137 | 138 | // Workaround for showing videos 139 | if ( 140 | json.media?.all ? 141 | json.media?.all[0]?.type === "video" 142 | : videos.length > 0 143 | ) { 144 | if (vanilla) { 145 | // Vanilla twitter can't display videos 146 | const result = { 147 | content: json.url.replace("twitter.com", "fxtwitter.com").replace("x.com", "fixupx.com"), 148 | components 149 | }; 150 | 151 | logger.debug("Accepted (Video)"); 152 | logger.trace(result); 153 | 154 | return { 155 | type: "reply", 156 | result: result 157 | }; 158 | } else { 159 | logger.debug("Rejected: Fixup sites with videos"); 160 | return false; 161 | } 162 | } 163 | 164 | let embeds: LAPIEmbed[] = [ 165 | { 166 | author: { 167 | name: `${json.author.name} (@${json.author.screen_name})`, 168 | iconURL: json.author.avatar_url, 169 | url: json.author.url 170 | }, 171 | color: 0x1DA1F2, 172 | description: json.text + (json.quote ? `\n>>> **${json.quote.author.name} (@${json.quote.author.screen_name})**\n${json.quote.text}` : ""), 173 | footer: { 174 | text: `❤️ ${num2str(json.likes)} 🔁 ${num2str(json.retweets)} 🗨️ ${num2str(json.replies)}${json.views ? " 👀 " + num2str(json.views) : ""}`, 175 | iconURL: "https://cdn-icons-png.flaticon.com/512/179/179342.png" 176 | }, 177 | timestamp: new Date(json.created_timestamp * 1000).toISOString(), 178 | url: json.url 179 | } 180 | ]; 181 | 182 | if (imageUrls.length > 0) { 183 | embeds[0].image = { 184 | url: imageUrls[0] 185 | }; 186 | 187 | embeds = embeds.concat(imageUrls.splice(1).map((imageUrl) => ({ 188 | image: { 189 | url: imageUrl 190 | }, 191 | url: json.url 192 | }))); 193 | } 194 | 195 | if (json.quote) { 196 | if (json.quote.media?.photos) { 197 | embeds[0].thumbnail = { 198 | url: json.quote.media.photos[0].url 199 | }; 200 | } else if (json.quote.media?.videos) { 201 | embeds[0].thumbnail = { 202 | url: json.quote.media.videos[0].thumbnail_url 203 | }; 204 | } 205 | } 206 | 207 | try { 208 | await obj.message.suppressEmbeds(); 209 | } catch (e) { 210 | logger.error("Failed to suppress embeds", e); 211 | // TODO: Remind user to enable Manage Messages permission 212 | } 213 | 214 | logger.debug("Accepted"); 215 | logger.trace({ 216 | embeds, 217 | components 218 | }); 219 | 220 | return { 221 | type: "reply", 222 | result: { 223 | embeds, 224 | components 225 | } 226 | }; 227 | } 228 | }; -------------------------------------------------------------------------------- /res/i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "ask": { 3 | "_description": "Ask the mysterious 8 ball.", 4 | "_name": "ask", 5 | "_question_description": "The question you want to ask", 6 | "_question_name": "question", 7 | "answer0": "It is certain.", 8 | "answer1": "It is decidedly so.", 9 | "answer10": "Reply hazy, try again.", 10 | "answer11": "Ask again later.", 11 | "answer12": "Better not tell you now.", 12 | "answer13": "Cannot predict now.", 13 | "answer14": "Concentrate and ask again.", 14 | "answer15": "Don't count on it.", 15 | "answer16": "My reply is no.", 16 | "answer17": "My sources say no.", 17 | "answer18": "Outlook not so good.", 18 | "answer19": "Very doubtful.", 19 | "answer2": "Without a doubt.", 20 | "answer3": "Yes - definitely.", 21 | "answer4": "You may rely on it.", 22 | "answer5": "As I see it, yes.", 23 | "answer6": "Most likely.", 24 | "answer7": "Outlook good.", 25 | "answer8": "Yes.", 26 | "answer9": "Signs point to yes." 27 | }, 28 | "avatar": { 29 | "_name": "User Images", 30 | "user": "User", 31 | "userAvatar": "User avatar", 32 | "guildAvatar": "Guild avatar", 33 | "banner": "Banner", 34 | "link": "✅ [Link]({{link}})", 35 | "none": "❌ None" 36 | }, 37 | "choice": { 38 | "_choices_description": "Separated by spaces", 39 | "_choices_name": "choices", 40 | "_description": "Curbs your decidophobia", 41 | "_name": "choice", 42 | "notEnoughChoices": "Not enough choices!", 43 | "result": "Result: {{result}}" 44 | }, 45 | "currency": { 46 | "_EUR_name": "Euro", 47 | "_HKD_name": "Hong Kong Dollar", 48 | "_JPY_name": "Japanese Yen", 49 | "_TWD_name": "Taiwan Dollar", 50 | "_USD_name": "US Dollar", 51 | "_amount_description": "How much", 52 | "_amount_name": "amount", 53 | "_description": "Spot currency conversion", 54 | "_name": "currency", 55 | "_source_description": "From what currency", 56 | "_source_name": "source", 57 | "_target_description": "To what currency, defaults to all", 58 | "_target_name": "target" 59 | }, 60 | "delete": { 61 | "_name": "Delete bot message", 62 | "deleted": "Message deleted.", 63 | "deletingOthersMessage": "You can only delete messages authored by you.", 64 | "notMyMessage": "Cannot delete messages from other users!", 65 | "noPermission": "I don't have permission to delete this message!" 66 | }, 67 | "dice": { 68 | "_description": "Roll a dice.", 69 | "_faces_description": "How many face are there of a dice", 70 | "_faces_name": "faces", 71 | "_n_description": "Number of dices", 72 | "_n_name": "n", 73 | "_name": "dice", 74 | "_offset_description": "Offset", 75 | "_offset_name": "offset", 76 | "offset": "with `{{offset}}` $t(dice.offsetDescription)", 77 | "roll": "{{n}}d{{faces}}{{offset}} = {{result}}" 78 | }, 79 | "forgetme": { 80 | "_description": "Forget your data", 81 | "_name": "forgetme", 82 | "success": "Your data has been deleted.", 83 | "failure": "You don't have any data stored." 84 | }, 85 | "ignoreme": { 86 | "_description": "Control the visibility of your messages to the bot.", 87 | "_name": "ignoreme", 88 | "ignoreWarning": "You will not be able to use any auto functions, including: \n```\n - Pixiv/Twitter embed generation\n - Scam link detection\n```\nYou can undo this change by using this command again.\nAre you sure about this? (Dismiss this message to cancel.)", 89 | "noticeWarning": "You will be able to use auto functions again.\nAre you sure about this? (Dismiss this message to cancel.)", 90 | "success": "Operation successful.", 91 | "failure": "Operation failed." 92 | }, 93 | "index": { 94 | "delete": "Delete", 95 | "error": "Error has been reported to bot author. Please wait for a fix!" 96 | }, 97 | "invite": { 98 | "_description": "Get the invite link for the bot.", 99 | "_name": "invite" 100 | }, 101 | "mine": { 102 | "_description": "Minesweeper game.", 103 | "_h_description": "Height of the board", 104 | "_h_name": "h", 105 | "_n_description": "Number of mines", 106 | "_n_name": "n", 107 | "_name": "mine", 108 | "_w_description": "Width of the board", 109 | "_w_name": "w", 110 | "mine": "There are **__{{mineCount}}__** mines in this {{h}}x{{w}} field. \n{{mineField}}" 111 | }, 112 | "moebooru": { 113 | "dimensions": "Dimensions", 114 | "searchResult": "Search Result", 115 | "sourceHeader": "Source" 116 | }, 117 | "language": { 118 | "_name": "language", 119 | "_description": "Set the language of the message sent from the bot for your messages.", 120 | "_language_name": "language", 121 | "_language_description": "The language you want to set.", 122 | "_default_name": "Default", 123 | "_default_description": "Reset to default", 124 | "_english_name": "$t(language.en-US)", 125 | "_tchinese_name": "$t(language.zh-TW)", 126 | "en-US": "English (US)", 127 | "zh-TW": "Traditional Chinese", 128 | "setSuccess": "Successfully set language to {{language}}.", 129 | "resetSuccess": "Successfully reset language to default.", 130 | "current": "Current language: {{language}} {{override}}", 131 | "notFound": "I can't find you in my database. Perhaps you are on the ignore list?" 132 | }, 133 | "ping": { 134 | "_description": "Ping the bot.", 135 | "_name": "ping" 136 | }, 137 | "pixiv": { 138 | "descriptionHeader": "Description:", 139 | "descriptionPlaceholder": "(None)", 140 | "imageNotFound": "No image found for id {{id}}", 141 | "sauceContent": "[ID: {{illust_id}}](https://www.pixiv.net/en/artworks/{{illust_id}})\t[Author: {{author}}](https://www.pixiv.net/en/users/{{author_id}})", 142 | "sauceHeader": "Sauce:", 143 | "titlePlaceholder": "Pixiv illustration" 144 | }, 145 | "roll": { 146 | "_description": "Defaults to 0-100", 147 | "_lower_description": "The minimum number", 148 | "_lower_name": "lower", 149 | "_name": "roll", 150 | "_upper_description": "The maximum number", 151 | "_upper_name": "upper", 152 | "invalidRange": "Invalid number range {{lower}} - {{upper}}!", 153 | "roll": "`{{points}}` points." 154 | }, 155 | "sauce": { 156 | "_name": "What is the sauce?", 157 | "another": "Another sauce?", 158 | "confidenceLevel": "Confidence level: {{similarity}}%", 159 | "error": "Error: {{error}}", 160 | "invalidUrl": "No usable image found!", 161 | "name": "Get sauce", 162 | "noSauce": "No sauce found!", 163 | "postNotFound": "The related post {{url}} is not found!", 164 | "unknown": "The sauce is unfamiliar to me... Here are some of the ingredients.\n```json\n{{json}}```", 165 | "whichImage": "Which image do you want to check?" 166 | }, 167 | "scam": { 168 | "ALL_PLATFORMS": "Threat posed to all defined platforms.", 169 | "ANDROID": "Threat posed to Android.", 170 | "ANY_PLATFORM": "Threat posed to at least one of the defined platforms.", 171 | "CHROME": "Threat posed to Chrome.", 172 | "EXECUTABLE": "An executable program.", 173 | "IOS": "Threat posed to iOS.", 174 | "LINUX": "Threat posed to Linux.", 175 | "MALWARE": "Malware threat type.", 176 | "OSX": "Threat posed to OS X.", 177 | "PLATFORM_TYPE_UNSPECIFIED": "Unknown platform.", 178 | "POTENTIALLY_HARMFUL_APPLICATION": "Potentially harmful application threat type.", 179 | "SOCIAL_ENGINEERING": "Social engineering threat type.", 180 | "THREAT_ENTRY_TYPE_UNSPECIFIED": "Unspecified.", 181 | "THREAT_TYPE_UNSPECIFIED": "Unknown.", 182 | "UNWANTED_SOFTWARE": "Unwanted software threat type.", 183 | "URL": "A URL.", 184 | "WINDOWS": "Threat posed to Windows.", 185 | "scam": "🚨Possibly hazardous link `{{link}}` found! \nThreat type: `$t(scam.{{threatType}})`\nPlatform type: `$t(scam.{{platformType}})`\nEntry type: `$t(scam.{{entryType}})`" 186 | }, 187 | "slap": { 188 | "_description": "Slap the user!", 189 | "_name": "slap", 190 | "_tool_description": "What to use to slap.", 191 | "_tool_name": "tool", 192 | "_victim_description": "The target you want to slap.", 193 | "_victim_name": "victim", 194 | "slap": "{{slapper}} used {{tool}} to attack {{victim}}, dealing {{damage}} points." 195 | }, 196 | "twitter": { 197 | "originalTweetButton": "Original tweet" 198 | } 199 | } -------------------------------------------------------------------------------- /src/commands/tool/sauce.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@app/Reporting"; 2 | import { MessageContextMenuCommand } from "@class/ApplicationCommand"; 3 | import type { LInteractionReplyOptions } from "@localizer/InteractionReplyOptions"; 4 | import type { LAPIEmbed } from "@localizer/data/APIEmbed"; 5 | import type { ApiPortal} from "@module/moebooru"; 6 | import { fetchList, genEmbed as genMoebooruEmbed } from "@module/moebooru"; 7 | import type { APISaucenao } from "@type/api/Saucenao"; 8 | import { ZAPISaucenaoBase } from "@type/api/saucenao/Base"; 9 | import { ZAPISaucenaoEHentai } from "@type/api/saucenao/EHentai"; 10 | import { ZAPISaucenaoMoebooru } from "@type/api/saucenao/Moebooru"; 11 | import { ZAPISaucenaoPixiv } from "@type/api/saucenao/Pixiv"; 12 | import { ZAPISaucenaoTwitter } from "@type/api/saucenao/Twitter"; 13 | import type { Message, MessageContextMenuCommandInteraction, StringSelectMenuInteraction } from "discord.js"; 14 | import { IllustMessageFactory } from "../../modules/pixiv"; 15 | import { findImagesFromMessage } from "../_lib"; 16 | import type { LBaseMessageOptions } from "@localizer/MessageOptions"; 17 | 18 | type Results = APISaucenao["results"]; 19 | 20 | const turn2thumbnail = (embed: LAPIEmbed) => { 21 | embed.thumbnail = embed.image; 22 | delete embed.image; 23 | 24 | return embed; 25 | }; 26 | 27 | const genPixivEmbed = async (pixiv_id: number | string, nsfw: boolean) => { 28 | const illustMessageFactory = new IllustMessageFactory(pixiv_id); 29 | await illustMessageFactory.getDetail(); 30 | if (illustMessageFactory.getType() === "illust") { 31 | const message = await illustMessageFactory.toMessage(nsfw); 32 | if (message !== null) { 33 | if (Array.isArray(message.embeds) && "image" in message.embeds[0]) { 34 | return turn2thumbnail(message.embeds[0]); 35 | } 36 | } 37 | } 38 | 39 | return null; 40 | }; 41 | 42 | const genEmbed = async (result: Results["0"], nsfw: boolean): Promise => { 43 | if (ZAPISaucenaoPixiv.check(result.data)) { 44 | const pixiv_id = result.data.pixiv_id ?? null; 45 | if (pixiv_id !== null) { 46 | const embed = await genPixivEmbed(pixiv_id, nsfw); 47 | if (embed !== null) { 48 | return embed; 49 | } 50 | } 51 | } else if (ZAPISaucenaoMoebooru.check(result.data)) { 52 | let provider: keyof typeof ApiPortal | null = null; 53 | let id: number | null = null; 54 | 55 | if ("yandere_id" in result.data) { 56 | provider = "yan"; 57 | id = result.data.yandere_id ?? null; 58 | } else if ("konachan_id" in result.data) { 59 | provider = "kon"; 60 | id = result.data.konachan_id ?? null; 61 | } else if ("danbooru_id" in result.data) { 62 | provider = "dan"; 63 | id = result.data.danbooru_id ?? null; 64 | } else if ("gelbooru_id" in result.data) { 65 | provider = "dan"; 66 | id = result.data.gelbooru_id ?? null; 67 | } else if ("sankaku_id" in result.data) { 68 | provider = "san"; 69 | id = result.data.sankaku_id ?? null; 70 | } 71 | 72 | if (provider !== null && id !== null) { 73 | const imageObjects = await fetchList(provider, [`id:${id}`], nsfw); 74 | if (imageObjects.length > 0) { 75 | const imageObject = imageObjects[0]; 76 | const matches = imageObject.source?.match(/illust_id=(\d{2,})|\d{4}\/\d{2}\/\d{2}\/\d{2}\/\d{2}\/\d{2}\/(\d{2,})_p|artworks\/(\d{2,})|img\/.*?\/(\d{2,})\./); 77 | 78 | if (matches) { 79 | const pixiv_id = matches.filter(m => m)[1]; 80 | 81 | if (pixiv_id) { 82 | const embed = await genPixivEmbed(pixiv_id, nsfw); 83 | if (embed !== null) { 84 | return embed; 85 | } 86 | } 87 | } 88 | 89 | // If fetching the original source fails, fallback 90 | return turn2thumbnail(genMoebooruEmbed(provider, imageObjects[0], true, nsfw)); 91 | } 92 | } 93 | } else if (ZAPISaucenaoEHentai.check(result.data)) { 94 | // TODO: Fetch gallery info 95 | return { 96 | title: "E-Hentai", 97 | description: [result.data.jp_name, result.data.eng_name].join("\n"), 98 | footer: { 99 | text: result.data.creator.join(", ") 100 | } 101 | } 102 | } else if (ZAPISaucenaoTwitter.check(result.data)) { 103 | return { 104 | title: "Twitter", 105 | description: result.data.ext_urls[0] 106 | }; 107 | } 108 | 109 | // These types are not supported, but we try to give useful info 110 | 111 | if (ZAPISaucenaoBase.check(result.data)) { 112 | return { 113 | title: "Unknown sauce", 114 | description: result.data.ext_urls.join("\n"), 115 | footer: { 116 | text: result.header.index_name 117 | } 118 | }; 119 | } 120 | 121 | return { 122 | title: "Unknown sauce", 123 | description: JSON.stringify(result.data, null, 2), 124 | footer: { 125 | text: result.header.index_name 126 | } 127 | }; 128 | }; 129 | 130 | // Some sauces are juicier than others... 131 | const PREFERENCE = [ 132 | 5, // Pixiv 133 | 8, // Nico Nico Seiga 134 | 41 // Twitter 135 | ]; 136 | 137 | const query = async (id: string, url: string, nsfw: boolean): Promise => { 138 | try { 139 | const res: APISaucenao = await fetch(`https://saucenao.com/search.php?api_key=${Bun.env.saucenao_key}&db=999&output_type=2&numres=10&url=${encodeURIComponent(url)}`) 140 | .then(res => res.json()); 141 | 142 | if (res.results === null) { 143 | return { 144 | content: { 145 | key: "sauce.noSauce" 146 | } 147 | }; 148 | } 149 | 150 | const results = res.results.sort((r1, r2) => { 151 | // For high similarities, sort by preference 152 | if (parseFloat(r1.header.similarity) > 90 && parseFloat(r2.header.similarity) > 90) { 153 | let i1 = PREFERENCE.indexOf(r1.header.index_id); 154 | let i2 = PREFERENCE.indexOf(r2.header.index_id); 155 | 156 | if (i1 === -1) { 157 | i1 = PREFERENCE.length; 158 | } 159 | 160 | if (i2 === -1) { 161 | i2 = PREFERENCE.length; 162 | } 163 | 164 | if (i1 !== i2) { 165 | return i1 - i2; 166 | } 167 | } 168 | 169 | // Then sort by similarity 170 | return parseFloat(r2.header.similarity) - parseFloat(r1.header.similarity); 171 | }); 172 | 173 | interactionResults[id] = results; 174 | 175 | for (const result of results) { 176 | const embed = await genEmbed(result, nsfw); 177 | const similarity = parseFloat(result.header.similarity); 178 | 179 | if (embed) { 180 | return { 181 | content: { 182 | key: "sauce.confidenceLevel", 183 | data: { similarity } 184 | }, 185 | embeds: [embed], 186 | components: [ 187 | { 188 | type: "ActionRow", 189 | components: [ 190 | { 191 | customId: "checkOtherSauces", 192 | type: "StringSelect", 193 | placeholder: { 194 | key: "sauce.another" 195 | }, 196 | options: results.map((result, i) => { 197 | const _similarity = parseFloat(result.header.similarity); 198 | return { 199 | emoji: _similarity > 90 ? "🟢" : (_similarity > 60 ? "🟡" : "🔴"), 200 | label: `(${result.header.similarity}%) - ${result.header.index_name}`.substring(0, 90), 201 | value: `${id}_${i}` 202 | } 203 | }) 204 | } 205 | ] 206 | } 207 | ] 208 | }; 209 | } 210 | } 211 | throw "Unknown"; 212 | } catch (e) { 213 | error("sauce", e); 214 | return { 215 | content: { 216 | key: "sauce.noSauce" 217 | } 218 | }; 219 | } 220 | }; 221 | 222 | const interactionResults: Record = {}; 223 | 224 | class Command extends MessageContextMenuCommand { 225 | public async onContextMenu(interaction: MessageContextMenuCommandInteraction): Promise { 226 | const message = interaction.targetMessage; 227 | const nsfw = interaction.channel ? ("nsfw" in interaction.channel ? interaction.channel.nsfw : false) : false; 228 | 229 | // Attachment shows first, then embeds. 230 | const urls = findImagesFromMessage(message); 231 | let url; 232 | 233 | if (!urls.length) { 234 | return { 235 | content: { 236 | key: "sauce.invalidUrl" 237 | } 238 | }; 239 | } else if (urls.length > 1) { 240 | return { 241 | content: { 242 | key: "sauce.whichImage" 243 | }, 244 | components: [ 245 | { 246 | type: "ActionRow", 247 | components: [ 248 | { 249 | customId: "pickURL", 250 | type: "StringSelect", 251 | options: urls.map((url, i) => ({ 252 | label: `${i + 1}. ${url}`, 253 | value: url 254 | })) 255 | } 256 | ] 257 | } 258 | ] 259 | } 260 | } else { 261 | url = urls[0]; 262 | } 263 | 264 | return await query(interaction.id, url, nsfw); 265 | } 266 | 267 | public async onSelectMenu(interaction: StringSelectMenuInteraction): Promise { 268 | const nsfw = interaction.channel ? ("nsfw" in interaction.channel ? interaction.channel.nsfw : false) : false; 269 | 270 | switch (interaction.customId) { 271 | case "pickURL": 272 | const url = interaction.values[0]; 273 | return await query(interaction.id, url, nsfw); 274 | case "checkOtherSauces": 275 | const [id, i] = interaction.values[0].split("_"); 276 | const results = interactionResults[id]; 277 | const result = results[parseInt(i)]; 278 | const embed = await genEmbed(result, nsfw); 279 | 280 | if (embed) { 281 | return { 282 | content: { 283 | key: "sauce.confidenceLevel", 284 | data: { 285 | similarity: parseFloat(result.header.similarity) 286 | } 287 | }, 288 | embeds: [embed], 289 | components: [ 290 | { 291 | type: "ActionRow", 292 | components: [ 293 | { 294 | customId: "checkOtherSauces", 295 | type: "StringSelect", 296 | placeholder: { 297 | key: "sauce.another" 298 | }, 299 | options: results.map((result, _i) => { 300 | const _similarity = parseFloat(result.header.similarity); 301 | return { 302 | emoji: _similarity > 90 ? "🟢" : (_similarity > 60 ? "🟡" : "🔴"), 303 | label: `(${result.header.similarity}%) - ${result.header.index_name}`.substring(0, 90), 304 | value: `${id}_${_i}`, 305 | default: i === `${_i}` 306 | } 307 | }) 308 | } 309 | ] 310 | } 311 | ] 312 | }; 313 | } 314 | break; 315 | } 316 | 317 | return { 318 | content: "UwU" 319 | }; 320 | } 321 | 322 | public async onTimeout(message: Message): Promise { 323 | // Remove string select 324 | const { content, embeds } = message; 325 | return { 326 | content, 327 | // TODO: Fix this ugly thing 328 | embeds: embeds.map(embed => embed.toJSON()), 329 | components: [] 330 | }; 331 | } 332 | } 333 | 334 | export const sauce = new Command({ 335 | name: "sauce", 336 | defer: true 337 | }); -------------------------------------------------------------------------------- /src/modules/pixiv.ts: -------------------------------------------------------------------------------- 1 | import type { Frames, PixivIllustItem } from "@book000/pixivts"; 2 | import { Pixiv } from "@book000/pixivts"; 3 | import type { LBaseMessageOptions } from "@localizer/MessageOptions"; 4 | import type { LAPIEmbed } from "@localizer/data/APIEmbed"; 5 | import { PrismaClient } from "@prisma/client"; 6 | import type { StealthModule } from "@type/StealthModule"; 7 | import { ZAPIImgur } from "@type/api/Imgur"; 8 | import assert from "assert-ts"; 9 | import type { TextChannel } from "discord.js"; 10 | import { mkdir, rm } from "fs/promises"; 11 | import { htmlToText } from "html-to-text"; 12 | import { Logger } from "tslog"; 13 | 14 | const logger = new Logger({ 15 | name: "pixiv", 16 | minLevel: Bun.env.NODE_ENV === "production" ? 3 : 0 17 | }); 18 | 19 | const client = new PrismaClient(); 20 | let pixivClient = await Pixiv.of(Bun.env.pixiv_refresh_token!); 21 | 22 | const proxy = (url: string) => url.replace("i.pximg.net", "i.yuki.sh"); 23 | 24 | class Illust { 25 | protected sublogger: Logger; 26 | protected item: PixivIllustItem; 27 | 28 | public constructor(item: PixivIllustItem) { 29 | this.sublogger = logger.getSubLogger({ 30 | name: "Illust" 31 | }); 32 | this.item = item; 33 | } 34 | 35 | public async toMessage(allowNSFW: boolean): Promise { 36 | const sublogger = this.sublogger.getSubLogger({ 37 | name: "toMessage" 38 | }); 39 | 40 | try { 41 | const imageUrls = (this.item.page_count === 1 ? 42 | [this.item.meta_single_page.original_image_url!] : 43 | this.item.meta_pages.map((page) => page.image_urls.original)) 44 | .slice(0, 10) // Discord API Limit 45 | .map(proxy); 46 | 47 | sublogger.debug("imageUrls", imageUrls); 48 | 49 | let embeds: LAPIEmbed[]; 50 | 51 | // Try to hint the CDN to cache our files 52 | for (const imageUrl of [proxy(this.item.user.profile_image_urls.medium), ...imageUrls]) { 53 | await fetch(imageUrl, { 54 | method: "HEAD" 55 | }); 56 | } 57 | 58 | embeds = imageUrls.map((url) => ({ 59 | image: { 60 | url 61 | }, 62 | url: `https://www.pixiv.net/artworks/${this.item.id}` 63 | })); 64 | 65 | if (!allowNSFW && this.item.x_restrict > 0) { 66 | embeds = [embeds[0]]; 67 | delete embeds[0].image; 68 | embeds[0].thumbnail = { 69 | url: "https://images.emojiterra.com/twitter/v14.0/128px/1f51e.png" 70 | }; 71 | } 72 | 73 | // Add metadata to the first 74 | const prefix = this.item.illust_ai_type > 1 ? "🤖 |" : ""; 75 | const suffix = this.item.page_count > 1 ? `(${this.item.page_count})` : ""; 76 | 77 | embeds[0] = { 78 | ...embeds[0], 79 | ...{ 80 | author: { 81 | name: this.item.title ? `${prefix} ${this.item.title} ${suffix}` : { 82 | key: `${prefix} $t(pixiv.titlePlaceholder) ${suffix}` 83 | }, 84 | iconURL: proxy(this.item.user.profile_image_urls.medium), 85 | url: `https://www.pixiv.net/artworks/${this.item.id}` 86 | }, 87 | color: this.item.x_restrict > 0 ? 0xd37a52 : 0x3D92F5, 88 | footer: { 89 | text: `❤️ ${this.item.total_bookmarks} | 👁️ ${this.item.total_view} | 🗨️ ${this.item.total_comments}`, 90 | iconURL: "https://s.pximg.net/www/images/pixiv_logo.gif" 91 | }, 92 | timestamp: new Date(this.item.create_date).toISOString(), 93 | fields: [{ 94 | name: { 95 | key: "pixiv.sauceHeader" 96 | }, 97 | value: { 98 | key: "pixiv.sauceContent", 99 | data: { 100 | illust_id: this.item.id, 101 | author: this.item.user.name, 102 | author_id: this.item.user.id 103 | } 104 | } 105 | }, { 106 | name: { 107 | key: "pixiv.descriptionHeader" 108 | }, 109 | value: htmlToText(this.item.caption, { 110 | limits: { 111 | maxInputLength: 1500 112 | }, 113 | tags: { "a": { format: "anchor", options: { ignoreHref: true } } } 114 | }).substring(0, 1020) || { 115 | key: "pixiv.descriptionPlaceholder" 116 | } 117 | }], 118 | url: `https://www.pixiv.net/artworks/${this.item.id}` 119 | } 120 | }; 121 | 122 | return { embeds }; 123 | } catch (e) { 124 | sublogger.error(e); 125 | return null; 126 | } 127 | } 128 | }; 129 | 130 | class Ugoira extends Illust { 131 | private zipUrl: string; 132 | private frames: Frames[]; 133 | 134 | public constructor(item: PixivIllustItem) { 135 | super(item); 136 | 137 | this.sublogger = logger.getSubLogger({ 138 | name: "Ugoira" 139 | }); 140 | this.zipUrl = ""; 141 | this.frames = []; 142 | } 143 | 144 | public async getMeta(): Promise { 145 | const sublogger = this.sublogger.getSubLogger({ 146 | name: "getMeta" 147 | }); 148 | 149 | try { 150 | const res = await pixivClient.ugoiraMetadata({ 151 | illustId: this.item.id 152 | }); 153 | 154 | sublogger.debug(res.data); 155 | 156 | this.zipUrl = res.data.ugoira_metadata.zip_urls.medium.replace("_ugoira600x600", "_ugoira1920x1080"); 157 | this.frames = res.data.ugoira_metadata.frames; 158 | 159 | return true; 160 | } catch (e) { 161 | sublogger.error(e); 162 | return false; 163 | } 164 | } 165 | 166 | public async toMessage(allowNSFW: boolean): Promise { 167 | const sublogger = this.sublogger.getSubLogger({ 168 | name: "toMessage" 169 | }); 170 | 171 | try { 172 | if (this.item.restrict && !allowNSFW) { 173 | return null; 174 | } 175 | 176 | // Check for cache 177 | const cache = await client.pixivCache.findFirst({ 178 | where: { 179 | id: this.item.id.toString() 180 | } 181 | }); 182 | 183 | if (cache) { 184 | // Validate cache 185 | const imgurRes = await fetch(`https://api.imgur.com/3/image/${cache.hash}`, { 186 | headers: { 187 | "Authorization": `Client-ID ${Bun.env.imgur_id}` 188 | } 189 | }); 190 | 191 | const imgurResJson = await imgurRes.json(); 192 | if (ZAPIImgur.check(imgurResJson)) { 193 | return { 194 | content: `https://imgur.com/${cache.hash}` 195 | }; 196 | } else { 197 | // Delete cache 198 | await client.pixivCache.delete({ 199 | where: { 200 | id: this.item.id.toString() 201 | } 202 | }); 203 | // Fall back to normal 204 | } 205 | } 206 | 207 | const tmpdir = `/tmp/${this.item.id}`; 208 | try { 209 | await rm(tmpdir, { 210 | recursive: true 211 | }); 212 | } catch (e) { } 213 | await mkdir(tmpdir); 214 | 215 | // Download zip to tmpdir 216 | await fetch(this.zipUrl, { 217 | headers: { 218 | "Referer": "https://www.pixiv.net/" 219 | } 220 | }) 221 | .then(x => Bun.write(`${tmpdir}/ugoira.zip`, x)); 222 | 223 | // Extract zip by unzip command 224 | const unzipProc = Bun.spawn(["unzip", "-qq", "-o", `${tmpdir}/ugoira.zip`, "-d", tmpdir]); 225 | await unzipProc.exited; 226 | 227 | // Create frame list for concat 228 | const frameList = this.frames.map(frame => `file '${frame.file}'\nduration ${frame.delay / 1000}`).join("\n"); 229 | await Bun.write(`${tmpdir}/input.txt`, frameList); 230 | 231 | // Convert frames to mp4 232 | const ffmpegProc = Bun.spawn([ 233 | "ffmpeg", 234 | "-f", "concat", 235 | "-safe", "0", 236 | "-i", `${tmpdir}/input.txt`, 237 | "-vsync", "vfr", 238 | "-pix_fmt", "yuv420p", 239 | `${tmpdir}/output.mp4` 240 | ], { 241 | cwd: tmpdir, 242 | stdout: null, 243 | stderr: "pipe" 244 | }); 245 | await ffmpegProc.exited; 246 | assert(ffmpegProc.exitCode === 0, `ffmpeg exited with non-zero code ${ffmpegProc.exitCode}\n${await Bun.readableStreamToText(ffmpegProc.stderr)}`); 247 | 248 | // Upload to imgur 249 | const formData = new FormData(); 250 | formData.append("video", Bun.file(`${tmpdir}/output.mp4`)); 251 | const imgurRes = await fetch("https://api.imgur.com/3/upload", { 252 | method: "POST", 253 | headers: { 254 | "Authorization": `Client-ID ${Bun.env.IMGUR_ID}` 255 | }, 256 | body: formData 257 | }); 258 | 259 | const imgurResJson = await imgurRes.json(); 260 | if (!(ZAPIImgur.check(imgurResJson) && imgurResJson.success)) { 261 | throw new Error("Failed to upload to imgur: " + JSON.stringify(imgurResJson)); 262 | } 263 | 264 | // Delete tmpdir 265 | await rm(tmpdir, { 266 | recursive: true 267 | }); 268 | 269 | // Update database cache 270 | await client.pixivCache.upsert({ 271 | create: { 272 | id: this.item.id.toString(), 273 | type: "u", 274 | hash: imgurResJson.data.id 275 | }, 276 | update: { 277 | type: "u", 278 | hash: imgurResJson.data.id 279 | }, 280 | where: { 281 | id: this.item.id.toString() 282 | } 283 | }); 284 | 285 | return { 286 | content: `https://imgur.com/${imgurResJson.data.id}` 287 | }; 288 | } catch (e) { 289 | sublogger.error(e); 290 | return null; 291 | } 292 | } 293 | }; 294 | 295 | export class IllustMessageFactory { 296 | private sublogger = logger.getSubLogger({ 297 | name: "IllustMessageFactory" 298 | }); 299 | private item: PixivIllustItem | null = null; 300 | 301 | public id: number; 302 | 303 | public constructor(id: number) { 304 | this.id = id; 305 | } 306 | 307 | public async getDetail(): Promise { 308 | const sublogger = this.sublogger.getSubLogger({ 309 | name: "getDetail" 310 | }); 311 | 312 | try { 313 | const res = await pixivClient.illustDetail({ 314 | illustId: this.id 315 | }); 316 | sublogger.debug(res.data); 317 | 318 | this.item = res.data.illust; 319 | return true; 320 | } catch (e) { 321 | sublogger.error(e); 322 | return false; 323 | } 324 | } 325 | 326 | public getType(): string | null { 327 | return this.item?.type ?? null; 328 | } 329 | 330 | public async toMessage(allowNSFW: boolean): Promise { 331 | const sublogger = this.sublogger.getSubLogger({ 332 | name: "toMessage" 333 | }); 334 | 335 | try { 336 | if (!this.item) { 337 | if (!await this.getDetail()) { 338 | return null; 339 | } 340 | } 341 | 342 | let illust: Illust | Ugoira | null = null; 343 | 344 | switch (this.getType()) { 345 | case "illust": 346 | case "manga": 347 | illust = new Illust(this.item!); 348 | break; 349 | case "ugoira": 350 | illust = new Ugoira(this.item!); 351 | if (!await (illust as Ugoira).getMeta()) { 352 | return null; 353 | } 354 | break; 355 | default: 356 | return null; 357 | } 358 | 359 | return illust.toMessage(allowNSFW); 360 | } catch (e) { 361 | sublogger.error(e); 362 | return null; 363 | } 364 | } 365 | } 366 | 367 | const worker = async () => { 368 | const sublogger = logger.getSubLogger({ 369 | name: "worker" 370 | }); 371 | 372 | try { 373 | sublogger.info("Refreshing Pixiv client"); 374 | pixivClient = await Pixiv.of(Bun.env.pixiv_refresh_token!); 375 | sublogger.info("Refreshed Pixiv client"); 376 | } catch (e) { 377 | sublogger.error(e); 378 | return; 379 | } 380 | }; 381 | setInterval(worker, 3000 * 1000); 382 | 383 | export const pixiv: StealthModule = { 384 | name: "pixiv", 385 | event: "messageCreate", 386 | pattern: /(artworks\/|illust_id=)(\d{2,10})/, 387 | action: async (obj) => { 388 | const illustID = obj.matches![2]; 389 | 390 | if (!isNaN(parseInt(illustID))) { 391 | const allowNSFW = (obj.message.channel as TextChannel).nsfw; 392 | const result = await new IllustMessageFactory(+illustID).toMessage(allowNSFW); 393 | 394 | if (result) { 395 | try { 396 | await obj.message.suppressEmbeds(true); 397 | } catch (e) { } 398 | 399 | return { 400 | type: "reply", 401 | result 402 | }; 403 | } 404 | } 405 | return false; 406 | } 407 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { BaseApplicationCommand } from "@class/ApplicationCommand"; 2 | import { LocalizableInteractionReplyOptionsAdapter } from "@localizer/InteractionReplyOptions"; 3 | import assert from "assert-ts"; 4 | import type { ApplicationCommandType, InteractionReplyOptions, Message, MessageEditOptions, TextChannel } from "discord.js"; 5 | import { ActivityType, Client, GatewayIntentBits, Locale, PermissionFlagsBits, PermissionsBitField } from "discord.js"; 6 | import { getName } from "./Localizations"; 7 | import { commands } from "./commands"; 8 | import { modules } from "./modules"; 9 | import { injectPrototype } from "./prototype"; 10 | import { random } from "./utils"; 11 | import { LActionRowDataLocalizer } from "@localizer/data/ActionRowData"; 12 | import { LocalizableBaseMessageOptionsAdapter } from "@localizer/MessageOptions"; 13 | import { PrismaClient } from "@prisma/client"; 14 | import { Logger } from "tslog"; 15 | 16 | const logger = new Logger({ 17 | name: "index", 18 | minLevel: Bun.env.NODE_ENV === "production" ? 3 : 0 19 | }); 20 | 21 | const TIMEOUT = 30 * 1000; 22 | 23 | const client = new Client({ 24 | intents: [ 25 | GatewayIntentBits.Guilds, 26 | GatewayIntentBits.GuildMessages, 27 | GatewayIntentBits.GuildMessageReactions, 28 | GatewayIntentBits.DirectMessages, 29 | GatewayIntentBits.DirectMessageReactions, 30 | GatewayIntentBits.MessageContent 31 | ] 32 | }); 33 | 34 | const prismaClient = new PrismaClient(); 35 | 36 | const fetchUserLocale = async (id: string, override?: boolean): Promise => { 37 | const sublogger = logger.getSubLogger({ 38 | name: "fetchUserLocale" 39 | }); 40 | 41 | try { 42 | const languageItem = await prismaClient.language.findFirst({ 43 | where: { 44 | id, 45 | type: "u", 46 | override 47 | } 48 | }); 49 | 50 | if (languageItem) { 51 | return languageItem.language as Locale; 52 | } 53 | 54 | return Locale.EnglishUS; 55 | } catch (e) { 56 | sublogger.error("fetchUserLocale", e); 57 | return Locale.EnglishUS; 58 | } 59 | }; 60 | 61 | const shouldIgnoreUser = async (id: string) => { 62 | const sublogger = logger.getSubLogger({ 63 | name: "shouldIgnoreUser" 64 | }); 65 | 66 | try { 67 | const ignoreItem = await prismaClient.ignore.findFirst({ 68 | where: { 69 | id, 70 | type: "u" 71 | } 72 | }); 73 | 74 | return ignoreItem !== null; 75 | } catch (e) { 76 | sublogger.error("shouldIgnoreUser", e); 77 | return false; 78 | } 79 | }; 80 | 81 | try { 82 | injectPrototype(); 83 | 84 | client.once("ready", async () => { 85 | // Error reporting 86 | const errorChannel = await client.channels.fetch(Bun.env.error_chid!) as TextChannel; 87 | const handleError = async (tag: string, e: unknown) => { 88 | const errorObj = logger.error(tag, e); 89 | // Encode the content into an attachment 90 | const attachment = Buffer.from(JSON.stringify({ 91 | time: new Date().toISOString(), 92 | errorObj 93 | }, null, 4)); 94 | 95 | await errorChannel.send({ 96 | content: `Error occurred in \`${tag}\``, 97 | files: [{ 98 | name: `${tag}.json`, 99 | attachment 100 | }] 101 | }); 102 | }; 103 | 104 | client.on("error", async (e) => { 105 | await handleError("client->error", e); 106 | }); 107 | 108 | const APICommands = commands.map(command => command.toAPI()); 109 | 110 | if (Bun.env.NODE_ENV === "production") { 111 | logger.info("Setting global commands"); 112 | try { 113 | await client.application!.commands.set(APICommands); 114 | } catch (e) { 115 | handleError("bot.setCommand", e); 116 | } 117 | logger.info("Setting global commands done"); 118 | } else { 119 | await client.application!.commands.set([]); 120 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 121 | for (const [_, guild] of client.guilds.cache) { 122 | try { 123 | await guild.commands.set(APICommands); 124 | logger.debug("bot.setCommand", `Set command for guild ${guild.name} (${guild.id})`); 125 | } catch (e) { 126 | logger.error("bot.setCommand", `Failed to set command for guild ${guild.name} (${guild.id}): ${e}`); 127 | } 128 | } 129 | } 130 | 131 | const createDeleteButton = (locale: Locale) => new LActionRowDataLocalizer({ 132 | type: "ActionRow", 133 | components: [{ 134 | customId: "delete", 135 | type: "Button", 136 | style: "Danger", 137 | emoji: { 138 | name: random(0, 10) === 0 ? "🚮" : "🗑️" 139 | } 140 | }] 141 | }).localize(locale); 142 | 143 | // A mapping from the source message's author id that triggered stealth modules to the replied message 144 | const sources: Record = {}; 145 | 146 | // Linked list storing all interaction ids and it's parent, null if it's the root 147 | const timeouts: Record = {}; 148 | 149 | client.on("interactionCreate", async (interaction) => { 150 | if (!interaction.isRepliable()) { 151 | return; 152 | } 153 | 154 | const locale = (await fetchUserLocale(interaction.user.id, true)) ?? interaction.locale; 155 | 156 | try { 157 | if (!await shouldIgnoreUser(interaction.user.id)) { 158 | // Store user's language if not exists or can be overridden 159 | await prismaClient.language.upsert({ 160 | where: { 161 | id: interaction.user.id, 162 | type: "u", 163 | override: false 164 | }, 165 | create: { 166 | id: interaction.user.id, 167 | type: "u", 168 | language: interaction.locale, 169 | override: false 170 | }, 171 | update: { 172 | language: interaction.locale 173 | } 174 | }); 175 | } 176 | } catch (e) { } 177 | 178 | const deleteButton = createDeleteButton(locale); 179 | 180 | const reply = async (response: InteractionReplyOptions, onTimeout?: BaseApplicationCommand["onTimeout"]) => { 181 | if (response.ephemeral) { 182 | if (interaction.replied || interaction.deferred) { 183 | await interaction.editReply(response) 184 | } else { 185 | await interaction.reply(response); 186 | } 187 | } else { 188 | const _components = response.components?.slice(); 189 | 190 | if (response.components) { 191 | response.components.push(deleteButton); 192 | } else { 193 | response.components = [deleteButton]; 194 | } 195 | 196 | if (interaction.replied || interaction.deferred) { 197 | await interaction.editReply(response) 198 | } else { 199 | await interaction.reply(response); 200 | } 201 | 202 | // Add interaction id to linked list 203 | // Root level commands 204 | 205 | let id = interaction.id; 206 | const timeout = setTimeout(async () => { 207 | try { 208 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 209 | const { components, ...x } = response; 210 | const msg = await interaction.editReply({ components: _components ?? [], ...x }); 211 | if (onTimeout) { 212 | const reply = new LocalizableInteractionReplyOptionsAdapter(await onTimeout(msg)).build(locale); 213 | await interaction.editReply(reply); 214 | } 215 | delete timeouts[id]; 216 | } catch (e) { } 217 | }, TIMEOUT); 218 | 219 | if (interaction.isCommand()) { 220 | timeouts[interaction.id] = timeout; 221 | } else if (interaction.isMessageComponent()) { 222 | const parent = interaction.message.interaction!.id; 223 | clearTimeout(timeouts[parent]); 224 | timeouts[parent] = timeout; 225 | id = parent; 226 | } 227 | } 228 | }; 229 | 230 | const generalErrorReply = new LocalizableInteractionReplyOptionsAdapter({ 231 | content: { 232 | key: "index.error" 233 | } 234 | }).build(locale); 235 | 236 | try { 237 | // Delete function 238 | if ( 239 | interaction.isButton() && interaction.customId === "delete" 240 | || interaction.isMessageContextMenuCommand() && interaction.command!.name === getName("delete").name 241 | ) { 242 | await interaction.deferReply({ 243 | ephemeral: true 244 | }); 245 | 246 | const sourceMessage = (interaction.isMessageContextMenuCommand() ? interaction.targetMessage : interaction.message) as Message; 247 | 248 | if (sourceMessage.deletable) { 249 | // Check if the interaction issuer is the message author or is an admin 250 | const guildUser = (await interaction.guild?.members.fetch(interaction.user))!; 251 | 252 | if (sourceMessage.author.id !== interaction.client.user!.id) { 253 | return await reply( 254 | new LocalizableInteractionReplyOptionsAdapter({ 255 | content: { 256 | key: "delete.notMyMessage" 257 | }, 258 | ephemeral: true 259 | }).build(locale) 260 | ); 261 | } 262 | 263 | if ( 264 | sources[sourceMessage.id] === interaction.user.id 265 | || sourceMessage.mentions.repliedUser?.id === interaction.user.id 266 | || sourceMessage.interaction && sourceMessage.interaction.user.id === interaction.user.id 267 | || guildUser.permissions.has(PermissionFlagsBits.Administrator) 268 | ) { 269 | await sourceMessage.delete(); 270 | return await reply( 271 | new LocalizableInteractionReplyOptionsAdapter({ 272 | content: { 273 | key: "delete.deleted" 274 | }, 275 | ephemeral: true 276 | }).build(locale) 277 | ); 278 | } else { 279 | return await reply( 280 | new LocalizableInteractionReplyOptionsAdapter({ 281 | content: { 282 | key: "delete.deletingOthersMessage" 283 | }, 284 | ephemeral: true 285 | }).build(locale) 286 | ); 287 | } 288 | } else { 289 | return await reply( 290 | new LocalizableInteractionReplyOptionsAdapter({ 291 | content: { 292 | key: "delete.noPermission" 293 | }, 294 | ephemeral: true 295 | }).build(locale) 296 | ); 297 | } 298 | } else if (interaction.isChatInputCommand()) { 299 | const command = commands.find(command => command.name === interaction.command?.name); 300 | assert(command !== undefined); 301 | assert(command.isSlashApplicationCommand()); 302 | 303 | if (command.defer) { 304 | await interaction.deferReply(); 305 | } 306 | 307 | let response: InteractionReplyOptions = generalErrorReply; 308 | 309 | try { 310 | response = new LocalizableInteractionReplyOptionsAdapter( 311 | await command.onCommand(interaction) 312 | ).build(locale); 313 | } catch (e) { 314 | handleError(`commands/${command.name}.onCommand`, e); 315 | } 316 | 317 | await reply(response, command.onTimeout); 318 | } else if (interaction.isUserContextMenuCommand()) { 319 | const command = commands.find(command => getName(command.name).name === interaction.command?.name); 320 | assert(command !== undefined); 321 | assert(command.isUserContextMenuCommand()); 322 | 323 | if (command.defer) { 324 | await interaction.deferReply(); 325 | } 326 | 327 | // TODO: wtf is this 328 | let response: InteractionReplyOptions = generalErrorReply; 329 | 330 | try { 331 | response = new LocalizableInteractionReplyOptionsAdapter( 332 | await command.onContextMenu(interaction) 333 | ).build(locale); 334 | } catch (e) { 335 | handleError(`commands/${command.name}.onContextMenu`, e); 336 | } 337 | 338 | await reply(response, command.onTimeout); 339 | } else if (interaction.isMessageContextMenuCommand()) { 340 | const command = commands.find(command => getName(command.name).name === interaction.command?.name) 341 | assert(command !== undefined); 342 | assert(command.isMessageContextMenuCommand()); 343 | 344 | if (command.defer) { 345 | await interaction.deferReply(); 346 | } 347 | 348 | // TODO: wtf is this 349 | let response: InteractionReplyOptions = generalErrorReply; 350 | try { 351 | response = new LocalizableInteractionReplyOptionsAdapter( 352 | await command.onContextMenu(interaction) 353 | ).build(locale); 354 | } catch (e) { 355 | handleError(`commands/${command.name}.onContextMenu`, e); 356 | } 357 | 358 | await reply(response, command.onTimeout); 359 | } else if (interaction.isMessageComponent()) { 360 | assert(interaction.message.interaction !== null && interaction.message.interaction !== undefined); 361 | const commandName = interaction.message.interaction.commandName; 362 | 363 | const command = commands.find(command => getName(command.name).name === commandName); 364 | assert(command !== undefined); 365 | 366 | if (command.defer) { 367 | await interaction.deferUpdate(); 368 | } 369 | 370 | let response: InteractionReplyOptions = generalErrorReply; 371 | 372 | try { 373 | if (interaction.isButton() && command.onButton) { 374 | response = new LocalizableInteractionReplyOptionsAdapter( 375 | await command.onButton(interaction) 376 | ).build(locale); 377 | } else if (interaction.isStringSelectMenu() && command.onSelectMenu) { 378 | response = new LocalizableInteractionReplyOptionsAdapter( 379 | await command.onSelectMenu(interaction) 380 | ).build(locale); 381 | } else { 382 | // TODO: Handle unknown interaction 383 | } 384 | } catch (e) { 385 | handleError(`commands/${command.name}.${interaction.isButton() ? "onButton" : "onSelectMenu"}`, e); 386 | } 387 | 388 | await reply(response, command.onTimeout); 389 | } else if (interaction.isModalSubmit()) { 390 | // TODO: Modal submit 391 | } 392 | } catch (e) { 393 | handleError("client->interactionCreate", e); 394 | await reply(generalErrorReply); 395 | } 396 | }); 397 | 398 | // StealthModule 399 | for (const event of ["messageCreate", "messageUpdate", "messageDelete"]) { 400 | client.on(event, async (message: Message) => { 401 | if (message.author.bot || await shouldIgnoreUser(message.author.id)) { 402 | return; 403 | } 404 | 405 | // Check if message's channel lets us to send message 406 | const botAsMember = await message.guild?.members.fetch(client.user!.id); 407 | if ("permissionsFor" in message.channel && !message.channel.permissionsFor(botAsMember!)?.has(PermissionsBitField.Flags.SendMessages)) { 408 | return; 409 | } 410 | 411 | const locale = (await fetchUserLocale(message.author.id) ?? message.guild?.preferredLocale ?? Locale.EnglishUS) as Locale; 412 | 413 | for (const module of modules.filter(module => module.event === event)) { 414 | let matches; 415 | 416 | // Remove spoilers from message 417 | const regex = /(\|\|)(.*?)(\|\|)/g; 418 | message.content = message.content.replace(regex, ""); 419 | 420 | if (module.pattern) { 421 | matches = message.content.match(module.pattern) ?? undefined; 422 | if (!matches) continue; 423 | } 424 | 425 | try { 426 | const _result = await module.action({ 427 | message, 428 | matches 429 | }); 430 | 431 | if (typeof _result === "object") { 432 | const deleteButton = createDeleteButton(locale); 433 | 434 | const result = new LocalizableBaseMessageOptionsAdapter( 435 | _result.result 436 | ).build(locale); 437 | 438 | const _components = result.components?.slice() ?? []; 439 | 440 | result.components = result.components ? [...result.components, deleteButton] : [deleteButton]; 441 | 442 | const msg = _result.type === "send" ? await message.channel.send(result) : await message.reply({ ...result, allowedMentions: { repliedUser: false } }); 443 | 444 | sources[msg.id] = message.author.id; 445 | 446 | setTimeout(async () => { 447 | try { 448 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 449 | const { components, ...x } = result; 450 | const edited = await msg.edit({ 451 | components: _components, 452 | ...x 453 | } as MessageEditOptions); 454 | 455 | if (module.onTimeout) { 456 | const reply = new LocalizableBaseMessageOptionsAdapter( 457 | await module.onTimeout(edited) 458 | ).build(locale); 459 | await edited.edit(reply); 460 | } 461 | } catch (e) { } 462 | }, TIMEOUT); 463 | } else { 464 | if (_result) break; 465 | } 466 | } catch (e) { 467 | handleError(`modules/${module.name}.${event}`, e); 468 | } 469 | } 470 | }); 471 | } 472 | 473 | // Set status per 15 min 474 | const updateStatus = () => { 475 | const guildCount = client.guilds.cache.size; 476 | const userCount = client.guilds.cache.reduce((acc, guild) => acc + (guild.memberCount ?? 0), 0); 477 | client.user!.setActivity({ 478 | name: `👥 ${userCount} | 🏠 ${guildCount}`, 479 | type: ActivityType.Watching 480 | }); 481 | }; 482 | setInterval(updateStatus, 15 * 60 * 1000); 483 | updateStatus(); 484 | 485 | logger.info(`Logged in as ${client.user!.tag}!`); 486 | }); 487 | 488 | client.login(Bun.env.TOKEN); 489 | } catch (e) { 490 | logger.error("client", e); 491 | } --------------------------------------------------------------------------------