Merge branch 'release/pudding'

This commit is contained in:
Siwoo Jeon 2024-10-27 23:10:53 +09:00
commit 0c7493f21c
Signed by: migan
GPG key ID: 036E9A8C5E8E48DA
65 changed files with 2379 additions and 6002 deletions

5
.gitignore vendored
View file

@ -79,6 +79,7 @@ web_modules/
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
docker.env
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
@ -138,3 +139,7 @@ db/
.idea/ .idea/
./database/ ./database/
.DS_Store .DS_Store
# Prisma
prisma/**/*.*
!prisma/schema.prisma

View file

@ -1,7 +0,0 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}

10
.vscode/settings.json vendored
View file

@ -1,10 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"eslint.nodePath": ".yarn/sdks"
}

File diff suppressed because one or more lines are too long

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = absRequire(`eslint/use-at-your-own-risk`);

View file

@ -1,14 +0,0 @@
{
"name": "eslint",
"version": "9.5.0-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

View file

@ -1,5 +0,0 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = absRequire(`prettier/bin/prettier.cjs`);

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real prettier your application uses
module.exports = absRequire(`prettier`);

View file

@ -1,7 +0,0 @@
{
"name": "prettier",
"version": "3.3.2-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View file

@ -1,239 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View file

@ -1,239 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View file

@ -1,27 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
// Defer to the real typescript your application uses
module.exports = absRequire(`typescript`);

View file

@ -1,10 +0,0 @@
{
"name": "typescript",
"version": "5.5.2-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

View file

@ -1 +0,0 @@
yarnPath: .yarn/releases/yarn-4.3.1.cjs

View file

@ -1,14 +1,16 @@
FROM node:18.20.3 FROM node:18.20.4
ENV DOCKERIZE_VERSION v0.2.0 ENV DOCKERIZE_VERSION v0.2.0
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
&& tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
RUN npm i -g pnpm
RUN mkdir app RUN mkdir app
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN yarn install RUN pnpm install
RUN yarn build RUN pnpm db:generate
RUN pnpm build
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]

View file

@ -6,37 +6,53 @@
### 종속성 ### 종속성
- 이 프로젝트는 Node.JS을 사용하고, 패키지 매니저를 Yarn Berry로 사용합니다. - 이 프로젝트는 Node.JS을 사용하고, 패키지 매니저를 PNPM을 사용합니다.
- 이 프로젝트는 MariaDB(또는 MySQL)를 사용합니다. - 이 프로젝트는 MariaDB(또는 MySQL)와 Database ORM인 Prisma를 사용합니다.
#### 종속성 설치 #### 종속성 설치
```sh ```sh
yarn install pnpm install
``` ```
### 설정 파일 ### 설정 파일
- 이 프로젝트는 설정파일을 프로젝트 루트에 있는 `config.json`으로 하며, 그 내용은 `config.example.json`에서 확인할 수 있습니다. - 이 프로젝트는 설정파일을 프로젝트 루트에 있는 `.env`으로 하며, 그 내용은 `example.env`에서 확인할 수 있습니다.
#### Docker 설정
- Docker로 실행할 경우, 해당 설정 파일을 `docker.env`으로 하며, 그 내용은 `example-docker.env`에서 확인할 수 있습니다.
### prisma 설정
- 해당 프로젝트는 Database ORM인 Prisma를 사용하여 해당 설정이 필요합니다.
1. 먼저 .env에 DATABASE_URL부분을 채워 줍니다.
- 예시: `mysql://username:user_password@hostname:port/database`
2. 터미널 에서 `pnpm db:push`를 합니다.
### 실행 ### 실행
위 두 과정을 정상적으로 따랐다면, 아래의 명령어로 봇을 실행할 수 있습니다. 위 과정을 정상적으로 따랐다면, 아래의 명령어로 봇을 실행할 수 있습니다.
#### 그냥 실행 (디버그용 로그 출력) #### 그냥 실행 (디버그용 로그 출력)
```sh ```sh
yarn dev pnpm dev
``` ```
#### 빌드 #### 빌드 후 실행
##### 빌드
```sh ```sh
yarn build pnpm build
``` ```
##### 실행 ##### 실행
```sh ```sh
yarn start pnpm start
``` ```

View file

@ -1,17 +0,0 @@
{
"bot": {
"owner_ID": "",
"token": "",
"prefix": ""
},
"train": {
"user_ID": ""
},
"mysql": {
"user": "",
"host": "",
"password": "",
"database": "",
"port": 3306
}
}

View file

@ -9,7 +9,7 @@ services:
ports: ports:
- "1502:3306" - "1502:3306"
env_file: env_file:
- "./.env" - "./docker.env"
networks: networks:
- muffin_ai - muffin_ai
discord_bot: discord_bot:
@ -19,6 +19,8 @@ services:
- muffin_ai - muffin_ai
depends_on: depends_on:
- database - database
env_file:
- "./.env"
networks: networks:
muffin_ai: muffin_ai:

View file

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
dockerize -wait tcp://database:1502 -timeout 20s dockerize -wait tcp://database:1502 -timeout 20s
yarn start pnpm start

5
example-docker.env Normal file
View file

@ -0,0 +1,5 @@
# Docker configs (MariaDB container)
MYSQL_USER=
MYSQL_PASSWORD=
MYSQL_DATABASE=
MYSQL_ROOT_PASSWORD=

View file

@ -1,4 +1,13 @@
MYSQL_USER= # Prisma configs
MYSQL_PASSWORD= # Environment variables declared in this file are automatically made available to Prisma.
MYSQL_DATABASE= # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
MYSQL_ROOT_PASSWORD=
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL=
# Bot configs
BOT_TOKEN=
BOT_OWNER_ID=
BOT_PREFIX=
TRAIN_USER_ID=

5
nodemon.json Normal file
View file

@ -0,0 +1,5 @@
{
"ext": "ts,json",
"exec": "pnpm build --sourcemap && node dist/src",
"ignore": ["dist/**/*.*"]
}

View file

@ -1,46 +1,49 @@
{ {
"name": "muffinbot", "name": "muffinbot",
"version": "3.2.1-cake.r241004a", "version": "4.0.0-pudding.r241027a",
"main": "dist/index.js", "main": "dist/src/index.js",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@prisma/client": "^5.20.0",
"@sapphire/decorators": "^6.1.0", "@sapphire/decorators": "^6.1.0",
"@sapphire/discord.js-utilities": "^7.3.0", "@sapphire/discord.js-utilities": "^7.3.0",
"@sapphire/framework": "^5.2.1", "@sapphire/framework": "^5.2.1",
"@sapphire/pieces": "^4.3.1", "@sapphire/pieces": "^4.3.1",
"@sapphire/utilities": "^3.17.0", "@sapphire/utilities": "^3.17.0",
"discord-api-types": "^0.37.93", "discord-api-types": "^0.37.101",
"discord.js": "^14.16.2", "discord.js": "^14.16.3",
"dokdo": "^0.6.2", "dokdo": "^1.0.1",
"mysql2": "^3.11.3", "dotenv": "^16.4.5",
"semver": "^7.6.3" "semver": "^7.6.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.11.1",
"@migan/prettier-config": "^1.2.0", "@migan/prettier-config": "^1.2.0",
"@types/node": "^20.14.12", "@types/node": "^22.7.4",
"@types/semver": "^7", "@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^7.17.0", "@typescript-eslint/parser": "^8.7.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.8.0", "eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"globals": "^15.8.0", "globals": "^15.9.0",
"nodemon": "^3.1.7",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"ts-node": "^10.9.2", "prisma": "^5.20.0",
"tsup": "^8.2.3", "typescript": "^5.6.2"
"typescript": "^5.5.4"
}, },
"scripts": { "scripts": {
"build": "tsup", "build": "tsc",
"dev": "cross-env NODE_ENV=development tsup --watch --onSuccess \"node --enable-source-maps dist\"", "dev": "cross-env NODE_ENV=development nodemon",
"start": "cross-env NODE_ENV=production node dist" "start": "cross-env NODE_ENV=production node dist/src",
"db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:generate": "prisma generate"
}, },
"resolutions": { "resolutions": {
"@types/ws": "^8.5.11", "@types/ws": "^8.5.11",
"ws": "8.18.0" "ws": "8.18.0"
}, }
"packageManager": "yarn@4.3.1"
} }

1679
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

34
prisma/schema.prisma Normal file
View file

@ -0,0 +1,34 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model learn {
id Int @id @default(autoincrement())
command String @db.VarChar(255)
result String @db.VarChar(255)
user_id String @db.VarChar(255)
created_at DateTime @default(now()) @db.DateTime(0)
}
model nsfw_content {
id Int @id @default(autoincrement())
text String @default("") @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0)
persona String @default("") @db.VarChar(50)
}
model statement {
id Int @id @default(autoincrement())
text String @db.VarChar(255)
search_text String @default("") @db.VarChar(255)
conversation String @default("") @db.VarChar(32)
created_at DateTime? @default(now()) @db.DateTime(0)
in_response_to String? @db.VarChar(255)
search_in_response_to String @default("") @db.VarChar(255)
persona String @default("") @db.VarChar(50)
}

View file

@ -1,28 +0,0 @@
CREATE TABLE `statement` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`text` varchar(255) DEFAULT NOT NULL,
`search_text` varchar(255) NOT NULL DEFAULT '',
`conversation` varchar(32) NOT NULL DEFAULT '',
`created_at` datetime DEFAULT current_timestamp(),
`in_response_to` varchar(255) DEFAULT NULL,
`search_in_response_to` varchar(255) NOT NULL DEFAULT '',
`persona` varchar(50) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
);
CREATE TABLE `nsfw_content` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`text` varchar(255) NOT NULL DEFAULT '',
`created_at` datetime DEFAULT current_timestamp(),
`persona` varchar(50) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
);
CREATE TABLE `learn` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`command` varchar(255) NOT NULL,
`result` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
primary key (`id`)
);

View file

@ -1,5 +0,0 @@
import './learning_data'
import './deleteLearn'
import './learn'
import './help'
import './list'

View file

@ -1,82 +0,0 @@
import { type Args, Command, container } from '@sapphire/framework'
import { codeBlock, type Message } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators'
@ApplyOptions<Command.Options>({
name: '배워',
aliases: ['공부'],
description: '단어를 가르치는 명령ㅇ어에요.',
detailedDescription: {
usage: '머핀아 배워 (등록할 단어) (대답)',
examples: [
'머핀아 배워 안녕 안녕!',
'머핀아 배워 "야 죽을래?" "아니요 ㅠㅠㅠ"',
'머핀아 배워 미간은_누구야? 이봇의_개발자요',
],
},
})
class LearnCommand extends Command {
public async messageRun(msg: Message, args: Args) {
if (typeof this.detailedDescription === 'string') return
const config = this.container.config
const command = (await args.pick('string').catch(() => null))?.replaceAll(
'_',
' ',
)
const result = (await args.pick('string').catch(() => null))?.replaceAll(
'_',
' ',
)
if (!command || !result) {
return await msg.reply(
codeBlock(
'md',
`사용법: ${this.detailedDescription.usage}\n` +
`예시: ${this.detailedDescription.examples?.map(example => example).join('\n')}`,
),
)
}
const commands: string[] = []
const aliases: string[] = []
this.container.stores.get('commands').forEach(module => {
commands.push(module.name)
module.aliases.forEach(alias => aliases.push(alias))
})
const ignore = [
...commands,
...aliases,
...this.container.dokdoAliases,
'미간',
'Migan',
'migan',
'간미',
]
const disallowed = ['@everyone', '@here', `<@${config.bot.owner_ID}>`]
const db = this.container.database
for (const ig of ignore) {
if (command.includes(ig)) {
return msg.reply('해ㄷ당 단어는 배울ㄹ 수 없어요.')
}
}
for (const di of disallowed) {
if (result.includes(di)) {
return msg.reply('해당 단ㅇ어는 개발자님이 특별히 금지하였ㅇ어요.')
}
}
await db.learn.insert({
user_id: msg.author.id,
command,
result,
})
await msg.reply(`${command}을/를 배웠ㅇ어요.`)
}
}
void container.stores.loadPiece({
piece: LearnCommand,
name: 'learn',
store: 'commands',
})

View file

@ -1,38 +0,0 @@
import { ApplyOptions } from '@sapphire/decorators'
import { type ResponseData } from '../modules'
import { Command, container } from '@sapphire/framework'
import { type Message } from 'discord.js'
@ApplyOptions<Command.Options>({
name: '데이터학습량',
aliases: ['학습데이터량', '데이터량'],
description: '봇이 학습한 데ㅇ이터량을 보여줘요.',
detailedDescription: {
usage: '머핀아 학습데이터량',
},
})
class LearnDataCommand extends Command {
public async messageRun(msg: Message<true>) {
const db = this.container.database
const data = await db.statement.all()
const nsfwData = await db.nsfwContent.all()
const learnData = await db.learn.all()
const userData = await db.learn.findOneAnotherKey('user_id', msg.author.id)
const muffin: ResponseData[] = []
data.forEach(row => {
if (row.persona === 'muffin') muffin.push(row)
else return
})
await msg.reply(`머핀 데이터: ${muffin.length}
nsfw 데이터: ${nsfwData.length}
단어: ${learnData.length}
${msg.author.username} 단어: ${userData.length}`)
}
}
void container.stores.loadPiece({
piece: LearnDataCommand,
name: 'learn_data',
store: 'commands',
})

View file

@ -1,49 +0,0 @@
import { ApplyOptions } from '@sapphire/decorators'
import { Message, codeBlock } from 'discord.js'
import { Command, container } from '@sapphire/framework'
@ApplyOptions<Command.Options>({
name: '리스트',
aliases: ['list', '목록'],
description: '당신이 가ㄹ르쳐준 단어를 나열해요.',
detailedDescription: {
usage: '머핀아 리스트',
},
})
class ListCommand extends Command {
public async messageRun(msg: Message<boolean>) {
const db = this.container.database
const data = await db.learn.findOneAnotherKey('user_id', msg.author.id)
const list: string[] = []
if (!data[0]) {
return await msg.channel.send(
'당신ㄴ은 단어를 가르쳐준 기억이 없ㅅ는데요.',
)
}
for (const listData of data) {
list.push(listData.command)
}
await msg.reply({
embeds: [
{
title: `${msg.author.username}님의 지식`,
description: `총합: ${data.length}\n${codeBlock(
'md',
list.map(item => `- ${item}`).join('\n'),
)}`,
color: 0x0000ff,
timestamp: new Date().toISOString(),
},
],
})
}
}
void container.stores.loadPiece({
piece: ListCommand,
name: 'list',
store: 'commands',
})

View file

@ -1,33 +1,45 @@
import { SapphireClient, container, LogLevel } from '@sapphire/framework' import { SapphireClient, container, LogLevel } from '@sapphire/framework'
import { GatewayIntentBits, Partials, type Snowflake } from 'discord.js' import { GatewayIntentBits, Partials } from 'discord.js'
import { ChatBot, NODE_ENV, MaaDatabase } from './modules' import { ChatBot, Config, NODE_ENV } from './modules'
import { PrismaClient } from '@prisma/client'
import { version } from '../package.json' import { version } from '../package.json'
import config from '../config.json'
import semver from 'semver' import semver from 'semver'
import './interaction-handlers/_load' const config = new Config()
import './listeners/_load'
import './Commands/_load'
container.config = config
container.prefix = config.bot.prefix
container.version = version
container.database = new MaaDatabase()
container.dokdoAliases = ['dokdo', 'dok', 'Dokdo', 'Dok', '테스트']
container.chatBot = new ChatBot(container.database)
const release = version const release = version
.slice((semver.coerce(version)?.toString() + '-').length) .slice((semver.coerce(version)?.toString() + '-').length)
.split('.')[1] .split('.')[1]
function getLastUpdated() {
const updated = release.match(/[0-9]/g)!.join('')
const year = updated.slice(0, 2)
const month = updated.slice(2, 4)
const day = updated.slice(4, 6)
return `20${year}-${month}-${day}`
}
container.config = config
container.prefix = config.bot.prefix
container.version = version
container.database = new PrismaClient()
container.dokdoAliases = ['dokdo', 'dok', 'Dokdo', 'Dok', '테스트']
container.chatBot = new ChatBot(container.database)
container.lastUpdated = new Date(getLastUpdated())
container.embedColors = {
default: 0xaddb87,
fail: 0xff0000,
success: 0x00ff00,
}
if (release.startsWith('e')) { if (release.startsWith('e')) {
container.release = 'EXPERIMENTAL' container.channel = 'EXPERIMENTAL'
} else if (release.startsWith('d')) { } else if (release.startsWith('d')) {
container.release = 'DEV' container.channel = 'DEV'
} else if (release.startsWith('p')) { } else if (release.startsWith('p')) {
container.release = 'PREVIEW' container.channel = 'PREVIEW'
} else { } else {
container.release = 'RELEASE' container.channel = 'RELEASE'
} }
export default class MuffinBot extends SapphireClient { export default class MuffinBot extends SapphireClient {
@ -49,40 +61,41 @@ export default class MuffinBot extends SapphireClient {
repliedUser: true, repliedUser: true,
}, },
partials: [Partials.Message, Partials.ThreadMember], partials: [Partials.Message, Partials.ThreadMember],
baseUserDirectory: null,
}) })
} }
public override async login(): Promise<string> { public override async login(): Promise<string> {
await container.chatBot.train(this) if (container.channel === 'RELEASE') {
if (!config.train.user_ID)
container.logger.info(
'[MuffinBot] .env파일에 TRAIN_USER_ID값이 없어서 학습 기능이 꺼졌어요.',
)
else await container.chatBot.train(this)
} else {
container.logger.info(
'[MuffinBot] 해당 채널은 RELEASE 채널이 아니라서 학습 기능이 꺼졌습니다.',
)
container.logger.info(`[MuffinBot] 현재 채널: ${container.channel}`)
}
return super.login(config.bot.token) return super.login(config.bot.token)
} }
} }
declare module '@sapphire/framework' { declare module '@sapphire/framework' {
interface Container { interface Container {
database: MaaDatabase database: PrismaClient
chatBot: ChatBot chatBot: ChatBot
prefix: string prefix: string
version: string version: string
dokdoAliases: string[] dokdoAliases: string[]
config: { config: Config
bot: { channel: 'EXPERIMENTAL' | 'DEV' | 'PREVIEW' | 'RELEASE'
owner_ID: Snowflake lastUpdated: Date
token: string embedColors: {
default: number
fail: number
success: number
} }
train: {
user_ID: Snowflake
}
mysql: {
user: string
host: string
password: string
database: string
port: number
}
}
release: 'EXPERIMENTAL' | 'DEV' | 'PREVIEW' | 'RELEASE'
} }
} }

View file

@ -1,17 +1,17 @@
import { import { ApplyOptions } from '@sapphire/decorators'
Args,
Command,
container,
DetailedDescriptionCommandObject,
} from '@sapphire/framework'
import { import {
type SelectMenuComponentOptionData, type SelectMenuComponentOptionData,
type Message, type User,
ChatInputCommandInteraction,
ComponentType, ComponentType,
codeBlock, codeBlock,
Message,
} from 'discord.js' } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators' import {
import { type LearnData } from '../modules' DetailedDescriptionCommandObject,
Command,
Args,
} from '@sapphire/framework'
@ApplyOptions<Command.Options>({ @ApplyOptions<Command.Options>({
name: '삭제', name: '삭제',
@ -22,26 +22,55 @@ import { type LearnData } from '../modules'
examples: ['머핀아 삭제 머핀'], examples: ['머핀아 삭제 머핀'],
}, },
}) })
class DeleteLearnCommand extends Command { export default class DeleteLearnCommand extends Command {
public async messageRun(msg: Message, args: Args) { public registerApplicationCommands(registry: Command.Registry) {
const CUSTOM_ID = 'maa$deleteLearn' registry.registerChatInputCommand(builder =>
const command = await args.rest('string').catch(() => null) builder
const options: SelectMenuComponentOptionData[] = [] .setName(this.name)
const db = this.container.database .setDescription(this.description)
const deleteDataList: string[] = [] .addStringOption(option =>
const [deleteDatas] = await db.database.execute<LearnData[]>( option
'SELECT * FROM learn WHERE command = ? AND user_id = ?;', .setRequired(true)
[command, msg.author.id], .setName('단어')
.setDescription('삭제할 단어를 입력해주세요.'),
),
) )
}
private async _run(ctx: Message | ChatInputCommandInteraction, args?: Args) {
let command: string | null
let user: User
if (ctx instanceof Message) {
command = await args!.rest('string').catch(() => null)
user = ctx.author
} else {
command = ctx.options.getString('단어', true)
user = ctx.user
}
const ephemeral =
ctx instanceof ChatInputCommandInteraction ? { ephemeral: true } : null
const CUSTOM_ID = 'maa$deleteLearn'
const options: SelectMenuComponentOptionData[] = []
const deleteDataList: string[] = []
if (!command) { if (!command) {
return await msg.reply( return await ctx.reply(
`사용법: \n\`\`\`${(this.detailedDescription as DetailedDescriptionCommandObject).usage}\`\`\``, `사용법: \n\`\`\`${(this.detailedDescription as DetailedDescriptionCommandObject).usage}\`\`\``,
) )
} }
const deleteDatas = await this.container.database.learn.findMany({
where: {
command,
user_id: user.id,
},
})
if (deleteDatas.length === 0) { if (deleteDatas.length === 0) {
return await msg.reply('해당하는 걸 찾ㅈ을 수 없어요.') return await ctx.reply({
...ephemeral,
content: '해당하는 걸 찾ㅈ을 수 없어요.',
})
} }
for (let i = 1; i <= deleteDatas.length; i++) { for (let i = 1; i <= deleteDatas.length; i++) {
@ -53,12 +82,14 @@ class DeleteLearnCommand extends Command {
}) })
} }
await msg.reply({ await ctx.reply({
...ephemeral,
embeds: [ embeds: [
{ {
title: '삭제', title: '삭제',
description: `${codeBlock('md', deleteDataList.join('\n'))}`, description: `${codeBlock('md', deleteDataList.join('\n'))}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
color: this.container.embedColors.default,
}, },
], ],
components: [ components: [
@ -67,7 +98,7 @@ class DeleteLearnCommand extends Command {
components: [ components: [
{ {
type: ComponentType.StringSelect, type: ComponentType.StringSelect,
customId: `${CUSTOM_ID}@${msg.author.id}`, customId: `${CUSTOM_ID}@${user.id}`,
placeholder: '지울 데이터를 선택해ㅈ주세요', placeholder: '지울 데이터를 선택해ㅈ주세요',
options: [ options: [
...options, ...options,
@ -83,10 +114,12 @@ class DeleteLearnCommand extends Command {
], ],
}) })
} }
public async messageRun(msg: Message, args: Args) {
await this._run(msg, args)
} }
void container.stores.loadPiece({ public async chatInputRun(interaction: ChatInputCommandInteraction) {
piece: DeleteLearnCommand, await this._run(interaction)
name: 'delete', }
store: 'commands', }
})

View file

@ -1,6 +1,10 @@
import { Args, Command, container } from '@sapphire/framework'
import { codeBlock, type Message } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators' import { ApplyOptions } from '@sapphire/decorators'
import { Args, Command } from '@sapphire/framework'
import {
type ChatInputCommandInteraction,
codeBlock,
Message,
} from 'discord.js'
@ApplyOptions<Command.Options>({ @ApplyOptions<Command.Options>({
name: '도움말', name: '도움말',
@ -11,9 +15,33 @@ import { ApplyOptions } from '@sapphire/decorators'
examples: ['머핀아 도움말', '머핀아 도움말 배워'], examples: ['머핀아 도움말', '머핀아 도움말 배워'],
}, },
}) })
class HelpCommand extends Command { export default class HelpCommand extends Command {
public async messageRun(msg: Message, args: Args) { public registerApplicationCommands(registry: Command.Registry) {
const commandName = await args.pick('string').catch(() => null) const commands = this.container.stores.get('commands').map(command => {
return {
name: command.name,
value: command.name,
}
})
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setName('명령어')
.setDescription('해당 명령어에 대ㅎ한 도움말을 볼 수 있어요.')
.addChoices(commands),
),
)
}
private async _run(ctx: Message | ChatInputCommandInteraction, args?: Args) {
let commandName: string | null
if (ctx instanceof Message) {
commandName = await args!.pick('string').catch(() => null)
} else {
commandName = ctx.options.getString('명령어')
}
if ( if (
!commandName || !commandName ||
!this.container.stores.get('commands').get(commandName) !this.container.stores.get('commands').get(commandName)
@ -24,7 +52,7 @@ class HelpCommand extends Command {
commandList.push(`${module.name} - ${module.description}`) commandList.push(`${module.name} - ${module.description}`)
}) })
await msg.reply({ await ctx.reply({
embeds: [ embeds: [
{ {
title: `${this.container.client.user?.username}의 도움말`, title: `${this.container.client.user?.username}의 도움말`,
@ -35,6 +63,7 @@ class HelpCommand extends Command {
footer: { footer: {
text: `머핀봇 버전: ${this.container.version}`, text: `머핀봇 버전: ${this.container.version}`,
}, },
color: this.container.embedColors.default,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
], ],
@ -44,7 +73,7 @@ class HelpCommand extends Command {
this.container.stores.get('commands').get(commandName)! this.container.stores.get('commands').get(commandName)!
if (typeof detailedDescription === 'string') return if (typeof detailedDescription === 'string') return
await msg.reply({ await ctx.reply({
embeds: [ embeds: [
{ {
title: `${this.container.client.user?.username}의 도움말`, title: `${this.container.client.user?.username}의 도움말`,
@ -81,15 +110,17 @@ class HelpCommand extends Command {
text: `머핀봇 버전: ${this.container.version}`, text: `머핀봇 버전: ${this.container.version}`,
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
color: this.container.embedColors.default,
}, },
], ],
}) })
} }
} }
public async messageRun(msg: Message, args: Args) {
await this._run(msg, args)
} }
void container.stores.loadPiece({ public async chatInputRun(interaction: ChatInputCommandInteraction) {
piece: HelpCommand, await this._run(interaction)
name: 'help', }
store: 'commands', }
})

View file

@ -0,0 +1,76 @@
import type { ChatInputCommandInteraction, Message } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators'
import { Command } from '@sapphire/framework'
import { platform, arch } from 'os'
@ApplyOptions<Command.Options>({
name: '정보',
description: '머핀봇의 정보를 알ㄹ려줘요.',
detailedDescription: {
usage: '머핀아 정보',
},
})
export default class InformationCommand extends Command {
public registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description),
)
}
private async _run(ctx: Message | ChatInputCommandInteraction) {
await ctx.reply({
embeds: [
{
title: `${this.container.client.user?.username}의 정ㅂ보`,
color: this.container.embedColors.default,
fields: [
{
name: '구동ㅎ환경',
value: `\`${platform()} ${arch()}\``,
inline: false,
},
{
name: '버ㅈ전',
value: `\`${this.container.version}\``,
inline: true,
},
{
name: '채ㄴ널',
value: `\`${this.container.channel.toLowerCase()}\``,
inline: true,
},
{
name: '최근 업ㄷ데이트 날짜',
value: `\`${this.container.lastUpdated.toLocaleDateString('ko', {
dateStyle: 'long',
})}\``,
inline: true,
},
{
name: '개ㅂ발자',
value: `\`${
(
await this.container.client.users.fetch(
this.container.config.bot.owner_ID,
)
).username
}\``,
inline: false,
},
],
thumbnail: {
url: this.container.client.user!.displayAvatarURL()!,
},
},
],
})
}
public async messageRun(msg: Message) {
await this._run(msg)
}
public async chatInputRun(interaction: ChatInputCommandInteraction) {
await this._run(interaction)
}
}

128
src/commands/learn.ts Normal file
View file

@ -0,0 +1,128 @@
import { ChatInputCommandInteraction, codeBlock, Message } from 'discord.js'
import { type Args, Command } from '@sapphire/framework'
import { ApplyOptions } from '@sapphire/decorators'
@ApplyOptions<Command.Options>({
name: '배워',
aliases: ['공부'],
description: '단어를 가르치는 명령ㅇ어에요.',
detailedDescription: {
usage: '머핀아 배워 (등록할 단어) (대답)',
examples: [
'머핀아 배워 안녕 안녕!',
'머핀아 배워 "야 죽을래?" "아니요 ㅠㅠㅠ"',
'머핀아 배워 미간은_누구야? 이봇의_개발자요',
],
},
})
export default class LearnCommand extends Command {
public registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder
.setName(this.name)
.setDescription(this.description)
.addStringOption(option =>
option
.setRequired(true)
.setName('단어')
.setDescription('등록할 단어를 입력해주세요.'),
)
.addStringOption(option =>
option
.setRequired(true)
.setName('대답')
.setDescription('해당 단어의 대답을 입력해주세요.'),
),
)
}
private async _run(ctx: Message | ChatInputCommandInteraction, args?: Args) {
if (ctx instanceof ChatInputCommandInteraction) await ctx.deferReply()
if (typeof this.detailedDescription === 'string') return
const config = this.container.config
const IG_MSG = '해ㄷ당 단어는 배울ㄹ 수 없어요.'
const DI_MSG = '해당 단ㅇ어는 개발자님이 특별히 금지하였ㅇ어요.'
const SUCCESS_MSG = '을/를 배웠ㅇ어요.'
let command: string | undefined
let result: string | undefined
if (ctx instanceof Message) {
command = (await args?.pick('string').catch(() => null))?.replaceAll(
'_',
' ',
)
result = (await args?.pick('string').catch(() => null))?.replaceAll(
'_',
' ',
)
} else {
command = ctx.options.getString('단어', true)
result = ctx.options.getString('대답', true)
}
if (!command || !result)
return await ctx.reply(
codeBlock(
'md',
`사용법: ${this.detailedDescription.usage}\n` +
`예시: ${this.detailedDescription.examples?.map(example => example).join('\n')}`,
),
)
const commands: string[] = []
const aliases: string[] = []
this.container.stores.get('commands').forEach(module => {
commands.push(module.name)
module.aliases.forEach(alias => aliases.push(alias))
})
const ignore = [
...commands,
...aliases,
...this.container.dokdoAliases,
'미간',
'Migan',
'migan',
'간미',
]
const disallowed = ['@everyone', '@here', `<@${config.bot.owner_ID}>`]
const user = ctx instanceof Message ? ctx.author : ctx.user
for (const ig of ignore) {
if (command.includes(ig))
return ctx instanceof Message
? await ctx.reply(IG_MSG)
: await ctx.editReply(IG_MSG)
}
for (const di of disallowed) {
if (result.includes(di))
return ctx instanceof Message
? await ctx.reply(DI_MSG)
: await ctx.editReply(DI_MSG)
}
await this.container.database.learn.create({
data: {
user_id: user.id,
command,
result,
},
})
return ctx instanceof Message
? await ctx.reply(command + SUCCESS_MSG)
: await ctx.editReply(command + SUCCESS_MSG)
}
public async messageRun(msg: Message, args: Args) {
await this._run(msg, args)
}
public async chatInputRun(interaction: ChatInputCommandInteraction) {
await this._run(interaction)
}
}

View file

@ -0,0 +1,50 @@
import { ChatInputCommandInteraction, Message } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators'
import { Command } from '@sapphire/framework'
@ApplyOptions<Command.Options>({
name: '데이터학습량',
aliases: ['학습데이터량', '데이터량'],
description: '봇이 학습한 데ㅇ이터량을 보여줘요.',
detailedDescription: {
usage: '머핀아 학습데이터량',
},
})
export default class LearnedDataCommand extends Command {
public registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description),
)
}
private async _run(ctx: Message | ChatInputCommandInteraction) {
const user = ctx instanceof Message ? ctx.author : ctx.user
const db = this.container.database
const data = await db.statement.findMany()
const nsfwData = await db.nsfw_content.findMany()
const learnData = await db.learn.findMany()
const userData = await db.learn.findMany({
where: {
user_id: user.id,
},
})
const muffin: any[] = []
data.forEach(row => {
if (row.persona === 'muffin') muffin.push(row)
else return
})
await ctx.reply(`머핀 데이터: ${muffin.length}
nsfw 데이터: ${nsfwData.length}
단어: ${learnData.length}
${user.username} 단어: ${userData.length}`)
}
public async messageRun(msg: Message) {
await this._run(msg)
}
public async chatInputRun(interaction: ChatInputCommandInteraction) {
await this._run(interaction)
}
}

65
src/commands/list.ts Normal file
View file

@ -0,0 +1,65 @@
import { ChatInputCommandInteraction, Message, codeBlock } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators'
import { Command } from '@sapphire/framework'
@ApplyOptions<Command.Options>({
name: '리스트',
aliases: ['list', '목록'],
description: '당신이 가ㄹ르쳐준 단어를 나열해요.',
detailedDescription: {
usage: '머핀아 리스트',
},
})
export default class ListCommand extends Command {
public registerApplicationCommands(registry: Command.Registry) {
registry.registerChatInputCommand(builder =>
builder.setName(this.name).setDescription(this.description),
)
}
private async _run(ctx: Message | ChatInputCommandInteraction) {
const user = ctx instanceof Message ? ctx.author : ctx.user
const ephemeral =
ctx instanceof ChatInputCommandInteraction ? { ephemeral: true } : null
const db = this.container.database
const data = await db.learn.findMany({
where: {
user_id: user.id,
},
})
const list: string[] = []
if (!data[0]) {
return await ctx.reply({
...ephemeral,
content: '당신ㄴ은 단어를 가르쳐준 기억이 없ㅅ는데요.',
})
}
for (const listData of data) {
list.push(listData.command)
}
await ctx.reply({
embeds: [
{
title: `${user.username}님의 지식`,
description: `총합: ${data.length}\n${codeBlock(
'md',
list.map(item => `- ${item}`).join('\n'),
)}`,
color: this.container.embedColors.default,
timestamp: new Date().toISOString(),
},
],
})
}
public async messageRun(msg: Message<boolean>) {
await this._run(msg)
}
public async chatInputRun(interaction: ChatInputCommandInteraction) {
await this._run(interaction)
}
}

View file

@ -1 +1 @@
import('./Client').then(a => new a.default().login()) import('./client').then(a => new a.default().login())

View file

@ -1 +0,0 @@
import './deleteLearn'

View file

@ -1,15 +1,14 @@
import {
container,
InteractionHandler,
InteractionHandlerTypes,
} from '@sapphire/framework'
import { type StringSelectMenuInteraction } from 'discord.js' import { type StringSelectMenuInteraction } from 'discord.js'
import { ApplyOptions } from '@sapphire/decorators' import { ApplyOptions } from '@sapphire/decorators'
import {
InteractionHandlerTypes,
InteractionHandler,
} from '@sapphire/framework'
@ApplyOptions<InteractionHandler.Options>({ @ApplyOptions<InteractionHandler.Options>({
interactionHandlerType: InteractionHandlerTypes.SelectMenu, interactionHandlerType: InteractionHandlerTypes.SelectMenu,
}) })
class DeleteLearnHandler extends InteractionHandler { export default class DeleteLearnHandler extends InteractionHandler {
private readonly _CUSTOM_ID = 'maa$deleteLearn' private readonly _CUSTOM_ID = 'maa$deleteLearn'
public async parse(interaction: StringSelectMenuInteraction) { public async parse(interaction: StringSelectMenuInteraction) {
@ -33,22 +32,26 @@ class DeleteLearnHandler extends InteractionHandler {
const decimalRegexp = /^[0-9]/g const decimalRegexp = /^[0-9]/g
if (id === 'cancel') if (id === 'cancel')
return interaction.editReply({ return await interaction.editReply({
embeds: [ embeds: [
{ {
title: '삭제', title: '삭제',
description: '아무것도 삭제하지 않았어요.', description: '아무것도 삭제하지 않았어요.',
color: 0x00ff00, color: this.container.embedColors.fail,
}, },
], ],
components: [], components: [],
}) })
const itemId = interaction.component.options.map(item => const itemId = interaction.component.options.map(item =>
item.value.endsWith(id) ? item.label.match(decimalRegexp)![0] : null, item.value.endsWith(`${id}`) ? item.label.match(decimalRegexp)![0] : null,
) )
await db.learn.delete(id) await db.learn.delete({
where: {
id: Number(id),
},
})
await interaction.editReply({ await interaction.editReply({
embeds: [ embeds: [
@ -56,15 +59,10 @@ class DeleteLearnHandler extends InteractionHandler {
title: '삭제', title: '삭제',
description: `${Number(itemId[0]!)}번을 정상적으로 삭제하ㅇ였어요.`, description: `${Number(itemId[0]!)}번을 정상적으로 삭제하ㅇ였어요.`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
color: this.container.embedColors.success,
}, },
], ],
components: [], components: [],
}) })
} }
} }
void container.stores.loadPiece({
piece: DeleteLearnHandler,
name: 'deleteLearn',
store: 'interaction-handlers',
})

View file

@ -1,3 +0,0 @@
import './messageCreate'
import './debug'
import './ready'

View file

@ -1,13 +1,7 @@
import { Listener, container } from '@sapphire/framework' import { Listener } from '@sapphire/framework'
class DebugListener extends Listener { export default class DebugListener extends Listener {
public async run(debug: string) { public async run(debug: string) {
this.container.logger.debug(`[MuffinBot] ${debug}`) this.container.logger.debug(`[MuffinBot] ${debug}`)
} }
} }
void container.stores.loadPiece({
piece: DebugListener,
name: 'debug',
store: 'listeners',
})

View file

@ -0,0 +1,12 @@
import { ChatInputCommandInteraction } from 'discord.js'
import { Listener } from '@sapphire/framework'
import { previewWarning } from '../modules'
export default class InteractionCreateListener extends Listener {
public async run(interaction: ChatInputCommandInteraction<'cached'>) {
if (interaction.isChatInputCommand()) {
if (this.container.channel !== 'RELEASE')
await previewWarning(interaction)
}
}
}

View file

@ -1,35 +1,21 @@
import { Listener, container } from '@sapphire/framework' import { noPerm, previewWarning } from '../modules'
import { Listener } from '@sapphire/framework'
import { type Message } from 'discord.js' import { type Message } from 'discord.js'
import { noPerm } from '../modules' import { Client } from 'dokdo'
import Dokdo from 'dokdo'
class MessageCreateListener extends Listener { export default class MessageCreateListener extends Listener {
public async run(msg: Message<true>) { public async run(msg: Message<true>) {
const prefix = this.container.prefix const prefix = this.container.prefix
const dokdo = new Dokdo(this.container.client, { const dokdo = new Client(this.container.client, {
aliases: ['dokdo', 'dok'], aliases: this.container.dokdoAliases,
owners: [this.container.config.bot.owner_ID], owners: [this.container.config.bot.owner_ID],
prefix: prefix, prefix: prefix,
noPerm, noPerm,
}) })
if (msg.author.bot) return if (msg.author.bot) return
if (msg.content.startsWith(prefix)) { if (msg.content.startsWith(prefix)) {
if (this.container.release === 'PREVIEW') { if (this.container.channel !== 'RELEASE') await previewWarning(msg)
await msg.reply({
embeds: [
{
title: '정식 출시 이전 버전 사용안내',
description:
`현재 이 버전의 ${this.container.client.user?.username}은 정식출시 되기 이전이라 많이 불안정할 수 있어요.\n` +
`만약 오류가 발견되면 ${(await this.container.client.users.fetch(this.container.config.bot.owner_ID)).username}님에게 알려주세요.\n`,
color: 0xff0000,
footer: {
text: `현재 브랜치: ${this.container.release.toLowerCase()} 버전: ${this.container.version}`,
},
},
],
})
}
const args = msg.content.slice(prefix.length).trim().split(/ +/g) const args = msg.content.slice(prefix.length).trim().split(/ +/g)
this.container.logger.debug(`[ChatBot] command: ${args.join(' ')}`) this.container.logger.debug(`[ChatBot] command: ${args.join(' ')}`)
@ -46,9 +32,3 @@ class MessageCreateListener extends Listener {
} }
} }
} }
void container.stores.loadPiece({
piece: MessageCreateListener,
name: 'messageCreate',
store: 'listeners',
})

View file

@ -1,9 +1,9 @@
import { container, Listener } from '@sapphire/framework'
import { ApplyOptions } from '@sapphire/decorators' import { ApplyOptions } from '@sapphire/decorators'
import { ActivityType, Client } from 'discord.js' import { ActivityType, Client } from 'discord.js'
import { Listener } from '@sapphire/framework'
@ApplyOptions<Listener.Options>({ once: true }) @ApplyOptions<Listener.Options>({ once: true })
class ClientReadyListener extends Listener { export default class ClientReadyListener extends Listener {
public async run(client: Client<true>) { public async run(client: Client<true>) {
function setStatus() { function setStatus() {
client.user.setActivity({ client.user.setActivity({
@ -18,9 +18,3 @@ class ClientReadyListener extends Listener {
this.container.logger.info(`[MuffinBot] 먹힐 준ㅂ비 완료`) this.container.logger.info(`[MuffinBot] 먹힐 준ㅂ비 완료`)
} }
} }
void container.stores.loadPiece({
piece: ClientReadyListener,
name: 'ready',
store: 'listeners',
})

View file

@ -1,19 +1,19 @@
import type { Client, Message, TextChannel } from 'discord.js' import type { Client, Message, TextChannel } from 'discord.js'
import type { PrismaClient } from '@prisma/client'
import { container } from '@sapphire/framework' import { container } from '@sapphire/framework'
import type { MaaDatabase } from './database'
export default class ChatBot { export default class ChatBot {
public constructor(public db: MaaDatabase) { public constructor(public db: PrismaClient) {}
setInterval(async () => {
this.db.ping()
}, 60000)
}
public async getResponse(msg: Message): Promise<string> { public async getResponse(msg: Message): Promise<string> {
const prefix = container.prefix const prefix = container.prefix
const data = await this.db.statement.all() const data = await this.db.statement.findMany()
const args = msg.content.slice(prefix.length).trim().split(/ +/g).join(' ') const args = msg.content.slice(prefix.length).trim().split(/ +/g).join(' ')
const learn = await this.db.learn.findOne(args) const learn = await this.db.learn.findMany({
where: {
command: args,
},
})
const learnData = learn[Math.floor(Math.random() * learn.length)] const learnData = learn[Math.floor(Math.random() * learn.length)]
const randomNumber = Math.round(Math.random() * (2 - 1) + 1) const randomNumber = Math.round(Math.random() * (2 - 1) + 1)
@ -30,7 +30,7 @@ export default class ChatBot {
let response: string let response: string
if ((msg.channel as TextChannel).nsfw) { if ((msg.channel as TextChannel).nsfw) {
const NSFWData = await this.db.nsfwContent.all() const NSFWData = await this.db.nsfw_content.findMany()
const dataList = [...data, ...NSFWData] const dataList = [...data, ...NSFWData]
response = dataList[Math.floor(Math.random() * dataList.length)].text response = dataList[Math.floor(Math.random() * dataList.length)].text
} else { } else {
@ -50,19 +50,23 @@ export default class ChatBot {
if (msg.author.bot) return if (msg.author.bot) return
if (msg.author.id === container.config.train.user_ID) { if (msg.author.id === container.config.train.user_ID) {
const response = await this.getResponse(msg) const response = await this.getResponse(msg)
await this.db.statement.insert({ await this.db.statement.create({
data: {
text: msg.content, text: msg.content,
persona: 'muffin', persona: 'muffin',
in_response_to: response, in_response_to: response,
},
}) })
} else { } else {
if (!(msg.channel as TextChannel).nsfw) return if (!(msg.channel as TextChannel).nsfw) return
if (!msg.content.startsWith(prefix)) return if (!msg.content.startsWith(prefix)) return
const persona = `user:${msg.author.username.slice(0, 50).toLowerCase()}` const persona = `user:${msg.author.username.slice(0, 50).toLowerCase()}`
const text = msg.content.replace(prefix, '') const text = msg.content.replace(prefix, '')
await this.db.nsfwContent.insert({ await this.db.nsfw_content.create({
data: {
text, text,
persona, persona,
},
}) })
} }
}) })

22
src/modules/config.ts Normal file
View file

@ -0,0 +1,22 @@
import 'dotenv/config'
function getConfigValue(
value: 'BOT_TOKEN' | 'BOT_OWNER_ID' | 'BOT_PREFIX',
) {
const configValue = process.env[value]
if (!configValue)
throw new Error(`.env 파일에서 ${value}값을 찾을 수 없어요.`)
return configValue
}
export default class MAAConfig {
public readonly bot = {
token: getConfigValue('BOT_TOKEN'),
owner_ID: getConfigValue('BOT_OWNER_ID'),
prefix: getConfigValue('BOT_PREFIX'),
}
public readonly train = {
user_ID: process.env.TRAIN_USER_ID,
}
}

View file

@ -1,27 +0,0 @@
import { LearnTable, NSFWContentTable, StatementTable } from './model'
import { container } from '@sapphire/framework'
import { createPool } from 'mysql2/promise'
export class MaaDatabase {
public readonly database = createPool({
...container.config.mysql,
keepAliveInitialDelay: 10000,
enableKeepAlive: true,
})
.on('release', conn => {
container.logger.debug(`[MaaDatabase] ${conn.threadId} Released.`)
})
.on('connection', conn => {
container.logger.debug(`[MaaDatabase] ${conn.threadId} Connected.`)
})
public statement = new StatementTable(this.database)
public nsfwContent = new NSFWContentTable(this.database)
public learn = new LearnTable(this.database)
public ping() {
this.database.getConnection().then(conn => {
conn.ping()
conn.release()
})
}
}

View file

@ -1,3 +0,0 @@
export * from './database'
export * from './type'
export * from './model'

View file

@ -1,3 +0,0 @@
export * from './statement'
export * from './learn'
export * from './nsfwContent'

View file

@ -1,72 +0,0 @@
import type { BaseTable, LearnData } from '../type'
import { type Pool } from 'mysql2/promise'
import { Snowflake } from 'discord.js'
import run from '../run'
export class LearnTable implements BaseTable<LearnData, string> {
public readonly name = 'learn'
public constructor(private _database: Pool) {}
public async all(): Promise<LearnData[]> {
const [rows] = await this._database.execute<LearnData[]>(
`SELECT * FROM ${this.name};`,
)
return rows
}
public async findOne(key: string): Promise<LearnData[]> {
const [rows] = await this._database.execute<LearnData[]>(
`SELECT * FROM ${this.name} WHERE command = ?;`,
[key],
)
return rows
}
public async insert(data: {
command: string
result: string
user_id: Snowflake
}): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(
`INSERT INTO ${this.name} (command, result, user_id) VALUES (?, ?, ?);`,
[data.command, data.result, data.user_id],
)
})
}
public async update(data: {
command: string
result: string
}): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(
`UPDATE ${this.name} SET result = ? WHERE command = ?;`,
[data.command, data.result],
)
})
}
public async delete(key: string): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(`DELETE FROM ${this.name} WHERE id = ?;`, [key])
})
}
public async findOneAnotherKey(
key: 'id' | 'command' | 'result' | 'user_id' | 'created_at',
data: any,
): Promise<LearnData[]> {
const [rows] = await this._database.execute<LearnData[]>(
`SELECT * FROM ${this.name} WHERE ${key} = ?;`,
[data],
)
return rows
}
}

View file

@ -1,66 +0,0 @@
import type { BaseTable, NSFWData } from '../type'
import { type Pool } from 'mysql2/promise'
import run from '../run'
export class NSFWContentTable implements BaseTable<NSFWData, number> {
public readonly name = 'nsfw_content'
public constructor(private _database: Pool) {}
public async all(): Promise<NSFWData[]> {
const [rows] = await this._database.execute<NSFWData[]>(
`SELECT * FROM ${this.name};`,
)
return rows
}
public async findOne(key: number): Promise<NSFWData[]> {
const [rows] = await this._database.execute<NSFWData[]>(
`SELECT * FROM ${this.name} WHERE id = ?;`,
[key],
)
return rows
}
public async insert(data: { text: string; persona: string }): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(
`INSERT INTO ${this.name} (text, persona) VALUES (?, ?);`,
[data.text, data.persona],
)
})
}
public async update(data: { id: number; text: string }): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(`UPDATE ${this.name} SET text = ? WHERE id = ?;`, [
data.text,
data.id,
])
})
}
public async delete(key: number): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(`DELETE FROM ${this} WHERE id = ?;`, [key])
})
}
public async findOneAnotherKey(
key: 'id' | 'text' | 'persona' | 'created_at',
data: any,
): Promise<NSFWData[]> {
const [rows] = await this._database.execute<NSFWData[]>(
`SELECT *
FROM ${this.name}
WHERE ${key} = ?;`,
[data],
)
return rows
}
}

View file

@ -1,76 +0,0 @@
import type { BaseTable, ResponseData } from '../type'
import { type Pool } from 'mysql2/promise'
import run from '../run'
export class StatementTable implements BaseTable<ResponseData, number> {
public readonly name = 'statement'
public constructor(private _database: Pool) {}
public async all(): Promise<ResponseData[]> {
const [rows] = await this._database.execute<ResponseData[]>(
`SELECT * FROM ${this.name};`,
)
return rows
}
public async findOne(key: number): Promise<ResponseData[]> {
const [rows] = await this._database.execute<ResponseData[]>(
`SELECT * FROM ${this.name} WHERE id = ?;`,
[key],
)
return rows
}
public async insert(data: {
text: string
persona: string
in_response_to: string
}): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(
`INSERT INTO ${this.name} (text, persona, in_response_to) VALUES (?, ?, ?);`,
[data.text, data.persona, data.in_response_to],
)
})
}
public async update(data: { id: number; text: string }): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(`UPDATE ${this.name} SET text = ? WHERE id = ?;`, [
data.text,
data.id,
])
})
}
public async delete(key: number): Promise<void> {
const db = await this._database.getConnection()
await run(db, async () => {
await db.execute(`DELETE FROM ${this.name} WHERE id = ?;`, [key])
})
}
public async findOneAnotherKey(
key:
| 'id'
| 'text'
| 'persona'
| 'created_at'
| 'search_text'
| 'conversation'
| 'in_response_to'
| 'search_in_response_to',
data: any,
): Promise<ResponseData[]> {
const [rows] = await this._database.execute<ResponseData[]>(
`SELECT * FROM ${this.name} WHERE ${key} = ?;`,
[data],
)
return rows
}
}

View file

@ -1,14 +0,0 @@
import { type PoolConnection } from 'mysql2/promise'
export default async function run(db: PoolConnection, fn: () => Promise<any>) {
try {
await db.beginTransaction()
await fn()
await db.commit()
} catch (err) {
console.error(err)
await db.rollback()
} finally {
db.release()
}
}

View file

@ -1,36 +0,0 @@
import type { RowDataPacket } from 'mysql2/promise'
import type { Snowflake } from 'discord.js'
export interface BaseData extends RowDataPacket {
id: number
text: string
created_at: string
persona: string
}
export interface ResponseData extends BaseData {
search_text: string
conversation: string
in_response_to: string | null
search_in_response_to: string
}
export interface LearnData extends RowDataPacket {
id: number
command: string
result: string
user_id: Snowflake
created_at: string
}
export { BaseData as NSFWData }
export interface BaseTable<T, V> {
name: string
all(): Promise<T[]>
findOne(key: V): Promise<T[]>
insert(data: any): Promise<void>
update(data: any): Promise<void>
delete(key: V): Promise<void>
findOneAnotherKey(key: string, data: any): Promise<T[]>
}

View file

@ -1,14 +1,7 @@
import { ResponseData, NSFWData, LearnData, MaaDatabase } from './database' import previewWarning from './previewWarning'
import { NODE_ENV } from './env' import { NODE_ENV } from './env'
import ChatBot from './ChatBot' import ChatBot from './chatBot'
import Config from './config'
import noPerm from './noPerm' import noPerm from './noPerm'
export { export { NODE_ENV, ChatBot, noPerm, Config, previewWarning }
ResponseData,
MaaDatabase,
LearnData,
NODE_ENV,
ChatBot,
NSFWData,
noPerm,
}

View file

@ -0,0 +1,21 @@
import type { ChatInputCommandInteraction, Message } from 'discord.js'
import { container } from '@sapphire/framework'
export default async function previewWarning(
ctx: Message<true> | ChatInputCommandInteraction<'cached'>,
) {
await ctx.channel!.send({
embeds: [
{
title: '정식 출시 이전 버전 사용안내',
description:
`현재 이 버전의 ${container.client.user?.username}은 정식출시 되기 이전이라 많이 불안정할 수 있어요.\n` +
`만약 오류가 발견되면 ${(await container.client.users.fetch(container.config.bot.owner_ID)).username}님에게 알려주세요.\n`,
color: 0xff0000,
footer: {
text: `현재 채널: ${container.channel.toLowerCase()} 버전: ${container.version}`,
},
},
],
})
}

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'tsup'
const sourcemap = process.env.NODE_ENV === 'development' ? true : false
export default defineConfig({
clean: true,
format: ['cjs'],
entry: ['src/index.ts'],
minify: true,
sourcemap,
})

3597
yarn.lock

File diff suppressed because it is too large Load diff