diff --git a/README.md b/README.md index 865eacd..f3750f6 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,42 @@ # oicq-guild -In development +[oicq](https://github.com/takayama-lily/oicq) guild plugin + +**Install:** ```bash -# edit test.js with your account and password -npm i typescript -g -npm i -npm test +npm i oicq-guild ``` -**how to clear the slider:** +**Usage:** - +```js +const { createClient } = require("oicq") +const { GuildApp } = require("oicq-guild") + +// input with your account and password +const account = 0 +const password = "" + +// create oicq client +const client = createClient(account) +client.login(password) + +// create guild app and bind it to an oicq client +const app = GuildApp.bind(client) -**how to analyze the hex:** +app.on("ready", function () { + console.log("My guild list:") + console.log(this.guilds) +}) - or - +app.on("message", e => { + console.log(e) + if (e.raw_message === "hello") + e.reply(`Hello, ${e.sender.nickname}!`) +}) +``` + +**how to clear the slider captcha:** + + diff --git a/demo/login.js b/demo/login.js new file mode 100644 index 0000000..eccd8d8 --- /dev/null +++ b/demo/login.js @@ -0,0 +1,25 @@ +"use strict" +const { createClient } = require("oicq") +const { GuildApp } = require("../lib/index") + +const account = 0 +const password = "" + +const client = createClient(account) +client.on("system.login.slider", function (e) { + console.log("input ticket:") + process.stdin.once("data", ticket => this.submitSlider(String(ticket).trim())) +}).login(password) + +const app = GuildApp.bind(client) + +app.on("ready", function () { + console.log("My guild list:") + console.log(this.guilds) +}) + +app.on("message", e => { + console.log(e) + if (e.raw_message === "hello") + e.reply(`Hello, ${e.sender.nickname}!`) +}) diff --git a/lib/app.ts b/lib/app.ts new file mode 100644 index 0000000..7c77dfc --- /dev/null +++ b/lib/app.ts @@ -0,0 +1,89 @@ +import EventEmitter from "events" +import { Client, ApiRejection } from "oicq" +import { pb } from "oicq/lib/core" +import { lock, NOOP, log } from "oicq/lib/common" +import { onFirstView, onGroupProMsg } from "./internal" +import { Guild } from "./guild" +import { GuildMessage } from "./message" + +declare module "oicq" { + export interface Client { + sendOidbSvcTrpcTcp: (cmd: string, body: Uint8Array) => Promise + } +} + +Client.prototype.sendOidbSvcTrpcTcp = async function (cmd: string, body: Uint8Array) { + const sp = cmd //OidbSvcTrpcTcp.0xf5b_1 + .replace("OidbSvcTrpcTcp.", "") + .split("_"); + const type1 = parseInt(sp[0], 16), type2 = parseInt(sp[1]); + body = pb.encode({ + 1: type1, + 2: type2, + 4: body, + 6: "android " + this.apk.ver, + }) + const payload = await this.sendUni(cmd, body) + log(payload) + const rsp = pb.decode(payload) + if (rsp[3] === 0) return rsp[4] + throw new ApiRejection(rsp[3], rsp[5]) +} + +export interface GuildApp { + on(event: "ready", listener: (this: this) => void): this; + on(event: "message", listener: (this: this, e: GuildMessage) => void): this; + once(event: "ready", listener: (this: this) => void): this; + once(event: "message", listener: (this: this, e: GuildMessage) => void): this; + off(event: "ready", listener: (this: this) => void): this; + off(event: "message", listener: (this: this, e: GuildMessage) => void): this; +} + +/** 获取应用程序入口 */ +export class GuildApp extends EventEmitter { + + protected readonly c: Client + + /** 我的频道id */ + tiny_id = "" + + /** 我加入的频道列表 */ + guilds = new Map() + + /** 获得所属的客户端对象 */ + get client() { + return this.c + } + + protected constructor(client: Client) { + super() + client.on("internal.sso", (cmd: string, payload: Buffer) => { + if (cmd === "trpc.group_pro.synclogic.SyncLogic.PushFirstView") + onFirstView.call(this, payload) + else if (cmd === "MsgPush.PushGroupProMsg") + onGroupProMsg.call(this, payload) + }) + client.on("system.online", _ => this.tiny_id = client.tiny_id) + this.c = client + lock(this, "c") + } + + /** 绑定QQ客户端 */ + static bind(client: Client) { + return new GuildApp(client) + } + + /** 重新加载频道列表 */ + reloadGuilds(): Promise { + this.c.sendUni("trpc.group_pro.synclogic.SyncLogic.SyncFirstView", pb.encode({ 1: 0, 2: 0, 3: 0 })).then(payload => { + this.tiny_id = String(pb.decode(payload)[6]) + }).catch(NOOP) + return new Promise((resolve, reject) => { + const id = setTimeout(reject, 5000) + this.once("ready", () => { + clearTimeout(id) + resolve() + }) + }) + } +} diff --git a/lib/channel.ts b/lib/channel.ts new file mode 100644 index 0000000..487c2b2 --- /dev/null +++ b/lib/channel.ts @@ -0,0 +1,83 @@ +import { randomBytes } from "crypto" +import { pb } from "oicq/lib/core" +import { lock } from "oicq/lib/common" +import { Sendable, Converter } from "oicq/lib/message" +import { ApiRejection } from "oicq" +import { Guild } from "./guild" + +export enum NotifyType { + Unknown = 0, + AllMessages = 1, + Nothing = 2, +} + +export enum ChannelType { + Unknown = 0, + Text = 1, + Voice = 2, + Live = 5, + App = 6, + Forum = 7, +} + +export class Channel { + + channel_name = "" + channel_type = ChannelType.Unknown + notify_type = NotifyType.Unknown + + constructor(public readonly guild: Guild, public readonly channel_id: string) { + lock(this, "guild") + lock(this, "channel_id") + } + + _renew(channel_name: string, notify_type: NotifyType, channel_type: ChannelType) { + this.channel_name = channel_name + this.notify_type = notify_type + this.channel_type = channel_type + } + + /** + * 发送频道消息 + * 暂时仅支持发送: 文本、AT、表情 + */ + async sendMessage(content: Sendable): Promise<{ seq: number, rand: number, time: number}> { + const payload = await this.guild.app.client.sendUni("MsgProxy.SendMsg", pb.encode({ + 1: { + 1: { + 1: { + 1: BigInt(this.guild.guild_id), + 2: Number(this.channel_id), + 3: this.guild.app.client.uin + }, + 2: { + 1: 3840, + 3: randomBytes(4).readUInt32BE() + } + }, + 3: { + 1: new Converter(content).rich + } + } + })) + const rsp = pb.decode(payload) + if (rsp[1]) + throw new ApiRejection(rsp[1], rsp[2]) + return { + seq: rsp[4][2][4], + rand: rsp[4][2][3], + time: rsp[4][2][6], + } + } + + /** 撤回频道消息 */ + async recallMessage(seq: number): Promise { + const body = pb.encode({ + 1: BigInt(this.guild.guild_id), + 2: Number(this.channel_id), + 3: Number(seq) + }) + await this.guild.app.client.sendOidbSvcTrpcTcp("OidbSvcTrpcTcp.0xf5e_1", body) + return true + } +} diff --git a/lib/guild.ts b/lib/guild.ts new file mode 100644 index 0000000..6eaaa53 --- /dev/null +++ b/lib/guild.ts @@ -0,0 +1,103 @@ +import { pb } from "oicq/lib/core" +import { lock } from "oicq/lib/common" +import { GuildApp } from "./app" +import { Channel} from "./channel" + +export enum GuildRole { + Member = 1, + GuildAdmin = 2, + Owner = 4, + ChannelAdmin = 5, +} + +export interface GuildMember { + tiny_id: string + card: string + nickname: string + role: GuildRole + join_time: number +} + +const members4buf = pb.encode({ + 1: 1, + 2: 1, + 3: 1, + 4: 1, + 5: 1, + 6: 1, + 7: 1, + 8: 1, +}) + +export class Guild { + + guild_name = "" + channels = new Map() + + constructor(public readonly app: GuildApp, public readonly guild_id: string) { + lock(this, "app") + lock(this, "guild_id") + } + + _renew(guild_name: string, proto: pb.Proto | pb.Proto[]) { + this.guild_name = guild_name + if (!Array.isArray(proto)) + proto = [proto] + const tmp = new Set() + for (const p of proto) { + const id = String(p[1]), name = String(p[8]), + notify_type = p[7], channel_type = p[9] + tmp.add(id) + if (!this.channels.has(id)) + this.channels.set(id, new Channel(this, id)) + const channel = this.channels.get(id)! + channel._renew(name, notify_type, channel_type) + } + for (let [id, _] of this.channels) { + if (!tmp.has(id)) + this.channels.delete(id) + } + } + + /** 获取频道成员列表 */ + async getMemberList() { + let index = 0 // todo member count over 500 + const body = pb.encode({ + 1: BigInt(this.guild_id), + 2: 3, + 3: 0, + 4: members4buf, + 6: index, + 8: 500, + 14: 2, + }) + const rsp = await this.app.client.sendOidbSvcTrpcTcp("OidbSvcTrpcTcp.0xf5b_1", body) + const list: GuildMember[] = [] + const members = Array.isArray(rsp[5]) ? rsp[5] : [rsp[5]] + const admins = Array.isArray(rsp[25]) ? rsp[25] : [rsp[25]] + for (const p of admins) { + const role = p[1] as GuildRole + const m = Array.isArray(p[2]) ? p[2] : [p[2]] + for (const p2 of m) { + list.push({ + tiny_id: String(p2[8]), + card: String(p2[2]), + nickname: String(p2[3]), + role, + join_time: p2[4], + }) + } + + } + for (const p of members) { + list.push({ + tiny_id: String(p[8]), + card: String(p[2]), + nickname: String(p[3]), + role: GuildRole.Member, + join_time: p[4], + }) + } + return list + } +} diff --git a/lib/index.ts b/lib/index.ts index e69de29..844f95b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -0,0 +1,3 @@ +export { GuildApp } from "./app" +export { Guild, GuildRole, GuildMember } from "./guild" +export { Channel, NotifyType, ChannelType } from "./channel" diff --git a/lib/internal.ts b/lib/internal.ts new file mode 100644 index 0000000..8d51251 --- /dev/null +++ b/lib/internal.ts @@ -0,0 +1,38 @@ +import { GuildApp } from "./app" +import { pb } from "oicq/lib/core" +import { Guild } from "./guild" +import { GuildMessage } from "./message" + +export function onFirstView(this: GuildApp, payload: Buffer) { + const proto = pb.decode(payload) + if (!proto[3]) return + if (!Array.isArray(proto[3])) proto[3] = [proto[3]] + const tmp = new Set() + for (let p of proto[3]) { + const id = String(p[1]), name = String(p[4]) + tmp.add(id) + if (!this.guilds.has(id)) + this.guilds.set(id, new Guild(this, id)) + const guild = this.guilds.get(id)! + guild._renew(name, p[3]) + } + for (let [id, _] of this.guilds) { + if (!tmp.has(id)) + this.guilds.delete(id) + } + this.client.logger.mark(`[Guild] 加载了${this.guilds.size}个频道`) + this.emit("ready") +} + +export function onGroupProMsg(this: GuildApp, payload: Buffer) { + try { + var msg = new GuildMessage(pb.decode(payload)) + } catch { + return + } + this.client.logger.info(`[Guild: ${msg.guild_name}, Member: ${msg.sender.nickname}]` + msg.raw_message) + const channel = this.guilds.get(msg.guild_id)?.channels.get(msg.channel_id) + if (channel) + msg.reply = channel.sendMessage.bind(channel) + this.emit("message", msg) +} diff --git a/lib/message.ts b/lib/message.ts new file mode 100644 index 0000000..44f2673 --- /dev/null +++ b/lib/message.ts @@ -0,0 +1,52 @@ +import { MessageElem, ApiRejection } from "oicq" +import { pb } from "oicq/lib/core" +import { parse, Sendable } from "oicq/lib/message" +import { lock } from "oicq/lib/common" + +export class GuildMessage { + /** 频道id */ + guild_id: string + guild_name: string + /** 子频道id */ + channel_id: string + channel_name: string + /** 消息序号(同一子频道中一般顺序递增) */ + seq: number + rand: number + time: number + message: MessageElem[] + raw_message: string + sender: { + tiny_id: string + nickname: string + } + + constructor(proto: pb.Proto) { + const head1 = proto[1][1][1] + const head2 = proto[1][1][2] + if (head2[1] !== 3840) + throw new Error("unsupport guild message type") + const body = proto[1][3] + const extra = proto[1][4] + this.guild_id = String(head1[1]) + this.channel_id = String(head1[2]) + this.guild_name = String(extra[2]) + this.channel_name = String(extra[3]) + this.sender = { + tiny_id: String(head1[4]), + nickname: String(extra[1]) + } + this.seq = head2[4] + this.rand = head2[3] + this.time = head2[6] + const parsed = parse(body[1]) + this.message = parsed.message + this.raw_message = parsed.brief + lock(this, "proto") + } + + /** 暂时仅支持发送: 文本、AT、表情 */ + async reply(content: Sendable): Promise<{ seq: number, rand: number, time: number }> { + throw new ApiRejection(-999999, "no channel") + } +} diff --git a/package.json b/package.json index d22af35..f6b2213 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oicq-guild", - "version": "0.0.0", + "version": "0.1.0", "description": "guild plugin for oicq", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -11,6 +11,13 @@ "oicq": "^2.2.2" }, "scripts": { - "test": "node test.js" - } + "test": "tsc && node test.js" + }, + "devDependencies": { + "@types/node": "^17.0.31" + }, + "files": [ + "lib/**/*.js", + "lib/**/*.d.ts" + ] } diff --git a/test.js b/test.js index c1d43fa..a097c02 100644 --- a/test.js +++ b/test.js @@ -1,9 +1,10 @@ "use strict" const { createClient } = require("oicq") const { log } = require("oicq/lib/common") +const { GuildApp } = require("./lib/index") -const account = 0 -const password = "******" +const account = Number(process.env.OICQ_GUILD_ACCOUNT) +const password = process.env.OICQ_GUILD_PASSWORD const client = createClient(account, { log_level: "warn", @@ -15,22 +16,14 @@ client.on("system.login.slider", function (e) { process.stdin.once("data", _ => this.login()) }).login(password) -const known = [ - "OnlinePush.PbPushGroupMsg", - "OnlinePush.PbPushDisMsg", - "OnlinePush.ReqPush", - "OnlinePush.PbPushTransMsg", - "OnlinePush.PbC2CMsgSync", - "MessageSvc.PushNotify", - "MessageSvc.PushReaded", - "ConfigPushSvc.PushDomain", - "ConfigPushSvc.PushReq", - "QualityTest.PushList", -] +const app = GuildApp.bind(client) -client.on("internal.sso", function (cmd, payload) { - if (known.includes(cmd)) return - console.log("received:", cmd) - log(payload) - console.log("") +process.stdin.on("data", async (data) => { + const cmd = String(data).trim() + try { + const res = await eval(cmd) + console.log(res) + } catch (e) { + console.log(e) + } })