326 lines
9.2 KiB
JavaScript
326 lines
9.2 KiB
JavaScript
const Koa = require("koa");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
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, cacheAvatars } = require("./bot.js");
|
|
const { config } = require("./config.js");
|
|
const {
|
|
textSHA1,
|
|
ensureAvatarDir,
|
|
ensureUploadDir,
|
|
composeAvatarFileName,
|
|
composeMediaFilePathFromID,
|
|
getOriginalMsgID,
|
|
} = 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();
|
|
const socketIDToSessionID = new Map();
|
|
|
|
io.use((socket, next) => {
|
|
const auth = socket.handshake.auth;
|
|
if (!auth) {
|
|
return next(Error("认证失败"));
|
|
}
|
|
const appName = auth.appName;
|
|
const appCode = config.appCode[appName];
|
|
if (!appCode || appCode !== auth.appCode) {
|
|
return next(Error("认证失败"));
|
|
}
|
|
if (
|
|
config.appAuthHash[appName] &&
|
|
textSHA1(auth.passphrase) !== config.appAuthHash[appName]
|
|
) {
|
|
return next(Error("口令错误"));
|
|
}
|
|
if (activeSessions.has(socket.handshake.auth.sessionID)) {
|
|
return next(Error("会话已存在"));
|
|
}
|
|
if (bot?.state !== bot.CONF.STATE.login) {
|
|
return next(Error("服务不可用"));
|
|
}
|
|
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(socket) {
|
|
activeSessions.delete(socket.handshake.auth.sessionID);
|
|
socketIDToSessionID.delete(socket.id);
|
|
}
|
|
function login(socket) {
|
|
const u = bot.user;
|
|
return {
|
|
id: u.UserName,
|
|
nickname: u.NickName,
|
|
displayName: u.NickName,
|
|
signature: u.Signature,
|
|
avatar: `${config.baseUrl}/session/${socketIDToSessionID.get(
|
|
socket.id
|
|
)}/avatar/${u.UserName}`,
|
|
};
|
|
}
|
|
function getContacts(socket) {
|
|
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/${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)) {
|
|
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, data, filename }) {
|
|
const targetContact = bot.contacts[target];
|
|
if (!targetContact) {
|
|
return "联系人不存在";
|
|
}
|
|
let resp;
|
|
switch (type) {
|
|
case "text":
|
|
resp = await bot.sendText(data, target);
|
|
break;
|
|
case "emotion":
|
|
resp = await bot.sendEmoticon(data, target);
|
|
break;
|
|
case "img":
|
|
case "video":
|
|
case "file": {
|
|
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(newFilename),
|
|
undefined,
|
|
target
|
|
);
|
|
mediaId = id;
|
|
} catch {
|
|
return "上传文件到远程服务器失败";
|
|
}
|
|
try {
|
|
if (type === "img") {
|
|
resp = await bot.sendPic(mediaId, target);
|
|
} else if (type === "video") {
|
|
resp = await bot.sendVideo(mediaId, target);
|
|
} else {
|
|
resp = await bot.sendDoc(
|
|
mediaId,
|
|
filename,
|
|
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(_, { composedID, target }) {
|
|
await bot.revokeMsg(getOriginalMsgID(composedID), target);
|
|
}
|
|
|
|
io.on("connection", (socket) => {
|
|
const sid = socket.handshake.auth.sessionID;
|
|
activeSessions.add(sid);
|
|
socketIDToSessionID.set(socket.id, sid);
|
|
socket.join(DEFAULT_ROOM);
|
|
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("服务不可用"));
|
|
}
|
|
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["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);
|
|
}
|
|
next();
|
|
});
|
|
|
|
/** 上传文件 */
|
|
router.post("/upload_file", async (ctx, next) => {
|
|
if (!activeSessions.has(ctx.request.headers['sid'])) {
|
|
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,
|
|
};
|