diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0181da6..39913f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: | npm config set provenance true $commit = "$(git log -1 --pretty=%B)" - if (!($commit -match "^((core|ws)@[0-9]+\.[0-9]+\.[0-9]+\+?)+$")) { + if (!($commit -match "^\s*((core|ws)@[0-9]+\.[0-9]+\.[0-9]+\+?)+\s*$")) { Write-Host "Not a release, skipping publish" exit 0 } diff --git a/.gitignore b/.gitignore index bc4dcd7..73f677e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ testem.log Thumbs.db .nx/cache -*.proto +# *.proto diff --git a/packages/core/.proto/extrabyte.proto b/packages/core/.proto/extrabyte.proto new file mode 100644 index 0000000..00f03fe --- /dev/null +++ b/packages/core/.proto/extrabyte.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; +package com.iamteer.wcf; + +message Extra { + enum PropertyKey { + Field_0 = 0; + Sign = 2; + Thumb = 3; + Extra = 4; + Xml = 7; + } + + message Property { + PropertyKey type = 1; + string value = 2; + } + + repeated Property properties = 3; +} \ No newline at end of file diff --git a/packages/core/.proto/roomdata.proto b/packages/core/.proto/roomdata.proto new file mode 100644 index 0000000..ed4e02a --- /dev/null +++ b/packages/core/.proto/roomdata.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; +package com.iamteer.wcf; + +message RoomData { + + message RoomMember { + string wxid = 1; + string name = 2; + int32 state = 3; + } + + repeated RoomMember members = 1; + + int32 field_2 = 2; + int32 field_3 = 3; + int32 field_4 = 4; + int32 room_capacity = 5; + int32 field_6 = 6; + int64 field_7 = 7; + int64 field_8 = 8; +} + diff --git a/packages/core/.proto/wcf.proto b/packages/core/.proto/wcf.proto new file mode 100644 index 0000000..40166a9 --- /dev/null +++ b/packages/core/.proto/wcf.proto @@ -0,0 +1,236 @@ +syntax = "proto3"; + +package wcf; +option java_package = "com.iamteer"; + +enum Functions { + FUNC_RESERVED = 0x00; + FUNC_IS_LOGIN = 0x01; + FUNC_GET_SELF_WXID = 0x10; + FUNC_GET_MSG_TYPES = 0x11; + FUNC_GET_CONTACTS = 0x12; + FUNC_GET_DB_NAMES = 0x13; + FUNC_GET_DB_TABLES = 0x14; + FUNC_GET_USER_INFO = 0x15; + FUNC_GET_AUDIO_MSG = 0x16; + FUNC_SEND_TXT = 0x20; + FUNC_SEND_IMG = 0x21; + FUNC_SEND_FILE = 0x22; + FUNC_SEND_XML = 0x23; + FUNC_SEND_EMOTION = 0x24; + FUNC_SEND_RICH_TXT = 0x25; + FUNC_SEND_PAT_MSG = 0x26; + FUNC_FORWARD_MSG = 0x27; + FUNC_ENABLE_RECV_TXT = 0x30; + FUNC_DISABLE_RECV_TXT = 0x40; + FUNC_EXEC_DB_QUERY = 0x50; + FUNC_ACCEPT_FRIEND = 0x51; + FUNC_RECV_TRANSFER = 0x52; + FUNC_REFRESH_PYQ = 0x53; + FUNC_DOWNLOAD_ATTACH = 0x54; + FUNC_GET_CONTACT_INFO = 0x55; + FUNC_REVOKE_MSG = 0x56; + FUNC_DECRYPT_IMAGE = 0x60; + FUNC_EXEC_OCR = 0x61; + FUNC_ADD_ROOM_MEMBERS = 0x70; + FUNC_DEL_ROOM_MEMBERS = 0x71; + FUNC_INV_ROOM_MEMBERS = 0x72; +} + +message Request +{ + Functions func = 1; + oneof msg + { + Empty empty = 2; + string str = 3; + TextMsg txt = 4; + PathMsg file = 5; + DbQuery query = 6; + Verification v = 7; + MemberMgmt m = 8; // 群成员管理,添加、删除、邀请 + XmlMsg xml = 9; + DecPath dec = 10; + Transfer tf = 11; + uint64 ui64 = 12 [jstype = JS_STRING]; // 64 位整数,通用 + bool flag = 13; + AttachMsg att = 14; + AudioMsg am = 15; + RichText rt = 16; + PatMsg pm = 17; + ForwardMsg fm = 18; + } +} + +message Response +{ + Functions func = 1; + oneof msg + { + int32 status = 2; // Int 状态,通用 + string str = 3; // 字符串 + WxMsg wxmsg = 4; // 微信消息 + MsgTypes types = 5; // 消息类型 + RpcContacts contacts = 6; // 联系人 + DbNames dbs = 7; // 数据库列表 + DbTables tables = 8; // 表列表 + DbRows rows = 9; // 行列表 + UserInfo ui = 10; // 个人信息 + OcrMsg ocr = 11; // OCR 结果 + }; +} + +message Empty { } + +message WxMsg +{ + bool is_self = 1; // 是否自己发送的 + bool is_group = 2; // 是否群消息 + uint64 id = 3 [jstype = JS_STRING]; // 消息 id + uint32 type = 4; // 消息类型 + uint32 ts = 5; // 消息类型 + string roomid = 6; // 群 id(如果是群消息的话) + string content = 7; // 消息内容 + string sender = 8; // 消息发送者 + string sign = 9; // Sign + string thumb = 10; // 缩略图 + string extra = 11; // 附加内容 + string xml = 12; // 消息 xml +} + +message TextMsg +{ + string msg = 1; // 要发送的消息内容 + string receiver = 2; // 消息接收人,当为群时可@ + string aters = 3; // 要@的人列表,逗号分隔 +} + +message PathMsg +{ + string path = 1; // 要发送的图片的路径 + string receiver = 2; // 消息接收人 +} + +message XmlMsg +{ + string receiver = 1; // 消息接收人 + string content = 2; // xml 内容 + string path = 3; // 图片路径 + int32 type = 4; // 消息类型 +} + +message MsgTypes { map types = 1; } + +message RpcContact +{ + string wxid = 1; // 微信 id + string code = 2; // 微信号 + string remark = 3; // 备注 + string name = 4; // 微信昵称 + string country = 5; // 国家 + string province = 6; // 省/州 + string city = 7; // 城市 + int32 gender = 8; // 性别 +} +message RpcContacts { repeated RpcContact contacts = 1; } + +message DbNames { repeated string names = 1; } + +message DbTable +{ + string name = 1; // 表名 + string sql = 2; // 建表 SQL +} +message DbTables { repeated DbTable tables = 1; } + +message DbQuery +{ + string db = 1; // 目标数据库 + string sql = 2; // 查询 SQL +} + +message DbField +{ + int32 type = 1; // 字段类型 + string column = 2; // 字段名称 + bytes content = 3; // 字段内容 +} +message DbRow { repeated DbField fields = 1; } +message DbRows { repeated DbRow rows = 1; } + +message Verification +{ + string v3 = 1; // 加密的用户名 + string v4 = 2; // Ticket + int32 scene = 3; // 添加方式:17 名片,30 扫码 +} + +message MemberMgmt +{ + string roomid = 1; // 要加的群ID + string wxids = 2; // 要加群的人列表,逗号分隔 +} + +message UserInfo +{ + string wxid = 1; // 微信ID + string name = 2; // 昵称 + string mobile = 3; // 手机号 + string home = 4; // 文件/图片等父路径 +} + +message DecPath +{ + string src = 1; // 源路径 + string dst = 2; // 目标路径 +} + +message Transfer +{ + string wxid = 1; // 转账人 + string tfid = 2; // 转账id transferid + string taid = 3; // Transaction id +} + +message AttachMsg +{ + uint64 id = 1 [jstype = JS_STRING]; // 消息 id + string thumb = 2; // 消息中的 thumb + string extra = 3; // 消息中的 extra +} + +message AudioMsg +{ + uint64 id = 1 [jstype = JS_STRING]; // 语音消息 id + string dir = 2; // 存放目录 +} + +message RichText +{ + string name = 1; // 显示名字 + string account = 2; // 公众号 id + string title = 3; // 标题 + string digest = 4; // 摘要 + string url = 5; // 链接 + string thumburl = 6; // 缩略图 + string receiver = 7; // 接收人 +} + +message PatMsg +{ + string roomid = 1; // 群 id + string wxid = 2; // wxid +} + +message OcrMsg +{ + int32 status = 1; // 状态 + string result = 2; // 结果 +} + +message ForwardMsg +{ + uint64 id = 1 [jstype = JS_STRING]; // 待转发消息 ID + string receiver = 2; // 转发接收目标,群为 roomId,个人为 wxid +} + diff --git a/packages/core/scripts/gen-proto.ps1 b/packages/core/scripts/gen-proto.ps1 index 9f91d11..bb04f86 100644 --- a/packages/core/scripts/gen-proto.ps1 +++ b/packages/core/scripts/gen-proto.ps1 @@ -43,9 +43,9 @@ function Get-Pbs { function Invoke-ProtoGen { Write-Host Compiling pb into ts files - $PROTOC_GEN_TS_PATH = "$PSScriptRoot/node_modules/.bin/protoc-gen-ts$ext" | Resolve-Path - $GRPC_TOOLS_NODE_PROTOC_PLUGIN = "$PSScriptRoot/node_modules/.bin/grpc_tools_node_protoc_plugin$ext" | Resolve-Path - $GRPC_TOOLS_NODE_PROTOC = "$PSScriptRoot/node_modules/.bin/grpc_tools_node_protoc$ext" | Resolve-Path + $PROTOC_GEN_TS_PATH = "$PSScriptRoot/../node_modules/.bin/protoc-gen-ts$ext" | Resolve-Path + $GRPC_TOOLS_NODE_PROTOC_PLUGIN = "$PSScriptRoot/../node_modules/.bin/grpc_tools_node_protoc_plugin$ext" | Resolve-Path + $GRPC_TOOLS_NODE_PROTOC = "$PSScriptRoot/../node_modules/.bin/grpc_tools_node_protoc$ext" | Resolve-Path # Generate ts codes for each .proto file using the grpc-tools for Node. $arguments = @( diff --git a/packages/core/src/lib/client.ts b/packages/core/src/lib/client.ts index b391bbd..abc1c4b 100644 --- a/packages/core/src/lib/client.ts +++ b/packages/core/src/lib/client.ts @@ -1,8 +1,10 @@ +import os from 'os'; import { Socket, SocketOptions } from '@rustup/nng'; import * as cp from 'child_process'; import debug from 'debug'; import { wcf } from './proto-generated/wcf'; import * as rd from './proto-generated/roomdata'; +import * as eb from './proto-generated/extrabyte'; import { EventEmitter } from 'events'; import { createTmpDir, @@ -43,10 +45,11 @@ export class Wcferry { private isMsgReceiving = false; private msgDispose?: () => void; private socket: Socket; - private localMode = false; + private readonly localMode; private readonly msgEventSub = new EventEmitter(); private options: Required; constructor(options?: WcferryOptions) { + this.localMode = !options?.host; this.options = { port: options?.port || 10086, host: options?.host || '127.0.0.1', @@ -54,9 +57,6 @@ export class Wcferry { cacheDir: options?.cacheDir || createTmpDir(), recvPyq: !!options?.recvPyq, }; - if (!options?.host) { - this.localMode = true; - } ensureDirSync(this.options.cacheDir); @@ -107,9 +107,7 @@ export class Wcferry { start() { try { - if (this.localMode) { - this.execDLL('start'); - } + this.execDLL('start'); this.socket.connect(this.createUrl()); this.trapOnExit(); if (this.msgListenerCount > 0) { @@ -122,6 +120,9 @@ export class Wcferry { } execDLL(verb: 'start' | 'stop') { + if (!this.localMode) { + return; + } const scriptPath = path.resolve(__dirname, '../../scripts/wcferry.ps1'); const process = cp.spawnSync( 'powershell', @@ -326,7 +327,7 @@ export class Wcferry { return Object.fromEntries( r.members.map((member) => [ member.wxid, - member.name ?? userDict[member.wxid], + member.name || userDict[member.wxid], ]) ); } @@ -338,14 +339,6 @@ export class Wcferry { * @returns 群名片 */ getAliasInChatRoom(wxid: string, roomid: string): string | undefined { - const [row] = this.dbSqlQuery( - 'MicroMsg.db', - `SELECT NickName FROM Contact WHERE UserName = '${wxid}';` - ); - const nickName = row?.['NickName']; - if (!nickName) { - return undefined; - } const [room] = this.dbSqlQuery( 'MicroMsg.db', `SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '${roomid}';` @@ -353,10 +346,28 @@ export class Wcferry { if (!room) { return undefined; } + const roomData = rd.com.iamteer.wcf.RoomData.deserialize( room['RoomData'] as Buffer ); - return roomData.members.find((m) => m.wxid === wxid)?.name; + return ( + roomData.members.find((m) => m.wxid === wxid)?.name || + this.getNickName(wxid)?.[0] + ); + } + + /** + * be careful to SQL injection + * @param wxids wxids + */ + getNickName(...wxids: string[]): Array { + const rows = this.dbSqlQuery( + 'MicroMsg.db', + `SELECT NickName FROM Contact WHERE UserName in (${wxids + .map((id) => `'${id}'`) + .join(',')});` + ); + return rows.map((row) => row['NickName'] as string | undefined); } /** @@ -699,6 +710,42 @@ export class Wcferry { return rsp.status; } + // TODO: get correct wechat files directory somewhere? + private readonly UserDir = path.join( + os.homedir(), + 'Documents', + 'WeChat Files' + ); + + private getMsgAttachments(msgid: string): { + extra?: string; + thumb?: string; + } { + const messages = this.dbSqlQuery( + 'MSG0.db', + `Select * from MSG WHERE MsgSvrID = "${msgid}"` + ); + const buf = messages?.[0]?.['BytesExtra']; + if (!Buffer.isBuffer(buf)) { + return {}; + } + const extraData = eb.com.iamteer.wcf.Extra.deserialize(buf); + const { properties } = extraData.toObject(); + if (!properties) { + return {}; + } + const propertyMap: Partial< + Record + > = Object.fromEntries(properties.map((p) => [p.type, p.value])); + const extra = propertyMap[eb.com.iamteer.wcf.Extra.PropertyKey.Extra]; + const thumb = propertyMap[eb.com.iamteer.wcf.Extra.PropertyKey.Thumb]; + + return { + extra: extra ? path.resolve(this.UserDir, extra) : '', + thumb: thumb ? path.resolve(this.UserDir, thumb) : '', + }; + } + /** * @deprecated 解密图片。这方法别直接调用,下载图片使用 `download_image`。 * @param src 加密的图片路径 @@ -720,22 +767,32 @@ export class Wcferry { /** * 下载图片 * @param msgid 消息中 id - * @param extra 消息中的 extra * @param dir 存放图片的目录(目录不存在会出错) + * @param extra 消息中的 extra, 如果为空,自动通过msgid获取 * @param times 超时时间(秒) * @returns 成功返回存储路径;空字符串为失败,原因见日志。 */ async downloadImage( msgid: string, - extra: string, dir: string, + extra?: string, + thumb?: string, times = 30 ): Promise { - if (this.downloadAttach(msgid, undefined, extra) !== 0) { + const msgAttachments = extra + ? { extra, thumb } + : this.getMsgAttachments(msgid); + if ( + this.downloadAttach( + msgid, + msgAttachments.thumb, + msgAttachments.extra + ) !== 0 + ) { return Promise.reject('Failed to download attach'); } for (let cnt = 0; cnt < times; cnt++) { - const path = this.decryptImage(extra, dir); + const path = this.decryptImage(msgAttachments.extra || '', dir); if (path) { return path; } diff --git a/packages/core/src/lib/proto-generated/extrabyte.ts b/packages/core/src/lib/proto-generated/extrabyte.ts new file mode 100644 index 0000000..5e1e04a --- /dev/null +++ b/packages/core/src/lib/proto-generated/extrabyte.ts @@ -0,0 +1,177 @@ +/* eslint-disable */ + //@ts-nocheck +/** + * Generated by the protoc-gen-ts. DO NOT EDIT! + * compiler version: 3.19.1 + * source: extrabyte.proto + * git: https://github.com/thesayyn/protoc-gen-ts */ +import * as pb_1 from "google-protobuf"; +export namespace com.iamteer.wcf { + export class Extra extends pb_1.Message { + #one_of_decls: number[][] = []; + constructor(data?: any[] | { + properties?: Extra.Property[]; + }) { + super(); + pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [3], this.#one_of_decls); + if (!Array.isArray(data) && typeof data == "object") { + if ("properties" in data && data.properties != undefined) { + this.properties = data.properties; + } + } + } + get properties() { + return pb_1.Message.getRepeatedWrapperField(this, Extra.Property, 3) as Extra.Property[]; + } + set properties(value: Extra.Property[]) { + pb_1.Message.setRepeatedWrapperField(this, 3, value); + } + static fromObject(data: { + properties?: ReturnType[]; + }): Extra { + const message = new Extra({}); + if (data.properties != null) { + message.properties = data.properties.map(item => Extra.Property.fromObject(item)); + } + return message; + } + toObject() { + const data: { + properties?: ReturnType[]; + } = {}; + if (this.properties != null) { + data.properties = this.properties.map((item: Extra.Property) => item.toObject()); + } + return data; + } + serialize(): Uint8Array; + serialize(w: pb_1.BinaryWriter): void; + serialize(w?: pb_1.BinaryWriter): Uint8Array | void { + const writer = w || new pb_1.BinaryWriter(); + if (this.properties.length) + writer.writeRepeatedMessage(3, this.properties, (item: Extra.Property) => item.serialize(writer)); + if (!w) + return writer.getResultBuffer(); + } + static deserialize(bytes: Uint8Array | pb_1.BinaryReader): Extra { + const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new Extra(); + while (reader.nextField()) { + if (reader.isEndGroup()) + break; + switch (reader.getFieldNumber()) { + case 3: + reader.readMessage(message.properties, () => pb_1.Message.addToRepeatedWrapperField(message, 3, Extra.Property.deserialize(reader), Extra.Property)); + break; + default: reader.skipField(); + } + } + return message; + } + serializeBinary(): Uint8Array { + return this.serialize(); + } + static deserializeBinary(bytes: Uint8Array): Extra { + return Extra.deserialize(bytes); + } + } + export namespace Extra { + export enum PropertyKey { + Field_0 = 0, + Sign = 2, + Thumb = 3, + Extra = 4, + Xml = 7 + } + export class Property extends pb_1.Message { + #one_of_decls: number[][] = []; + constructor(data?: any[] | { + type?: Extra.PropertyKey; + value?: string; + }) { + super(); + pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls); + if (!Array.isArray(data) && typeof data == "object") { + if ("type" in data && data.type != undefined) { + this.type = data.type; + } + if ("value" in data && data.value != undefined) { + this.value = data.value; + } + } + } + get type() { + return pb_1.Message.getFieldWithDefault(this, 1, Extra.PropertyKey.Field_0) as Extra.PropertyKey; + } + set type(value: Extra.PropertyKey) { + pb_1.Message.setField(this, 1, value); + } + get value() { + return pb_1.Message.getFieldWithDefault(this, 2, "") as string; + } + set value(value: string) { + pb_1.Message.setField(this, 2, value); + } + static fromObject(data: { + type?: Extra.PropertyKey; + value?: string; + }): Property { + const message = new Property({}); + if (data.type != null) { + message.type = data.type; + } + if (data.value != null) { + message.value = data.value; + } + return message; + } + toObject() { + const data: { + type?: Extra.PropertyKey; + value?: string; + } = {}; + if (this.type != null) { + data.type = this.type; + } + if (this.value != null) { + data.value = this.value; + } + return data; + } + serialize(): Uint8Array; + serialize(w: pb_1.BinaryWriter): void; + serialize(w?: pb_1.BinaryWriter): Uint8Array | void { + const writer = w || new pb_1.BinaryWriter(); + if (this.type != Extra.PropertyKey.Field_0) + writer.writeEnum(1, this.type); + if (this.value.length) + writer.writeString(2, this.value); + if (!w) + return writer.getResultBuffer(); + } + static deserialize(bytes: Uint8Array | pb_1.BinaryReader): Property { + const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new Property(); + while (reader.nextField()) { + if (reader.isEndGroup()) + break; + switch (reader.getFieldNumber()) { + case 1: + message.type = reader.readEnum(); + break; + case 2: + message.value = reader.readString(); + break; + default: reader.skipField(); + } + } + return message; + } + serializeBinary(): Uint8Array { + return this.serialize(); + } + static deserializeBinary(bytes: Uint8Array): Property { + return Property.deserialize(bytes); + } + } + } +} + diff --git a/packages/core/src/lib/proto-generated/extrabyte_grpc_pb.js b/packages/core/src/lib/proto-generated/extrabyte_grpc_pb.js new file mode 100644 index 0000000..97b3a24 --- /dev/null +++ b/packages/core/src/lib/proto-generated/extrabyte_grpc_pb.js @@ -0,0 +1 @@ +// GENERATED CODE -- NO SERVICES IN PROTO \ No newline at end of file