commit 00325efb509da0bd68c9e4998712bece57c64440 Author: zenord Date: Fri Aug 14 23:06:31 2026 +0800 Initial gori agent gateway diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0900635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf01c19 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# gori-agent-gateway + +Hermes-gateway-style multi-IM Agent Gateway for routing instant-message webhooks to configurable CLI agents. It is intentionally standalone and not tied to Pi. + +## Hermes reference + +This project follows the Hermes gateway pattern: platform adapters normalize inbound messages, a central gateway applies policy/session/concurrency handling, then replies are sent through the originating adapter. The Feishu adapter ports the key Hermes behavior for tenant token caching, URL challenge handling, token verification, `im.message.receive_v1` parsing, mention cleanup, and message replies. + +## Platform status + +| Platform | Inbound | Outbound | Notes | +| --- | --- | --- | --- | +| Feishu/Lark | Implemented | Implemented | Replies to `im.message.receive_v1` via `/im/v1/messages/{message_id}/reply`. | +| WeCom | 501 scaffold | Implemented | Uses `gettoken` and `message/send`; inbound callback verification/encryption is not in v1. | +| personal WeChat | External webhook scaffold | Synchronous webhook response | Native iLink/personal WeChat integration is not included in v1. | +| QQ | 501 scaffold | Implemented | Uses QQ Bot access token and user/group text-message heuristics. | +| Generic webhook | Implemented | Synchronous JSON | HMAC-SHA256 signed JSON endpoint for local bridges and tests. | + +## Install and run + +Requires Node.js 20+. + +```bash +npm install +cp config.example.json config.json +GORI_GATEWAY_CONFIG=./config.json npm run dev +``` + +Build and run compiled output: + +```bash +npm run typecheck +npm run build +GORI_GATEWAY_CONFIG=./config.json npm start +``` + +Endpoints: + +- `GET /health` +- `GET /platforms` +- `POST /webhook/feishu` +- `POST /webhook/wecom` +- `POST /webhook/qq` +- `POST /webhook/generic` +- `POST /webhook/weixin` + +## Configuration + +Config is loaded from `GORI_GATEWAY_CONFIG`. If the variable is not set, the gateway loads `config.example.json` from the current working directory. + +Important sections: + +- `policy.allowedUsers`: allow only listed normalized user IDs when non-empty. +- `policy.allowedChats`: allow only listed normalized chat IDs when non-empty. +- `policy.requireMentionInGroup`: if true, group messages are ignored unless the adapter reports a bot mention. +- `defaultAgent`: selected when a chat has not chosen an agent. +- `agents[]`: named CLI agents using `child_process.spawn(command, args)` with `shell: false`. + +CLI agent options: + +- `inputMode: "stdin"`: sends the message text to stdin. +- `inputMode: "arg"`: appends the message text as the final argv item. +- `timeoutMs`: kills slow processes. +- `outputMaxBytes`: caps captured stdout/stderr. +- `cwd`: optional working directory for the agent process. + +## Commands + +The gateway handles these commands per chat before invoking an agent: + +- `/help` +- `/status` +- `/agents` +- `/agent ` +- `/new` + +## Feishu setup + +1. Create a Feishu/Lark custom app and enable bot messaging. +2. Configure event subscription for `im.message.receive_v1`. +3. Set the request URL to `https:///webhook/feishu`. +4. Put `appId`, `appSecret`, and `verificationToken` into your config. +5. Add bot display names to `platforms.feishu.botNames` so group mention detection can also work from flattened text. + +The adapter accepts Feishu URL verification challenges and returns `{ "challenge": "..." }`. + +## Generic webhook + +Payload: + +```json +{ + "chat_id": "demo-chat", + "user_id": "demo-user", + "text": "hello", + "message_id": "optional-message-id", + "is_group": false, + "mentions_bot": true +} +``` + +Signature header: + +- Header name: `X-Gori-Signature` +- Format: `sha256=` or just `` +- Input: exact JSON request body bytes as sent by the client +- Algorithm: HMAC-SHA256 using `platforms.webhook.secret` + +Example: + +```bash +body='{"chat_id":"demo-chat","user_id":"demo-user","text":"/status"}' +sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac 'replace-me' -hex | awk '{print $2}') +curl -sS http://localhost:3000/webhook/generic \ + -H 'Content-Type: application/json' \ + -H "X-Gori-Signature: sha256=$sig" \ + -d "$body" +``` + +Response shape: + +```json +{ "ok": true, "output": "...agent or command output..." } +``` + +## Security notes + +- Do not expose the gateway publicly without HTTPS and upstream authentication/rate limits. +- Keep platform secrets outside source control; use a copied config file or secret manager. +- CLI agents are spawned without a shell, but they can still execute arbitrary local code. Only configure trusted commands. +- Use `allowedUsers` and `allowedChats` in production. +- Keep `requireMentionInGroup` enabled for group chats to avoid accidental agent invocation. +- Set conservative `timeoutMs` and `outputMaxBytes` values for external-facing agents. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..c7c3830 --- /dev/null +++ b/config.example.json @@ -0,0 +1,59 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 3000 + }, + "policy": { + "allowedUsers": [], + "allowedChats": [], + "requireMentionInGroup": true + }, + "defaultAgent": "echo", + "agents": [ + { + "name": "echo", + "command": "node", + "args": ["scripts/echo-agent.js"], + "inputMode": "stdin", + "timeoutMs": 30000, + "outputMaxBytes": 64000 + }, + { + "name": "kimi", + "command": "kimi", + "args": [], + "inputMode": "arg", + "timeoutMs": 120000, + "outputMaxBytes": 64000 + } + ], + "platforms": { + "feishu": { + "enabled": false, + "appId": "cli_xxx", + "appSecret": "replace-me", + "verificationToken": "replace-me", + "botNames": ["Gori Agent"] + }, + "wecom": { + "enabled": false, + "corpId": "wwxxxx", + "agentId": "1000001", + "secret": "replace-me" + }, + "qq": { + "enabled": false, + "appId": "1020xxxx", + "clientSecret": "replace-me" + }, + "webhook": { + "enabled": true, + "secret": "replace-me" + }, + "weixin": { + "enabled": false, + "mode": "external-webhook", + "secret": "replace-me" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..39f9327 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1504 @@ +{ + "name": "gori-agent-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gori-agent-gateway", + "version": "0.1.0", + "dependencies": { + "express": "^4.19.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.10", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab9f316 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "gori-agent-gateway", + "version": "0.1.0", + "description": "Hermes-gateway-style multi-IM agent gateway for CLI agents.", + "type": "module", + "main": "dist/server.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/server.js", + "dev": "tsx src/server.ts" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "express": "^4.19.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.10", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } +} diff --git a/scripts/echo-agent.js b/scripts/echo-agent.js new file mode 100755 index 0000000..d5ca4a4 --- /dev/null +++ b/scripts/echo-agent.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +const chunks = []; +process.stdin.on("data", (chunk) => chunks.push(chunk)); +process.stdin.on("end", () => { + const input = Buffer.concat(chunks).toString("utf8").trim(); + console.log(input ? `echo: ${input}` : "echo: (empty)"); +}); + +if (process.stdin.isTTY) { + const input = process.argv.slice(2).join(" ").trim(); + console.log(input ? `echo: ${input}` : "echo: (empty)"); +} diff --git a/src/agents/agent-registry.ts b/src/agents/agent-registry.ts new file mode 100644 index 0000000..09af147 --- /dev/null +++ b/src/agents/agent-registry.ts @@ -0,0 +1,33 @@ +import type { AgentRunner } from "../core/types.js"; + +export class AgentRegistry { + private readonly agents = new Map(); + + constructor(private readonly defaultAgentName: string) {} + + register(agent: AgentRunner): void { + if (this.agents.has(agent.name)) { + throw new Error(`Agent already registered: ${agent.name}`); + } + this.agents.set(agent.name, agent); + } + + get(name?: string): AgentRunner { + const requested = name || this.defaultAgentName; + const agent = this.agents.get(requested); + if (!agent) throw new Error(`Unknown agent: ${requested}`); + return agent; + } + + has(name: string): boolean { + return this.agents.has(name); + } + + defaultName(): string { + return this.defaultAgentName; + } + + list(): string[] { + return [...this.agents.keys()].sort(); + } +} diff --git a/src/agents/cli-agent.ts b/src/agents/cli-agent.ts new file mode 100644 index 0000000..492c2ba --- /dev/null +++ b/src/agents/cli-agent.ts @@ -0,0 +1,80 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import type { CliAgentConfig } from "../config.js"; +import type { AgentRequest, AgentResponse, AgentRunner } from "../core/types.js"; + +export class CliAgent implements AgentRunner { + readonly name: string; + + constructor(private readonly config: CliAgentConfig) { + this.name = config.name; + } + + async run(request: AgentRequest): Promise { + const args = this.config.inputMode === "arg" + ? [...this.config.args, request.input] + : this.config.args; + + const child = spawn(this.config.command, args, { + cwd: this.config.cwd, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + GORI_AGENT_NAME: this.name, + GORI_AGENT_SESSION_ID: request.sessionId, + GORI_AGENT_PLATFORM: request.platform, + GORI_AGENT_CHAT_ID: request.chatId, + GORI_AGENT_USER_ID: request.userId, + GORI_AGENT_MESSAGE_ID: request.messageId || "" + } + }); + + let stdout = ""; + let stderr = ""; + let killedByTimeout = false; + const maxBytes = this.config.outputMaxBytes; + + const appendLimited = (current: string, chunk: Buffer): string => { + if (Buffer.byteLength(current) >= maxBytes) return current; + const combined = Buffer.concat([Buffer.from(current), chunk]); + return combined.subarray(0, maxBytes).toString("utf8"); + }; + + child.stdout.on("data", (chunk: Buffer) => { + stdout = appendLimited(stdout, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr = appendLimited(stderr, chunk); + }); + + const timer = setTimeout(() => { + killedByTimeout = true; + child.kill("SIGTERM"); + setTimeout(() => { + child.kill("SIGKILL"); + }, 2_000).unref(); + }, this.config.timeoutMs); + + if (this.config.inputMode === "stdin") { + child.stdin.end(request.input); + } else { + child.stdin.end(); + } + + const [code, signal] = await once(child, "close") as [number | null, NodeJS.Signals | null]; + clearTimeout(timer); + + if (killedByTimeout) { + throw new Error(`Agent '${this.name}' timed out after ${this.config.timeoutMs}ms`); + } + if (code !== 0) { + throw new Error(`Agent '${this.name}' exited with code ${code ?? signal}: ${stderr || stdout}`.trim()); + } + + return { + agentName: this.name, + text: stdout.trim() || stderr.trim() || "(empty response)" + }; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8440eb3 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +const cliAgentSchema = z.object({ + name: z.string().min(1), + command: z.string().min(1), + args: z.array(z.string()).default([]), + inputMode: z.enum(["stdin", "arg"]).default("stdin"), + timeoutMs: z.number().int().positive().default(120_000), + outputMaxBytes: z.number().int().positive().default(64_000), + cwd: z.string().optional() +}); + +const policySchema = z.object({ + allowedUsers: z.array(z.string()).default([]), + allowedChats: z.array(z.string()).default([]), + requireMentionInGroup: z.boolean().default(false) +}); + +const feishuSchema = z.object({ + enabled: z.boolean().default(false), + appId: z.string().default(""), + appSecret: z.string().default(""), + verificationToken: z.string().default(""), + botNames: z.array(z.string()).default([]) +}); + +const wecomSchema = z.object({ + enabled: z.boolean().default(false), + corpId: z.string().default(""), + agentId: z.string().default(""), + secret: z.string().default("") +}); + +const qqSchema = z.object({ + enabled: z.boolean().default(false), + appId: z.string().default(""), + clientSecret: z.string().default("") +}); + +const webhookSchema = z.object({ + enabled: z.boolean().default(true), + secret: z.string().default("") +}); + +const weixinSchema = z.object({ + enabled: z.boolean().default(false), + mode: z.enum(["external-webhook", "not-implemented"]).default("external-webhook"), + secret: z.string().default("") +}); + +export const configSchema = z.object({ + server: z.object({ + host: z.string().default("0.0.0.0"), + port: z.number().int().positive().max(65_535).default(3000) + }).default({}), + policy: policySchema.default({}), + defaultAgent: z.string().min(1).default("echo"), + agents: z.array(cliAgentSchema).min(1), + platforms: z.object({ + feishu: feishuSchema.default({}), + wecom: wecomSchema.default({}), + qq: qqSchema.default({}), + webhook: webhookSchema.default({}), + weixin: weixinSchema.default({}) + }).default({}) +}); + +export type AppConfig = z.infer; +export type CliAgentConfig = z.infer; +export type GatewayPolicy = z.infer; + +export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig { + const resolvedPath = path.resolve(configPath || "config.example.json"); + const raw = fs.readFileSync(resolvedPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const config = configSchema.parse(parsed); + + if (!config.agents.some((agent) => agent.name === config.defaultAgent)) { + throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`); + } + + return config; +} diff --git a/src/core/adapter.ts b/src/core/adapter.ts new file mode 100644 index 0000000..8664220 --- /dev/null +++ b/src/core/adapter.ts @@ -0,0 +1,15 @@ +import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "./types.js"; + +export interface PlatformAdapter { + name: string; + handleWebhook(context: WebhookRequestContext): Promise; + sendMessage(message: OutgoingMessage): Promise; +} + +export function jsonResponse(body: unknown, status = 200): WebhookResponse { + return { status, body }; +} + +export function notImplemented(platform: string): WebhookResponse { + return jsonResponse({ ok: false, error: `${platform} inbound webhook is not implemented in v1` }, 501); +} diff --git a/src/core/command-router.ts b/src/core/command-router.ts new file mode 100644 index 0000000..5c46013 --- /dev/null +++ b/src/core/command-router.ts @@ -0,0 +1,57 @@ +import { AgentRegistry } from "../agents/agent-registry.js"; +import { SessionStore } from "./session-store.js"; +import type { IncomingMessage } from "./types.js"; + +export interface CommandResult { + handled: boolean; + text?: string; +} + +export class CommandRouter { + constructor( + private readonly agents: AgentRegistry, + private readonly sessions: SessionStore + ) {} + + route(message: IncomingMessage, sessionId: string): CommandResult { + const text = message.text.trim(); + if (!text.startsWith("/")) return { handled: false }; + + const [command, ...args] = text.split(/\s+/); + switch (command) { + case "/help": + return { + handled: true, + text: [ + "Commands:", + "/help - show this help", + "/status - show gateway/session status", + "/agents - list available agents", + "/agent - select an agent for this chat", + "/new - reset this chat session" + ].join("\n") + }; + case "/status": { + const session = this.sessions.getSession(sessionId); + return { + handled: true, + text: `OK\nplatform=${message.platform}\nchat=${message.chatId}\nagent=${session.selectedAgent || this.agents.defaultName()}` + }; + } + case "/agents": + return { handled: true, text: `Available agents: ${this.agents.list().join(", ")}` }; + case "/agent": { + const agentName = args[0]; + if (!agentName) return { handled: true, text: "Usage: /agent " }; + if (!this.agents.has(agentName)) return { handled: true, text: `Unknown agent: ${agentName}` }; + this.sessions.setSelectedAgent(sessionId, agentName); + return { handled: true, text: `Selected agent: ${agentName}` }; + } + case "/new": + this.sessions.reset(sessionId); + return { handled: true, text: "Started a new session for this chat." }; + default: + return { handled: false }; + } + } +} diff --git a/src/core/gateway.ts b/src/core/gateway.ts new file mode 100644 index 0000000..6e76075 --- /dev/null +++ b/src/core/gateway.ts @@ -0,0 +1,112 @@ +import type { GatewayPolicy } from "../config.js"; +import { AgentRegistry } from "../agents/agent-registry.js"; +import type { PlatformAdapter } from "./adapter.js"; +import { CommandRouter } from "./command-router.js"; +import { SessionStore, sessionIdFor } from "./session-store.js"; +import type { IncomingMessage } from "./types.js"; + +export interface GatewayResult { + ok: boolean; + reply?: string; + ignored?: boolean; + error?: string; +} + +export class Gateway { + private readonly locks = new Map>(); + readonly commandRouter: CommandRouter; + + constructor( + private readonly policy: GatewayPolicy, + private readonly agents: AgentRegistry, + private readonly sessions: SessionStore + ) { + this.commandRouter = new CommandRouter(agents, sessions); + } + + async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise { + const policyError = this.checkPolicy(message); + if (policyError) return { ok: true, ignored: true, error: policyError }; + + const sessionId = sessionIdFor(message.platform, message.chatId); + return this.withChatLock(sessionId, async () => { + const command = this.commandRouter.route(message, sessionId); + if (command.handled) { + const reply = command.text || ""; + await adapter.sendMessage({ + target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw }, + text: reply, + replyTo: message.messageId + }); + return { ok: true, reply }; + } + + const session = this.sessions.getSession(sessionId); + const agent = this.agents.get(session.selectedAgent); + try { + const response = await agent.run({ + input: message.text, + sessionId, + platform: message.platform, + chatId: message.chatId, + userId: message.userId, + messageId: message.messageId + }); + await adapter.sendMessage({ + target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw }, + text: response.text, + replyTo: message.messageId + }); + return { ok: true, reply: response.text }; + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + const reply = `Agent error: ${errorText}`; + if (!options.synchronous) { + await adapter.sendMessage({ + target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw }, + text: reply, + replyTo: message.messageId + }); + } + return { ok: false, error: errorText, reply }; + } + }); + } + + stats(): { sessions: number; lockedChats: number } { + return { ...this.sessions.stats(), lockedChats: this.locks.size }; + } + + private checkPolicy(message: IncomingMessage): string | undefined { + if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) { + return `User not allowed: ${message.userId}`; + } + if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) { + return `Chat not allowed: ${message.chatId}`; + } + if (this.policy.requireMentionInGroup && message.isGroup && !message.mentionsBot) { + return "Mention required in group chat"; + } + return undefined; + } + + private async withChatLock(key: string, fn: () => Promise): Promise { + const previous = this.locks.get(key) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.then(() => current); + this.locks.set(key, queued); + + await previous; + try { + return await fn(); + } finally { + release(); + if (this.locks.get(key) === queued) { + this.locks.delete(key); + } + } + } +} diff --git a/src/core/platform-registry.ts b/src/core/platform-registry.ts new file mode 100644 index 0000000..a91d291 --- /dev/null +++ b/src/core/platform-registry.ts @@ -0,0 +1,22 @@ +import type { PlatformAdapter } from "./adapter.js"; + +export class PlatformRegistry { + private readonly adapters = new Map(); + + register(adapter: PlatformAdapter): void { + if (this.adapters.has(adapter.name)) { + throw new Error(`Platform adapter already registered: ${adapter.name}`); + } + this.adapters.set(adapter.name, adapter); + } + + get(name: string): PlatformAdapter { + const adapter = this.adapters.get(name); + if (!adapter) throw new Error(`Unknown platform adapter: ${name}`); + return adapter; + } + + list(): string[] { + return [...this.adapters.keys()].sort(); + } +} diff --git a/src/core/session-store.ts b/src/core/session-store.ts new file mode 100644 index 0000000..7fb1b27 --- /dev/null +++ b/src/core/session-store.ts @@ -0,0 +1,37 @@ +export interface ChatSession { + selectedAgent?: string; + createdAt: number; + updatedAt: number; +} + +export class SessionStore { + private readonly sessions = new Map(); + + getSession(sessionId: string): ChatSession { + const existing = this.sessions.get(sessionId); + if (existing) return existing; + + const now = Date.now(); + const session: ChatSession = { createdAt: now, updatedAt: now }; + this.sessions.set(sessionId, session); + return session; + } + + setSelectedAgent(sessionId: string, agentName: string): void { + const session = this.getSession(sessionId); + session.selectedAgent = agentName; + session.updatedAt = Date.now(); + } + + reset(sessionId: string): void { + this.sessions.delete(sessionId); + } + + stats(): { sessions: number } { + return { sessions: this.sessions.size }; + } +} + +export function sessionIdFor(platform: string, chatId: string): string { + return `${platform}:${chatId}`; +} diff --git a/src/core/text.ts b/src/core/text.ts new file mode 100644 index 0000000..f805e51 --- /dev/null +++ b/src/core/text.ts @@ -0,0 +1,36 @@ +export function stripBotMentions(text: string): string { + return text + .replace(/]*>.*?<\/at>/g, " ") + .replace(/<@!?[A-Za-z0-9_:-]+>/g, " ") + .replace(/@[\w\-\u4e00-\u9fa5]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function flattenText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(flattenText).filter(Boolean).join(" "); + if (!value || typeof value !== "object") return ""; + + const record = value as Record; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + if (Array.isArray(record.content)) return flattenText(record.content); + if (Array.isArray(record.elements)) return flattenText(record.elements); + if (Array.isArray(record.children)) return flattenText(record.children); + if (record.tag === "text" && typeof record.un_escape_text === "string") return record.un_escape_text; + + return Object.values(record).map(flattenText).filter(Boolean).join(" "); +} + +export function parseMaybeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..b8304a4 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,61 @@ +import type { Request } from "express"; + +export type PlatformName = "feishu" | "wecom" | "qq" | "webhook" | "weixin" | string; + +export interface MessageTarget { + platform: PlatformName; + chatId: string; + userId?: string; + threadId?: string; + raw?: unknown; +} + +export interface IncomingMessage { + platform: PlatformName; + chatId: string; + userId: string; + text: string; + messageId?: string; + isGroup?: boolean; + mentionsBot?: boolean; + raw?: unknown; +} + +export interface OutgoingMessage { + target: MessageTarget; + text: string; + replyTo?: string; +} + +export interface WebhookRequestContext { + req: Request; + body: unknown; + headers: Request["headers"]; + query: Request["query"]; + rawBody?: Buffer; +} + +export interface WebhookResponse { + status?: number; + headers?: Record; + body?: unknown; +} + +export interface AgentRequest { + input: string; + sessionId: string; + platform: PlatformName; + chatId: string; + userId: string; + messageId?: string; +} + +export interface AgentResponse { + text: string; + agentName: string; +} + +export interface AgentRunner { + name: string; + run(request: AgentRequest): Promise; +} diff --git a/src/platforms/feishu/adapter.ts b/src/platforms/feishu/adapter.ts new file mode 100644 index 0000000..bf3d1a5 --- /dev/null +++ b/src/platforms/feishu/adapter.ts @@ -0,0 +1,114 @@ +import type { AppConfig } from "../../config.js"; +import { jsonResponse } from "../../core/adapter.js"; +import type { PlatformAdapter } from "../../core/adapter.js"; +import type { Gateway } from "../../core/gateway.js"; +import { flattenText, parseMaybeJson, stripBotMentions } from "../../core/text.js"; +import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; +import type { FeishuTenantTokenResponse, FeishuWebhookBody } from "./types.js"; + +export class FeishuAdapter implements PlatformAdapter { + readonly name = "feishu"; + private tenantToken?: { token: string; expiresAt: number }; + + constructor( + private readonly config: AppConfig["platforms"]["feishu"], + private readonly gateway: Gateway + ) {} + + async handleWebhook(context: WebhookRequestContext): Promise { + const body = context.body as FeishuWebhookBody; + if (body.challenge) return jsonResponse({ challenge: body.challenge }); + + const token = body.header?.token || body.token; + if (this.config.verificationToken && token !== this.config.verificationToken) { + return jsonResponse({ ok: false, error: "invalid verification token" }, 401); + } + + if (body.header?.event_type !== "im.message.receive_v1") { + return jsonResponse({ ok: true, ignored: true }); + } + + const message = body.event?.message; + const sender = body.event?.sender?.sender_id; + if (!message?.chat_id || !sender || !message.content) { + return jsonResponse({ ok: false, error: "missing Feishu message fields" }, 400); + } + + const content = parseMaybeJson(message.content); + const rawText = flattenText(content); + const mentionsBot = this.hasBotMention(rawText, message.mentions); + const normalized: IncomingMessage = { + platform: this.name, + chatId: message.chat_id, + userId: sender.open_id || sender.user_id || sender.union_id || "unknown", + text: stripBotMentions(rawText), + messageId: message.message_id, + isGroup: message.chat_type !== "p2p", + mentionsBot, + raw: body + }; + + void this.gateway.receive(normalized, this).catch((error) => { + console.error("Feishu gateway error", error); + }); + return jsonResponse({ ok: true }); + } + + async sendMessage(message: OutgoingMessage): Promise { + if (!message.replyTo) { + console.warn("Feishu sendMessage requires replyTo message_id; skipping"); + return; + } + const token = await this.getTenantAccessToken(); + const response = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(message.replyTo)}/reply`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ + msg_type: "text", + content: JSON.stringify({ text: message.text }) + }) + }); + + if (!response.ok) { + throw new Error(`Feishu reply failed: ${response.status} ${await response.text()}`); + } + const data = await response.json() as { code?: number; msg?: string }; + if (data.code && data.code !== 0) { + throw new Error(`Feishu reply failed: ${data.code} ${data.msg || ""}`.trim()); + } + } + + private async getTenantAccessToken(): Promise { + if (this.tenantToken && this.tenantToken.expiresAt > Date.now() + 60_000) { + return this.tenantToken.token; + } + const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ app_id: this.config.appId, app_secret: this.config.appSecret }) + }); + if (!response.ok) { + throw new Error(`Feishu token request failed: ${response.status} ${await response.text()}`); + } + const data = await response.json() as FeishuTenantTokenResponse; + if (data.code !== 0 || !data.tenant_access_token) { + throw new Error(`Feishu token request failed: ${data.code} ${data.msg || ""}`.trim()); + } + this.tenantToken = { + token: data.tenant_access_token, + expiresAt: Date.now() + Math.max(1, (data.expire || 7200) - 120) * 1000 + }; + return this.tenantToken.token; + } + + private hasBotMention( + text: string, + mentions: NonNullable["message"]>["mentions"] | undefined + ): boolean { + if (mentions && mentions.length > 0) return true; + return this.config.botNames.some((name) => text.includes(`@${name}`) || text.includes(name)); + } +} diff --git a/src/platforms/feishu/types.ts b/src/platforms/feishu/types.ts new file mode 100644 index 0000000..b1206b4 --- /dev/null +++ b/src/platforms/feishu/types.ts @@ -0,0 +1,38 @@ +export interface FeishuTenantTokenResponse { + code: number; + msg?: string; + tenant_access_token?: string; + expire?: number; +} + +export interface FeishuWebhookBody { + challenge?: string; + token?: string; + header?: { + token?: string; + event_type?: string; + event_id?: string; + }; + event?: { + message?: { + message_id?: string; + chat_id?: string; + chat_type?: string; + content?: string; + message_type?: string; + mentions?: Array<{ + key?: string; + name?: string; + id?: { open_id?: string; user_id?: string; union_id?: string }; + }>; + }; + sender?: { + sender_id?: { open_id?: string; user_id?: string; union_id?: string }; + }; + }; +} + +export interface FeishuReplyResponse { + code: number; + msg?: string; +} diff --git a/src/platforms/qq/adapter.ts b/src/platforms/qq/adapter.ts new file mode 100644 index 0000000..da84be6 --- /dev/null +++ b/src/platforms/qq/adapter.ts @@ -0,0 +1,58 @@ +import type { AppConfig } from "../../config.js"; +import { notImplemented } from "../../core/adapter.js"; +import type { PlatformAdapter } from "../../core/adapter.js"; +import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; +import type { QqAccessTokenResponse, QqSendMessageResponse } from "./types.js"; + +export class QqAdapter implements PlatformAdapter { + readonly name = "qq"; + private accessToken?: { token: string; expiresAt: number }; + + constructor(private readonly config: AppConfig["platforms"]["qq"]) {} + + async handleWebhook(_context: WebhookRequestContext): Promise { + return notImplemented("QQ"); + } + + async sendMessage(message: OutgoingMessage): Promise { + const token = await this.getAccessToken(); + const raw = (message.target.raw || {}) as Record; + const groupOpenId = typeof raw.group_openid === "string" ? raw.group_openid : undefined; + const userOpenId = typeof raw.user_openid === "string" ? raw.user_openid : undefined; + const isGroup = Boolean(groupOpenId) || message.target.chatId.startsWith("group:"); + const targetId = groupOpenId || userOpenId || message.target.chatId.replace(/^group:/, "").replace(/^user:/, ""); + const path = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`; + + const response = await fetch(`https://api.sgroup.qq.com${path}`, { + method: "POST", + headers: { + Authorization: `QQBot ${token}`, + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ content: message.text, msg_id: message.replyTo }) + }); + if (!response.ok) throw new Error(`QQ send failed: ${response.status} ${await response.text()}`); + const data = await response.json() as QqSendMessageResponse; + if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim()); + } + + private async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token; + + const response = await fetch("https://bots.qq.com/app/getAppAccessToken", { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ appId: this.config.appId, clientSecret: this.config.clientSecret }) + }); + if (!response.ok) throw new Error(`QQ token request failed: ${response.status} ${await response.text()}`); + const data = await response.json() as QqAccessTokenResponse; + if (!data.access_token) { + throw new Error(`QQ token request failed: ${data.error || "unknown"} ${data.error_description || ""}`.trim()); + } + this.accessToken = { + token: data.access_token, + expiresAt: Date.now() + Math.max(1, (data.expires_in || 7200) - 120) * 1000 + }; + return this.accessToken.token; + } +} diff --git a/src/platforms/qq/types.ts b/src/platforms/qq/types.ts new file mode 100644 index 0000000..cc19226 --- /dev/null +++ b/src/platforms/qq/types.ts @@ -0,0 +1,13 @@ +export interface QqAccessTokenResponse { + access_token?: string; + expires_in?: number; + token_type?: string; + error?: string; + error_description?: string; +} + +export interface QqSendMessageResponse { + id?: string; + code?: number; + message?: string; +} diff --git a/src/platforms/webhook/adapter.ts b/src/platforms/webhook/adapter.ts new file mode 100644 index 0000000..29f2caa --- /dev/null +++ b/src/platforms/webhook/adapter.ts @@ -0,0 +1,68 @@ +import crypto from "node:crypto"; +import type { AppConfig } from "../../config.js"; +import { jsonResponse } from "../../core/adapter.js"; +import type { PlatformAdapter } from "../../core/adapter.js"; +import type { Gateway } from "../../core/gateway.js"; +import { firstHeader } from "../../core/text.js"; +import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; + +interface GenericWebhookPayload { + chat_id?: string; + user_id?: string; + text?: string; + message_id?: string; + is_group?: boolean; + mentions_bot?: boolean; +} + +export function verifyHmacSha256(rawBody: string, signature: string | undefined, secret: string): boolean { + if (!secret) return true; + if (!signature) return false; + + const expectedHex = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + const actualHex = signature.startsWith("sha256=") ? signature.slice("sha256=".length) : signature; + + if (!/^[a-fA-F0-9]+$/.test(actualHex)) return false; + const expected = Buffer.from(expectedHex, "hex"); + const actual = Buffer.from(actualHex, "hex"); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +} + +export class GenericWebhookAdapter implements PlatformAdapter { + readonly name = "webhook"; + + constructor( + private readonly config: AppConfig["platforms"]["webhook"], + private readonly gateway: Gateway + ) {} + + async handleWebhook(context: WebhookRequestContext): Promise { + const rawBody = context.rawBody?.toString("utf8") ?? JSON.stringify(context.body ?? {}); + const signature = firstHeader(context.headers["x-gori-signature"] as string | string[] | undefined); + if (!verifyHmacSha256(rawBody, signature, this.config.secret)) { + return jsonResponse({ ok: false, error: "invalid signature" }, 401); + } + + const payload = context.body as GenericWebhookPayload; + if (!payload.chat_id || !payload.user_id || typeof payload.text !== "string") { + return jsonResponse({ ok: false, error: "chat_id, user_id and text are required" }, 400); + } + + const message: IncomingMessage = { + platform: this.name, + chatId: payload.chat_id, + userId: payload.user_id, + text: payload.text, + messageId: payload.message_id, + isGroup: payload.is_group, + mentionsBot: payload.mentions_bot, + raw: payload + }; + const result = await this.gateway.receive(message, this, { synchronous: true }); + return jsonResponse(result.ok ? { ok: true, output: result.reply } : { ok: false, error: result.error, output: result.reply }, result.ok ? 200 : 500); + } + + async sendMessage(_message: OutgoingMessage): Promise { + // Generic webhook responses are returned synchronously from handleWebhook. + } +} diff --git a/src/platforms/wecom/adapter.ts b/src/platforms/wecom/adapter.ts new file mode 100644 index 0000000..f566ce3 --- /dev/null +++ b/src/platforms/wecom/adapter.ts @@ -0,0 +1,53 @@ +import type { AppConfig } from "../../config.js"; +import { notImplemented } from "../../core/adapter.js"; +import type { PlatformAdapter } from "../../core/adapter.js"; +import type { OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; +import type { WeComAccessTokenResponse, WeComSendMessageResponse } from "./types.js"; + +export class WeComAdapter implements PlatformAdapter { + readonly name = "wecom"; + private accessToken?: { token: string; expiresAt: number }; + + constructor(private readonly config: AppConfig["platforms"]["wecom"]) {} + + async handleWebhook(_context: WebhookRequestContext): Promise { + return notImplemented("WeCom"); + } + + async sendMessage(message: OutgoingMessage): Promise { + const token = await this.getAccessToken(); + const response = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + touser: message.target.userId || message.target.chatId, + msgtype: "text", + agentid: Number(this.config.agentId) || this.config.agentId, + text: { content: message.text }, + safe: 0 + }) + }); + if (!response.ok) throw new Error(`WeCom send failed: ${response.status} ${await response.text()}`); + const data = await response.json() as WeComSendMessageResponse; + if (data.errcode !== 0) throw new Error(`WeCom send failed: ${data.errcode} ${data.errmsg || ""}`.trim()); + } + + private async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.token; + + const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken"); + url.searchParams.set("corpid", this.config.corpId); + url.searchParams.set("corpsecret", this.config.secret); + const response = await fetch(url); + if (!response.ok) throw new Error(`WeCom token request failed: ${response.status} ${await response.text()}`); + const data = await response.json() as WeComAccessTokenResponse; + if (data.errcode !== 0 || !data.access_token) { + throw new Error(`WeCom token request failed: ${data.errcode} ${data.errmsg || ""}`.trim()); + } + this.accessToken = { + token: data.access_token, + expiresAt: Date.now() + Math.max(1, (data.expires_in || 7200) - 120) * 1000 + }; + return this.accessToken.token; + } +} diff --git a/src/platforms/wecom/types.ts b/src/platforms/wecom/types.ts new file mode 100644 index 0000000..e6b2e1d --- /dev/null +++ b/src/platforms/wecom/types.ts @@ -0,0 +1,11 @@ +export interface WeComAccessTokenResponse { + errcode: number; + errmsg?: string; + access_token?: string; + expires_in?: number; +} + +export interface WeComSendMessageResponse { + errcode: number; + errmsg?: string; +} diff --git a/src/platforms/weixin/adapter.ts b/src/platforms/weixin/adapter.ts new file mode 100644 index 0000000..cfe1605 --- /dev/null +++ b/src/platforms/weixin/adapter.ts @@ -0,0 +1,49 @@ +import type { AppConfig } from "../../config.js"; +import { jsonResponse, notImplemented } from "../../core/adapter.js"; +import type { PlatformAdapter } from "../../core/adapter.js"; +import type { Gateway } from "../../core/gateway.js"; +import type { IncomingMessage, OutgoingMessage, WebhookRequestContext, WebhookResponse } from "../../core/types.js"; + +interface ExternalWeixinPayload { + chat_id?: string; + user_id?: string; + text?: string; + message_id?: string; + is_group?: boolean; + mentions_bot?: boolean; +} + +export class WeixinAdapter implements PlatformAdapter { + readonly name = "weixin"; + + constructor( + private readonly config: AppConfig["platforms"]["weixin"], + private readonly gateway: Gateway + ) {} + + async handleWebhook(context: WebhookRequestContext): Promise { + if (this.config.mode === "not-implemented") return notImplemented("Weixin"); + + const payload = context.body as ExternalWeixinPayload; + if (!payload.chat_id || !payload.user_id || typeof payload.text !== "string") { + return jsonResponse({ ok: false, error: "external Weixin webhook requires chat_id, user_id and text" }, 400); + } + + const message: IncomingMessage = { + platform: this.name, + chatId: payload.chat_id, + userId: payload.user_id, + text: payload.text, + messageId: payload.message_id, + isGroup: payload.is_group, + mentionsBot: payload.mentions_bot, + raw: payload + }; + const result = await this.gateway.receive(message, this, { synchronous: true }); + return jsonResponse(result.ok ? { ok: true, output: result.reply } : { ok: false, error: result.error, output: result.reply }, result.ok ? 200 : 500); + } + + async sendMessage(_message: OutgoingMessage): Promise { + // Personal WeChat has no official native iLink adapter in v1. External bridges should consume the synchronous webhook response. + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..d3a0e63 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,78 @@ +import express from "express"; +import { loadConfig } from "./config.js"; +import { AgentRegistry } from "./agents/agent-registry.js"; +import { CliAgent } from "./agents/cli-agent.js"; +import { Gateway } from "./core/gateway.js"; +import { PlatformRegistry } from "./core/platform-registry.js"; +import { SessionStore } from "./core/session-store.js"; +import type { PlatformAdapter } from "./core/adapter.js"; +import { FeishuAdapter } from "./platforms/feishu/adapter.js"; +import { WeComAdapter } from "./platforms/wecom/adapter.js"; +import { QqAdapter } from "./platforms/qq/adapter.js"; +import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js"; +import { WeixinAdapter } from "./platforms/weixin/adapter.js"; + +const config = loadConfig(); +const sessions = new SessionStore(); +const agents = new AgentRegistry(config.defaultAgent); +for (const agentConfig of config.agents) { + agents.register(new CliAgent(agentConfig)); +} + +const gateway = new Gateway(config.policy, agents, sessions); +const platforms = new PlatformRegistry(); +const adapters: PlatformAdapter[] = [ + new FeishuAdapter(config.platforms.feishu, gateway), + new WeComAdapter(config.platforms.wecom), + new QqAdapter(config.platforms.qq), + new GenericWebhookAdapter(config.platforms.webhook, gateway), + new WeixinAdapter(config.platforms.weixin, gateway) +]; +for (const adapter of adapters) platforms.register(adapter); + +const app = express(); +app.use(express.json({ + limit: "1mb", + verify: (req, _res, buf) => { + (req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf); + } +})); + +app.get("/health", (_req, res) => { + res.json({ ok: true, gateway: gateway.stats() }); +}); + +app.get("/platforms", (_req, res) => { + res.json({ ok: true, platforms: platforms.list(), agents: agents.list() }); +}); + +function mountWebhook(routeName: string, adapterName = routeName): void { + app.post(`/webhook/${routeName}`, async (req, res) => { + try { + const response = await platforms.get(adapterName).handleWebhook({ + req, + body: req.body, + headers: req.headers, + query: req.query, + rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody + }); + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value); + } + res.status(response.status || 200).json(response.body ?? { ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Webhook ${routeName} failed`, error); + res.status(500).json({ ok: false, error: message }); + } + }); +} + +for (const platformName of ["feishu", "wecom", "qq", "weixin"]) { + mountWebhook(platformName); +} +mountWebhook("generic", "webhook"); + +app.listen(config.server.port, config.server.host, () => { + console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cc914a7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}