first version

This commit is contained in:
2021-11-01 22:30:51 +08:00
parent da2a96865c
commit d964439624
143 changed files with 29617 additions and 101 deletions
+155 -83
View File
@@ -1,19 +1,21 @@
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 formidable = require("formidable");
const dayjs = require("dayjs");
const { Server } = require("socket.io");
const { createServer } = require("http");
const { bot, getDisplayName } = require("./bot.js");
const { bot, getDisplayName, cacheAvatars } = require("./bot.js");
const { config } = require("./config.js");
const {
textSHA1,
ensureAvatarDir,
ensureUploadDir,
composeAvatarFileName,
composeMediaFilePathFromID,
getOriginalMsgID,
} = require("./util.js");
const DEFAULT_ROOM = "default_room";
@@ -26,67 +28,65 @@ const io = new Server(httpServer, {
path: "/socket.io",
});
const activeSessions = new Set();
const socketIDToSessionID = new Map();
io.use((socket, next) => {
const auth = socket.handshake.auth;
if (!auth) {
next(Error("认证失败"));
return;
return next(Error("认证失败"));
}
const appName = auth.appName;
const appCode = config.appCode[appName];
if (!appCode || appCode !== auth.appCode) {
next(Error("认证失败"));
return;
return next(Error("认证失败"));
}
if (textSHA1(auth.passphrase) !== config.appAuthHash[appName]) {
next(Error("口令错误"));
return;
return next(Error("口令错误"));
}
if (activeSessions.has(socket.handshake.auth.sessionID)) {
return next(Error("会话已存在"));
}
if (bot?.state !== bot.CONF.STATE.login) {
next(Error("服务不可用"));
return;
return next(Error("服务不可用"));
}
next().then(() => {
bot.sendText(
`【上线通知】
DATETIME: ${dayjs().format("YYYY-MM-DDTHH:MM:ss")}
APP_NAME: ${appName}
PLATFORM: ${auth.platform || ""}`,
"filehelper"
);
next();
const text = `【上线通知】${"\n"}${appName}⌟ on ${
auth.platform
} connected at ${dayjs().format("YYYY-MM-DDTHH:MM:ss")}`;
bot.sendText(text, "filehelper");
pushMessage({
messageID: "notice_" + (Date.now() + Math.random()),
createTimestamp: Math.trunc(Date.now() / 1000),
type: "text",
fromID: "filehelper",
fromName: "你自己",
fromNickName: "你自己",
toID: bot.user.UserName,
toName: "你自己",
toNickName: bot.user.NickName,
isGroupMessage: false,
isSentBySelf: false,
content: text,
});
});
function deactivateSession() {
activeSessions.delete(socket.id);
function deactivateSession(socket) {
activeSessions.delete(socket.handshake.auth.sessionID);
socketIDToSessionID.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) {
function login(socket) {
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}`,
avatar: `${config.baseUrl}/session/${socketIDToSessionID.get(
socket.id
)}/avatar/${u.UserName}`,
};
}
function getContacts(sessionID) {
function getContacts(socket) {
function wrapContact(contact) {
return {
id: contact.UserName,
@@ -95,54 +95,73 @@ function getContacts(sessionID) {
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,
avatar: `${config.baseUrl}/session/${socketIDToSessionID.get(
socket.id
)}/avatar/${contact.UserName}`,
type: null,
};
}
const data = { contacts: [], specialContacts: [], groups: [] };
for (const uid of Object.keys(bot.contacts)) {
const contact = bot.contacts[uid];
const wrapped = wrapContact(contact);
if (bot.Contact.isPublicContact(contact)) {
continue;
} else if (bot.Contact.isSpContact(contact)) {
if (["filehelper"].includes(contact.UserName)) {
data.specialContacts.push(wrapContact(contact));
wrapped.type = "special";
data.specialContacts.push(wrapped);
}
continue;
} else if (bot.Contact.isRoomContact(contact)) {
wrapped.type = "group";
data.groups.push(wrapContact(contact));
} else if (!contact.isSelf) {
wrapped.type = "friend";
data.contacts.push(wrapContact(contact));
}
}
return data;
}
async function sendMessage({ type, target, filename, fileUri, data }) {
async function sendMessage(_, { type, target, data, filename }) {
const targetContact = bot.contacts[target];
if (!targetContact) {
return "联系人不存在";
}
const to = targetContact.UserName;
let resp;
switch (type) {
case "text":
await bot.sendText(data, target);
resp = await bot.sendText(data, target);
break;
case "emotion":
await bot.sendEmoticon(data, to);
resp = await bot.sendEmoticon(data, target);
break;
case "img":
case "video":
case "file": {
const filepath = path.join(ensureUploadDir(), fileUri);
if (!fs.existsSync(filepath)) {
const defaultFilenameMap = {
img: "pic.jpg",
video: "video.mp4",
emotion: "pic.gif",
file: "file",
};
const fileUri = data;
const oldFilename = path.join(ensureUploadDir(), fileUri);
const newFilename = path.join(
ensureUploadDir(),
filename || Date.now() + Math.random() + defaultFilenameMap[type]
);
if (!fs.existsSync(oldFilename)) {
return "图片不存在";
}
let mediaId;
try {
fs.renameSync(oldFilename, newFilename);
// NOTICE: uploadMedia函数会优先从file.path中取文件名
const { mediaId: id } = await bot.uploadMedia(
fs.createReadStream(filepath),
filename,
targetContact.UserName
fs.createReadStream(newFilename),
undefined,
target
);
mediaId = id;
} catch {
@@ -150,56 +169,56 @@ async function sendMessage({ type, target, filename, fileUri, data }) {
}
try {
if (type === "img") {
await bot.sendPic(mediaId, to);
resp = await bot.sendPic(mediaId, target);
} else if (type === "video") {
await bot.sendVideo(mediaId, to);
resp = await bot.sendVideo(mediaId, target);
} else {
await bot.sendDoc(
resp = await bot.sendDoc(
mediaId,
filename,
fs.statSync(filepath).size,
path.extname(filepath),
to
fs.statSync(newFilename).size,
path.extname(newFilename),
target
);
}
} catch (e) {
return e?.tips || e?.message || "消息发送失败";
}
// 上传后删除
fs.rm(newFilename, () => {});
break;
}
default:
return "不支持的文件类型";
}
return {
messageID: resp.MsgID,
};
}
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");
async function revokeMessage(_, { composedID, target }) {
await bot.revokeMsg(getOriginalMsgID(composedID), target);
}
io.on("connection", (socket) => {
console.log("connected");
activeSessions.add(socket.id);
const sid = socket.handshake.auth.sessionID;
activeSessions.add(sid);
socketIDToSessionID.set(socket.id, sid);
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);
function wrapWithCallback(func) {
return async function (data, callback) {
const res = func.call(this, socket, data);
if (res instanceof Promise) {
callback && callback(await res);
} else {
callback && callback(res);
}
};
}
socket.on("disconnect", wrapWithCallback(deactivateSession));
socket.on("login", wrapWithCallback(login));
socket.on("get_contacts", wrapWithCallback(getContacts));
socket.on("send_message", wrapWithCallback(sendMessage));
socket.on("revoke_message", wrapWithCallback(revokeMessage));
socket.use((_, next) => {
if (bot?.state !== bot.CONF.STATE.login) {
return next(Error("服务不可用"));
@@ -220,20 +239,26 @@ router.get("/session/:sessionID/avatar/:userID", (ctx, next) => {
const filename = composeAvatarFileName(contact);
const filepath = filename && path.join(ensureAvatarDir(), filename);
if (filepath && fs.existsSync(filepath)) {
ctx.response.headers["Cache-Control"] = "max-age=3600";
ctx.response.headers["Content-Type"] = "image/jpeg";
ctx.response.body = fs.createReadStream(filepath);
} else {
cacheAvatars();
ctx.throw(404);
}
next();
});
/** 获取消息文件,包含图片 */
router.get("/session/:sessionID/media/:composedID/:filename", (ctx, next) => {
if (!activeSessions.has(ctx.params.sessionID)) {
ctx.throw(401);
}
const filename = composeMediaFilePathFromID(
ctx.params.composedID,
ctx.params.filename
);
if (fs.existsSync(filename)) {
ctx.response.headers["Cache-Control"] = "max-age=3600";
ctx.response.body = fs.createReadStream(filename);
} else {
ctx.throw(404);
@@ -241,10 +266,57 @@ router.get("/session/:sessionID/media/:composedID/:filename", (ctx, next) => {
next();
});
/** 上传文件 */
router.post("/upload_file", async (ctx, next) => {
if (!activeSessions.has(ctx.request.headers.session_id)) {
ctx.throw(401);
}
const filename = dayjs().format("YYYYMMDD_") + String(Math.random()).slice(2);
const filepath = path.join(ensureUploadDir(), filename);
const form = formidable({ multiples: false });
form.on("fileBegin", (_, file) => {
file.filepath = filepath;
});
await new Promise((resolve, reject) => {
form.parse(ctx.req, (err, _, files) => {
if (err) {
reject(err);
} else {
ctx.response.status = 200;
ctx.response.type = "application/json";
ctx.response.body = JSON.stringify({ tempFileName: filename });
// 10分钟后删除上传的临时文件
setTimeout(() => {
if (fs.existsSync(filepath)) {
fs.rmSync(filepath);
}
}, 600000);
resolve();
}
});
});
next();
});
app.use(cors());
app.use(router.routes());
httpServer.listen(config.httpPort, config.httpHost);
async function pushMessage(message) {
if (message.filename) {
for (const sid of await io.allSockets()) {
const sessionID = socketIDToSessionID.get(sid);
const msg = {
...message,
fileUrl: `${config.baseUrl}/session/${sessionID}/media/${message.messageID}/${message.filename}`,
};
io.to(DEFAULT_ROOM).emit("message", msg);
}
} else {
io.in(DEFAULT_ROOM).emit("message", message);
}
}
module.exports = {
pushMessage,
};