feat(server): first version
This commit is contained in:
Generated
+3348
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user