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