From 76ec291f479c0aea5131a5c753532fbeeb530b13 Mon Sep 17 00:00:00 2001 From: szdytom Date: Sun, 29 Oct 2023 20:44:36 +0800 Subject: [PATCH] refactor arch --- .cache/.gitkeep | 0 .gitignore | 10 +- README.md | 25 +++- auth/authlib.mjs | 184 ++++++++++++++++++++++++++++++ authlib.js | 109 ------------------ index.js | 75 ------------ index.mjs | 69 +++++++++++ package.json | 12 ++ plugin/event-promise/index.mjs | 11 ++ plugin/event-promise/package.json | 9 ++ utils.js | 22 ---- utils/index.mjs | 29 +++++ 12 files changed, 343 insertions(+), 212 deletions(-) create mode 100644 .cache/.gitkeep create mode 100644 auth/authlib.mjs delete mode 100644 authlib.js delete mode 100644 index.js create mode 100644 index.mjs create mode 100644 plugin/event-promise/index.mjs create mode 100644 plugin/event-promise/package.json delete mode 100644 utils.js create mode 100644 utils/index.mjs diff --git a/.cache/.gitkeep b/.cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 74b1af1..f10159f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,18 @@ # Node.js node_modules/ +package-lock.json # Editor .vscode/ *.swp +# Config +config.json + # Credential credential.json -session.json +credentials.json + +# Session Cache Folder +.cache/* +!.cache/.gitkeep diff --git a/README.md b/README.md index 9c98deb..4e6d637 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,34 @@ # Compass Bot -## 登录 +## 安装 + +运行下面命令安装 Node.JS 的依赖包: + +```sh +npm i +``` + +## 配置认证数据 在项目根目录下创建 `credentials.json`,包含如下内容: ```json { - "endpoint": "Yggdrasil API的调用点", - "username": "用户名", - "password": "密码" + "endpoint": "Yggdrasil API 调用点", + "endpoint_auth": "Yggdrasil API 用户服务器调用点(可选)", + "endpoint_session": "Yggdrasil API 会话服务器调用点(可选)", + "accounts": [{ + "handle": "登录名(在支持非邮箱登录的 Yggdrasil 服务器上可选)", + "password": "密码", + "profiles": ["角色名1", "角色名2"] + }] } ``` ## 运行 +运行下面命令启动: + ```sh -node index.js --version= --owner= +node index.mjs profile@host:port ``` diff --git a/auth/authlib.mjs b/auth/authlib.mjs new file mode 100644 index 0000000..e2c22a7 --- /dev/null +++ b/auth/authlib.mjs @@ -0,0 +1,184 @@ +import axios from 'axios'; +import { readJsonFile, writeJsonFile } from '../utils/index.mjs'; + +export class NoCredentialError extends Error { + constructor(profile) { + super(`No credentials configured for profile ${profile}.`); + } +}; + +export class ProfileNotSelectedError extends Error { + constructor() { + super('No profile was selected.'); + } +}; + +export class ProfileAlreadySelectedError extends Error { + constructor(profile) { + super(`Profile was already selected to ${profile}.`); + } +}; + +export class YggdrasilEndpoint { + constructor(endpoint, endpoint_auth, endpoint_session) { + this.endpoint = endpoint; + this.endpoint_auth = endpoint_auth || this.endpoint + '/authserver'; + this.endpoint_session = endpoint_session || this.endpoint + '/sessionserver'; + } + + auth_url(path) { + return this.endpoint_auth + '/' + path; + } +}; + +export class Account { + constructor(handle, password) { + this.handle = handle; + this.password = password; + } + + async auth(endpoint) { + let auth_info = (await axios.post(endpoint.auth_url('authenticate'), { + username: this.handle, + password: this.password, + })).data; + + return new SessionCache(auth_info.accessToken, auth_info.clientToken + , auth_info.selectedProfile, auth_info.availableProfiles); + } +}; + +export class Credentials { + constructor(options) { + this.endpoint = new YggdrasilEndpoint(options.endpoint, options.endpoint_auth, options.endpoint_session); + + this.profiles = new Map(); + this.accounts = []; + for (let account_option of options.accounts) { + let account = new Account(account_option.handle || account_option.profiles[0] + , account_option.password); + this.accounts.push(account); + for (let profile of account_option.profiles) { + this.profiles.set(profile, account); + } + } + } + + credentialOf(profile) { + return this.profiles.get(profile); + } + + static async fromFile(path) { + let options = await readJsonFile(path); + return new Credentials(options); + } + + async authProfile(profile) { + let sc = await SessionCache.load(profile); + if (sc != null && !(await sc.validate(this.endpoint))) { sc = null; } + + if (sc == null) { + let account = this.credentialOf(profile); + if (account == null) { throw new NoCredentialError(profile); } + sc = await account.auth(this.endpoint); + await sc.selectProfile(this.endpoint, profile); + await sc.store(); + } + return sc; + } +}; + +export class SessionCache { + constructor(accessToken, clientToken, selectedProfile, availableProfiles) { + this.accessToken = accessToken; + this.clientToken = clientToken; + this.selectedProfile = selectedProfile; + this.availableProfiles = availableProfiles; + } + + name() { + return this.selectedProfile.name; + } + + session() { + return { + accessToken: this.accessToken, + clientToken: this.clientToken, + selectedProfile: this.selectedProfile, + }; + } + + async store() { + if (this.selectedProfile == null) { throw new ProfileNotSelectedError(); } + await writeJsonFile(`.cache/${this.name()}.json`, this.session()); + } + + static async load(profile) { + let cache_data = await readJsonFile(`.cache/${profile}.json`); + if (cache_data == null) { return null; } + return new SessionCache(cache_data.accessToken, cache_data.clientToken, cache_data.selectedProfile); + } + + async validate(endpoint) { + let vres = await axios.post(endpoint.auth_url('validate'), { + accessToken: this.accessToken, + clientToken: this.clientToken, + }, { validateStatus: (status) => (status === 403 || status === 204) }); + return vres.status == 204; + } + + async selectProfile(endpoint, profile) { + if (this.selectedProfile != null) { + if (this.name() != profile) { + throw new ProfileAlreadySelectedError(this.name()); + } else { return; } + } + + if (this.availableProfiles == null) { + throw new NoCredentialError(profile); + } + + let profile_info = null; + for (let info of this.availableProfiles) { + if (info.name == profile) { + profile_info = info; + break; + } + } + + if (profile_info == null) { throw new NoCredentialError(profile); } + + let session_info = (await axios.post(endpoint.auth_url('refresh'), { + accessToken: this.accessToken, + clientToken: this.clientToken, + selectedProfile: profile_info, + })).data; + this.selectedProfile = session_info.selectedProfile; + this.accessToken = session_info.accessToken; + this.availableProfiles = null; + } + + mineflayer(endpoint) { + if (this.selectedProfile == null) { throw new ProfileNotSelectedError(); } + + return { + username: this.name(), + authServer: endpoint.endpoint_auth, + sessionServer: endpoint.endpoint_session, + auth: (client, options) => { + client.username = this.name(); + client.session = this.session(); + options.accessToken = this.accessToken; + options.clientToken = this.clientToken; + options.haveCredentials = true; + client.emit('session', client.session); + options.connect(client); + }, + }; + } +}; + +export default { + NoCredentialError, ProfileNotSelectedError, ProfileAlreadySelectedError + , YggdrasilEndpoint, Account, Credentials, SessionCache +}; \ No newline at end of file diff --git a/authlib.js b/authlib.js deleted file mode 100644 index db3c034..0000000 --- a/authlib.js +++ /dev/null @@ -1,109 +0,0 @@ -const axios = require('axios'); -const { readJsonFile, writeJsonFile } = require('./utils'); - -async function readCredentials() { - let credentials = await readJsonFile('credential.json'); - if (!credentials.endpoint_auth) { - credentials.endpoint_auth = credentials.endpoint + '/authserver'; - } - - if (!credentials.endpoint_session) { - credentials.endpoint_session = credentials.endpoint + '/sessionserver'; - } - - if (!credentials.handle) { - credentials.handle = credentials.username; - } - - return credentials; -} - -async function readSessionCache() { - return readJsonFile('session.json'); -} - -async function storeSessionCache(data) { - return writeJsonFile('session.json', data); -} - -async function yggdrailLogin(credentials) { - let account_info = (await axios.post(`${credentials.endpoint_auth}/authenticate`, { - username: credentials.handle, - password: credentials.password, - })).data; - - let profile_info = null; - for (let info of account_info.availableProfiles) { - if (info.name == credentials.username) { - profile_info = info; - break; - } - } - - let session_info = (await axios.post(`${credentials.endpoint_auth}/refresh`, { - accessToken: account_info.accessToken, - clientToken: account_info.clientToken, - selectedProfile: profile_info, - })).data; - - return { - accessToken: session_info.accessToken, - clientToken: session_info.clientToken, - selectedProfile: session_info.selectedProfile, - }; -} - -async function yggdrasilAuth(credentials) { - if (credentials == null) { - credentials = await readCredentials(); - } - - let cache = await readSessionCache(); - if (cache != null && cache.selectedProfile?.name != credentials.username) { - cache = null; - } - - if (cache != null) { - let vres = await axios.post(`${credentials.endpoint_auth}/validate`, { - accessToken: cache.accessToken, - clientToken: cache.clientToken, - }, { - validateStatus: function (status) { - return status === 403 || status === 204; - } - }); - - if (vres.status == 403) { - cache = null; - } - } - - let session_info = cache; - if (session_info == null) { - session_info = await yggdrailLogin(credentials); - storeSessionCache(session_info); - } - - return session_info; -} - -async function mineflayer() { - let credentials = await readCredentials(); - let session_info = await yggdrasilAuth(credentials); - return { - username: credentials.username, - authServer: credentials.endpoint_auth, - sessionServer: credentials.endpoint_session, - auth: (client, options) => { - client.username = credentials.username; - client.session = session_info; - options.accessToken = session_info.accessToken; - options.clientToken = session_info.clientToken; - options.haveCredentials = true; - client.emit('session', session_info); - options.connect(client); - }, - }; -} - -module.exports = { readCredentials, yggdrasilAuth, mineflayer }; diff --git a/index.js b/index.js deleted file mode 100644 index 6cce53e..0000000 --- a/index.js +++ /dev/null @@ -1,75 +0,0 @@ -const authlib = require('./authlib'); -const mineflayer = require('mineflayer'); -const { pathfinder, Movements } = require('mineflayer-pathfinder'); -const { GoalNear } = require('mineflayer-pathfinder').goals -const repl = require('repl'); -const domain = require('domain'); -const yargs = require('yargs'); -const { parseURL } = require('./utils'); - -const args = yargs.option('protocal', { - description: 'minecraft server version', - type: 'string', - requiresArg: false, -}).option('owner', { - description: 'bot owner name', - type: 'string', - requiresArg: false -}).help().alias('help', 'h').argv; - -const [host, port] = parseURL(args._[0]); -const version = args.protocal; - -const repl_domain = domain.create(); - -repl_domain.on('error', (err) => { - console.error('Caught error:', err); -}); - -async function main() { - let authinfo = await authlib.mineflayer(); - let bot = mineflayer.createBot({ - host, port, version, - ...authinfo, - }); - bot.loadPlugin(pathfinder); - - bot.on('kicked', console.warn); - bot.on('error', console.warn); - - bot.once('spawn', () => { - repl_domain.run(() => { - let r = repl.start({ - prompt: "bot > ", - input: process.stdin, - output: process.stdout, - color: true, - terminal: true, - }); - - const deafult_tactic = new Movements(bot); - const peaceful_tactic = new Movements(bot); - peaceful_tactic.canDig = false; - peaceful_tactic.scafoldingBlocks = []; - - r.context.deafult_tactic = deafult_tactic; - r.context.peaceful_tactic = peaceful_tactic; - r.context.bot = bot; - r.context.authinfo = authinfo; - r.context.Movements = Movements; - r.context.GoalNear = GoalNear; - r.context.mineflayer = mineflayer; - r.context.owner = () => { - if (!args.owner) { - return null; - } - return bot.players[args.owner]; - }; - r.context.control = require('./control'); - r.context.Vec3 = require('vec3').Vec3; - }); - }); -} - -main(); - diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..7f48bcb --- /dev/null +++ b/index.mjs @@ -0,0 +1,69 @@ +import authlib from './auth/authlib.mjs'; +import mineflayer from 'mineflayer'; +import yargs from 'yargs'; +import { parseLogin, waitEvent } from './utils/index.mjs'; +import repl from 'node:repl'; +import vm from 'node:vm'; + +async function main() { + const args = yargs((await import('yargs/helpers')).hideBin(process.argv)) + .option('protocal', { + description: 'Minecraft server version', + type: 'string', + requiresArg: false, + }).option('owner', { + description: 'Bot\'s owner name.', + type: 'string', + requiresArg: false, + }).option('no-repl', { + description: 'Disable bot REPL control.', + type: "boolean", + }).option('credentials-lib', { + description: 'Credentials\' library file.', + type: "string", + default: "credentials.json", + }).usage('Uasge: profile@host:port').help().alias('help', 'h').argv; + + let login_info = args._[0]; + if (login_info == null) { return; } + const [name, host, port] = parseLogin(login_info); + const credential_info = await authlib.Credentials.fromFile(args.credentialsLib); + if (credential_info == null) { + throw new Error(`Cannot load credential ${args.credentialsLib}`); + } + + const session = await credential_info.authProfile(name); + const bot = mineflayer.createBot({ + host, port, version: args.protocal, + ...session.mineflayer(credential_info.endpoint) + }); + + await waitEvent(bot, 'inject_allowed'); + bot.loadPlugin((await import('mineflayer-event-promise')).default); + await bot.waitEvent('spawn'); + + let context = vm.createContext(); + context.bot = bot; + context.Vec3 = (await import('vec3')).Vec3; + context.mineflayer = mineflayer; + context.owner = () => { + if (!args.owner) { return null; } + return bot.players[args.owner]; + }; + if (!args.noRepl) { + let r = repl.start({ + prompt: 'local > ', + input: process.stdin, + output: process.stdout, + color: true, + terminal: true, + ignoreUndefined: true, + }); + r.context = context; + } +} + +main().catch(err => { + console.error('Error: ', err); + process.exit(1); +}); diff --git a/package.json b/package.json index 4a02541..e22ea24 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,22 @@ { "name": "compass-bot", + "description": "Create minecraft bot swarms and control them easily.", + "main": "index.mjs", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/szdytom/compass-bot.git" + }, "dependencies": { "axios": "^1.6.0", + "debug": "^4.3.4", "mineflayer": "^4.14.0", + "mineflayer-event-promise": "file:plugin/event-promise", "mineflayer-pathfinder": "^2.4.5", "prismarine-viewer": "^1.25.0", "yargs": "^17.7.2" + }, + "engines": { + "node": ">=20" } } diff --git a/plugin/event-promise/index.mjs b/plugin/event-promise/index.mjs new file mode 100644 index 0000000..f8f32bd --- /dev/null +++ b/plugin/event-promise/index.mjs @@ -0,0 +1,11 @@ +import debug from 'debug'; +const log = debug('mineflayer-event-promise'); + +export default function inject(bot) { + debug('Injected!'); + bot.waitEvent = (event) => { + return new Promise((resolve, _reject) => { + bot.once(event, resolve); + }); + }; +} diff --git a/plugin/event-promise/package.json b/plugin/event-promise/package.json new file mode 100644 index 0000000..241c3b7 --- /dev/null +++ b/plugin/event-promise/package.json @@ -0,0 +1,9 @@ +{ + "name": "mineflayer-event-promise", + "description": "Promise-styled event system for mineflayer bot", + "type": "module", + "main": "index.mjs", + "dependencies": { + "debug": "^4.3.4" + } +} diff --git a/utils.js b/utils.js deleted file mode 100644 index d0cf9e9..0000000 --- a/utils.js +++ /dev/null @@ -1,22 +0,0 @@ -const fs = require('fs/promises'); - -async function readJsonFile(path) { - try { - const data = await fs.readFile(path, 'utf8'); - return JSON.parse(data); - } catch (error) { - return null; - } -} - -function writeJsonFile(path, data) { - const json_string = JSON.stringify(data); - return fs.writeFile(path, json_string, 'utf8'); -} - -function parseURL(url) { - const [host, port] = url.split(':'); - return [host, port ? parseInt(port) : undefined]; -} - -module.exports = { readJsonFile, writeJsonFile, parseURL }; diff --git a/utils/index.mjs b/utils/index.mjs new file mode 100644 index 0000000..db4c85e --- /dev/null +++ b/utils/index.mjs @@ -0,0 +1,29 @@ +import fs from 'node:fs/promises'; + +export async function readJsonFile(path) { + try { + const data = await fs.readFile(path, 'utf8'); + return JSON.parse(data); + } catch (error) { + return null; + } +} + +export function writeJsonFile(path, data) { + const json_string = JSON.stringify(data); + return fs.writeFile(path, json_string, 'utf8'); +} + +export function parseLogin(url) { + const [profile_host, port] = url.split(':'); + const [profile, host] = profile_host.split('@'); + return [profile, host, port ? parseInt(port) : undefined]; +} + +export function waitEvent(em, event) { + return new Promise((resolve, _reject) => { + em.once(event, resolve); + }); +} + +export default { readJsonFile, writeJsonFile, parseLogin, waitEvent };