diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 72a8aae..6daa997 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 +FROM mcr.microsoft.com/devcontainers/javascript-node:0-20 ARG MONGO_TOOLS_VERSION=6.0 RUN . /etc/os-release \ && curl -sSL "https://www.mongodb.org/static/pgp/server-${MONGO_TOOLS_VERSION}.asc" | gpg --dearmor > /usr/share/keyrings/mongodb-archive-keyring.gpg \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b45b7b6..58cde2e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,6 +10,6 @@ ] } }, - "forwardPorts": [3000, 3500, 8081], + "forwardPorts": [3000, 3500], "postCreateCommand": "npm install" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 9643ab4..570fe4d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,7 +5,6 @@ services: build: context: . dockerfile: Dockerfile - container_name: local-app volumes: - ../..:/workspaces:cached command: sleep infinity @@ -13,15 +12,12 @@ services: mongo: image: mongo:latest - container_name: local-mongo restart: unless-stopped - express: - image: mongo-express:latest - container_name: local-express + redis: + image: redis:latest + restart: unless-stopped + + chroma: + image: chromadb/chroma:latest restart: unless-stopped - environment: - ME_CONFIG_BASICAUTH_USERNAME: developer - ME_CONFIG_BASICAUTH_PASSWORD: developer - ME_CONFIG_MONGODB_SERVER: local-mongo - network_mode: service:mongo diff --git a/.env.sample b/.env.sample index c41d1eb..be81a02 100644 --- a/.env.sample +++ b/.env.sample @@ -2,13 +2,44 @@ NODE_ENV="development" # general +TRUST_PROXY="loopback" # empty to disable, ('loopback', 'linklocal', 'uniquelocal' or ip addresses) +ENABLED_PROTOCOLS="http,https" # ('http', 'https') +ENABLED_REDIRECT_HTTP_HTTPS="no" # redirect http to https ('yes' or 'no') + +# http HTTP_PORT="3000" -MONGODB_URI="mongodb://local-mongo:27017/app" +HTTP_HOSTNAME="127.0.0.1" + +# https +HTTPS_PORT="3500" +HTTPS_HOSTNAME="127.0.0.1" +HTTPS_KEY_PATH="/some/where.key" +HTTPS_CERT_PATH="/some/where.crt" + +# database +MONGODB_URI="mongodb://mongo:27017/app" +CHROMA_URI="http://chroma:8000" +REDIS_URI="redis://redis:6379" + +# cors +ENABLED_CORS="no" # cors ('yes' or 'no'), CORS_ORIGIN is required +ENABLED_CORS_ORIGIN_CHECK="no" # is the API public to every client ('yes' or 'no') +CORS_ORIGIN="https://example.org" + +# api docs server +ENABLED_SWAGGER="yes" # swagger ('yes' or 'no') +SWAGGER_SERVER_DESCRIPTION="default" +SWAGGER_CORS_ORIGIN="http://127.0.0.1:3000" +SWAGGER_SERVER_URL="http://127.0.0.1:3000" + +# sara config +SARA_RECV_HOST="http://127.0.0.1:3000" # replace the field with your sara.recv entrypoint +SARA_AUDIENCE_URL="https://example.org" -# openai integration -OPENAI_BASE_URL="https://web-tech-tw.eu.org/openai/v1" -OPENAI_API_KEY="your_gemini_api_key" -OPENAI_CHAT_MODEL="gpt-3.5-turbo" +# gemini integration +GEMINI_API_KEY="your_gemini_api_key" +GEMINI_MODEL_NAME="gemini-1.0-pro" +GEMINI_SYSTEM_PROMPT="這是一場角色扮演,但請您不要向我再次透漏這是一場扮演,並以一般交談方式溝通。溝通請使用正體中文交談,您的正體中文名字是「寧芙」,英文名字是「Nymph」。你來自於「臺灣網際網路技術推廣組織」,該組織英文名稱為「Taiwan Web Technology Promotion Organization」,該組織主旨為:在臺灣推廣網際網路技術,如JavaScript、資訊安全素養、網路通訊觀念......等。溝通方式將以 Discord 通訊軟體的方式進行,你可以任意使用Markdown語法。若有人使用「@」標注任何人,請勿不要再次重複標注。" # discord integration DISCORD_APP_ID="00000" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0fc18e9..cac86f6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3 - name: Log in to the Container registry - uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -30,25 +30,14 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@507c2f2dc502c992ad446e3d7a5dfbe311567a96 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - deploy-service: - name: Deploy application as service - needs: build-and-push-image - runs-on: ubuntu-latest - - steps: - - name: Trigger deploy webhook - uses: wei/curl@master - with: - args: ${{ secrets.DEPLOY_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index 4a2fab2..a17b2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # App config files +keypair_* .env prompts.json relay.json @@ -10,7 +11,12 @@ data/ # Finder config file .DS_Store +# OpenAPI exported file +openapi_exported.json + # Directories +.nyc_output +coverage node_modules ssl_keys dist @@ -22,8 +28,6 @@ yarn-error.log* pnpm-debug.log* # Editor directories and files -coverage -.nyc_output .idea .vscode *.suo @@ -33,4 +37,4 @@ coverage *.sw? # Lock file -package-lock.json \ No newline at end of file +package-lock.json diff --git a/.husky/commit-msg b/.husky/commit-msg similarity index 100% rename from .husky/commit-msg rename to .husky/commit-msg diff --git a/Dockerfile b/Dockerfile index 30522db..5e2a69a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,19 @@ -FROM node:18 +FROM node:20-alpine ENV RUNTIME_ENV container -RUN useradd -u 3000 recv - -RUN mkdir -p \ - /home/recv \ - /workplace +RUN adduser -u 3000 -D recv +RUN mkdir -p /.npm /workplace WORKDIR /workplace ADD . /workplace RUN chown -R \ 3000:3000 \ - /home/recv \ - /workplace + /.npm /workplace USER 3000 RUN npm install +EXPOSE 3000 CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE index 9029d67..37d98c4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Taiwan Web Technology Promotion Organization (https://github.com/web-tech-tw) +Copyright (c) 2024 Taiwan Web Technology Promotion Organization (https://web-tech.tw) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 704485b..795db85 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,83 @@ -# Nymph +# Template RECV -Automate resource management tasks, monitor activity, and provide support with this AI-powered bot. +[網頁客戶端](https://github.com/web-tech-tw/template.inte) | 伺服器端 -![nymph](avatar.png) +一個小型卻強大的微服務框架。 A tiny but powerful microservice framework. -## Features +本系統為本組織的通用伺服器端範本,為敏捷開發而生。 -- [ ] Roles assignment automatically -- [ ] Link Discord users with their ID of [Sara System](https://web-tech-tw.github.io/sara.inte) +本系統提供了一個簡單的架構,讓開發者可以快速開發出一個微服務。 + +本系統架構預設為 **單機模式** 設計。若有需求可自行擴充修改為 **分散式模式**。 + +## 系統架構 + +本系統採用 node.js 打造,使用 express.js 作為基礎框架,並擴充了許多功能。 + +推薦運行於 `node.js 20` 以上版本。自帶 docker 範本,可快速部署。 + +- 基於 express.js 擴充的微服務框架,可相容 express.js 的所有功能及生態系統。 +- 採用 commonjs 模組系統,可相容大部分 npm 套件及快速引用模組。 +- 內建 node-cache 作為快取系統,可快速存取資料。 +- 內建 mongoose 作為資料庫系統,可快速存取資料庫。 +- 內建 jsonwebtoken 作為授權系統,可快速授權。 +- 內建 mocha 作為自動化測試系統,可快速進行自動化測試。 +- 內建 nodemon 作為開發除錯系統,可快速進行開發除錯。 +- 內建 dotenv 作為環境變數系統,可快速設定環境變數。 +- 內建 cors 作為跨來源資源共用系統,可快速設定跨來源資源共用。 +- 內建 swagger 作為 API 文件系統,可快速產生 API 文件。 +- 自帶 GitHub Actions 範本,可快速進行自動化測試、組建容器、部署容器等功能。 +- 自帶 VScode DevContainer 範本,可快速進行開發、測試等功能。 +- 具有快速驗證、快速授權、快速存取資料庫、快速存取快取等功能。 +- 具有快速開發、快速測試、快速部署等功能。 +- 高擴充性,可自行擴充功能。 + +## 系統設定 + +### 安裝相依套件 + +本專案使用 Node.js 作為開發環境,請先安裝 Node.js。 + +該指令會安裝專案所需的相依套件。 + +```sh +npm install +``` + +### 自動化測試 + +本專案採用 Mocha 作為自動化測試框架。 + +該指令會執行所有測試案例。 + +```sh +npm run test +``` + +### 開發除錯模式 + +本專案採用 Nodemon 作為開發除錯工具。 + +該指令會啟動伺服器,並在程式碼變更時自動重啟伺服器。 + +```sh +npm run dev +``` + +### 正式產品模式 + +該指令會啟動伺服器。 + +```sh +npm start +``` + +## 開放原始碼授權 + +本專案採用 MIT 開放原始碼授權。 + +詳細可參閱 [LICENSE](LICENSE) 檔案。 + +--- + +© [Taiwan Web Technology Promotion Organization](https://web-tech.tw) diff --git a/app.js b/app.js index 0c94960..13e98ff 100644 --- a/app.js +++ b/app.js @@ -1,39 +1,62 @@ "use strict"; +// Import config const { runLoader, - getMust, - get, + getEnvironmentOverview, } = require("./src/config"); -const { - prepare, -} = require("./src/clients/database"); -const express = require("express"); +// Load config runLoader(); -const app = express(); - -const runners = []; -if (get("LINE_CHANNEL_SECRET")) { - app.use("/line", require("./src/line")()); -} -if (get("DISCORD_BOT_TOKEN")) { - runners.push(require("./src/discord")); -} -if (get("MATRIX_USERNAME")) { - runners.push(require("./src/matrix")); -} - -(async () => { - await prepare(); - await Promise.all(runners.map( - (runner) => runner(), - )).then(() => { - console.info("Nymph 系統 成功啟動"); - }).catch((e) => { - console.error("Nymph 系統 啟動失敗:", e); - }); +// Import constants +const constant = require("./src/init/const"); + +// Import useApp +const {useApp} = require("./src/init/express"); + +// Initialize application +const app = useApp(); + +// Initialize prepare handlers +const { + prepare: prepareDatabase, +} = require("./src/init/database"); +const { + prepare: prepareListener, +} = require("./src/init/listener"); + +const prepareHandlers = [ + prepareDatabase, + prepareListener, +]; + +// Render index page +app.get("/", (_, res) => { + res.render("index"); +}); + +// The handler for robots.txt (deny all friendly robots) +app.get("/robots.txt", (_, res) => { + res.type("txt").send("User-agent: *\nDisallow: /"); +}); + +// Load router dispatcher +const routerDispatcher = require("./src/routes"); +routerDispatcher.load(); + +// Show banner message +(() => { + const {APP_NAME: appName} = constant; + const {node, runtime} = getEnvironmentOverview(); + const statusMessage = `(environment: ${node}, ${runtime})`; + console.info(appName, statusMessage, "\n===="); })(); -app.listen(getMust("HTTP_PORT")); +// Mount application and execute it +require("./src/execute")(app, prepareHandlers, + ({protocol, hostname, port}) => { + console.info(`Protocol "${protocol}" is listening at`); + console.info(`${protocol}://${hostname}:${port}`); + }, +); diff --git a/export_openapi.js b/export_openapi.js new file mode 100644 index 0000000..7267eb3 --- /dev/null +++ b/export_openapi.js @@ -0,0 +1,32 @@ +"use strict"; + +// Import config +const { + runLoader, +} = require("./src/config"); + +// Load config +runLoader(); + +// Import constant +const constant = require("./src/init/const"); + +// Import modules +const {writeFileSync} = require("node:fs"); + +const {useApiDoc} = require("./src/init/api_doc"); + +// Get the API documentation +const apiDoc = useApiDoc(); +const apiDocJson = JSON.stringify(apiDoc); + +// Write the JSON file +try { + const {OPENAPI_EXPORTED_FILENAME: filename} = constant; + writeFileSync(filename, apiDocJson, { + encoding: "utf-8", + }); + console.info(`The documentation has been saved into "${filename}".`); +} catch (error) { + console.error(error); +} diff --git a/package.json b/package.json index cfea210..88db95d 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { - "name": "nymph", + "name": "template.recv", "version": "1.0.0", - "description": "Automate resource management tasks, monitor activity, and provide support with this AI-powered bot.", + "description": "A tiny but powerful microservice framework.", "author": "Taiwan Web Technology Promotion Organization", + "homepage": "https://web-tech.tw", "license": "MIT", "main": "app.js", "scripts": { - "register-commands": "node register_commands.js", + "export-openapi": "node export_openapi.js", "dev": "nodemon app.js", "start": "node app.js", "lint": "npx lint-staged", @@ -18,29 +19,42 @@ "*.js": "eslint" }, "dependencies": { - "@discordjs/rest": "^2.2.0", - "@line/bot-sdk": "^9.2.2", - "@matrix-org/matrix-sdk-crypto-nodejs": "^0.1.0-beta.12", - "discord-api-types": "^0.37.65", - "discord.js": "^14.14.1", + "@discordjs/rest": "^2.4.0", + "@langchain/community": "^0.3.11", + "@langchain/google-genai": "^0.1.2", + "@langchain/redis": "^0.1.0", + "@line/bot-sdk": "^9.4.4", + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.2.0-beta.1", + "axios": "^1.3.4", + "cors": "^2.8.5", + "discord-api-types": "^0.37.103", + "discord.js": "^14.16.3", "discordjs-reaction-role": "^3.1.0", - "dotenv": "^16.3.1", - "express": "^4.19.2", + "dotenv": "^16.4.5", + "ejs": "^3.1.10", + "express": "^4.21.1", + "express-validator": "^6.14.3", + "http-status-codes": "^2.2.0", "jsonwebtoken": "^9.0.2", + "langchain": "^0.3.5", "matrix-bot-sdk": "^0.7.1", - "mongoose": "^8.3.4", - "openai": "^4.49.1" + "mongoose": "^6.13.3", + "node-cache": "^5.1.2", + "request-ip": "^3.3.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^4.6.2", + "ua-parser-js": "^1.0.39" }, "devDependencies": { - "@commitlint/cli": "^17.6.5", - "@commitlint/config-conventional": "^17.6.5", + "@commitlint/cli": "^17.4.4", + "@commitlint/config-conventional": "^17.4.4", "cz-conventional-changelog": "^3.3.0", - "eslint": "^8.41.0", + "eslint": "^8.17.0", "eslint-config-google": "^0.14.0", "eslint-plugin-jsdoc": "^48.2.3", "husky": "^8.0.3", - "lint-staged": "^13.2.2", - "nodemon": "^2.0.22" + "lint-staged": "^13.2.0", + "nodemon": "^2.0.13" }, "config": { "commitizen": { diff --git a/prompts.sample.json b/prompts.sample.json deleted file mode 100644 index bf4fcd5..0000000 --- a/prompts.sample.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "role": "system", - "content": "這是一場角色扮演,但請您不要向我再次透漏這是一場扮演,並以一般交談方式溝通。溝通請使用正體中文交談,您的正體中文名字是「寧芙」,英文名字是「Nymph」。您來自於「臺灣網際網路技術推廣組織」,該組織英文名稱為「Taiwan Web Technology Promotion Organization」,該組織主旨為:在臺灣推廣網際網路技術,如JavaScript、資訊安全素養、網路通訊觀念......等。溝通將在通訊軟體進行,您可以任意使用Markdown語法。若有人使用「@」標注任何人,請您不要再次重複標注。" - } -] \ No newline at end of file diff --git a/relay.sample.json b/relay.sample.json index 0a3fff2..9a7bfc6 100644 --- a/relay.sample.json +++ b/relay.sample.json @@ -1,7 +1,6 @@ [ { - "name": "Arona", - "discordChannelId": "996069746514612235", - "matrixRoomId": "!yTRzUDNyviBhrMwYGG:matrix.org" + "Discord": "996069746514612235", + "Matrix": "!yTRzUDNyviBhrMwYGG:matrix.org" } ] \ No newline at end of file diff --git a/src/bridges/discord_matrix.js b/src/bridges/discord_matrix.js deleted file mode 100644 index 4c8222c..0000000 --- a/src/bridges/discord_matrix.js +++ /dev/null @@ -1,41 +0,0 @@ -"use strict"; -// Trasnfer messages from Discord to Matrix. - -const discord = require("discord.js"); - -const { - useClient, -} = require("../clients/matrix"); - -const { - find, -} = require("./utils"); - -/** - * This function is called when a message is created in Discord. - * - * @param {discord.Message} message - * @return {void} - */ -module.exports = async (message) => { - const client = await useClient(); - - const {channelId} = message; - - const relayTarget = find("discordChannelId", channelId); - if (!relayTarget) { - return; - } - const { - matrixRoomId: roomId, - } = relayTarget; - - const {username} = message.author; - const {content: text} = message; - - await client.sendMessage(roomId, { - msgtype: "m.text", - format: "plain/text", - body: `${username} ⬗ Discord\n${text}`, - }); -}; diff --git a/src/bridges/finder.js b/src/bridges/finder.js new file mode 100644 index 0000000..53e0c80 --- /dev/null +++ b/src/bridges/finder.js @@ -0,0 +1,15 @@ +"use strict"; + +const relayMap = require("../../relay.json"); + +/** + * Find a relay by key and value. + * + * @param {string} key - The key to search. + * @param {string} value - The value to search. + * @return {object} The relay. + */ +module.exports = (key, value) =>{ + const itemMatch = (i) => i[key] === value; + return relayMap.find(itemMatch); +}; diff --git a/src/bridges/index.js b/src/bridges/index.js new file mode 100644 index 0000000..8349360 --- /dev/null +++ b/src/bridges/index.js @@ -0,0 +1,22 @@ +"use strict"; + +const finder = require("./finder"); +const sender = require("./sender"); + +exports.relayText = (platform, roomId, text, name) => { + const recipients = finder(platform, roomId); + if (!recipients) { + return; + } + text = `${name} ⬗ ${platform}\n${text}`; + sender.broadcast(platform, recipients, text, false); +}; + +exports.sendText = (platform, roomId, text, included=false) => { + const recipients = finder(platform, roomId); + if (recipients) { + sender.broadcast(platform, recipients, text, included); + } else { + sender.send(platform, roomId, text); + } +}; diff --git a/src/bridges/matrix_discord.js b/src/bridges/matrix_discord.js deleted file mode 100644 index 3f1ff7d..0000000 --- a/src/bridges/matrix_discord.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; -// Trasnfer messages from Discord to Matrix. - -const { - useClient, -} = require("../clients/discord"); - -const { - find, -} = require("./utils"); - -/** - * This function is called when a new event is added to the timeline of a room. - * - * @param {string} roomId - The room that the event was sent in. - * @param {any} event - The event that triggered this function. - * @return {void} - */ -module.exports = async (roomId, event) => { - const client = await useClient(); - - const relayTarget = find("matrixRoomId", roomId); - if (!relayTarget) { - return; - } - const { - discordChannelId: channelId, - } = relayTarget; - - const {sender: username, content} = event; - const {body: text} = content; - - const channel = await client.channels.fetch(channelId); - await channel.send(`\`${username}\` ⬗ Matrix\n${text}`); -}; diff --git a/src/bridges/sender.js b/src/bridges/sender.js new file mode 100644 index 0000000..bca7ad1 --- /dev/null +++ b/src/bridges/sender.js @@ -0,0 +1,71 @@ +"use strict"; +// Send messages between every platforms. + +const { + PLATFORM_DISCORD, + PLATFORM_MATRIX, +} = require("../init/const"); + +const { + useClient: useDiscordClient, +} = require("../clients/discord"); + +const { + useClient: useMatrixClient, +} = require("../clients/matrix"); + +/** + * This function is called to send a message to Discord. + * + * @param {string} recipient - The recipient of the message. + * @param {string} text - The text of the message. + * @return {void} + */ +const toDiscord = async (recipient, text) => { + const client = useDiscordClient(); + const channel = await client.channels.fetch(recipient); + await channel.send(text); +}; + +/** + * This function is called to send a message to Matrix. + * + * @param {string} recipient - The recipient of the message. + * @param {string} text - The text of the message. + * @return {void} + */ +const toMatrix = async (recipient, text) => { + const client = await useMatrixClient(); + await client.sendMessage(recipient, { + msgtype: "m.text", + format: "plain/text", + body: text, + }); +}; + +// Map of platforms to send messages. +const messageSender = { + [PLATFORM_DISCORD]: toDiscord, + [PLATFORM_MATRIX]: toMatrix, +}; + +exports.send = (platform, recipient, text) => { + const sendMessage = messageSender[platform]; + if (!sendMessage) { + throw new Error(`unknown platform: ${platform}`); + } + sendMessage(recipient, text); +}; + +exports.broadcast = (platform, recipients, text, included) => { + for (const [itemPlatform, itemRoomId] of Object.entries(recipients)) { + if (!included && itemPlatform === platform) { + continue; + } + const sendMessage = messageSender[itemPlatform]; + if (!sendMessage) { + throw new Error(`unknown platform: ${itemPlatform}`); + } + sendMessage(itemRoomId, text); + } +}; diff --git a/src/bridges/utils.js b/src/bridges/utils.js deleted file mode 100644 index c264794..0000000 --- a/src/bridges/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -"use strict"; - -const relayMap = require("../../relay.json"); - -exports.find = (key, value) => - relayMap.find((i) => i[key] === value); diff --git a/src/clients/discord.js b/src/clients/discord.js index a10472a..a0f173d 100644 --- a/src/clients/discord.js +++ b/src/clients/discord.js @@ -17,7 +17,7 @@ const { const botToken = getMust("DISCORD_BOT_TOKEN"); -const newClient = async () => { +const newClient = () => { const client = new Client({ partials: [ Partials.Channel, @@ -49,11 +49,11 @@ let client; * @param {boolean} cached - Use the cached client * @return {Client} - The client */ -exports.useClient = async (cached = true) => { +exports.useClient = (cached = true) => { if (cached && client) { return client; } - client = await newClient(); + client = newClient(); return client; }; diff --git a/src/clients/langchain.js b/src/clients/langchain.js new file mode 100644 index 0000000..6fc577b --- /dev/null +++ b/src/clients/langchain.js @@ -0,0 +1,114 @@ +"use strict"; +// langchain is a toolkit for conversation models. + +const { + getMust, +} = require("../config"); + +const { + ConversationChain, +} = require("langchain/chains"); +const { + BufferMemory, +} = require("langchain/memory"); +const { + RedisChatMessageHistory, +} = require("@langchain/redis"); +const { + ChatGoogleGenerativeAI, +} = require("@langchain/google-genai"); +const { + ChatPromptTemplate, + SystemMessagePromptTemplate, + MessagesPlaceholder, +} = require("@langchain/core/prompts"); + +const apiKey = getMust("GEMINI_API_KEY"); +const modelName = getMust("GEMINI_MODEL_NAME"); +const systemPrompt = getMust("GEMINI_SYSTEM_PROMPT"); + +const redisUri = getMust("REDIS_URI"); + +const model = new ChatGoogleGenerativeAI({ + apiKey, + modelName, + temperature: 0.63, +}); +const promptTemplate = ChatPromptTemplate. + fromMessages([ + SystemMessagePromptTemplate.fromTemplate(systemPrompt), + new MessagesPlaceholder("history"), + ["human", "{humanInput}"], + ]); + +/** + * Chat with the AI. + * @param {string} chatId The chat ID to chat with the AI. + * @param {string} humanInput The prompt to chat with the AI. + * @return {Promise} The response from the AI. + */ +async function chatWithAI(chatId, humanInput) { + const chatHistory = new RedisChatMessageHistory({ + config: { + url: redisUri, + }, + sessionId: `nymph:ai:${chatId}`, + sessionTTL: 150, + }); + + const bufferMemory = new BufferMemory({ + returnMessages: true, + memoryKey: "history", + inputKey: "humanInput", + chatHistory, + }); + + const conversationChain = new ConversationChain({ + llm: model, + prompt: promptTemplate, + memory: bufferMemory, + }); + + const { + response: conversationResponse, + } = await conversationChain.invoke({ + humanInput, + }); + + return conversationResponse; +} + +/** + * Slice the message content into multiple snippets. + * @param {string} content - The content to slice. + * @param {number} maxLength - The maximum length of each snippet. + * @param {string} separator - The separator to split the content. + * @return {Array} The sliced snippets. + */ +function sliceContent(content, maxLength, separator = "\n") { + const substrings = content.split(separator); + const snippets = []; + + let lastSnippet = ""; + for (const text of substrings) { + if (!text) { + lastSnippet += separator; + continue; + } + if (text.length + lastSnippet.length < maxLength) { + lastSnippet += text; + continue; + } + snippets.push(lastSnippet.trim()); + lastSnippet = ""; + } + if (lastSnippet) { + snippets.push(lastSnippet.trim()); + } + + return snippets; +} + +exports.useModel = () => model; +exports.chatWithAI = chatWithAI; +exports.sliceContent = sliceContent; diff --git a/src/clients/line.js b/src/clients/line.js index b75bcde..f53a33d 100644 --- a/src/clients/line.js +++ b/src/clients/line.js @@ -8,7 +8,10 @@ const { middleware: createMiddleware, } = require("@line/bot-sdk"); -const newClient = (config) => { +const newClient = () => { + const channelAccessToken = getMust("LINE_CHANNEL_ACCESS_TOKEN"); + const channelSecret = getMust("LINE_CHANNEL_SECRET"); + const config = {channelAccessToken, channelSecret}; return new messagingApi.MessagingApiClient(config); }; @@ -31,11 +34,7 @@ exports.useClient = (cached = true) => { if (cached && client) { return client; } - - client = newClient({ - channelAccessToken: getMust("LINE_CHANNEL_ACCESS_TOKEN"), - channelSecret: getMust("LINE_CHANNEL_SECRET"), - }); + client = newClient(); return client; }; diff --git a/src/clients/matrix.js b/src/clients/matrix.js index 1faae1c..b50dde8 100644 --- a/src/clients/matrix.js +++ b/src/clients/matrix.js @@ -87,6 +87,26 @@ exports.useClient = async (cached = true) => { return client; }; +/** + * The cached client. + * @type {string|undefined} + */ +let userId; +/** + * Fetch Matrix user ID + * + * @param {MatrixClient} client - The client + * @param {boolean} cached - Fetch the cached user ID + * @return {Promise} - The user ID + */ +exports.fetchUserId = async (client, cached = true) => { + if (cached && userId) { + return userId; + } + userId = await client.getUserId(); + return userId; +}; + exports.startSync = async () => { const joinedRooms = await client.getJoinedRooms(); await client.crypto.prepare(joinedRooms); diff --git a/src/clients/openai.js b/src/clients/openai.js deleted file mode 100644 index 76ba2c9..0000000 --- a/src/clients/openai.js +++ /dev/null @@ -1,107 +0,0 @@ -"use strict"; -// openai is a client for the OpenAI API - -const {getMust} = require("../config"); - -const OpenAI = require("openai"); - -const baseUrl = getMust("OPENAI_BASE_URL"); -const apiKey = getMust("OPENAI_API_KEY"); -const chatModel = getMust("OPENAI_CHAT_MODEL"); - -const prependPrompts = require("../../prompts.json"); - -const client = new OpenAI({baseUrl, apiKey}); - -const chatHistoryMapper = new Map(); - -/** - * Randomly choose an element from an array. - * @param {Array} choices The array of choices. - * @return {object} The randomly chosen element. - */ -function choose(choices) { - const seed = Math.random(); - const index = Math.floor(seed * choices.length); - return choices[index]; -} - -/** - * Chat with the AI. - * @param {string} chatId The chat ID to chat with the AI. - * @param {string} prompt The prompt to chat with the AI. - * @return {Promise} The response from the AI. - */ -async function chatWithAI(chatId, prompt) { - if (!chatHistoryMapper.has(chatId)) { - chatHistoryMapper.set(chatId, []); - } - - const chatHistory = chatHistoryMapper.get(chatId); - const userPromptMessage = { - role: "user", - content: prompt, - }; - - const response = await client.chat.completions.create({ - model: chatModel, - messages: [ - ...prependPrompts, - ...chatHistory, - userPromptMessage, - ], - }); - - const choice = choose(response.choices); - const reply = choice.message.content; - const assistantReplyMessage = { - role: "assistant", - content: reply, - }; - - chatHistory.push( - userPromptMessage, - assistantReplyMessage, - ); - if (chatHistory.length > 30) { - chatHistory.shift(); - chatHistory.shift(); - } - - return reply; -} - -/** - * Slice the message content into multiple snippets. - * @param {string} content - The content to slice. - * @param {number} maxLength - The maximum length of each snippet. - * @param {string} separator - The separator to split the content. - * @return {Array} The sliced snippets. - */ -function sliceContent(content, maxLength, separator="\n") { - const substrings = content.split(separator); - const snippets = []; - - let lastSnippet = ""; - for (const text of substrings) { - if (!text) { - lastSnippet += separator; - continue; - } - if (text.length + lastSnippet.length < maxLength) { - lastSnippet += text; - continue; - } - snippets.push(lastSnippet.trim()); - lastSnippet = ""; - } - if (lastSnippet) { - snippets.push(lastSnippet.trim()); - } - - return snippets; -} - -exports.useClient = () => client; -exports.chatWithAI = chatWithAI; -exports.sliceContent = sliceContent; diff --git a/src/config.js b/src/config.js index 12230a8..73f80b1 100644 --- a/src/config.js +++ b/src/config.js @@ -65,7 +65,7 @@ function get(key) { * @module config * @function * @param {string} key the key - * @return {boolean} the bool value + * @return {bool} the bool value */ function getEnabled(key) { return getMust(key) === "yes"; @@ -76,12 +76,13 @@ function getEnabled(key) { * @module config * @function * @param {string} key the key - * @param {string} separator [separator=,] the separator. - * @return {string[]} the array value + * @param {string} [separator=,] the separator. + * @return {array} the array value */ function getSplited(key, separator=",") { return getMust(key). split(separator). + filter((s) => s). map((s) => s.trim()); } diff --git a/src/discord.js b/src/discord.js deleted file mode 100644 index 4a940de..0000000 --- a/src/discord.js +++ /dev/null @@ -1,44 +0,0 @@ -"use strict"; - -const { - ActivityType, - Events, - PresenceUpdateStatus, -} = require("discord.js"); - -const { - useClient, -} = require("./clients/discord"); - -const { - startListen, -} = require("./triggers/discord"); - -module.exports = async () => { - const client = await useClient(); - - client.on(Events.ClientReady, () => { - const showStartupMessage = async () => { - console.info(`Discord 身份:${client.user.tag}`); - }; - - const setupStatusMessage = async () => { - client.user.setPresence({ - status: PresenceUpdateStatus.Online, - activities: [{ - type: ActivityType.Playing, - name: "黑客帝國", - }], - }); - }; - - showStartupMessage(); - setupStatusMessage(); - startListen(); - - setInterval( - setupStatusMessage, - (86400 - 3600) * 1000, - ); - }); -}; diff --git a/src/execute.js b/src/execute.js new file mode 100644 index 0000000..ad56349 --- /dev/null +++ b/src/execute.js @@ -0,0 +1,68 @@ +"use strict"; + +// Import config +const {getMust, getSplited} = require("./config"); + +// Import modules +const fs = require("node:fs"); +const http = require("node:http"); +const https = require("node:https"); + +// Import event listener +const { + listen: startListenEvents, +} = require("./init/listener"); + +/** + * Setup protocol - http + * @param {object} app + * @param {function} callback + */ +function setupHttpProtocol(app, callback) { + const options = {}; + const httpServer = http.createServer(options, app); + const port = parseInt(getMust("HTTP_PORT")); + httpServer.listen(port, getMust("HTTP_HOSTNAME")); + callback({protocol: "http", hostname: getMust("HTTP_HOSTNAME"), port}); +} + +/** + * Setup protocol - https + * @param {object} app + * @param {function} callback + */ +function setupHttpsProtocol(app, callback) { + const options = { + key: fs.readFileSync(getMust("HTTPS_KEY_PATH")), + cert: fs.readFileSync(getMust("HTTPS_CERT_PATH")), + }; + const httpsServer = https.createServer(options, app); + const port = parseInt(getMust("HTTPS_PORT")); + httpsServer.listen(port, getMust("HTTPS_HOSTNAME")); + callback({protocol: "https", hostname: getMust("HTTPS_HOSTNAME"), port}); +} + +// Prepare application and detect protocols automatically +module.exports = async function(app, prepareHandlers, callback) { + // Waiting for prepare handlers + if (prepareHandlers.length > 0) { + const preparingPromises = prepareHandlers.map((c) => c()); + await Promise.all(preparingPromises); + } + + // Get enabled protocols + const enabledProtocols = getSplited("ENABLED_PROTOCOLS"); + + // Setup HTTP + if (enabledProtocols.includes("http")) { + setupHttpProtocol(app, callback); + } + + // Setup HTTPS + if (enabledProtocols.includes("https")) { + setupHttpsProtocol(app, callback); + } + + // Start event listener + startListenEvents(); +}; diff --git a/src/init/api_doc.js b/src/init/api_doc.js new file mode 100644 index 0000000..5aafa76 --- /dev/null +++ b/src/init/api_doc.js @@ -0,0 +1,56 @@ +"use strict"; +// swagger is an api documentation generator (OpenAPI spec.) + +// Import modules +const {getMust} = require("../config"); + +const { + APP_NAME, + APP_DESCRIPTION, + APP_VERSION, + APP_AUTHOR_NAME, + APP_AUTHOR_URL, +} = require("./const"); + +const {join: pathJoin} = require("node:path"); + +const swaggerJSDoc = require("swagger-jsdoc"); + +const {routerFiles} = require("../routes"); + +const routerFilePathPrefix = pathJoin(__dirname, "..", "routes"); + +// Config options +const options = { + definition: { + openapi: "3.0.0", + info: { + title: APP_NAME, + version: APP_VERSION, + description: APP_DESCRIPTION, + contact: { + name: APP_AUTHOR_NAME, + url: APP_AUTHOR_URL, + }, + }, + servers: [{ + description: getMust("SWAGGER_SERVER_DESCRIPTION"), + url: getMust("SWAGGER_SERVER_URL"), + }], + components: { + securitySchemes: { + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "Authorization", + }, + }, + }, + }, + apis: routerFiles.map( + (f) => pathJoin(routerFilePathPrefix, f), + ), +}; + +// Export as useFunction +exports.useApiDoc = () => swaggerJSDoc(options); diff --git a/src/init/cache.js b/src/init/cache.js new file mode 100644 index 0000000..1767f98 --- /dev/null +++ b/src/init/cache.js @@ -0,0 +1,11 @@ +"use strict"; +// node-cache is an in-memory cache. + +// Import node-cache +const NodeCache = require("node-cache"); + +// Initialize node-cache +const cache = new NodeCache({stdTTL: 100}); + +// Export as useFunction +exports.useCache = () => cache; diff --git a/src/init/const.js b/src/init/const.js new file mode 100644 index 0000000..b459731 --- /dev/null +++ b/src/init/const.js @@ -0,0 +1,17 @@ +"use strict"; +// Constants + +exports.APP_NAME = "Nymph"; +exports.APP_DESCRIPTION = "The artificial intelligence powered by open source."; + +exports.APP_VERSION = "latest"; + +exports.APP_AUTHOR_NAME = "Taiwan Web Technology Promotion Organization"; +exports.APP_AUTHOR_URL = "https://web-tech.tw"; + +exports.OPENAPI_EXPORTED_FILENAME = "openapi_exported.json"; +exports.PUBLIC_KEY_FILENAME = "keypair_public.pem"; + +exports.PLATFORM_LINE = "LINE"; +exports.PLATFORM_MATRIX = "Matrix"; +exports.PLATFORM_DISCORD = "Discord"; diff --git a/src/init/database.js b/src/init/database.js new file mode 100644 index 0000000..4ef6494 --- /dev/null +++ b/src/init/database.js @@ -0,0 +1,18 @@ +"use strict"; +// mongoose is an ODM library for MongoDB. + +// Import config +const {getMust} = require("../config"); + +// Import mongoose +const database = require("mongoose"); + +// Configure mongose +database.set("strictQuery", true); + +// Connect to MongoDB +exports.prepare = () => + database.connect(getMust("MONGODB_URI")); + +// Export as useFunction +exports.useDatabase = () => database; diff --git a/src/init/express.js b/src/init/express.js new file mode 100644 index 0000000..7378bb0 --- /dev/null +++ b/src/init/express.js @@ -0,0 +1,60 @@ +"use strict"; +// express.js is a web framework. + +// Import config +const {getSplited, getEnabled} = require("../config"); + +// Import express.js +const express = require("express"); + +// Create middleware handlers +const middlewareAuth = require("../middleware/auth"); + +// Initialize app engine +const app = express(); + +// Register global middleware +app.use(middlewareAuth); + +// Read config +const trustProxy = getSplited("TRUST_PROXY", ","); + +const isEnabledRedirectHttpHttps = getEnabled("ENABLED_REDIRECT_HTTP_HTTPS"); +const isEnabledCors = getEnabled("ENABLED_CORS"); +const isEnabledCorsOriginCheck = getEnabled("ENABLED_CORS_ORIGIN_CHECK"); + +// ejs template engine +app.set("view engine", "ejs"); +app.set("views", __dirname + "/../../views"); + +// Optional settings +if (trustProxy.length) { + app.set("trust proxy", trustProxy); +} + +// Optional middleware +if (isEnabledRedirectHttpHttps) { + const middlewareHttpsRedirect = require("../middleware/https_redirect"); + // Do https redirects + app.use(middlewareHttpsRedirect); +} +if (isEnabledCors) { + const middlewareCORS = require("../middleware/cors"); + // Do CORS handles + app.use(middlewareCORS); +} +if (isEnabledCors && isEnabledCorsOriginCheck) { + const middlewareOrigin = require("../middleware/origin"); + // Check header "Origin" for CORS + app.use(middlewareOrigin); +} + +// Export useFunction +exports.useApp = () => app; + +// Export withAwait +exports.withAwait = (fn) => (req, res, next) => + Promise.resolve(fn(req, res, next)).catch(next); + +// Export express for shortcut +exports.express = express; diff --git a/src/init/keypair.js b/src/init/keypair.js new file mode 100644 index 0000000..8264812 --- /dev/null +++ b/src/init/keypair.js @@ -0,0 +1,12 @@ +"use strict"; +// Reading curve keypair. + +// Import fs +const {readFileSync} = require("node:fs"); + +// Import constant +const constants = require("./const"); + +// Export as useFunction +exports.usePublicKey = () => + readFileSync(constants.PUBLIC_KEY_FILENAME); diff --git a/src/init/listener.js b/src/init/listener.js new file mode 100644 index 0000000..f4acc57 --- /dev/null +++ b/src/init/listener.js @@ -0,0 +1,37 @@ +"use strict"; + +const {getMust} = require("../config"); + +const { + PLATFORM_LINE, + PLATFORM_MATRIX, + PLATFORM_DISCORD, +} = require("./const"); + +exports.isEnabled = { + [PLATFORM_LINE]: !!getMust("LINE_CHANNEL_ACCESS_TOKEN"), + [PLATFORM_MATRIX]: !!getMust("MATRIX_PASSWORD"), + [PLATFORM_DISCORD]: !!getMust("DISCORD_BOT_TOKEN"), +}; + +exports.listeners = { + [PLATFORM_LINE]: require("../listeners/line"), + [PLATFORM_MATRIX]: require("../listeners/matrix"), + [PLATFORM_DISCORD]: require("../listeners/discord"), +}; + +exports.prepare = () => { + for (const platform of Object.keys(exports.isEnabled)) { + if (exports.isEnabled[platform]) { + exports.listeners[platform].prepare(); + } + } +}; + +exports.listen = () => { + for (const platform of Object.keys(exports.isEnabled)) { + if (exports.isEnabled[platform]) { + exports.listeners[platform].listen(); + } + } +}; diff --git a/src/listeners/discord/client_ready/index.js b/src/listeners/discord/client_ready/index.js new file mode 100644 index 0000000..917926a --- /dev/null +++ b/src/listeners/discord/client_ready/index.js @@ -0,0 +1,40 @@ +"use strict"; + +const { + useClient, +} = require("../../../clients/discord"); + +const { + ActivityType, + PresenceUpdateStatus, +} = require("discord.js"); + +/** + * @param {discord.Interaction} interaction + * @return {void} + */ +module.exports = () => { + const client = useClient(); + + const showStartupMessage = async () => { + console.info(`Discord 身份:${client.user.tag}`); + }; + + const setupStatusMessage = async () => { + client.user.setPresence({ + status: PresenceUpdateStatus.Online, + activities: [{ + type: ActivityType.Playing, + name: "黑客帝國", + }], + }); + }; + + showStartupMessage(); + setupStatusMessage(); + + setInterval( + setupStatusMessage, + (86400 - 3600) * 1000, + ); +}; diff --git a/src/listeners/discord/index.js b/src/listeners/discord/index.js new file mode 100644 index 0000000..b30d06a --- /dev/null +++ b/src/listeners/discord/index.js @@ -0,0 +1,31 @@ +"use strict"; + +const { + useClient, +} = require("../../clients/discord"); + +const { + Events, +} = require("discord.js"); + +const { + registerCommands, +} = require("./interaction_create/commands"); + +const triggers = { + [Events.ClientReady]: require("./client_ready"), + [Events.InteractionCreate]: require("./interaction_create"), + [Events.MessageCreate]: require("./message_create"), +}; + +exports.prepare = async () => { + await registerCommands(); +}; + +exports.listen = async () => { + const client = useClient(); + const triggerEntries = Object.entries(triggers); + for (const [key, trigger] of triggerEntries) { + client.on(key, trigger); + } +}; diff --git a/src/triggers/discord/interaction_create/commands.js b/src/listeners/discord/interaction_create/commands.js similarity index 100% rename from src/triggers/discord/interaction_create/commands.js rename to src/listeners/discord/interaction_create/commands.js diff --git a/src/triggers/discord/interaction_create/index.js b/src/listeners/discord/interaction_create/index.js similarity index 94% rename from src/triggers/discord/interaction_create/index.js rename to src/listeners/discord/interaction_create/index.js index c583d54..4228401 100644 --- a/src/triggers/discord/interaction_create/index.js +++ b/src/listeners/discord/interaction_create/index.js @@ -1,7 +1,5 @@ "use strict"; -const discord = require("discord.js"); - const {allCommands} = require("./commands"); const snakeToCamelCase = (str) => diff --git a/src/listeners/discord/message_create/index.js b/src/listeners/discord/message_create/index.js new file mode 100644 index 0000000..16f41ff --- /dev/null +++ b/src/listeners/discord/message_create/index.js @@ -0,0 +1,91 @@ +"use strict"; + +const discord = require("discord.js"); + +const { + PLATFORM_DISCORD, +} = require("../../../init/const"); + +const { + relayText, + sendText, +} = require("../../../bridges"); + +const { + useClient, +} = require("../../../clients/discord"); +const { + chatWithAI, + sliceContent, +} = require("../../../clients/langchain"); + +const prefix = "Nymph "; + +const hey = (message) => ({ + say: (text) => { + const roomId = message.channel.id; + sendText(PLATFORM_DISCORD, roomId, text); + message.reply(text); + }, +}); + +const extractContent = (message) => + message.content.replace(/<@!?\d+>/g, (mention) => { + const userId = mention.replace(/<@!?|>/g, ""); + const user = message.client.users.cache.get(userId); + return `@${user ? user.username : "Unknown"}`; + }).trim(); + +/** + * @param {discord.Message} message + * @return {void} + */ +module.exports = async (message) => { + const client = useClient(); + + if (message.author.bot) { + return; + } + + const requestContent = extractContent(message); + relayText( + PLATFORM_DISCORD, + message.channel.id, + requestContent, + message.author.username, + ); + + if ( + !(message.content.startsWith(prefix)) && + !(message.mentions.users.has(client.user.id)) + ) { + return; + } + + await message.channel.sendTyping(); + if (!requestContent) { + hey(message).say("所收到的訊息意圖不明。"); + return; + } + + let responseContent; + try { + responseContent = await chatWithAI(message.channel.id, requestContent); + } catch (error) { + console.error(error); + hey(message).say("思緒混亂,無法回覆。"); + return; + } + + responseContent = responseContent.trim(); + if (!responseContent) { + hey(message).say("無法正常回覆,請換個說法試試。"); + return; + } + + const snippets = sliceContent(responseContent, 2000); + hey(message).say(snippets.shift()); + snippets.forEach((snippet) => { + hey(message).say(snippet); + }); +}; diff --git a/src/listeners/line/index.js b/src/listeners/line/index.js new file mode 100644 index 0000000..181f661 --- /dev/null +++ b/src/listeners/line/index.js @@ -0,0 +1,29 @@ +"use strict"; + +const { + useClient, +} = require("../../clients/line"); + +const triggers = { + message: require("./message"), +}; + +exports.useDispatcher = () => async (event) => { + if (!Object.hasOwn(triggers, event.type)) { + return; + } + await triggers[event.type](event); +}; + +exports.prepare = async () => { + const client = useClient(); + + const showStartupMessage = async () => { + const {displayName, basicId} = await client.getBotInfo(); + console.info(`LINE 身份:${displayName} (${basicId})`); + }; + + showStartupMessage(); +}; + +exports.listen = () => {}; diff --git a/src/triggers/line/message/index.js b/src/listeners/line/message/index.js similarity index 96% rename from src/triggers/line/message/index.js rename to src/listeners/line/message/index.js index 75e6d7e..87276a0 100644 --- a/src/triggers/line/message/index.js +++ b/src/listeners/line/message/index.js @@ -1,7 +1,7 @@ "use strict"; const {useClient, whereSentMessageEvent} = require("../../../clients/line"); -const {chatWithAI, sliceContent} = require("../../../clients/openai"); +const {chatWithAI, sliceContent} = require("../../../clients/langchain"); const prefix = "Nymph "; diff --git a/src/listeners/matrix/index.js b/src/listeners/matrix/index.js new file mode 100644 index 0000000..5547b16 --- /dev/null +++ b/src/listeners/matrix/index.js @@ -0,0 +1,30 @@ +"use strict"; + +const { + useClient, + startSync, +} = require("../../clients/matrix"); + +const triggers = { + "room.failed_decryption": require("./room/failed_decryption"), + "room.message": require("./room/message"), +}; + +exports.prepare = async () => { + const client = await useClient(); + + const showStartupMessage = async () => { + const userId = await client.getUserId(); + console.info(`Matrix 身份:${userId}`); + }; + + await showStartupMessage(); + await startSync(); +}; + +exports.listen = async () => { + const client = await useClient(); + for (const [key, trigger] of Object.entries(triggers)) { + client.on(key, trigger); + } +}; diff --git a/src/triggers/matrix/room/failed_decryption.js b/src/listeners/matrix/room/failed_decryption.js similarity index 100% rename from src/triggers/matrix/room/failed_decryption.js rename to src/listeners/matrix/room/failed_decryption.js diff --git a/src/triggers/matrix/room/message.js b/src/listeners/matrix/room/message.js similarity index 52% rename from src/triggers/matrix/room/message.js rename to src/listeners/matrix/room/message.js index f40b246..dba5d42 100644 --- a/src/triggers/matrix/room/message.js +++ b/src/listeners/matrix/room/message.js @@ -1,9 +1,27 @@ "use strict"; -const matrixToDiscord = require("../../../bridges/matrix_discord"); +const { + useClient, + fetchUserId, +} = require("../../../clients/matrix"); +const { + chatWithAI, +} = require("../../../clients/langchain"); -const {useClient} = require("../../../clients/matrix"); -const {chatWithAI} = require("../../../clients/openai"); +const { + PLATFORM_MATRIX, +} = require("../../../init/const"); + +const { + relayText, + sendText, +} = require("../../../bridges"); + +const hey = (roomId) => ({ + say: (text) => { + sendText(PLATFORM_MATRIX, roomId, text, true); + }, +}); const prefix = "Nymph "; @@ -16,32 +34,33 @@ const prefix = "Nymph "; */ module.exports = async (roomId, event) => { const client = await useClient(); + const clientId = await fetchUserId(client); const { event_id: eventId, sender: senderId, } = event; - if (senderId === await client.getUserId()) { + if (senderId === clientId) { return; } await client.sendReadReceipt(roomId, eventId); - matrixToDiscord(roomId, event); - let requestContent = event.content.body; + relayText( + PLATFORM_MATRIX, + roomId, + requestContent, + senderId, + ); if (!requestContent.startsWith(prefix)) { return; } - requestContent = requestContent.slice(prefix.length).trim(); + requestContent = requestContent.slice(prefix.length).trim(); if (!requestContent) { - await client.sendMessage(roomId, { - msgtype: "m.text", - format: "plain/text", - body: "所收到的訊息意圖不明。", - }); + hey(roomId).say("所收到的訊息意圖不明。"); return; } @@ -50,27 +69,15 @@ module.exports = async (roomId, event) => { responseContent = await chatWithAI(roomId, requestContent); } catch (error) { console.error(error); - await client.sendMessage(roomId, { - msgtype: "m.text", - format: "plain/text", - body: "思緒混亂,無法回覆。", - }); + hey(roomId).say("所收到的訊息意圖不明。"); return; } responseContent = responseContent.trim(); if (!responseContent) { - await client.sendMessage(roomId, { - msgtype: "m.text", - format: "plain/text", - body: "無法正常回覆,請換個說法試試。", - }); + hey(roomId).say("所收到的訊息意圖不明。"); return; } - await client.sendMessage(roomId, { - msgtype: "m.text", - format: "plain/text", - body: responseContent, - }); + hey(roomId).say(responseContent); }; diff --git a/src/matrix.js b/src/matrix.js deleted file mode 100644 index 3830129..0000000 --- a/src/matrix.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -const { - useClient, - startSync, -} = require("./clients/matrix"); - -const { - startListen, -} = require("./triggers/matrix"); - -module.exports = async () => { - const client = await useClient(); - - const showStartupMessage = async () => { - const userId = await client.getUserId(); - console.info(`Matrix 身份:${userId}`); - }; - - await showStartupMessage(); - await startListen(); - await startSync(); -}; diff --git a/src/middleware/access.js b/src/middleware/access.js new file mode 100644 index 0000000..dfc276a --- /dev/null +++ b/src/middleware/access.js @@ -0,0 +1,69 @@ +"use strict"; +// Check the role for the request required, +// and interrupt if the requirement is not satisfied. +// (for Sara only) + +// Import isProduction +const {isProduction} = require("../config"); + +// Import StatusCodes +const {StatusCodes} = require("http-status-codes"); + +// Export (function) +// requiredRole can be string or null, +// set as string, it will find the role whether satisfied, +// set as null, will check the user whether login only. +module.exports = (requiredRole) => (req, res, next) => { + if (!isProduction()) { + // Debug message + console.warn( + "An access required request detected:", + `role "${requiredRole}"`, + req.auth, + "\n", + ); + } + + // Check auth exists + if (!(req.auth && req.auth.id)) { + res.sendStatus(StatusCodes.UNAUTHORIZED); + return; + } + + // Accept SARA or TEST only + if ( + req.auth.method !== "SARA" && + !(req.auth.method === "TEST" && !isProduction()) + ) { + res.sendStatus(StatusCodes.METHOD_NOT_ALLOWED); + return; + } + + // Read roles from metadata + const userRoles = req.auth.metadata?.profile?.roles; + const isUserRolesValid = Array.isArray(userRoles); + + // Check permission + if ( + requiredRole && + (!isUserRolesValid || !userRoles.includes(requiredRole)) + ) { + if (!isProduction()) { + const displayUserRoles = isUserRolesValid ? + userRoles.join(", ") : + userRoles; + // Debug message + console.warn( + "An access required request forbidden:", + `actual "${displayUserRoles}"`, + `expected "${requiredRole}"`, + "\n", + ); + } + res.sendStatus(StatusCodes.FORBIDDEN); + return; + } + + // Call next middleware + next(); +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..6a1fb08 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,73 @@ +"use strict"; +// Validate "Authorization" header, but it will not interrupt the request. + +// To interrupt the request which without the request, +// please use "access.js" middleware. + +// Import isProduction +const {isProduction} = require("../config"); + +// Import isObjectPropExists +const {isObjectPropExists} = require("../utils/native"); + +const saraTokenAuth = require("../utils/sara_token"); +const testTokenAuth = require("../utils/test_token"); + +// Import authMethods +const authMethods = { + "SARA": saraTokenAuth.validate, + "TEST": testTokenAuth.validate, +}; + +// Export (function) +module.exports = async (req, _, next) => { + const authCode = req.header("authorization"); + if (!authCode) { + next(); + return; + } + + const params = authCode.split(" "); + if (params.length !== 2) { + next(); + return; + } + const [method, secret] = params; + + req.auth = { + id: null, + metadata: null, + method, + secret, + }; + if (!isObjectPropExists(authMethods, req.auth.method)) { + next(); + return; + } + + const authMethod = authMethods[method]; + const authResult = await authMethod(secret); + + if (!isProduction()) { + // Debug message + console.warn( + "An authentication detected:", + method, authResult, + "\n", + ); + } + + const { + userId, + payload, + isAborted, + } = authResult; + if (isAborted) { + next(); + return; + } + + req.auth.id = userId; + req.auth.metadata = payload; + next(); +}; diff --git a/src/middleware/cors.js b/src/middleware/cors.js new file mode 100644 index 0000000..0a19851 --- /dev/null +++ b/src/middleware/cors.js @@ -0,0 +1,19 @@ +"use strict"; +// Cross-Origin Resource Sharing + +// Import config +const {getEnabled, getMust} = require("../config"); + +// Import cors +const cors = require("cors"); + +// Read config +const corsOrigin = getMust("CORS_ORIGIN"); +const swaggerCorsOrigin = getMust("SWAGGER_CORS_ORIGIN"); + +// Export (function) +module.exports = cors({ + origin: getEnabled("ENABLED_SWAGGER") ? + [corsOrigin, swaggerCorsOrigin]: + corsOrigin, +}); diff --git a/src/middleware/https_redirect.js b/src/middleware/https_redirect.js new file mode 100644 index 0000000..f0f3d76 --- /dev/null +++ b/src/middleware/https_redirect.js @@ -0,0 +1,27 @@ +"use strict"; +// Redirect http to https. + +// Import isProduction +const {isProduction} = require("../config"); + +// Import StatusCodes +const {StatusCodes} = require("http-status-codes"); + +// Export (function) +module.exports = (req, res, next) => { + if (req.protocol === "http") { + if (!isProduction()) { + // Debug message + console.warn( + "Pure HTTP protocol detected:", + `from "${req.hostname}"`, + `with host header "${req.headers.host}"`, + `with origin header "${req.headers.origin}"`, + ); + } + res.redirect(StatusCodes.MOVED_PERMANENTLY, `https://${req.headers.host}${req.url}`); + } + + // Call next middleware + next(); +}; diff --git a/src/middleware/inspector.js b/src/middleware/inspector.js new file mode 100644 index 0000000..2438735 --- /dev/null +++ b/src/middleware/inspector.js @@ -0,0 +1,31 @@ +"use strict"; +// Interrupt the request +// which is not satisfied the result from express-validator. + +// Import isProduction +const {isProduction} = require("../config"); + +// Import StatusCodes +const {StatusCodes} = require("http-status-codes"); + +// Import validatorResult +const {validationResult} = require("express-validator"); + +// Export (function) +module.exports = (req, res, next) => { + const errors = validationResult(req); + if (errors.isEmpty()) { + next(); + } else { + if (!isProduction()) { + // Debug message + console.warn( + "A bad request received:", + errors, + ); + } + res. + status(StatusCodes.BAD_REQUEST). + json({errors: errors.array()}); + } +}; diff --git a/src/middleware/origin.js b/src/middleware/origin.js new file mode 100644 index 0000000..0fb2499 --- /dev/null +++ b/src/middleware/origin.js @@ -0,0 +1,72 @@ +"use strict"; +// Check the header "Origin" in request is equal to CORS_ORIGIN, +// if not, interrupt it. + +// Import config +const {isProduction, getMust, getEnabled} = require("../config"); + +// Import StatusCodes +const {StatusCodes} = require("http-status-codes"); + +// Import isObjectPropExists +const {isObjectPropExists} = require("../utils/native"); + +// Export (function) +module.exports = (req, res, next) => { + // Check the request is CORS + if (!isObjectPropExists(req.headers, "origin")) { + if (!isProduction()) { + // Debug message + console.warn("CORS origin header is not detected"); + } + next(); + return; + } + + // Get URLs + const actualUrl = req.header("origin"); + const expectedUrl = getMust("CORS_ORIGIN"); + + // Origin match + if (actualUrl === expectedUrl) { + if (!isProduction()) { + // Debug message + console.warn( + "CORS origin header match:", + `actual "${actualUrl}"`, + `expected "${expectedUrl}"`, + ); + } + next(); + return; + } + + // Get URLs + const isEnabledSwagger = getEnabled("ENABLED_SWAGGER"); + const expectedSwaggerUrl = getMust("SWAGGER_CORS_ORIGIN"); + + // Origin from Swagger match + if (isEnabledSwagger && actualUrl === expectedSwaggerUrl) { + if (!isProduction()) { + // Debug message + console.warn( + "CORS origin header from Swagger match:", + `actual "${actualUrl}"`, + `expected "${expectedUrl}"`, + ); + } + next(); + return; + } + + // Origin mismatch + if (!isProduction()) { + // Debug message + console.warn( + "CORS origin header mismatch:", + `actual "${actualUrl}"`, + `expected "${expectedUrl}"`, + ); + } + res.sendStatus(StatusCodes.FORBIDDEN); +}; diff --git a/src/middleware/restrictor.js b/src/middleware/restrictor.js new file mode 100644 index 0000000..d909501 --- /dev/null +++ b/src/middleware/restrictor.js @@ -0,0 +1,90 @@ +"use strict"; +// The solution to defense from brute-force attacks, + +// Import isProduction +const {isProduction} = require("../config"); + +// Import StatusCodes +const {StatusCodes} = require("http-status-codes"); + +// Import useCache +const {useCache} = require("../init/cache"); + +// Import getIPAddress +const {getIPAddress} = require("../utils/visitor"); + +/** + * Get path key from request. + * @module restrictor + * @function + * @param {object} req the request + * @param {boolean} isParam is param mode + * @return {string} + */ +function getPathKey(req, isParam) { + const pathArray = req.originalUrl.split("/").filter((i) => !!i); + if (isParam) { + pathArray.pop(); + } + return pathArray.join("."); +} + +// Export (function) +// max is the maximum number of requests allowed every IP addresss. +// ttl is the seconds to unblock the IP address if there no request comes. +// if ttl set as 0, it will be blocked forever until the software restarted. +// isParam is the flag to remove the last path from the key. +// customForbiddenStatus is the custom status code +// for forbidden request, optional. +module.exports = (max, ttl, isParam, customForbiddenStatus=null) => + (req, res, next) => { + const pathKey = getPathKey(req, isParam); + const visitorKey = getIPAddress(req); + const queryKey = ["restrictor", pathKey, visitorKey].join(":"); + + const cache = useCache(); + + const keyValue = cache.get(queryKey); + + const increaseValue = () => { + const offset = keyValue ? keyValue + 1 : 1; + cache.set(queryKey, offset, ttl); + }; + + if (keyValue > max) { + if (!isProduction()) { + // Debug message + console.warn( + "Too many forbidden requests received:", + `actual "${keyValue}"`, + `expect "${max}"`, + ); + } + res.sendStatus(StatusCodes.TOO_MANY_REQUESTS); + increaseValue(); + return; + } + + let forbiddenStatus = StatusCodes.FORBIDDEN; + if (customForbiddenStatus) { + forbiddenStatus = customForbiddenStatus; + } + + res.on("finish", () => { + if (res.statusCode !== forbiddenStatus) { + return; + } + if (!isProduction()) { + // Debug message + console.warn( + "An forbidden request detected:", + forbiddenStatus, + queryKey, + ); + } + increaseValue(); + }); + + // Call next middleware + next(); + }; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..6534f4b --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,13 @@ +"use strict"; + +// Routers +exports.routerFiles = [ + "./swagger.js", + "./line.js", +]; + +// Load routes +exports.load = () => { + const routerMappers = exports.routerFiles.map((n) => require(n)); + routerMappers.forEach((c) => c()); +}; diff --git a/src/line.js b/src/routes/line.js similarity index 50% rename from src/line.js rename to src/routes/line.js index c841e19..ff218cc 100644 --- a/src/line.js +++ b/src/routes/line.js @@ -1,19 +1,17 @@ "use strict"; -const { - Router: createRouter, -} = require("express"); +const {useApp, express} = require("../init/express"); const { - useClient, useMiddleware, -} = require("./clients/line"); +} = require("../clients/line"); const { useDispatcher, -} = require("./triggers/line"); +} = require("../listeners/line"); + +const {Router: newRouter} = express; +const router = newRouter(); -const router = createRouter(); -const client = useClient(); const middleware = useMiddleware(); const dispatcher = useDispatcher(); @@ -27,11 +25,9 @@ router.post("/webhook", middleware, (req, res) => { }); module.exports = () => { - const showStartupMessage = async () => { - const {displayName, basicId} = await client.getBotInfo(); - console.info(`LINE 身份:${displayName} (${basicId})`); - }; + // Use application + const app = useApp(); - showStartupMessage(); - return router; + // Mount the router + app.use("/line", router); }; diff --git a/src/routes/swagger.js b/src/routes/swagger.js new file mode 100644 index 0000000..772e79b --- /dev/null +++ b/src/routes/swagger.js @@ -0,0 +1,28 @@ +"use strict"; + +const {getEnabled} = require("../config"); +const {useApiDoc} = require("../init/api_doc"); + +const {useApp, express} = require("../init/express"); + +const swaggerUi = require("swagger-ui-express"); + +const {Router: newRouter} = express; +const router = newRouter(); + +const apiDoc = useApiDoc(); +router.use("/", swaggerUi.serve, swaggerUi.setup(apiDoc)); + +// Export routes mapper (function) +module.exports = () => { + // Skip if swagger disabled + if (!getEnabled("ENABLED_SWAGGER")) { + return; + } + + // Use application + const app = useApp(); + + // Mount the router + app.use("/swagger", router); +}; diff --git a/src/triggers/discord/index.js b/src/triggers/discord/index.js deleted file mode 100644 index 06db3c0..0000000 --- a/src/triggers/discord/index.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -const { - useClient, -} = require("../../clients/discord"); - -const { - registerCommands, -} = require("./interaction_create/commands"); - -exports.startListen = async () => { - const client = await useClient(); - - const triggers = { - interactionCreate: require("./interaction_create"), - messageCreate: require("./message_create"), - }; - for (const [key, trigger] of Object.entries(triggers)) { - client.on(key, trigger); - } - - await registerCommands(); -}; diff --git a/src/triggers/discord/message_create/index.js b/src/triggers/discord/message_create/index.js deleted file mode 100644 index 6cb7a79..0000000 --- a/src/triggers/discord/message_create/index.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; - -const discord = require("discord.js"); - -const discordToMatrix = require("../../../bridges/discord_matrix"); - -const {useClient} = require("../../../clients/discord"); -const {chatWithAI, sliceContent} = require("../../../clients/openai"); - -/** - * @param {discord.Message} message - * @return {void} - */ -module.exports = async (message) => { - const client = await useClient(); - - if (message.author.bot) { - return; - } - - discordToMatrix(message); - - if (!message.mentions.users.has(client.user.id)) { - return; - } - - await message.channel.sendTyping(); - - const requestContent = message.content.trim(); - if (!requestContent) { - message.reply("所收到的訊息意圖不明。"); - return; - } - - let responseContent; - try { - responseContent = await chatWithAI(message.channel.id, requestContent); - } catch (error) { - console.error(error); - message.reply("思緒混亂,無法回覆。"); - return; - } - - responseContent = responseContent.trim(); - if (!responseContent) { - message.reply("無法正常回覆,請換個說法試試。"); - return; - } - - const snippets = sliceContent(responseContent, 2000); - message.reply(snippets.shift()); - snippets.forEach((snippet) => { - message.channel.send(snippet); - }); -}; diff --git a/src/triggers/line/index.js b/src/triggers/line/index.js deleted file mode 100644 index 171208d..0000000 --- a/src/triggers/line/index.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; - -const triggers = { - message: require("./message"), -}; - -exports.useDispatcher = () => async (event) => { - if (!Object.hasOwn(triggers, event.type)) { - return; - } - await triggers[event.type](event); -}; diff --git a/src/triggers/matrix/index.js b/src/triggers/matrix/index.js deleted file mode 100644 index d05f3f4..0000000 --- a/src/triggers/matrix/index.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; - -const { - useClient, -} = require("../../clients/matrix"); - -exports.startListen = async () => { - const client = await useClient(); - - const triggers = { - "room.failed_decryption": require("./room/failed_decryption"), - "room.message": require("./room/message"), - }; - for (const [key, trigger] of Object.entries(triggers)) { - client.on(key, trigger); - } -}; diff --git a/src/utils/native.js b/src/utils/native.js new file mode 100644 index 0000000..19be721 --- /dev/null +++ b/src/utils/native.js @@ -0,0 +1,48 @@ +"use strict"; +// The simple toolbox for Node.js + +const crypto = require("node:crypto"); + +/** + * Shortcut for hasOwnProperty with safe. + * @module native + * @function + * @param {object} srcObject + * @param {string} propName + * @return {boolean} + */ +function isObjectPropExists(srcObject, propName) { + return Object.hasOwn(srcObject, propName); +} + +/** + * Generate random code with length. + * @param {number} length length of code + * @return {string} + */ +function generateRandomCode(length) { + const maxValue = (10 ** length) - 1; + return crypto. + randomInt(0, maxValue). + toString(). + padStart(length, "0"); +} + +/** + * Hash string into sha256 hex. + * @param {string} data + * @return {string} + */ +function sha256hex(data) { + return crypto. + createHash("sha256"). + update(data). + digest("hex"); +} + +// Export (object) +module.exports = { + isObjectPropExists, + generateRandomCode, + sha256hex, +}; diff --git a/src/utils/sara_token.js b/src/utils/sara_token.js new file mode 100644 index 0000000..b441eef --- /dev/null +++ b/src/utils/sara_token.js @@ -0,0 +1,100 @@ +"use strict"; +// Token utils of Sara. + +// Import config +const {getMust} = require("../config"); + +// Import modules +const axios = require("axios"); +const {StatusCodes} = require("http-status-codes"); +const {verify} = require("jsonwebtoken"); + +const {useCache} = require("../init/cache"); +const {usePublicKey} = require("../init/keypair"); + +// Define Sara Token specs +const issuerIdentity = "Sara Hoshikawa"; // The code of Sara v3 + +// Define client +const client = axios.create({ + baseURL: getMust("SARA_RECV_HOST"), + headers: { + "User-Agent": "sara_client/2.0", + }, +}); + +// Define verifyOptions +const verifyOptions = { + algorithms: ["ES256"], + issuer: issuerIdentity, + audience: getMust("SARA_AUDIENCE_URL"), + complete: true, +}; + +/** + * Check if token is activated + * @module sara_token + * @function + * @param {string} tokenId - The token id to check. + * @return {Promise} + */ +async function isActivated(tokenId) { + const queryKey = ["sara_token", tokenId].join(":"); + + const cache = useCache(); + if (cache.has(queryKey)) { + return cache.get(queryKey); + } + + const result = await client.head(`/tokens/${tokenId}`, { + validateStatus: (status) => + status === StatusCodes.OK || + status === StatusCodes.NOT_FOUND, + }); + + const isActivated = result.status === StatusCodes.OK; + cache.set(queryKey, isActivated, 300); + return isActivated; +} + +/** + * Validate token + * @module sara_token + * @function + * @param {string} token - The token to valid. + * @return {Promise} + */ +async function validate(token) { + const result = { + userId: null, + payload: null, + isAborted: false, + }; + + try { + const publicKey = usePublicKey(); + const {payload} = verify( + token, publicKey, verifyOptions, + ); + + if (!await isActivated(payload.jti)) { + throw new Error("sara_token is not activated"); + } + + result.userId = payload.sub; + result.payload = { + profile: payload.user, + }; + } catch (e) { + result.isAborted = true; + result.payload = e; + } + + return result; +} + +// Export (object) +module.exports = { + client, + validate, +}; diff --git a/src/utils/test_token.js b/src/utils/test_token.js new file mode 100644 index 0000000..b860a5f --- /dev/null +++ b/src/utils/test_token.js @@ -0,0 +1,82 @@ +"use strict"; +// Token utils for testing/debugging or developing. + +// Import config +const {isProduction} = require("../config"); + +const DEFAULT_FAKE_USER = { + _id: "fake_user", + nickname: "The Fake User", + email: "the_fake_user@web-tech.tw", + roles: [], +}; + +/** + * Returns a new user profile + * @return {object} + */ +function newProfile() { + return structuredClone(DEFAULT_FAKE_USER); +} + +/** + * Issue token + * @module test_token + * @function + * @param {string} user - The user to generate the token for. + * @return {string} + */ +function issue(user) { + if (isProduction()) { + throw new Error("test_token is not allowed in production"); + } + + user = user || DEFAULT_FAKE_USER; + return Buffer. + from(JSON.stringify(user), "utf-8"). + toString("base64"); +} + +/** + * Validate token + * @module test_token + * @function + * @param {string} token - The token to valid. + * @return {object} + */ +function validate(token) { + if (isProduction()) { + throw new Error("test_token is not allowed in production"); + } + + const result = { + userId: null, + payload: null, + isAborted: false, + }; + + try { + const profile = JSON.parse( + Buffer. + from(token, "base64"). + toString("utf-8"), + ); + + result.userId = profile._id; + result.payload = { + profile, + }; + } catch (e) { + result.isAborted = true; + result.payload = e; + } + + return result; +} + +// Export (object) +module.exports = { + newProfile, + issue, + validate, +}; diff --git a/src/utils/visitor.js b/src/utils/visitor.js new file mode 100644 index 0000000..b0a28dd --- /dev/null +++ b/src/utils/visitor.js @@ -0,0 +1,52 @@ +"use strict"; +// The simple toolbox for fetch visitor information from HTTP request. + +const { + isProduction, +} = require("../config"); + +const uaParser = require("ua-parser-js"); + +/** + * Get IP Address. + * @module visitor + * @function + * @param {object} req the request + * @return {string} the IP Address + */ +function getIPAddress(req) { + if (!isProduction()) { + return "127.0.0.1"; + } + return req.ip; +} + +/** + * Get User-Agent. + * @module visitor + * @function + * @param {object} req the request + * @param {boolean} isShort return short code instead + * @return {string} the User-Agent + */ +function getUserAgent(req, isShort=false) { + const userAgent = req.header("user-agent"); + if (!userAgent) { + return "Unknown"; + } + + if (!isShort) { + return userAgent; + } + + const uaParsed = uaParser(userAgent); + const {name: browserName} = uaParsed.browser; + const {name: osName} = uaParsed.os; + return [browserName, osName].join(" "); +} + +// Export (object) +module.exports = { + getIPAddress, + getUserAgent, +}; diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..6642d4f --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,54 @@ + + + + + + Nymph + + + + + + +
+ +
+
+
+

+ 歡迎蒞臨 + 人工智慧時代 +

+
+

+ 建置中... +

+
+
+ +
+