feat(server): first version

This commit is contained in:
2021-10-30 00:30:23 +08:00
commit 7e8985972c
11 changed files with 4224 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# v-chat
微信多客户端方案。服务器通过Web版微信接口提供收发微信消息的能力,并允许多个客户端同时连接。
+3348
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "bot-server",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@journeyapps/sqlcipher": "^5.2.0",
"@koa/cors": "^3.1.0",
"@koa/router": "^10.1.1",
"commander": "^8.3.0",
"dayjs": "^1.10.7",
"inquirer": "^8.2.0",
"koa": "^2.13.4",
"qrcode-terminal": "^0.12.0",
"socket.io": "^4.3.1",
"sqlite": "^4.0.23",
"wechat4u": "^0.7.7"
}
}
+95
View File
@@ -0,0 +1,95 @@
const fs = require("fs");
const path = require("path");
const WeChat = require("wechat4u");
const { config, globalData } = require("./config.js");
const { ensureAvatarDir, composeAvatarFileName } = require("./util");
let sessionData;
try {
sessionData = JSON.parse(
fs.readFileSync(path.join(config.dataDir, "session.json"), {
encoding: "utf-8",
})
);
globalData.hasSessionData = true;
} catch {}
const bot = new WeChat(sessionData);
// 标志正在缓存头像
let cacheAvatarWorkingFlag = 0;
/** 判断是否群聊消息 */
function isGroupMessage(msg) {
return msg.getPeerUserName().startsWith("@@");
}
/** 获取联系人显示名称,备注优先 */
function getDisplayName(usernameOrContact) {
if (!usernameOrContact) {
return null;
}
let contact = usernameOrContact;
if (typeof usernameOrContact === "string") {
contact = bot.contacts[usernameOrContact];
}
return (
contact.RemarkName ||
contact.DisplayName ||
contact.NickName ||
contact.UserName
);
}
/** 获取用户昵称 */
function getNickName(usernameOrContact) {
let contact = usernameOrContact;
if (typeof usernameOrContact === "string") {
contact = bot.contacts[usernameOrContact];
}
return contact.NickName || contact.UserName;
}
/** 获取群聊消息的发言人 */
function getGroupMsgSpeaker(msg) {
if (!isGroupMessage(msg) || msg.isSendBySelf) {
return null;
}
const speakerID = msg["OriginalContent"].split(":", 1)[0];
return bot.contacts[speakerID];
}
/** 获取群聊消息发送的真实内容 */
function getGroupMsgContent(msg) {
const content = msg["Content"];
if (!isGroupMessage(msg) || msg.isSendBySelf) {
return content;
}
return content.replace(/^.*?:\n/, "");
}
/** 缓存用户头像 */
async function cacheAvatars() {
const flag = Date.now() + Math.random();
cacheAvatarWorkingFlag = flag;
const dir = ensureAvatarDir();
for (const uid of Object.keys(bot.contacts)) {
if (cacheAvatarWorkingFlag !== flag) {
// 联系人再次更新了,结束当前的工作,让新的调用来做缓存工作
break;
}
const contact = bot.contacts[uid];
const filename = path.join(dir, composeAvatarFileName(contact));
if (!fs.existsSync(filename)) {
const { data } = await bot.getHeadImg(contact.HeadImgUrl);
fs.writeFileSync(filename, data);
}
}
}
module.exports = {
bot,
getNickName,
getDisplayName,
getGroupMsgSpeaker,
getGroupMsgContent,
cacheAvatars,
};
+47
View File
@@ -0,0 +1,47 @@
const fs = require("fs");
const path = require("path");
const config = {
dbFile: "./wxbot.db",
dataDir: "./wxbot-data",
mediaDir: "./wxbot-data/media",
cacheDir: "./wxbot-data/cache",
serve: true,
httpHost: "127.0.0.1",
httpPort: 9200,
baseUrl: "http:127.0.0.1",
debugMode: false,
appCode: {},
appAuthHash: {},
};
const globalData = {
// 是否有旧的登录数据可重用
hasSessionData: false,
};
/**
* 加载配置文件
* @param file {string}
*/
function loadConfig(file) {
try {
const data = JSON.parse(fs.readFileSync(file, { encoding: "utf-8" }));
Object.assign(config, data);
if (!data.mediaDir) {
config.mediaDir = path.join(config.dataDir, "media");
}
if (!data.cacheDir) {
config.cacheDir = path.join(config.dataDir, "cache");
}
fs.mkdirSync(config.mediaDir, { recursive: true });
fs.mkdirSync(config.cacheDir, { recursive: true });
} catch (e) {
throw Error("加载配置文件失败:" + e.message);
}
}
module.exports = {
globalData,
config,
loadConfig,
};
+88
View File
@@ -0,0 +1,88 @@
const inquirer = require("inquirer");
const sqlite3 = require("@journeyapps/sqlcipher");
const sqlite = require("sqlite");
let pushSQL =
"INSERT INTO msg (\
msg_id,\
create_time,\
msg_type,\
composed_type,\
content,\
from_name,\
from_nickname,\
to_name,\
to_nickname,\
is_group_message,\
speaking_group_member_name,\
filename,\
display_filename\
) VALUES (\
$messageID,\
$createTimestamp,\
$messageType,\
$type,\
$content,\
$fromName,\
$fromNickName,\
$toName,\
$toNickName,\
$isGroupMessage,\
$speakingGroupMemberName,\
$filename,\
$displayFilename\
)";
let db, pushStatement;
async function initDB(dbFile) {
db = await sqlite.open({
filename: dbFile,
driver: sqlite3.Database,
});
while (true) {
try {
const { password } = await inquirer.prompt([
{ type: "password", name: "password", message: "密码:", prefix: "" },
]);
password && (await db.exec(`PRAGMA KEY=${password}`));
await db.exec(
`CREATE TABLE IF NOT EXISTS msg (
msg_id TEXT NOT NULL,
create_time INTEGER NOT NULL,
msg_type INTEGER NOT NULL,
composed_type TEXT NOT NULL,
content TEXT,
from_name TEXT,
from_nickname TEXT,
to_name TEXT,
to_nickname TEXT,
is_group_message INTEGER,
speaking_group_member_name TEXT,
filename TEXT,
display_filename TEXT)`
);
break;
} catch (e) {
if (e.name === "SQLITE_NOTADB") {
console.warn("密码错误");
} else {
throw e;
}
}
}
pushStatement = await db.prepare(pushSQL);
}
/** 将消息存储到数据库 */
async function pushMessage(message) {
const m = {};
for (const key of Object.keys(message)) {
m["$" + key] = message[key];
}
await pushStatement.run(m);
}
module.exports = {
initDB,
pushMessage,
};
+63
View File
@@ -0,0 +1,63 @@
const fs = require("fs");
const path = require("path");
const qrCode = require("qrcode-terminal");
const { program } = require("commander");
const { config, loadConfig, globalData } = require("./config.js");
// 在引入bot前加载配置,因为bot依赖config数据
program.requiredOption("-c, --config-file <string>", "配置文件");
const { configFile } = program.parse().opts();
try {
loadConfig(configFile);
} catch {
console.error("加载配置文件失败");
process.exit(1);
}
const { bot, cacheAvatars } = require("./bot.js");
const { processMessage } = require("./message.js");
const { initDB } = require("./db.js");
require("./socket.js");
async function start() {
await initDB(config.dbFile);
bot.on("uuid", (uuid) => {
console.log("登录二维码:", "https://login.weixin.qq.com/qrcode/" + uuid);
qrCode.generate("https://login.weixin.qq.com/l/" + uuid, {
small: true,
});
});
bot.on("login", () => {
console.log("登录成功");
fs.writeFileSync(
path.join(config.dataDir, "session.json"),
JSON.stringify(bot.botData)
);
});
bot.on("contacts-updated", () => {
cacheAvatars();
});
bot.on("message", (msg) => {
processMessage(msg).catch((e) => bot.emit("error", e));
});
bot.on("error", console.error);
try {
if (globalData.hasSessionData) {
await bot
.restart()
.then(() => {
return bot.sendText("[server restarted from session]", "filehelper");
})
.catch(async () => {
await bot.stop();
await bot.start();
});
} else {
await bot.start();
}
} catch (e) {
console.error("启动失败", e);
}
}
start();
+209
View File
@@ -0,0 +1,209 @@
const path = require("path");
const fs = require("fs");
const socket = require("./socket.js");
const db = require("./db.js");
const {
bot,
getDisplayName,
getGroupMsgSpeaker,
getNickName,
} = require("./bot.js");
const {
composeMessageID,
composeMediaFilePath,
composeFilename,
composeCacheFilePath,
fileMD5,
ensureMediaDir,
} = require("./util");
async function processMessage(msg) {
if ([50, 51, 52, 53, 9999].includes(msg.MsgType)) {
return;
}
const peerContact = bot.contacts[msg.getPeerUserName()];
const fromContact = bot.contacts[msg["FromUserName"]];
const toContact = bot.contacts[msg["ToUserName"]];
if (!peerContact || !fromContact || !toContact) {
// TODO: notify self
return;
}
if (
bot.Contact.isPublicContact(peerContact) ||
(bot.Contact.isSpContact(peerContact) &&
peerContact.UserName !== "filehelper")
) {
return;
}
ensureMediaDir(msg);
const msgID = msg["MsgId"];
const msgType = msg["MsgType"];
const m = {
messageID: msgID,
composedID: composeMessageID(msg),
createTimestamp: msg["CreateTime"],
messageType: msgType,
fromID: msg["FromUserName"],
toID: msg["ToUserName"],
fromName: getDisplayName(fromContact),
fromNickName: getNickName(fromContact),
toName: getDisplayName(toContact),
toNickName: getNickName(toContact),
type: "unknown",
content: null,
group: bot.Contact.isRoomContact(peerContact) ? peerContact : null,
speakingGroupMember: null,
filename: null,
displayFilename: msg["FileName"] || "",
isGroupMessage: bot.Contact.isRoomContact(peerContact),
};
if (m.isGroupMessage) {
if (!msg.isSendBySelf) {
m.speakingGroupMember = getGroupMsgSpeaker(msg);
}
}
switch (msgType) {
case bot.CONF.MSGTYPE_TEXT:
// 文本消息
m.type = "text";
m.content = msg["Content"];
break;
case bot.CONF.MSGTYPE_EMOTICON:
case bot.CONF.MSGTYPE_IMAGE: {
const suffix = msgType === bot.CONF.MSGTYPE_IMAGE ? ".jpg" : ".gif";
m.type = "img";
m.content =
`${msgType === bot.CONF.MSGTYPE_IMAGE ? "图片" : "表情"}` +
(msg["FileName"] || "");
if (msg["HasProductId"]) {
m.type = "unknown";
m.content = "【表情贴图】请在手机上查看";
break;
}
const cachePath = composeCacheFilePath(msg, suffix);
// ENHANCE: 提前判断表情是否存在,节约带宽
const res = await bot.getMsgImg(msg.MsgId);
fs.writeFileSync(cachePath, res.data);
const md5 = await fileMD5(cachePath);
m.filename = md5 + suffix;
const filename = composeMediaFilePath(msg, m.filename);
if (fs.existsSync(filename)) {
fs.rmSync(cachePath);
} else {
fs.renameSync(cachePath, filename);
}
break;
}
case bot.CONF.MSGTYPE_VOICE: {
m.type = "voice";
m.content = "【语音消息】";
m.filename = composeFilename(msg, ".mp3");
const { data } = await bot.getVoice(msg.MsgId);
fs.writeFileSync(composeMediaFilePath(msg, m.filename), data);
break;
}
case bot.CONF.MSGTYPE_VIDEO:
case bot.CONF.MSGTYPE_MICROVIDEO: {
m.type = "video";
const suffix = ".mp4";
const cachePath = composeCacheFilePath(msg, suffix);
const res = await bot.getVideo(msg.MsgId);
fs.writeFileSync(cachePath, res.data);
const md5 = await fileMD5(cachePath);
m.filename = md5 + suffix;
const filename = composeMediaFilePath(msg, m.filename);
if (fs.existsSync(filename)) {
fs.rmSync(cachePath);
} else {
fs.renameSync(cachePath, filename);
}
break;
}
case bot.CONF.MSGTYPE_APP:
m.type = "file";
if (msg.AppMsgType === 5) {
m.type = "text";
m.content = "【链接】" + msg["FileName"] + ": " + msg["Url"];
break;
} else if (msg.AppMsgType == 6) {
if (!(msg.FileSize < 10 * 1024 * 1024)) {
// 忽略10M以上的大文件
m.type = "unknown";
m.content = "【文件】" + m.displayFilename;
m.filename = null;
break;
}
const suffix = path.extname(m.displayFilename);
const cachePath = composeCacheFilePath(msg, suffix);
const res = await bot.getDoc(
msg.FromUserName,
msg.MediaId,
msg.FileName
);
fs.writeFileSync(cachePath, res.data);
const md5 = await fileMD5(cachePath);
m.filename = md5 + suffix;
const filename = composeMediaFilePath(msg, m.filename);
if (fs.existsSync(filename)) {
fs.rmSync(cachePath);
} else {
fs.renameSync(cachePath, filename);
}
break;
}
break;
default:
break;
}
if (m.type === "unknown") {
m.content ||= "未知类型的消息,请在手机上查看";
}
const dbMsg = {
messageID: m.composedID,
createTimestamp: m.createTimestamp,
type: m.type,
messageType: m.messageType,
fromName: m.fromName,
fromNickName: m.fromNickName,
toName: m.toName,
toNickName: m.toNickName,
isGroupMessage: m.isGroupMessage,
speakingGroupMemberName:
(m.speakingGroupMember && getDisplayName(m.speakingGroupMember)) || null,
content: m.content,
filename: m.filename,
displayFilename: m.displayFilename,
};
const clientMsg = {
msgID: m.messageID,
composedID: m.composedID,
createTimestamp: m.createTimestamp,
type: m.type,
fromID: m.fromID,
fromName: m.fromName,
fromNickName: m.fromNickName,
toID: m.toID,
toName: m.toName,
toNickName: m.toNickName,
isGroupMessage: m.isGroupMessage,
speakingGroupMemberID:
m.speakingGroupMember && m.speakingGroupMember.UserName,
speakingGroupMemberName:
(m.speakingGroupMember && getDisplayName(m.speakingGroupMember)) || null,
content: m.content,
filename: m.filename,
displayFilename: m.displayFilename,
};
await db.pushMessage(dbMsg);
socket.pushMessage(clientMsg);
}
module.exports = {
processMessage,
};
+15
View File
@@ -0,0 +1,15 @@
{
"dbFile": "/home/master/desktop/wxbot.db",
"dataDir": "/home/master/desktop",
"serve": true,
"httpHost": "127.0.0.1",
"httpPort": 9200,
"debugMode": false,
"baseUrl": "http:127.0.0.1",
"appCode": {
"mattuy-laptop": "iasdjfkwe3ds-qw3rjkwjfda8"
},
"appAuthHash": {
"mattuy-laptop": "b9c901e0ee98486361b2e5461442af3f3863961f"
}
}
+250
View File
@@ -0,0 +1,250 @@
const Koa = require("koa");
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");
const Router = require("@koa/router");
const cors = require("@koa/cors");
const dayjs = require("dayjs");
const { Server } = require("socket.io");
const { createServer } = require("http");
const { bot, getDisplayName } = require("./bot.js");
const { config } = require("./config.js");
const {
textSHA1,
ensureAvatarDir,
ensureUploadDir,
composeAvatarFileName,
} = require("./util.js");
const DEFAULT_ROOM = "default_room";
const app = new Koa();
const router = new Router();
const httpServer = createServer(app.callback());
const io = new Server(httpServer, {
cors: true,
path: "/socket.io",
});
const activeSessions = new Set();
io.use((socket, next) => {
const auth = socket.handshake.auth;
if (!auth) {
next(Error("认证失败"));
return;
}
const appName = auth.appName;
const appCode = config.appCode[appName];
if (!appCode || appCode !== auth.appCode) {
next(Error("认证失败"));
return;
}
if (textSHA1(auth.passphrase) !== config.appAuthHash[appName]) {
next(Error("口令错误"));
return;
}
if (bot?.state !== bot.CONF.STATE.login) {
next(Error("服务不可用"));
return;
}
next().then(() => {
bot.sendText(
`【上线通知】
DATETIME: ${dayjs().format("YYYY-MM-DDTHH:MM:ss")}
APP_NAME: ${appName}
PLATFORM: ${auth.platform || ""}`,
"filehelper"
);
});
});
function deactivateSession() {
activeSessions.delete(socket.id);
}
async function pushMessage(message) {
if (message.filename) {
for (const sid of await io.allSockets()) {
const msg = {
...message,
fileUrl: `${config.baseUrl}/session/${sid}/media/${message.composedID}/${message.filename}`,
};
io.to(sid).emit("message", msg);
}
} else {
io.in(DEFAULT_ROOM).emit("message", message);
}
}
function login(sessionID) {
const u = bot.user;
return {
id: u.UserName,
nickname: u.NickName,
displayName: u.NickName,
signature: u.Signature,
avatar: `${config.baseUrl}/session/${sessionID}/avatar/${u.UserName}`,
};
}
function getContacts(sessionID) {
function wrapContact(contact) {
return {
id: contact.UserName,
nickname: contact.NickName,
displayName: getDisplayName(contact),
signature: contact.Signature,
nameInitial: contact.PYInitial || contact.RemarkPYInitial,
nameQuanPin: contact.PYQuanPin || contact.RemarkPYQuanPin,
avatar: `${config.baseUrl}/session/${sessionID}/avatar/${contact.UserName}`,
isFriend: contact.ContactFlag === 1,
};
}
const data = { contacts: [], specialContacts: [], groups: [] };
for (const uid of Object.keys(bot.contacts)) {
const contact = bot.contacts[uid];
if (bot.Contact.isPublicContact(contact)) {
continue;
} else if (bot.Contact.isSpContact(contact)) {
if (["filehelper"].includes(contact.UserName)) {
data.specialContacts.push(wrapContact(contact));
}
continue;
} else if (bot.Contact.isRoomContact(contact)) {
data.groups.push(wrapContact(contact));
} else if (!contact.isSelf) {
data.contacts.push(wrapContact(contact));
}
}
return data;
}
async function sendMessage({ type, target, filename, fileUri, data }) {
const targetContact = bot.contacts[target];
if (!targetContact) {
return "联系人不存在";
}
const to = targetContact.UserName;
switch (type) {
case "text":
await bot.sendText(data, target);
break;
case "emotion":
await bot.sendEmoticon(data, to);
break;
case "img":
case "video":
case "file": {
const filepath = path.join(ensureUploadDir(), fileUri);
if (!fs.existsSync(filepath)) {
return "图片不存在";
}
let mediaId;
try {
const { mediaId: id } = await bot.uploadMedia(
fs.createReadStream(filepath),
filename,
targetContact.UserName
);
mediaId = id;
} catch {
return "上传文件到远程服务器失败";
}
try {
if (type === "img") {
await bot.sendPic(mediaId, to);
} else if (type === "video") {
await bot.sendVideo(mediaId, to);
} else {
await bot.sendDoc(
mediaId,
filename,
fs.statSync(filepath).size,
path.extname(filepath),
to
);
}
} catch (e) {
return e?.tips || e?.message || "消息发送失败";
}
break;
}
}
}
async function revokeMessage({ msgID, target }) {
await bot.revokeMsg(msgID, target);
}
async function uploadFile({ data }) {
const uploadDir = ensureUploadDir();
const filename = dayjs().format("YYYYMMDD_") + String(Math.random()).slice(1);
const filepath = path.join(uploadDir, filename);
await promisify(fs.writeFile)(filepath, data);
// 10分钟后删除上传的临时文件
setTimeout(() => {
if (fs.existsSync(filepath)) {
fs.rmSync(filepath);
}
}, 600000);
return { localFileName: filename };
}
function handleNotImplemented(_, cb) {
cb?.call(null, "Not Implemented");
}
io.on("connection", (socket) => {
console.log("connected");
activeSessions.add(socket.id);
socket.join(DEFAULT_ROOM);
socket.on("disconnect", deactivateSession);
socket.on("login", login.bind(null, socket.id));
socket.on("get_contacts", getContacts.bind(null, socket.id));
socket.on("send_message", sendMessage);
socket.on("revoke_message", revokeMessage);
socket.on("upload_file", uploadFile);
socket.onAny(handleNotImplemented);
socket.use((_, next) => {
if (bot?.state !== bot.CONF.STATE.login) {
return next(Error("服务不可用"));
}
next();
});
});
/** 获取头像 */
router.get("/session/:sessionID/avatar/:userID", (ctx, next) => {
if (!activeSessions.has(ctx.params.sessionID)) {
ctx.throw(401);
}
const contact = bot.contacts[ctx.params.userID];
if (!contact) {
ctx.throw(404);
}
const filename = composeAvatarFileName(contact);
const filepath = filename && path.join(ensureAvatarDir(), filename);
if (filepath && fs.existsSync(filepath)) {
ctx.response.headers["Content-Type"] = "image/jpeg";
ctx.response.body = fs.createReadStream(filepath);
} else {
ctx.throw(404);
}
next();
});
/** 获取消息文件,包含图片 */
router.get("/session/:sessionID/media/:composedID/:filename", (ctx, next) => {
const filename = composeMediaFilePathFromID(
ctx.params.composedID,
ctx.params.filename
);
if (fs.existsSync(filename)) {
ctx.response.body = fs.createReadStream(filename);
} else {
ctx.throw(404);
}
next();
});
app.use(cors());
app.use(router.routes());
httpServer.listen(config.httpPort, config.httpHost);
module.exports = {
pushMessage,
};
+81
View File
@@ -0,0 +1,81 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const dayjs = require("dayjs");
const { config } = require("./config");
function composeMessageID(msg) {
return dayjs.unix(msg["CreateTime"]).format("YYYYMMDD") + msg["MsgId"];
}
function composeFilename(msg, suffix = "") {
const dt = dayjs.unix(msg["CreateTime"]);
const datetime = dt.format("YYYYMMDD");
return path.join(datetime + msg["MsgId"] + suffix);
}
function composeMediaFilePath(msg, filename) {
const year = dayjs.unix(msg["CreateTime"]).format("YYYY");
return path.join(config.mediaDir, year, filename);
}
function composeMediaFilePathFromID(composedID, filename) {
return path.join(config.mediaDir, String(composedID).slice(0, 4), filename);
}
function composeCacheFilePath(msg, suffix = "") {
return path.join(config.cacheDir, composeFilename(msg, suffix));
}
function composeAvatarFileName(contact) {
const name =
contact.RemarkName ||
contact.DisplayName ||
contact.NickName ||
contact.UserName;
return textSHA1(name) + ".jpg";
}
function ensureMediaDir(msg) {
const dir = composeMediaFilePath(msg, "");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function ensureAvatarDir() {
const dir = path.join(config.cacheDir, "avatar");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function ensureUploadDir() {
const dir = path.join(config.cacheDir, "upload");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function fileMD5(filename) {
return new Promise((reslove) => {
let md5sum = crypto.createHash("md5");
let stream = fs.createReadStream(filename);
stream.on("data", function (chunk) {
md5sum.update(chunk);
});
stream.on("end", function () {
let md5 = md5sum.digest("hex");
reslove(md5);
});
});
}
function textSHA1(text) {
return crypto
.createHash("sha1")
.update(Buffer.from(text, "utf-8"))
.digest("hex");
}
module.exports = {
composeMessageID,
composeFilename,
composeMediaFilePath,
composeMediaFilePathFromID,
composeCacheFilePath,
composeAvatarFileName,
ensureMediaDir,
ensureAvatarDir,
ensureUploadDir,
fileMD5,
textSHA1,
};