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, };