diff --git a/CHANGELOG.md b/CHANGELOG.md index fe48fc8..d94853b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,17 @@ -# v1.0.13-SNAPSHOT +# v1.0.13 *** +#### 2023.12.18 + +* feat: 实现网页端登入 +* feat: 支持读取消息,触发`message`事件 +* future: 支持更多事件和发送消息 + +#### 2023.12.16 + +* feat: 获取历史聊天内容api + #### 2023.12.15 * feat: 支持扫码登入自动获取mys_ck diff --git a/lib/bot.ts b/lib/bot.ts index ef95d55..f9b8762 100644 --- a/lib/bot.ts +++ b/lib/bot.ts @@ -1,7 +1,7 @@ import * as log4js from "log4js"; import crypto from "crypto"; -import axios, {AxiosResponse} from "axios"; -import {Encode, fetchQrCode, getHeaders, lock, md5Stream, TMP_PATH} from "./common" +import axios from "axios"; +import {Encode, getMysCk, lock, md5Stream, TMP_PATH} from "./common" import EventEmitter from "node:events"; import {verifyPKCS1v15} from "./verify"; import { @@ -24,7 +24,6 @@ import {Perm, Villa, VillaInfo} from "./villa"; import {Readable} from "node:stream"; import {WsClient} from "./ws"; import {HttpClient} from "./http"; -import {UClient} from "./uClient"; const pkg = require("../package.json") @@ -85,7 +84,7 @@ export interface Config { pub_key: string /** logger配置,默认info */ - level?: LogLevel + log_level?: LogLevel /** 启动的端口号,默认8081 */ port?: number @@ -98,9 +97,6 @@ export interface Config { /** 测试别野id,如果机器人未上线,则需要填入测试别野id,否则无法使用ws */ villa_id?: number - /** 是否用户登入 */ - user_login?: boolean - /** * 米游社上传图片需要ck,因为不是调用的官方开发api,后续补上官方开发api * 优先使用官方接口进行图片上传,此上传接口仅在官方接口不可用时自动调用 @@ -124,7 +120,6 @@ export class Bot extends EventEmitter { private readonly enSecret: string private readonly jwkKey: crypto.JsonWebKey | undefined private [INTERVAL]!: NodeJS.Timeout - private uClient!: UClient private statistics = { start_time: Date.now(), send_msg_cnt: 0, @@ -138,17 +133,15 @@ export class Bot extends EventEmitter { readonly vl = new Map() private client: HttpClient | WsClient | undefined; private keepAlive: boolean - private keepUseAlive: boolean public handler: Map constructor(props: Config) { super(); this.config = { - level: 'info', + log_level: 'info', is_verify: true, villa_id: 0, - user_login: false, ...props } if (!this.config?.pub_key?.length) throw new RobotRunTimeError(-1, '未配置公钥,请配置后重试') @@ -161,11 +154,10 @@ export class Bot extends EventEmitter { if (!this.config.ws) this.jwkKey = this.pubKey.export({format: "jwk"}) this.enSecret = this.encryptSecret() this.logger = log4js.getLogger(`[BOT_ID:${this.config.bot_id}]`) - this.logger.level = this.config.level as LogLevel + this.logger.level = this.config.log_level as LogLevel this.keepAlive = true - this.keepUseAlive = true this.printPkgInfo() - if (this.config.mys_ck === "" || this.config.user_login) this.getMysCk((ck: string) => { + if (this.config.mys_ck === "") getMysCk.call(this, (ck: string) => { this.config.mys_ck = ck fs.writeFile(`${this.config.data_dir}/cookie`, ck, () => {}) this.run().then(() => this.emit("online")) @@ -185,18 +177,21 @@ export class Bot extends EventEmitter { return this.statistics } + get interval() { + return this[INTERVAL] + } + + set interval(i: NodeJS.Timeout) { + this[INTERVAL] = i + } + private run() { return new Promise(resolve => { - const runs = () => { - if (this.config.ws) { - this.newWsClient(resolve).then() - } else { - this.client = new HttpClient(this, this.config, resolve) - } + if (this.config.ws) { + this.newWsClient(resolve).then() + } else { + this.client = new HttpClient(this, this.config, resolve) } - if (this.config.user_login) { - this.newUClient(runs).then() - } else runs() }) } @@ -204,10 +199,6 @@ export class Bot extends EventEmitter { this.keepAlive = k } - setUseAlive(s: boolean) { - this.keepUseAlive = s - } - private async newWsClient(cb: Function) { try { this.client = await WsClient.new(this, cb) @@ -227,87 +218,6 @@ export class Bot extends EventEmitter { } } - private async newUClient(cb: Function) { - try { - this.uClient = await UClient.new(this, cb) - this.uClient.on("close", async (code, reason) => { - this.uClient.destroy() - if (!this.keepUseAlive) return - this.logger.error(`uclient连接已断开,reason ${reason.toString() || 'unknown'}(${code}),5秒后将自动重连...`) - setTimeout(async () => { - await this.newUClient(cb) - }, 5000) - }) - } catch (err) { - if ((err as Error).message.includes("not login")) { - this.logger.error("mys_ck已失效,请重新删除cookie后扫码登入") - fs.unlinkSync(`${this.config.data_dir}/cookie`) - return - } - this.logger.error(`${(err as Error).message || "uclient建立连接失败"}, 5秒后将自动重连...`) - setTimeout(async () => { - await this.newUClient(cb) - }, 5000) - } - } - - private getMysCk(cb: Function) { - if (!fs.existsSync(`${this.config.data_dir}/cookie`)) fs.writeFileSync(`${this.config.data_dir}/cookie`, "") - let ck: string = fs.readFileSync(`${this.config.data_dir}/cookie`, "utf-8") - if (ck && ck !== "") { - cb(ck) - return - } - const handler = async (data: Buffer) => { - clearInterval(this[INTERVAL]) - this._QrCodeLogin().then() - } - process.stdin.on("data", handler) - this.on("qrLogin.success", ck => { - this.logger.info("二维码扫码登入成功") - process.stdin.off("data", handler) - cb(ck) - }) - this.on("qrLogin.error", e => { - this.logger.error("登入失败:reason " + e) - }) - this._QrCodeLogin().then() - } - - private async _QrCodeLogin() { - const {img, ticket} = await fetchQrCode.call(this); - console.log("请用米游社扫描二维码,回车刷新二维码") - console.log(`二维码已保存到${img}`) - this[INTERVAL] = setInterval(async () => { - this.logger.debug('请求二维码状态...') - const res: AxiosResponse = await axios.post("https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus?ticket=" + ticket, {}, { - headers: getHeaders() - }) - let status = res?.data - if (!status) return - if (status.message !== 'OK') { - this.emit("qrLogin.error", status?.message || "unknown") - clearInterval(this[INTERVAL]) - return - } - status = status?.data?.status - if (!status) return - if (status === 'Confirmed') { - const set_cookie = res.headers["set-cookie"] - if (!set_cookie) { - this.emit("qrLogin.error", "没有获取到cookie, 请刷新重试") - clearInterval(this[INTERVAL]) - return - } - let cookie = "" - for (let ck of set_cookie) cookie += ck.split("; ")[0] + "; " - if (cookie === "") this.emit("qrLogin.error", "获取到的cookie为空,请刷新二维码重新获取") - else this.emit("qrLogin.success", cookie) - clearInterval(this[INTERVAL]) - } - }, 1000) - } - /** 输出包信息 */ private printPkgInfo() { this.logger.mark("---------------") @@ -488,10 +398,6 @@ export class Bot extends EventEmitter { /** ws退出登入,只有回调是ws才有用 */ async logout() { if (this.client instanceof WsClient) { - if (this.config.user_login) { - this.keepUseAlive = false - this.uClient.close() - } if (!(await (this.client as WsClient).doPLogout())) { this.logger.warn("本地将直接关闭连接...") this.keepAlive = false diff --git a/lib/common.ts b/lib/common.ts index 20db0d0..c7ea34a 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -6,6 +6,7 @@ import qr, {Bitmap} from "qr-image" import {promisify} from "util"; import axios, {AxiosResponse} from "axios"; import fs from "node:fs"; +import {UClient} from "./uClient"; export const UintSize = 32 << (~0 >>> 63) export const _W = UintSize - 1 @@ -237,7 +238,7 @@ export function localIP(): string | undefined { } /** 获取二维码 */ -export async function fetchQrCode(this: Bot) { +export async function fetchQrCode(this: Bot | UClient) { let res = await axios.post("https://passport-api.miyoushe.com/account/ma-cn-passport/web/createQRLogin", {}, { headers: getHeaders() }) @@ -252,7 +253,7 @@ export async function fetchQrCode(this: Bot) { size: 1, customize: logQrcode }) - const f = `./data/${this.config.bot_id}/mysQr.png` + const f = `${this.config.data_dir}/mysQr.png` await promisify(stream.pipeline)(io, fs.createWriteStream(f)); return {img: f, ticket: info.ticket} } @@ -299,6 +300,74 @@ export function getHeaders() { } } +/** 获取米游社cookie */ +export function getMysCk(this: any, cb: Function) { + if (fs.existsSync(`${this.config.data_dir}/cookie`)) { + const ck = fs.readFileSync(`${this.config.data_dir}/cookie`, "utf-8") + if (ck && ck !== "") { + cb(ck) + return + } + } + const handler = async (data: Buffer) => { + clearInterval(this.interval) + _QrCodeLogin.call(this).then() + } + process.stdin.on("data", handler) + this.on("qrLogin.success", (ck: any) => { + this.logger.info("二维码扫码登入成功") + process.stdin.off("data", handler) + cb(ck) + }) + this.on("qrLogin.error", (e: any) => { + this.logger.error("登入失败:reason " + e) + }) + _QrCodeLogin.call(this).then() +} + +async function _QrCodeLogin(this: Bot | UClient) { + const {img, ticket} = await fetchQrCode.call(this); + console.log("请用米游社扫描二维码,回车刷新二维码") + console.log(`二维码已保存到${img}`) + this.interval = setInterval(async () => { + this.logger.debug('请求二维码状态...') + const res: AxiosResponse = await axios.post("https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus?ticket=" + ticket, {}, { + headers: getHeaders() + }) + let status = res?.data + if (!status) return + if (status.message !== 'OK') { + this.emit("qrLogin.error", status?.message || "unknown") + clearInterval(this.interval) + return + } + status = status?.data?.status + if (!status) return + if (status === 'Confirmed') { + const set_cookie = res.headers["set-cookie"] + if (!set_cookie) { + this.emit("qrLogin.error", "没有获取到cookie, 请刷新重试") + clearInterval(this.interval) + return + } + let cookie = "" + for (let ck of set_cookie) cookie += ck.split("; ")[0] + "; " + if (cookie === "") this.emit("qrLogin.error", "获取到的cookie为空,请刷新二维码重新获取") + else this.emit("qrLogin.success", cookie) + clearInterval(this.interval) + } + }, 1000) +} + +/** clientUniqueId */ +export function ZO(Un = 0) { + let e = ((4294967295 & Date.now()) >>> 0).toString(2) + , t = Math.floor(Math.random() * (Math.pow(2, 20) - 1)) + , n = e + Un.toString(2).padStart(11, "0") + t.toString(2).padStart(20, "0"); + return Un = 2047 & ++Un, + parseInt(n, 2) +} + function shouldEscape(s: string): boolean { return /[^a-zA-Z0-9\-_\.~]/.test(s) } diff --git a/lib/core/deivce.ts b/lib/core/deivce.ts new file mode 100644 index 0000000..ef71903 --- /dev/null +++ b/lib/core/deivce.ts @@ -0,0 +1,46 @@ +function genDeviceId() { + let e = Sr(); + if (e = "".concat(e.replace(/-/g, ""), "a"), + e = function (r) { + let i = "0123456789abcdefghigklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZa0".split("") + , o = i.length + 1 + , s = +r + , a = []; + do { + let c = s % o; + s = (s - c) / o, + a.unshift(i[c]) + } while (s); + return a.join("") + }(parseInt(e, 16)), + e.length > 22 && (e = e.slice(0, 22)), + e.length < 22) + for (let t = 22 - e.length, n = 0; n < t; n++) + e += "0"; + return e +} + +function Sr() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (e) { + const t = 16 * Math.random() | 0; + return (e === "x" ? t : 3 & t | 8).toString(16) + }) +} + +export interface Device { + deviceId: string + model: string + platform: string + timestamp: number + version: string +} + +export function genDeviceConfig(): Device { + return { + deviceId: genDeviceId(), + model: "Web|Chrome|119.0.0.0", + platform: "web", + timestamp: 0, + version: "5.9.0" + } +} \ No newline at end of file diff --git a/lib/core/index.ts b/lib/core/index.ts new file mode 100644 index 0000000..da3e3ba --- /dev/null +++ b/lib/core/index.ts @@ -0,0 +1,2 @@ +export * as Network from "./network" +export {genDeviceConfig} from "./deivce" \ No newline at end of file diff --git a/lib/core/network.ts b/lib/core/network.ts new file mode 100644 index 0000000..1bac166 --- /dev/null +++ b/lib/core/network.ts @@ -0,0 +1,56 @@ +import WebSocket from "ws"; +import axios from "axios"; +import {UClient, UClientRunTimeError} from "../uClient"; +import {Device} from "./deivce"; + +export class Network extends WebSocket { + readonly remote: string + + private constructor(url: string) { + super(url) + this.remote = url + } + + static async new(c: UClient, uid: number, device: Device) { + const {data} = await axios.get("https://bbs-api.miyoushe.com/vila/wapi/own/member/info", { + headers: { + "Accept": "application/json, text/plain, */*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Connection": "keep-alive", + "Cookie": c.config.mys_ck, + 'Origin': 'https://dby.miyoushe.com', + 'Referer': 'https://dby.miyoushe.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + 'x-rpc-client_type': 4, + "x-rpc-device_fp": '98cfc8c7-b24b-45ff-a0e2-19f9e09d5000', + "x-rpc-device_id": '98cfc8c7-b24b-45ff-a0e2-19f9e09d5000', + "x-rpc-platform": 4 + } + }) + const info = data?.data + if (info.user_id != uid) throw new UClientRunTimeError(-1, "米游社cookie对应的uid和配置的uid账号不一致") + if (!info) throw new UClientRunTimeError(-1, `uclient获取连接信息出错,reason ${data.message || 'unknown'}`) + c.logger.debug("请求info接口成功...") + await Network.submitConfig(device) + c.logger.debug("提交config成功...") + const url = `wss://ws.rong-edge.com/websocket?appId=tdrvipkstcl55&token=${info.token.split("@")[0] + "@"}&sdkVer=5.9.0&pid=&apiVer=browser%7CChrome%7C119.0.0.0&protocolVer=3` + return new Network(url) + } + + private static async submitConfig(config: Device) { + const {data} = await axios.post("https://cloudcontrol.rong-edge.com/v1/config", config, { + headers: { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Content-Type": "application/json", + "Origin": "https://dby.miyoushe.com", + "Rc-App-Key": "tdrvipkstcl55", + "Referer": "https://dby.miyoushe.com/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" + } + }) + if (data.code != 200) throw new UClientRunTimeError(-1, `提交config失败,reason ${data.message}||unknown`) + } +} \ No newline at end of file diff --git a/lib/element.ts b/lib/element.ts index 2f65089..e50aa92 100644 --- a/lib/element.ts +++ b/lib/element.ts @@ -8,10 +8,11 @@ import { LinkMsg, LinkRoomMsg, MentionedInfo, - MsgContentInfo, Panel, PreviewLinkMsg, TextMsg + MsgContentInfo, Panel, PreviewLinkMsg, RobotCardMsg, TextMsg, VillaCardMsg } from "./message"; import {Bot, RobotRunTimeError} from "./bot"; import {Villa} from "./villa"; +import * as fs from "fs"; export interface Text { type: 'text' @@ -35,11 +36,13 @@ export interface Template { export interface RobotCard { type: 'robot' id: string + name?: string } export interface VillaCard { type: 'villa' - id: string + id: number | string + name?: string } export interface Button { @@ -142,8 +145,8 @@ export class Msg { private readonly villa_id: number private readonly c: Bot private post_id!: string - private villa_card!: string - private robot_card!: string + private villa_card!: VillaCardMsg + private robot_card!: RobotCardMsg private img!: ImageMsg private t: string private brief: string @@ -185,6 +188,10 @@ export class Msg { await this[m.type](m) } catch (e) { this.c.logger.error(`消息{type: ${m.type}}转换失败,reason ${(e as Error).message}`) + if ((e as Error).message.includes("登录失效")) { + fs.unlink(`${this.c.config.data_dir}/cookie`, () => {}) + this.c.config.mys_ck = "" + } } } return this @@ -198,11 +205,11 @@ export class Msg { } } as MsgContentInfo if (this.robot_card) { - tmg.content = {bot_id: this.robot_card} + tmg.content = this.robot_card this.brief = `[分享机器人](${this.robot_card})` this.obj_name = 'MHY:RobotCard' } else if (this.villa_card) { - tmg.content = {villa_id: this.villa_card} + tmg.content = this.villa_card this.brief = `[分享别野](${this.villa_card})` this.obj_name = 'MHY:VillaCard' } else if (this.post_id) { @@ -451,12 +458,16 @@ export class Msg { private villa(m: VillaCard) { if (this.villa_card) return - this.villa_card = String(m.id) + this.villa_card = {} as VillaCardMsg + m.id && (this.villa_card.villa_id = String(m.id)) + m.name && (this.villa_card.villa_name = String(m.name)) } private robot(m: RobotCard) { if (this.robot_card) return - this.robot_card = m.id + this.robot_card = {} as RobotCardMsg + m.id && (this.robot_card.bot_id = m.id) + m.name && (this.robot_card.name = String(m.name)) } private style(obj: any, len: number) { diff --git a/lib/index.ts b/lib/index.ts index ec3663f..4a7c531 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,4 +1,5 @@ export {Bot, createBot} from "./bot" export {Nat, verifyPKCS1v15} from "./verify" export {fromMCode} from "./message" -export {segment} from "./element" \ No newline at end of file +export {segment} from "./element" +export {UClient, createUClient} from "./uClient" \ No newline at end of file diff --git a/lib/message.ts b/lib/message.ts index e9613bf..30f5484 100644 --- a/lib/message.ts +++ b/lib/message.ts @@ -56,11 +56,13 @@ export interface TextMsg { /** 分享别野卡片 */ export interface VillaCardMsg { villa_id: string + villa_name?: string } /** 分享机器人卡片 */ export interface RobotCardMsg { bot_id: string + name?: string } /** 图片消息 */ diff --git a/lib/parser.ts b/lib/parser.ts index 738c212..b75d875 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -11,9 +11,24 @@ import { import {MessageRet} from "./event/baseEvent"; import {Quotable, Bot} from "./bot"; import {Villa, VillaInfo} from "./villa"; -import {At, Badge, Button, CType, Elem, Image, Link, LinkRoom, PreviewLink, Template, Text} from "./element"; +import { + At, + Badge, + Button, + CType, + Elem, + Image, + Link, + LinkRoom, Post, + PreviewLink, + RobotCard, + Template, + Text, + VillaCard +} from "./element"; import {Entity} from "./message"; import {User} from "./event/cbEvents"; +import {UClient} from "./uClient"; export type Events = JoinVilla | SendMessage | CreateBot | DeleteBot | AddQuickEmoticon | AuditCallback @@ -31,30 +46,32 @@ const auditResult = ["None", "Pass", "Reject"] const objName = ["UnknownObjectName", "Text", "Post"] export default class Parser { - public event_type: string - private readonly baseEvent: BaseEvent + public event_type!: string + private readonly baseEvent!: BaseEvent private readonly event_data: any - private readonly c: Bot + private readonly c: Bot | UClient - constructor(c: Bot, event: any) { + constructor(c: Bot | UClient, event?: any) { this.c = c - this.event_type = et[event.type] || event.type - this.baseEvent = { - source: { - villa_id: Number(event.robot.villa_id), - bot: event.robot.template - }, - id: event.id, - created_time: Number(event.created_at), - send_time: Number(event.send_at) + if (event) { + this.event_type = et[event.type] || event.type + this.baseEvent = { + source: { + villa_id: Number(event.robot.villa_id), + bot: event.robot.template + }, + id: event.id, + created_time: Number(event.created_at), + send_time: Number(event.send_at) + } + this.event_data = event?.extend_data?.EventData || event?.extend_data } - this.event_data = event?.extend_data?.EventData || event?.extend_data } async doParse(): Promise> { const es: [string, any][] = Object.entries(this.event_data) const rs = new Array() - let info = await Villa.getInfo(this.c, this.baseEvent.source.villa_id) as VillaInfo + let info = await Villa.getInfo((this.c as Bot), this.baseEvent.source.villa_id) as VillaInfo this.baseEvent.source.villa_name = info.name for (let [k, v] of es) { switch (k) { @@ -97,7 +114,7 @@ export default class Parser { message_id: v.msg_uid, send_time: Number(v.send_at) } - return this.c.sendMsg(v.room_id, this.baseEvent.source.villa_id, content, quote ? q : undefined) + return (this.c as Bot).sendMsg(v.room_id, this.baseEvent.source.villa_id, content, quote ? q : undefined) } } as SendMessage) this.c.logger.info(`recv from: [Villa: ${info.name || "unknown"}(${this.baseEvent.source.villa_id}), Member: ${v.nickname}(${v.from_user_id})] ${msg}`) @@ -114,8 +131,8 @@ export default class Parser { rs.push({ ...this.baseEvent }) - this.c.logger.info(`机器人 ${this.baseEvent.source.bot.name}(${this.baseEvent.source.bot.id})被移出大别野[${info.name || "unknown"}](${this.baseEvent.source.villa_id})`) - this.c.vl.delete(this.baseEvent.source.villa_id) + this.c.logger.info(`机器人 ${this.baseEvent.source.bot.name}(${this.baseEvent.source.bot.id})被移出大别野[${info.name || "unknown"}](${this.baseEvent.source.villa_id})`); + (this.c as Bot).vl.delete(this.baseEvent.source.villa_id) break case "AddQuickEmoticon": case "add_quick_emoticon": @@ -129,10 +146,10 @@ export default class Parser { bot_msg_id: v.bot_msg_id, is_cancel: v.is_cancel, reply: (content: Elem | Elem[]): Promise => { - return this.c.sendMsg(v.room_id, this.baseEvent.source.villa_id, content) + return (this.c as Bot).sendMsg(v.room_id, this.baseEvent.source.villa_id, content) } } as AddQuickEmoticon) - const member = await (await Villa.get(this.c, this.baseEvent.source.villa_id))?.getMemberInfo(v.uid) + const member = await (await Villa.get((this.c as Bot), this.baseEvent.source.villa_id))?.getMemberInfo(v.uid) this.c.logger.info(`recv from: [Villa: ${info.name || "unknown"}(${this.baseEvent.source.villa_id}), Member: ${member?.basic?.nickname || "unknown"}(${v.uid})] [${v.is_cancel ? '取消' : '回复'}快捷表情]${v.emoticon}`) break case "AuditCallback": @@ -146,11 +163,11 @@ export default class Parser { pass_through: v.pass_through, audit_result: typeof v.audit_result === 'string' ? v.audit_result : (v.audit_result = auditResult[Number(v.audit_result)]), reply: (content: Elem | Elem[]): Promise => { - return this.c.sendMsg(v.room_id, this.baseEvent.source.villa_id, content) + return (this.c as Bot).sendMsg(v.room_id, this.baseEvent.source.villa_id, content) } } as AuditCallback) - this.c.logger.info(`${v.audit_id}审核结果:${v.audit_result}`) - this.c.handler.get(v.audit_id)?.(v.audit_result) + this.c.logger.info(`${v.audit_id}审核结果:${v.audit_result}`); + (this.c as Bot).handler.get(v.audit_id)?.(v.audit_result) break case "ClickMsgComponent": case "click_msg_component": @@ -164,10 +181,10 @@ export default class Parser { template_id: v.template_id || 0, extra: v.extra, reply: (content: Elem | Elem[]): Promise => { - return this.c.sendMsg(v.room_id, this.baseEvent.source.villa_id, content) + return (this.c as Bot).sendMsg(v.room_id, this.baseEvent.source.villa_id, content) } } as ClickMsgComponent) - const mem = await (await Villa.get(this.c, this.baseEvent.source.villa_id))?.getMemberInfo(v.uid) + const mem = await (await Villa.get((this.c as Bot), this.baseEvent.source.villa_id))?.getMemberInfo(v.uid) this.c.logger.info(`recv from: [Villa: ${info.name || "unknown"}(${this.baseEvent.source.villa_id}), Member: ${mem?.basic?.nickname || "unknown"}(${v.uid})] 点击消息组件${v.component_id}`) break } @@ -176,8 +193,68 @@ export default class Parser { return rs } + doPtParse(proto: any) { + const obj_name = proto[4] + if (/^MHY:((SYS)|(SIG)):.*$/.test(obj_name)) return + const content = JSON.parse(proto[5]) + let src = proto[13] + if (src) src = JSON.parse(src) + const source = proto[18]?.[1]?.split("|") + const m = proto[16]?.split(":") + const msg = { + from_uid: Number(proto[1]) || proto[1], + villa_id: Number(proto[3]), + obj_name: obj_name, + message: this.parseContent(content.content, content.panel), + send_time: Number(proto[6]), + msg_id: proto[9], + src: src.osSrc || "unknown", + msg: m[1] || "", + nickname: m[0] || "unknown", + villa_name: source[0].trim() || "unknown", + room_name: source[1].trim() || "unknown", + extra: JSON.parse(proto[15]), + room_id: Number(proto[19]) + } + this.c.logger.info(`recv from: [Villa: ${msg.villa_name || "unknown"}(${msg.villa_id}), Member: ${msg.nickname}(${msg.from_uid})] ${msg.msg}`) + this.c.emit("message", msg) + } + /** 米游社用户暂时只能对机器人发送MHY:Text类型消息,所以暂时只解析MYH:Text类型消息和组件消息 */ private parseContent(content: any, panel?: any): Elem[] { + /** 解析MHY:RobotCard */ + if (content.bot_id) { + return [{ + type: "robot", + id: content.bot_id, + name: content.name + } as RobotCard] + } + /** 解析MHY:VillaCard */ + if (content.villa_id) { + return [{ + type: "villa", + id: Number(content.villa_id), + name: content.villa_name + } as VillaCard] + } + /** 解析MHY:Image */ + if (content.url) { + return [{ + type: 'image', + file: content.url, + ...content?.size, + size: content.file_size + } as Image] + } + /** 解析MHY:Post */ + if (content.post_id) { + return [{ + type: 'post', + id: Number(content.post_id) + } as Post] + } + /** 解析MHY:Text */ const rs: Elem[] = [] const text = content.text const entities = content.entities as Array @@ -185,7 +262,8 @@ export default class Parser { const preview = content?.preview_link const badge = content?.badge let now = 0 - entities.sort((x, y) => (x?.offset || 0) - (y?.offset || 0)) + if (!entities) return [] + entities?.sort((x, y) => (x?.offset || 0) - (y?.offset || 0)) for (let i = 0; i < entities.length; i++) { const entity = { entity: [entities[i].entity], @@ -208,7 +286,7 @@ export default class Parser { now = entity.offset || 0 } if (e.type === "mentioned_robot") { - if (e.bot_id === this.c.config.bot_id) { + if (e.bot_id === (this.c as Bot)?.config?.bot_id) { elem = undefined continue } diff --git a/lib/uClient.ts b/lib/uClient.ts index 9a1fe20..936170c 100644 --- a/lib/uClient.ts +++ b/lib/uClient.ts @@ -1,93 +1,219 @@ -import WebSocket from "ws"; +import EventEmitter from "node:events"; +import {Network} from "./core/network"; +import * as log4js from "log4js" +import fs from "fs"; +import {BUF0, getMysCk, lock, ZO} from "./common"; import Writer from "./ws/writer"; -import axios from "axios"; -import {Bot, RobotRunTimeError} from "./bot"; -import {lock} from "./common"; +import * as pb from "./ws/protobuf/index"; +import Parser from "./parser"; +import {deepDecode} from "./ws/protobuf/index"; +import {Device, genDeviceConfig} from "./core/deivce"; + +const pkg = require("../package.json") + +export class UClientRunTimeError { + constructor(public code: number, public message: string = "unknown") { + this.code = code + this.message = message + } +} export interface UClientConfig { - /** 用户id */ - uid: string - /** ws地址 */ - url: string - token: string + /** 米游社cookie,可手动配置也可扫码获取 */ + mys_ck?: string + /** 登入账号的uid */ + uid: number + /** 数据储存路径 */ + data_dir?: string + /** 日志级别 */ + log_level?: LogLevel } -const HEARTBEAT = Symbol("HEARTBEAT") -const HANDLER = Symbol("HANDLER") +enum CMD_NUMBER { + "qryMsg" = 0x52, + "pullSeAtts" = 0x50, + "pullUS" = 0x50, + "reportsdk" = 0x50, + "pullMsg" = 0x50, + "pullUgMsg" = 0x50, + "ppMsgP" = 0x32, + "qryRelationR" = 0x50, + "pullUgSes" = 0x50 +} + +type CMD = + "qryMsg" + | "pullSeAtts" + | "pullUS" + | "reportsdk" + | "pullMsg" + | "pullUgMsg" + | "ppMsgP" + | "qryRelationR" + | "pullUgSes" + +export type LogLevel = 'all' | 'mark' | 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off' + +export interface UClient { + /** 服务启动成功 */ + on(name: 'online', listener: (this: this) => void): this + + on(name: string, listener: (this: this, ...args: any[]) => void): this +} + +const NET = Symbol("NET") +const INTERVAL = Symbol("INTERVAL") const FN_SEND = Symbol("FN_SEND") +const HANDLER = Symbol("HANDLER") +const HEARTBEAT = Symbol("HEARTBEAT") + +export class UClient extends EventEmitter { + private keepAlive: boolean + private [NET]!: Network + private [INTERVAL]!: NodeJS.Timeout + private [HANDLER]: Map + private [HEARTBEAT]!: NodeJS.Timeout | number + readonly logger: log4js.Logger + readonly uid: number + readonly config: UClientConfig + private readonly device: Device + private readonly sig = { + seq: 1, + timestamp_pullUgMsg: 0 + } -export class UClient extends WebSocket { - private readonly uid: string - private readonly c: Bot - private readonly info: UClientConfig - private readonly [HANDLER]: Map - private [HEARTBEAT]!: NodeJS.Timeout - private seq: number - - private constructor(c: Bot, info: UClientConfig, cb: Function) { - super(info.url) - this.seq = 0 - this.uid = info.uid - this.info = info - this.c = c + constructor(config: UClientConfig) { + super() + this.config = { + log_level: "info", + ...config + } + if (!this.config.uid) throw new UClientRunTimeError(-1, "未配置uid,请配置后重启") + this.uid = this.config.uid + if (!fs.existsSync("./data")) fs.mkdirSync("./data") + if (!this.config.data_dir) this.config.data_dir = `./data/${this.config.uid}` + if (!fs.existsSync(this.config.data_dir)) fs.mkdirSync(this.config.data_dir) + if (!fs.existsSync(`${this.config.data_dir}/device.json`)) { + this.device = genDeviceConfig() + fs.writeFileSync(`${this.config.data_dir}/device.json`, JSON.stringify(this.device, null, "\t")) + } else { + this.device = JSON.parse(fs.readFileSync(`${this.config.data_dir}/device.json`, "utf-8")) + } + this.keepAlive = true this[HANDLER] = new Map - this.watchEvents(cb) - - lock(this, "info") - lock(this, "c") - } - - static async new(c: Bot, cb: Function) { - const {data} = await axios.get("https://bbs-api.miyoushe.com/vila/wapi/own/member/info", { - headers: { - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", - "Connection": "keep-alive", - "Cookie": c.config.mys_ck, - 'Origin': 'https://dby.miyoushe.com', - 'Referer': 'https://dby.miyoushe.com/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', - 'x-rpc-client_type': 4, - "x-rpc-device_fp": '98cfc8c7-b24b-45ff-a0e2-19f9e09d5000', - "x-rpc-device_id": '98cfc8c7-b24b-45ff-a0e2-19f9e09d5000', - "x-rpc-platform": 4 - } + this.logger = log4js.getLogger(`[${this.uid}]`) + this.logger.level = this.config.log_level as LogLevel + this.printPkgInfo() + if (!this.config.mys_ck) getMysCk.call(this, (ck: string) => { + this.config.mys_ck = ck + fs.writeFile(`${this.config.data_dir}/cookie`, ck, () => {}) + this.run().then(() => this.emit("online")) }) - const info = data?.data - if (!info) throw new RobotRunTimeError(-11, `uclient获取连接信息出错,reason ${data.message || 'unknown'}`) - const url = `wss://ws.rong-edge.com/websocket?appId=tdrvipkstcl55&token=${info.token.split("@")[0] + "@"}&sdkVer=5.9.0&pid=&apiVer=browser%7CChrome%7C119.0.0.0&protocolVer=3` - return new UClient(c, { - uid: info.user_id, - url: url, - token: info.token - }, cb) - } - - async getMsg(msgId: string, villa_id: number, room_id: number) { - const seq = this.seq & 0xffff - this.seq++ - const writer = new Writer() - .writeU8(0x52) - .writeWithU16Length("qryMsg") - .writeWithU16Length(String(this.uid)) + else this.run().then(() => this.emit("online")) + + lock(this, "config") + lock(this, "sig") + } + + get interval() { + return this[INTERVAL] + } + + set interval(i: NodeJS.Timeout) { + this[INTERVAL] = i + } + + setKeepAlive(s: boolean) { + this.keepAlive = s + } + + /** 输出包信息 */ + private printPkgInfo() { + this.logger.mark("---------------") + this.logger.mark(`Package Version: ${pkg.name}@${pkg.version} (Released on ${pkg.update})`) + this.logger.mark(`Repository Url: ${pkg.repository}`) + this.logger.mark("---------------") + } + + private run() { + return new Promise(resolve => { + this.newUClient(resolve).then() + }) + } + + private async newUClient(cb: Function) { + try { + this[NET] = await Network.new(this, this.uid, this.device) + this[NET].on("close", async (code, reason) => { + this.clear() + if (!this.keepAlive) return + this.logger.error(`uclient连接已断开,reason ${reason.toString() || 'unknown'}(${code}),5秒后将自动重连...`) + setTimeout(async () => { + await this.newUClient(cb) + }, 5000) + }) + this[NET].on("open", () => { + this.logger.info(`建立连接成功,ws地址:${this[NET].remote}`) + this[HEARTBEAT] = setInterval((function heartbeat(this: UClient): Function { + this[NET].send(Buffer.from("c0", 'hex'), () => { + this.logger.debug(`心跳包发送成功`) + }) + return heartbeat.bind(this) as Function + }).call(this), 15000) + cb() + }) + this[NET].on("message", (data) => { + this.packetListener(data as Buffer) + }) + } catch (err) { + if ((err as Error).message.includes("not login")) { + this.logger.error("mys_ck已失效,请重新删除cookie后扫码登入") + fs.unlinkSync(`${this.config.data_dir}/cookie`) + return + } + if ((err as Error).message.includes("账号不一致")) { + this.logger.error((err as Error).message) + return + } + this.logger.error(`${(err as Error).message || "uclient建立连接失败"}, 5秒后将自动重连...`) + setTimeout(async () => { + await this.newUClient(cb) + }, 5000) + } + } + + async getMsg(msgId: string, time:number, villa_id: number, room_id: number) { + const body = { + 1: String(villa_id), + 2: 10, + 3: { + 1: time, + 2: msgId, + 3: String(room_id) + } + } + const {pkt, seq} = this.buildPkt(pb.encode(body), "qryMsg", this.uid) + return await this[FN_SEND](pkt, seq) + } + + private buildPkt(body: Uint8Array | Buffer, cmd: CMD, id: string | number) { + const seq = this.sig.seq & 0xffff + this.sig.seq++ + const writer = new Writer().writeU8(CMD_NUMBER[cmd]) + .writeWithU16Length(cmd) + .writeWithU16Length(String(id)) .writeU16BE(seq) - .writeU8(0x0a) - .writeWithU8Length(String(villa_id)) - .writeBytes(Buffer.from("100a1a2408e0d780bdc53112", "hex")) - .writeWithU8Length(msgId) - .writeU8(0x1a) - .writeWithU8Length(String(room_id)) - return await this[FN_SEND](writer.read(), seq) + .writeBytes(body) + return {pkt: writer.read(), seq} } - private async [FN_SEND](pkt: Buffer, seq: number) { + private async [FN_SEND](pkt: Buffer, seq: number): Promise { return new Promise((resolve, reject) => { - this.send(pkt, (err) => { - if (err) reject(new RobotRunTimeError(-11, `[${this.info.uid}] 数据包发送失败 seq: ${seq}, reason: ${err.message}`)) + this[NET].send(pkt, (err) => { + if (err) reject(new UClientRunTimeError(-1, `数据包发送失败 seq: ${seq}, reason: ${err.message}`)) const timer = setTimeout(() => { this[HANDLER].delete(seq) - reject(new RobotRunTimeError(-11, `[${this.info.uid}] 数据包接收超时 seq: ${seq}`)) + reject(new UClientRunTimeError(-1, `数据包接收超时 seq: ${seq}`)) }, 5000) this[HANDLER].set(seq, (r: any) => { clearTimeout(timer) @@ -98,27 +224,117 @@ export class UClient extends WebSocket { }) } - private watchEvents(cb: Function) { - this.on("open", () => { - this.c.logger.info(`[${this.info.uid}]建立连接成功,ws地址:${this.info.url}`) - this[HEARTBEAT] = setInterval(() => { - this.send(Buffer.from("c0", 'hex'), () => { - this.c.logger.debug(`[${this.info.uid}]心跳包发送成功`) - }) - }, 15000) - cb() - }) - this.on("message", data => { - this.dealMsg(data as Buffer) - }) + private async packetListener(buf: Buffer) { + const type = buf.readUint8() + if (type === 0xd0) return + switch (type) { + case 0x21: + const seq = buf.readUint16BE(1) + this.sig.seq = seq + 1 + await this.sendInitPkt() + break + case 0x61: + this.listener0x61(buf.slice(1)) + break + case 0x31: + await this.listener0x31(buf.slice(1)) + break + } } - private dealMsg(buf: Buffer) { + private async sendInitPkt() { + this[NET].send(this.buildPkt(pb.encode({ + 1: 0 + }), "pullSeAtts", this.uid).pkt) + this[NET].send(this.buildPkt(pb.encode({ + 1: Date.now() + }), "pullUS", this.uid).pkt) + this[NET].send(this.buildPkt(pb.encode({ + 1: Date.now(), + 2: 0, + 4: 1, + 6: Date.now(), + 7: 1 + }), "pullMsg", this.uid).pkt) + const {pkt, seq} = this.buildPkt(pb.encode({ + 1: 0 + }), "pullUgMsg", this.uid) + this.sig.timestamp_pullUgMsg = (await this[FN_SEND](pkt, seq))?.[2] + this[NET].send(this.buildPkt(pb.encode({ + 1: 1, + 2: 100, + 3: 0, + 4: 0 + }), "qryRelationR", this.uid).pkt) + this[NET].send(this.buildPkt(pb.encode({ + 1: `{"engine":"5.9.0","imlib-next":"5.9.0"}` + }), "reportsdk", this.uid).pkt) + this[NET].send(this.buildPkt(pb.encode({ + 1: 0, + 2: "RC:SRSMsg", + 3: `{"lastMessageSendTime":${Date.now()}}`, + 6: String(this.uid), + 9: 0, + 10: ZO(), + 13: BUF0 + }), "ppMsgP", "SIG").pkt) + this[NET].send(this.buildPkt(pb.encode({ + 1: 0, + 2: 0 + }), "pullUgSes", this.uid).pkt) + } + private listener0x61(buf: Buffer) { + const seq = buf.readUint16BE() + const time = buf.readUint32BE(2) + const status = buf.readUint16BE(6) + let body: any = pb.decode(buf.slice(8)) + if (!this[HANDLER].has(seq)) return + this[HANDLER].get(seq)?.(body) } - destroy() { + private async listener0x31(buf: Buffer) { + const time = buf.readUint32BE() + const cmdL = buf.readUint16BE(4) + const cmd = buf.slice(6, 6 + cmdL).toString() + const uL = buf.readUint16BE(6 + cmdL) + const from_uid = buf.slice(8 + cmdL, 8 + cmdL + uL).toString() + const body = pb.decode(buf.slice(10 + cmdL + uL)) + if (body[1] === 0x06) { + // 发送pullUgMsg + const {pkt, seq} = this.buildPkt(pb.encode({ + 1: this.sig.timestamp_pullUgMsg + }), "pullUgMsg", this.uid) + let payload = await this[FN_SEND](pkt, seq) + this.sig.timestamp_pullUgMsg = payload[2] + if (!payload[1]) return + payload = payload[1] + !Array.isArray(payload) && (payload = [payload]) + for (let pkt of payload) { + pkt = deepDecode(pkt["encoded"], { + 1: "string", 3: "string", 4: "string", 5: "string", 9: "string", + 13: "string", 15: "string", 16: "string", 18: { + 1: "string" + }, 19: "string" + }) + new Parser(this).doPtParse(pkt) + } + } else if (body[1] === 0x07) { + // 发送qryMsgChange + } + } + + private clear() { clearInterval(this[HEARTBEAT]) this[HANDLER].clear() } + + logout() { + this.keepAlive = false + this[NET].close() + } +} + +export function createUClient(config: UClientConfig) { + return new UClient(config) } \ No newline at end of file diff --git a/lib/ws/protobuf/index.ts b/lib/ws/protobuf/index.ts index 521c2ea..d47c3f1 100644 --- a/lib/ws/protobuf/index.ts +++ b/lib/ws/protobuf/index.ts @@ -6,25 +6,32 @@ export interface Encodable { export class Proto implements Encodable { [tag: number]: any + get length() { return this.encoded.length } + constructor(private encoded: Buffer, decoded?: Proto) { if (decoded) Reflect.setPrototypeOf(this, decoded) } + toString() { return this.encoded.toString() } + toHex() { return this.encoded.toString("hex") } + toBase64() { return this.encoded.toString("base64") } + toBuffer() { return this.encoded } + [Symbol.toPrimitive]() { return this.toString() } @@ -57,18 +64,18 @@ function _encode(writer: pb.Writer, tag: number, value: any) { const head = tag << 3 | type writer.uint32(head) switch (type) { - case 0: - if (value < 0) - writer.sint64(value) - else - writer.int64(value) - break - case 2: - writer.bytes(value) - break - case 1: - writer.double(value) - break + case 0: + if (value < 0) + writer.sint64(value) + else + writer.int64(value) + break + case 2: + writer.bytes(value) + break + case 1: + writer.double(value) + break } } @@ -103,24 +110,25 @@ export function decode(encoded: Buffer): Proto { const tag = k >> 3, type = k & 0b111 let value, decoded switch (type) { - case 0: - value = long2int(reader.int64()) - break - case 1: - value = long2int(reader.fixed64()) - break - case 2: - value = Buffer.from(reader.bytes()) - try { - decoded = decode(value) - } catch { } - value = new Proto(value, decoded) - break - case 5: - value = reader.fixed32() - break - default: - return null as any + case 0: + value = long2int(reader.int64()) + break + case 1: + value = long2int(reader.fixed64()) + break + case 2: + value = Buffer.from(reader.bytes()) + try { + decoded = decode(value) + } catch { + } + value = new Proto(value, decoded) + break + case 5: + value = reader.fixed32() + break + default: + return null as any } if (Array.isArray(result[tag])) { result[tag].push(value) @@ -133,3 +141,21 @@ export function decode(encoded: Buffer): Proto { } return result } + +export function deepDecode(encoded: Buffer, type?: any) { + let proto: any + try { + if (type === "string") return encoded.toString() + proto = decode(encoded) + } catch { + return encoded.toString() + } + if (!proto) return encoded.toString() + delete proto["encoded"] + const keys = Object.keys(proto) + for (let k of keys) { + if (proto[Number(k)] instanceof Proto) + proto[Number(k)] = deepDecode(proto[Number(k)]["encoded"], type?.[Number(k)]) + } + return proto +} diff --git a/package.json b/package.json index 4661907..d5d1129 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "mysv", - "version": "1.0.12", - "update": "2023/12/10", + "version": "1.0.13", + "update": "2023/12/18", "description": "米游社大别野机器人js-sdk", "main": "lib/index.js", "types": "lib/index.d.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "bot": "tsc && node app.js", - "inspect": "tsc && node --inspect app.js" + "inspect": "tsc && node --inspect app.js", + "client": "tsc && node uclient.js" }, "repository": "https://github.com/nk-ava/mysv.git", "homepage": "https://github.com/nk-ava/mysv.git#README",