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