From 8790ba5dadb3fd9b8ac77b62f2523d7e146b0425 Mon Sep 17 00:00:00 2001 From: Claudia Date: Sun, 1 Feb 2026 11:06:37 +0100 Subject: [PATCH] feat: Add multi-account Matrix support - Modified shared.ts to maintain a Map of clients per accountId - Each account gets its own Matrix client instance - Backwards compatible with single-account usage - resolveMatrixAuth now accepts accountId parameter - stopSharedClient can stop specific or all accounts Files changed: - src/matrix/client/shared.ts (main changes) - src/matrix/client/config.ts (accountId support) - src/matrix/accounts.ts (list enabled accounts) - src/matrix/monitor/index.ts (pass accountId) - src/types.ts (accounts config type) --- .gitignore | 3 + CHANGELOG.md | 59 + index.ts | 18 + openclaw.plugin.json | 11 + package-lock.json | 2194 ++++++++++++++++++++++++++++ package.json | 36 + src/actions.ts | 185 +++ src/channel.directory.test.ts | 56 + src/channel.ts | 417 ++++++ src/config-schema.ts | 62 + src/directory-live.ts | 175 +++ src/group-mentions.ts | 61 + src/matrix/accounts.test.ts | 83 ++ src/matrix/accounts.ts | 164 +++ src/matrix/actions.ts | 15 + src/matrix/actions/client.ts | 53 + src/matrix/actions/messages.ts | 120 ++ src/matrix/actions/pins.ts | 70 + src/matrix/actions/reactions.ts | 84 ++ src/matrix/actions/room.ts | 88 ++ src/matrix/actions/summary.ts | 77 + src/matrix/actions/types.ts | 84 ++ src/matrix/active-client.ts | 11 + src/matrix/client.test.ts | 57 + src/matrix/client.ts | 9 + src/matrix/client/config.ts | 216 +++ src/matrix/client/create-client.ts | 127 ++ src/matrix/client/logging.ts | 35 + src/matrix/client/runtime.ts | 4 + src/matrix/client/shared.ts | 277 ++++ src/matrix/client/storage.ts | 131 ++ src/matrix/client/types.ts | 34 + src/matrix/credentials.ts | 103 ++ src/matrix/deps.ts | 57 + src/matrix/format.test.ts | 34 + src/matrix/format.ts | 22 + src/matrix/index.ts | 11 + src/matrix/monitor/allowlist.ts | 58 + src/matrix/monitor/auto-join.ts | 68 + src/matrix/monitor/direct.ts | 105 ++ src/matrix/monitor/events.ts | 103 ++ src/matrix/monitor/handler.ts | 645 ++++++++ src/matrix/monitor/index.ts | 279 ++++ src/matrix/monitor/location.ts | 83 ++ src/matrix/monitor/media.test.ts | 103 ++ src/matrix/monitor/media.ts | 113 ++ src/matrix/monitor/mentions.ts | 31 + src/matrix/monitor/replies.ts | 96 ++ src/matrix/monitor/room-info.ts | 58 + src/matrix/monitor/rooms.ts | 43 + src/matrix/monitor/threads.ts | 64 + src/matrix/monitor/types.ts | 39 + src/matrix/poll-types.test.ts | 22 + src/matrix/poll-types.ts | 157 ++ src/matrix/probe.ts | 70 + src/matrix/send.test.ts | 172 +++ src/matrix/send.ts | 255 ++++ src/matrix/send/client.ts | 63 + src/matrix/send/formatting.ts | 92 ++ src/matrix/send/media.ts | 220 +++ src/matrix/send/targets.test.ts | 102 ++ src/matrix/send/targets.ts | 144 ++ src/matrix/send/types.ts | 109 ++ src/onboarding.ts | 432 ++++++ src/outbound.ts | 53 + src/resolve-targets.ts | 89 ++ src/runtime.ts | 14 + src/tool-actions.ts | 160 ++ src/types.ts | 108 ++ tsconfig.check.json | 14 + 70 files changed, 9407 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 index.ts create mode 100644 openclaw.plugin.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/actions.ts create mode 100644 src/channel.directory.test.ts create mode 100644 src/channel.ts create mode 100644 src/config-schema.ts create mode 100644 src/directory-live.ts create mode 100644 src/group-mentions.ts create mode 100644 src/matrix/accounts.test.ts create mode 100644 src/matrix/accounts.ts create mode 100644 src/matrix/actions.ts create mode 100644 src/matrix/actions/client.ts create mode 100644 src/matrix/actions/messages.ts create mode 100644 src/matrix/actions/pins.ts create mode 100644 src/matrix/actions/reactions.ts create mode 100644 src/matrix/actions/room.ts create mode 100644 src/matrix/actions/summary.ts create mode 100644 src/matrix/actions/types.ts create mode 100644 src/matrix/active-client.ts create mode 100644 src/matrix/client.test.ts create mode 100644 src/matrix/client.ts create mode 100644 src/matrix/client/config.ts create mode 100644 src/matrix/client/create-client.ts create mode 100644 src/matrix/client/logging.ts create mode 100644 src/matrix/client/runtime.ts create mode 100644 src/matrix/client/shared.ts create mode 100644 src/matrix/client/storage.ts create mode 100644 src/matrix/client/types.ts create mode 100644 src/matrix/credentials.ts create mode 100644 src/matrix/deps.ts create mode 100644 src/matrix/format.test.ts create mode 100644 src/matrix/format.ts create mode 100644 src/matrix/index.ts create mode 100644 src/matrix/monitor/allowlist.ts create mode 100644 src/matrix/monitor/auto-join.ts create mode 100644 src/matrix/monitor/direct.ts create mode 100644 src/matrix/monitor/events.ts create mode 100644 src/matrix/monitor/handler.ts create mode 100644 src/matrix/monitor/index.ts create mode 100644 src/matrix/monitor/location.ts create mode 100644 src/matrix/monitor/media.test.ts create mode 100644 src/matrix/monitor/media.ts create mode 100644 src/matrix/monitor/mentions.ts create mode 100644 src/matrix/monitor/replies.ts create mode 100644 src/matrix/monitor/room-info.ts create mode 100644 src/matrix/monitor/rooms.ts create mode 100644 src/matrix/monitor/threads.ts create mode 100644 src/matrix/monitor/types.ts create mode 100644 src/matrix/poll-types.test.ts create mode 100644 src/matrix/poll-types.ts create mode 100644 src/matrix/probe.ts create mode 100644 src/matrix/send.test.ts create mode 100644 src/matrix/send.ts create mode 100644 src/matrix/send/client.ts create mode 100644 src/matrix/send/formatting.ts create mode 100644 src/matrix/send/media.ts create mode 100644 src/matrix/send/targets.test.ts create mode 100644 src/matrix/send/targets.ts create mode 100644 src/matrix/send/types.ts create mode 100644 src/onboarding.ts create mode 100644 src/outbound.ts create mode 100644 src/resolve-targets.ts create mode 100644 src/runtime.ts create mode 100644 src/tool-actions.ts create mode 100644 src/types.ts create mode 100644 tsconfig.check.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3c4593898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7d7e0e835 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +## 2026.1.29 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.23 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.22 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.21 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.20 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17-1 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.16 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.15 + +### Changes +- Version alignment with core OpenClaw release numbers. + +## 2026.1.14 + +### Features +- Version alignment with core OpenClaw release numbers. +- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name). +- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support. +- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts. +- Threads: replyToMode controls and thread replies (off/inbound/always). +- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes. +- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info. +- Auto-join invites with allowlist support. +- Status + probe reporting for health checks. diff --git a/index.ts b/index.ts new file mode 100644 index 000000000..9a1e24a5b --- /dev/null +++ b/index.ts @@ -0,0 +1,18 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; + +import { matrixPlugin } from "./src/channel.js"; +import { setMatrixRuntime } from "./src/runtime.js"; + +const plugin = { + id: "matrix", + name: "Matrix", + description: "Matrix channel plugin (matrix-js-sdk)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setMatrixRuntime(api.runtime); + api.registerChannel({ plugin: matrixPlugin }); + }, +}; + +export default plugin; diff --git a/openclaw.plugin.json b/openclaw.plugin.json new file mode 100644 index 000000000..30ce39257 --- /dev/null +++ b/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "matrix", + "channels": [ + "matrix" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..1eab99cd5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2194 @@ +{ + "name": "@openclaw/matrix", + "version": "2026.1.29", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/matrix", + "version": "2026.1.29", + "dependencies": { + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "@vector-im/matrix-bot-sdk": "0.8.0-element.3", + "markdown-it": "14.1.0", + "music-metadata": "^11.10.6", + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^5.9.3" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@matrix-org/matrix-sdk-crypto-nodejs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.4.0.tgz", + "integrity": "sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==", + "hasInstallScript": true, + "dependencies": { + "https-proxy-agent": "^7.0.5", + "node-downloader-helper": "^2.1.9" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/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==" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "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==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "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==", + "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.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "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==" + }, + "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==" + }, + "node_modules/@types/node": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==" + }, + "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==" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "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==", + "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==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" + }, + "node_modules/@vector-im/matrix-bot-sdk": { + "version": "0.8.0-element.3", + "resolved": "https://registry.npmjs.org/@vector-im/matrix-bot-sdk/-/matrix-bot-sdk-0.8.0-element.3.tgz", + "integrity": "sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==", + "dependencies": { + "@matrix-org/matrix-sdk-crypto-nodejs": "0.4.0", + "@types/express": "^4.17.21", + "@types/request": "^2.48.13", + "another-json": "^0.2.0", + "async-lock": "^1.4.0", + "chalk": "4", + "express": "^4.21.2", + "glob-to-regexp": "^0.4.1", + "hash.js": "^1.1.7", + "html-to-text": "^9.0.5", + "htmlencode": "^0.0.4", + "lowdb": "1", + "lru-cache": "^10.0.1", + "mkdirp": "^3.0.1", + "morgan": "^1.10.0", + "postgres": "^3.4.1", + "request": "^2.88.2", + "request-promise": "^4.2.6", + "sanitize-html": "^2.11.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/another-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", + "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "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==" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "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.14.0", + "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==", + "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==", + "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==", + "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/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "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==", + "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==" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.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==", + "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==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "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==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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==", + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "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.14.0", + "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/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/file-type": { + "version": "21.3.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", + "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "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/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlencode": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/htmlencode/-/htmlencode-0.0.4.tgz", + "integrity": "sha512-0uDvNVpzj/E2TfvLLyyXhKBRvF1y84aZsyRxRXFsQobnHaL4pcaXk+Y9cnFlvnxrBLeXDNq/VJBD+ngdBgQG1w==" + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "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==", + "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/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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==" + }, + "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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "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==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "node_modules/lowdb": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", + "integrity": "sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==", + "dependencies": { + "graceful-fs": "^4.1.3", + "is-promise": "^2.1.0", + "lodash": "4", + "pify": "^3.0.0", + "steno": "^0.4.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/music-metadata": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.1.tgz", + "integrity": "sha512-8FT+lSLznASDhn5KNJtQE6ZH95VqhxtKWNPrvdfhlqgbdZZEEAXehx+xpUvas4VuEZAu49BhQgLa3NlmPeRaww==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "file-type": "^21.3.0", + "media-typer": "^1.1.0", + "strtok3": "^10.3.4", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/music-metadata/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/music-metadata/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==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-downloader-helper": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.10.tgz", + "integrity": "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==", + "bin": { + "ndh": "bin/ndh" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "engines": { + "node": "*" + } + }, + "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==", + "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==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", + "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "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==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "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==", + "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==", + "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/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", + "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", + "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "dependencies": { + "bluebird": "^3.5.0", + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request-promise-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", + "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "dependencies": { + "lodash": "^4.17.19" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "engines": { + "node": ">=0.6" + } + }, + "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" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sanitize-html": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "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==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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==", + "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==", + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/steno": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/steno/-/steno-0.4.4.tgz", + "integrity": "sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==", + "dependencies": { + "graceful-fs": "^4.1.3" + } + }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=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==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "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==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/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==", + "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, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..d6ee687dd --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@openclaw/matrix", + "version": "2026.1.29", + "type": "module", + "description": "OpenClaw Matrix channel plugin", + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "matrix", + "label": "Matrix", + "selectionLabel": "Matrix (plugin)", + "docsPath": "/channels/matrix", + "docsLabel": "matrix", + "blurb": "open protocol; install the plugin to enable.", + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/matrix", + "localPath": "extensions/matrix", + "defaultChoice": "npm" + } + }, + "dependencies": { + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "@vector-im/matrix-bot-sdk": "0.8.0-element.3", + "markdown-it": "14.1.0", + "music-metadata": "^11.10.6", + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/src/actions.ts b/src/actions.ts new file mode 100644 index 000000000..113680a3b --- /dev/null +++ b/src/actions.ts @@ -0,0 +1,185 @@ +import { + createActionGate, + readNumberParam, + readStringParam, + type ChannelMessageActionAdapter, + type ChannelMessageActionContext, + type ChannelMessageActionName, + type ChannelToolSend, +} from "openclaw/plugin-sdk"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { handleMatrixAction } from "./tool-actions.js"; +import type { CoreConfig } from "./types.js"; + +export const matrixMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); + if (!account.enabled || !account.configured) return []; + const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions); + const actions = new Set(["send", "poll"]); + if (gate("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (gate("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (gate("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (gate("memberInfo")) actions.add("member-info"); + if (gate("channelInfo")) actions.add("channel-info"); + return Array.from(actions); + }, + supportsAction: ({ action }) => action !== "poll", + extractToolSend: ({ args }): ChannelToolSend | null => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") return null; + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) return null; + return { to }; + }, + handleAction: async (ctx: ChannelMessageActionContext) => { + const { action, params, cfg } = ctx; + const resolveRoomId = () => + readStringParam(params, "roomId") ?? + readStringParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + return await handleMatrixAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToId: replyTo ?? undefined, + threadId: threadId ?? undefined, + }, + cfg, + ); + } + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { required: true }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = typeof params.remove === "boolean" ? params.remove : undefined; + return await handleMatrixAction( + { + action: "react", + roomId: resolveRoomId(), + messageId, + emoji, + remove, + }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleMatrixAction( + { + action: "reactions", + roomId: resolveRoomId(), + messageId, + limit, + }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleMatrixAction( + { + action: "readMessages", + roomId: resolveRoomId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + }, + cfg, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleMatrixAction( + { + action: "editMessage", + roomId: resolveRoomId(), + messageId, + content, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleMatrixAction( + { + action: "deleteMessage", + roomId: resolveRoomId(), + messageId, + }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(params, "messageId", { required: true }); + return await handleMatrixAction( + { + action: + action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + roomId: resolveRoomId(), + messageId, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(params, "userId", { required: true }); + return await handleMatrixAction( + { + action: "memberInfo", + userId, + roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"), + }, + cfg, + ); + } + + if (action === "channel-info") { + return await handleMatrixAction( + { + action: "channelInfo", + roomId: resolveRoomId(), + }, + cfg, + ); + } + + throw new Error(`Action ${action} is not supported for provider matrix.`); + }, +}; diff --git a/src/channel.directory.test.ts b/src/channel.directory.test.ts new file mode 100644 index 000000000..86b547833 --- /dev/null +++ b/src/channel.directory.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; + +import { matrixPlugin } from "./channel.js"; +import { setMatrixRuntime } from "./runtime.js"; + +describe("matrix directory", () => { + beforeEach(() => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env, homeDir) => homeDir(), + }, + } as PluginRuntime); + }); + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + matrix: { + dm: { allowFrom: ["matrix:@alice:example.org", "bob"] }, + groupAllowFrom: ["@dana:example.org"], + groups: { + "!room1:example.org": { users: ["@carol:example.org"] }, + "#alias:example.org": { users: [] }, + }, + }, + }, + } as unknown as CoreConfig; + + expect(matrixPlugin.directory).toBeTruthy(); + expect(matrixPlugin.directory?.listPeers).toBeTruthy(); + expect(matrixPlugin.directory?.listGroups).toBeTruthy(); + + await expect( + matrixPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "user:@alice:example.org" }, + { kind: "user", id: "bob", name: "incomplete id; expected @user:server" }, + { kind: "user", id: "user:@carol:example.org" }, + { kind: "user", id: "user:@dana:example.org" }, + ]), + ); + + await expect( + matrixPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "room:!room1:example.org" }, + { kind: "group", id: "#alias:example.org" }, + ]), + ); + }); +}); diff --git a/src/channel.ts b/src/channel.ts new file mode 100644 index 000000000..9ffc53696 --- /dev/null +++ b/src/channel.ts @@ -0,0 +1,417 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; + +import { matrixMessageActions } from "./actions.js"; +import { MatrixConfigSchema } from "./config-schema.js"; +import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js"; +import type { CoreConfig } from "./types.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + type ResolvedMatrixAccount, +} from "./matrix/accounts.js"; +import { resolveMatrixAuth } from "./matrix/client.js"; +import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js"; +import { probeMatrix } from "./matrix/probe.js"; +import { sendMessageMatrix } from "./matrix/send.js"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { matrixOutbound } from "./outbound.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; +import { + listMatrixDirectoryGroupsLive, + listMatrixDirectoryPeersLive, +} from "./directory-live.js"; + +const meta = { + id: "matrix", + label: "Matrix", + selectionLabel: "Matrix (plugin)", + docsPath: "/channels/matrix", + docsLabel: "matrix", + blurb: "open protocol; configure a homeserver + access token.", + order: 70, + quickstartAllowFrom: true, +}; + +function normalizeMatrixMessagingTarget(raw: string): string | undefined { + let normalized = raw.trim(); + if (!normalized) return undefined; + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("matrix:")) { + normalized = normalized.slice("matrix:".length).trim(); + } + const stripped = normalized.replace(/^(room|channel|user):/i, "").trim(); + return stripped || undefined; +} + +function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + +export const matrixPlugin: ChannelPlugin = { + id: "matrix", + meta, + onboarding: matrixOnboardingAdapter, + pairing: { + idLabel: "matrixUserId", + normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), + notifyApproval: async ({ id }) => { + await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + }, + reload: { configPrefixes: ["channels.matrix"] }, + configSchema: buildChannelConfigSchema(MatrixConfigSchema), + config: { + listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + clearBaseFields: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "initialSyncLimit", + ], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.homeserver, + }), + resolveAllowFrom: ({ cfg }) => + ((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom), + }, + security: { + resolveDmPolicy: ({ account }) => ({ + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + policyPath: "channels.matrix.dm.policy", + allowFromPath: "channels.matrix.dm.allowFrom", + approveHint: formatPairingApproveHint("matrix"), + normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(), + }), + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; + const groupPolicy = + account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + "- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.", + ]; + }, + }, + groups: { + resolveRequireMention: resolveMatrixGroupRequireMention, + resolveToolPolicy: resolveMatrixGroupToolPolicy, + }, + threading: { + resolveReplyToMode: ({ cfg }) => + (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off", + buildToolContext: ({ context, hasRepliedRef }) => { + const currentTarget = context.To; + return { + currentChannelId: currentTarget?.trim() || undefined, + currentThreadTs: + context.MessageThreadId != null + ? String(context.MessageThreadId) + : context.ReplyToId, + hasRepliedRef, + }; + }, + }, + messaging: { + normalizeTarget: normalizeMatrixMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(matrix:)?[!#@]/i.test(trimmed)) return true; + return trimmed.includes(":"); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const entry of account.config.dm?.allowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") continue; + ids.add(raw.replace(/^matrix:/i, "")); + } + + for (const entry of account.config.groupAllowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") continue; + ids.add(raw.replace(/^matrix:/i, "")); + } + + const groups = account.config.groups ?? account.config.rooms ?? {}; + for (const room of Object.values(groups)) { + for (const entry of room.users ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") continue; + ids.add(raw.replace(/^matrix:/i, "")); + } + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const lowered = raw.toLowerCase(); + const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; + if (cleaned.startsWith("@")) return `user:${cleaned}`; + return cleaned; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => { + const raw = id.startsWith("user:") ? id.slice("user:".length) : id; + const incomplete = !raw.startsWith("@") || !raw.includes(":"); + return { + kind: "user", + id, + ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), + }; + }); + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() || ""; + const groups = account.config.groups ?? account.config.rooms ?? {}; + const ids = Object.keys(groups) + .map((raw) => raw.trim()) + .filter((raw) => Boolean(raw) && raw !== "*") + .map((raw) => raw.replace(/^matrix:/i, "")) + .map((raw) => { + const lowered = raw.toLowerCase(); + if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw; + if (raw.startsWith("!")) return `room:${raw}`; + return raw; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return ids; + }, + listPeersLive: async ({ cfg, query, limit }) => + listMatrixDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listMatrixDirectoryGroupsLive({ cfg, query, limit }), + }, + resolver: { + resolveTargets: async ({ cfg, inputs, kind, runtime }) => + resolveMatrixTargets({ cfg, inputs, kind, runtime }), + }, + actions: matrixMessageActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) return null; + if (!input.homeserver?.trim()) return "Matrix requires --homeserver"; + const accessToken = input.accessToken?.trim(); + const password = input.password?.trim(); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) return "Matrix requires --user-id when using --password"; + if (!password) return "Matrix requires --password when using --user-id"; + } + return null; + }, + applyAccountConfig: ({ cfg, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId: DEFAULT_ACCOUNT_ID, + name: input.name, + }); + if (input.useEnv) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + matrix: { + ...namedConfig.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(namedConfig as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: input.password?.trim(), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, + }, + outbound: matrixOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + channel: "matrix", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs, cfg }) => { + try { + const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig }); + return await probeMatrix({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + userId: auth.userId, + timeoutMs, + }); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs: 0, + }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.homeserver, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastProbeAt: runtime?.lastProbeAt ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.homeserver, + }); + ctx.log?.info( + `[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`, + ); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorMatrixProvider } = await import("./matrix/index.js"); + return monitorMatrixProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + initialSyncLimit: account.config.initialSyncLimit, + replyToMode: account.config.replyToMode, + accountId: account.accountId, + }); + }, + }, +}; diff --git a/src/config-schema.ts b/src/config-schema.ts new file mode 100644 index 000000000..5d08fc73b --- /dev/null +++ b/src/config-schema.ts @@ -0,0 +1,62 @@ +import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const matrixActionSchema = z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + }) + .optional(); + +const matrixDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + }) + .optional(); + +const matrixRoomSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + autoReply: z.boolean().optional(), + users: z.array(allowFromEntry).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .optional(); + +export const MatrixConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + homeserver: z.string().optional(), + userId: z.string().optional(), + accessToken: z.string().optional(), + password: z.string().optional(), + deviceName: z.string().optional(), + initialSyncLimit: z.number().optional(), + encryption: z.boolean().optional(), + allowlistOnly: z.boolean().optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + replyToMode: z.enum(["off", "first", "all"]).optional(), + threadReplies: z.enum(["off", "inbound", "always"]).optional(), + textChunkLimit: z.number().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + mediaMaxMb: z.number().optional(), + autoJoin: z.enum(["always", "allowlist", "off"]).optional(), + autoJoinAllowlist: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + dm: matrixDmSchema, + groups: z.object({}).catchall(matrixRoomSchema).optional(), + rooms: z.object({}).catchall(matrixRoomSchema).optional(), + actions: matrixActionSchema, +}); diff --git a/src/directory-live.ts b/src/directory-live.ts new file mode 100644 index 000000000..67fc5e563 --- /dev/null +++ b/src/directory-live.ts @@ -0,0 +1,175 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; + +import { resolveMatrixAuth } from "./matrix/client.js"; + +type MatrixUserResult = { + user_id?: string; + display_name?: string; +}; + +type MatrixUserDirectoryResponse = { + results?: MatrixUserResult[]; +}; + +type MatrixJoinedRoomsResponse = { + joined_rooms?: string[]; +}; + +type MatrixRoomNameState = { + name?: string; +}; + +type MatrixAliasLookup = { + room_id?: string; +}; + +async function fetchMatrixJson(params: { + homeserver: string; + path: string; + accessToken: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise { + const res = await fetch(`${params.homeserver}${params.path}`, { + method: params.method ?? "GET", + headers: { + Authorization: `Bearer ${params.accessToken}`, + "Content-Type": "application/json", + }, + body: params.body ? JSON.stringify(params.body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim().toLowerCase() ?? ""; +} + +export async function listMatrixDirectoryPeersLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) return []; + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const res = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/user_directory/search", + method: "POST", + body: { + search_term: query, + limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20, + }, + }); + const results = res.results ?? []; + return results + .map((entry) => { + const userId = entry.user_id?.trim(); + if (!userId) return null; + return { + kind: "user", + id: userId, + name: entry.display_name?.trim() || undefined, + handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined, + raw: entry, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; +} + +async function resolveMatrixRoomAlias( + homeserver: string, + accessToken: string, + alias: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + }); + return res.room_id?.trim() || null; + } catch { + return null; + } +} + +async function fetchMatrixRoomName( + homeserver: string, + accessToken: string, + roomId: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, + }); + return res.name?.trim() || null; + } catch { + return null; + } +} + +export async function listMatrixDirectoryGroupsLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) return []; + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + + if (query.startsWith("#")) { + const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query); + if (!roomId) return []; + return [ + { + kind: "group", + id: roomId, + name: query, + handle: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + if (query.startsWith("!")) { + return [ + { + kind: "group", + id: query, + name: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + const joined = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/joined_rooms", + }); + const rooms = joined.joined_rooms ?? []; + const results: ChannelDirectoryEntry[] = []; + + for (const roomId of rooms) { + const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId); + if (!name) continue; + if (!name.toLowerCase().includes(query)) continue; + results.push({ + kind: "group", + id: roomId, + name, + handle: `#${name}`, + }); + if (results.length >= limit) break; + } + + return results; +} diff --git a/src/group-mentions.ts b/src/group-mentions.ts new file mode 100644 index 000000000..05d63ce2d --- /dev/null +++ b/src/group-mentions.ts @@ -0,0 +1,61 @@ +import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; + +import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; +import type { CoreConfig } from "./types.js"; + +export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + const lower = roomId.toLowerCase(); + if (lower.startsWith("matrix:")) { + roomId = roomId.slice("matrix:".length).trim(); + } + if (roomId.toLowerCase().startsWith("channel:")) { + roomId = roomId.slice("channel:".length).trim(); + } + if (roomId.toLowerCase().startsWith("room:")) { + roomId = roomId.slice("room:".length).trim(); + } + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const resolved = resolveMatrixRoomConfig({ + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; + if (resolved) { + if (resolved.autoReply === true) return false; + if (resolved.autoReply === false) return true; + if (typeof resolved.requireMention === "boolean") return resolved.requireMention; + } + return true; +} + +export function resolveMatrixGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + const lower = roomId.toLowerCase(); + if (lower.startsWith("matrix:")) { + roomId = roomId.slice("matrix:".length).trim(); + } + if (roomId.toLowerCase().startsWith("channel:")) { + roomId = roomId.slice("channel:".length).trim(); + } + if (roomId.toLowerCase().startsWith("room:")) { + roomId = roomId.slice("room:".length).trim(); + } + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const resolved = resolveMatrixRoomConfig({ + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; + return resolved?.tools; +} diff --git a/src/matrix/accounts.test.ts b/src/matrix/accounts.test.ts new file mode 100644 index 000000000..2f1cfdb10 --- /dev/null +++ b/src/matrix/accounts.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CoreConfig } from "../types.js"; +import { resolveMatrixAccount } from "./accounts.js"; + +vi.mock("./credentials.js", () => ({ + loadMatrixCredentials: () => null, + credentialsMatchConfig: () => false, +})); + +const envKeys = [ + "MATRIX_HOMESERVER", + "MATRIX_USER_ID", + "MATRIX_ACCESS_TOKEN", + "MATRIX_PASSWORD", + "MATRIX_DEVICE_NAME", +]; + +describe("resolveMatrixAccount", () => { + let prevEnv: Record = {}; + + beforeEach(() => { + prevEnv = {}; + for (const key of envKeys) { + prevEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envKeys) { + const value = prevEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("treats access-token-only config as configured", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-access", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(true); + }); + + it("requires userId + password when no access token is set", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(false); + }); + + it("marks password auth as configured when userId is present", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(true); + }); +}); diff --git a/src/matrix/accounts.ts b/src/matrix/accounts.ts new file mode 100644 index 000000000..f761732af --- /dev/null +++ b/src/matrix/accounts.ts @@ -0,0 +1,164 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js"; +import { resolveMatrixConfig } from "./client.js"; +import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; + +export type ResolvedMatrixAccount = { + accountId: string; + enabled: boolean; + name?: string; + configured: boolean; + homeserver?: string; + userId?: string; + accessToken?: string; + config: MatrixAccountConfig; +}; + +/** + * List account IDs explicitly configured in channels.matrix.accounts + */ +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) continue; + ids.add(normalizeAccountId(key)); + } + return [...ids]; +} + +/** + * List account IDs referenced in bindings for matrix channel + */ +function listBoundAccountIds(cfg: CoreConfig): string[] { + const bindings = cfg.bindings; + if (!Array.isArray(bindings)) return []; + const ids = new Set(); + for (const binding of bindings) { + if (binding.match?.channel === "matrix" && binding.match?.accountId) { + ids.add(normalizeAccountId(binding.match.accountId)); + } + } + return [...ids]; +} + +/** + * List all Matrix account IDs (configured + bound) + */ +export function listMatrixAccountIds(cfg: CoreConfig): string[] { + const ids = Array.from( + new Set([ + DEFAULT_ACCOUNT_ID, + ...listConfiguredAccountIds(cfg), + ...listBoundAccountIds(cfg), + ]), + ); + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { + const ids = listMatrixAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * Get account-specific config from channels.matrix.accounts[accountId] + */ +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): MatrixAccountConfig | undefined { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as MatrixAccountConfig | undefined; + if (direct) return direct; + + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find( + (key) => normalizeAccountId(key) === normalized + ); + return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined; +} + +/** + * Merge base matrix config with account-specific overrides + */ +function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig { + const base = cfg.channels?.matrix ?? {}; + // Extract base config without 'accounts' key + const { accounts: _ignored, ...baseConfig } = base as MatrixConfig; + const accountConfig = resolveAccountConfig(cfg, accountId) ?? {}; + + // Account config overrides base config + return { ...baseConfig, ...accountConfig }; +} + +export function resolveMatrixAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedMatrixAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeMatrixAccountConfig(params.cfg, accountId); + + // Check if this is a non-default account - use account-specific auth + const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID || accountId === "default"; + + // For non-default accounts, use account-specific credentials + // For default account, use base config or env + let homeserver = merged.homeserver; + let userId = merged.userId; + let accessToken = merged.accessToken; + + if (isDefaultAccount) { + // Default account can fall back to env vars + const resolved = resolveMatrixConfig(params.cfg, process.env); + homeserver = homeserver || resolved.homeserver; + userId = userId || resolved.userId; + accessToken = accessToken || resolved.accessToken; + } + + const baseEnabled = params.cfg.channels?.matrix?.enabled !== false; + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const hasHomeserver = Boolean(homeserver); + const hasAccessToken = Boolean(accessToken); + const hasPassword = Boolean(merged.password); + const hasUserId = Boolean(userId); + const hasPasswordAuth = hasUserId && hasPassword; + + // Check for stored credentials (only for default account) + const stored = isDefaultAccount ? loadMatrixCredentials(process.env) : null; + const hasStored = + stored && homeserver + ? credentialsMatchConfig(stored, { + homeserver: homeserver, + userId: userId || "", + }) + : false; + + const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored)); + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + configured, + homeserver: homeserver || undefined, + userId: userId || undefined, + accessToken: accessToken || undefined, + config: merged, + }; +} + +export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] { + return listMatrixAccountIds(cfg) + .map((accountId) => resolveMatrixAccount({ cfg, accountId })) + .filter((account) => account.enabled && account.configured); +} diff --git a/src/matrix/actions.ts b/src/matrix/actions.ts new file mode 100644 index 000000000..34d24b6dd --- /dev/null +++ b/src/matrix/actions.ts @@ -0,0 +1,15 @@ +export type { + MatrixActionClientOpts, + MatrixMessageSummary, + MatrixReactionSummary, +} from "./actions/types.js"; +export { + sendMatrixMessage, + editMatrixMessage, + deleteMatrixMessage, + readMatrixMessages, +} from "./actions/messages.js"; +export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; +export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; +export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; +export { reactMatrixMessage } from "./send.js"; diff --git a/src/matrix/actions/client.ts b/src/matrix/actions/client.ts new file mode 100644 index 000000000..efbd6d62b --- /dev/null +++ b/src/matrix/actions/client.ts @@ -0,0 +1,53 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../types.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export async function resolveActionClient( + opts: MatrixActionClientOpts = {}, +): Promise { + ensureNodeRuntime(); + if (opts.client) return { client: opts.client, stopOnDone: false }; + const active = getActiveMatrixClient(); + if (active) return { client: active, stopOnDone: false }; + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off actions. + } + } + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/src/matrix/actions/messages.ts b/src/matrix/actions/messages.ts new file mode 100644 index 000000000..60f69e219 --- /dev/null +++ b/src/matrix/actions/messages.ts @@ -0,0 +1,120 @@ +import { + EventType, + MsgType, + RelationType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { summarizeMatrixRawEvent } from "./summary.js"; +import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; + +export async function sendMatrixMessage( + to: string, + content: string, + opts: MatrixActionClientOpts & { + mediaUrl?: string; + replyToId?: string; + threadId?: string; + } = {}, +) { + return await sendMessageMatrix(to, content, { + mediaUrl: opts.mediaUrl, + replyToId: opts.replyToId, + threadId: opts.threadId, + client: opts.client, + timeoutMs: opts.timeoutMs, + }); +} + +export async function editMatrixMessage( + roomId: string, + messageId: string, + content: string, + opts: MatrixActionClientOpts = {}, +) { + const trimmed = content.trim(); + if (!trimmed) throw new Error("Matrix edit requires content"); + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const newContent = { + msgtype: MsgType.Text, + body: trimmed, + } satisfies RoomMessageEventContent; + const payload: RoomMessageEventContent = { + msgtype: MsgType.Text, + body: `* ${trimmed}`, + "m.new_content": newContent, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: messageId, + }, + }; + const eventId = await client.sendMessage(resolvedRoom, payload); + return { eventId: eventId ?? null }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function deleteMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { reason?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + await client.redactEvent(resolvedRoom, messageId, opts.reason); + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function readMatrixMessages( + roomId: string, + opts: MatrixActionClientOpts & { + limit?: number; + before?: string; + after?: string; + } = {}, +): Promise<{ + messages: MatrixMessageSummary[]; + nextBatch?: string | null; + prevBatch?: string | null; +}> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 20; + const token = opts.before?.trim() || opts.after?.trim() || undefined; + const dir = opts.after ? "f" : "b"; + // @vector-im/matrix-bot-sdk uses doRequest for room messages + const res = await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, + { + dir, + limit, + from: token, + }, + ) as { chunk: MatrixRawEvent[]; start?: string; end?: string }; + const messages = res.chunk + .filter((event) => event.type === EventType.RoomMessage) + .filter((event) => !event.unsigned?.redacted_because) + .map(summarizeMatrixRawEvent); + return { + messages, + nextBatch: res.end ?? null, + prevBatch: res.start ?? null, + }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/src/matrix/actions/pins.ts b/src/matrix/actions/pins.ts new file mode 100644 index 000000000..a29dfba45 --- /dev/null +++ b/src/matrix/actions/pins.ts @@ -0,0 +1,70 @@ +import { + EventType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type RoomPinnedEventsEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { fetchEventSummary, readPinnedEvents } from "./summary.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function pinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const current = await readPinnedEvents(client, resolvedRoom); + const next = current.includes(messageId) ? current : [...current, messageId]; + const payload: RoomPinnedEventsEventContent = { pinned: next }; + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); + return { pinned: next }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function unpinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const current = await readPinnedEvents(client, resolvedRoom); + const next = current.filter((id) => id !== messageId); + const payload: RoomPinnedEventsEventContent = { pinned: next }; + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); + return { pinned: next }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function listMatrixPins( + roomId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const pinned = await readPinnedEvents(client, resolvedRoom); + const events = ( + await Promise.all( + pinned.map(async (eventId) => { + try { + return await fetchEventSummary(client, resolvedRoom, eventId); + } catch { + return null; + } + }), + ) + ).filter((event): event is MatrixMessageSummary => Boolean(event)); + return { pinned, events }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/src/matrix/actions/reactions.ts b/src/matrix/actions/reactions.ts new file mode 100644 index 000000000..044ef46c5 --- /dev/null +++ b/src/matrix/actions/reactions.ts @@ -0,0 +1,84 @@ +import { + EventType, + RelationType, + type MatrixActionClientOpts, + type MatrixRawEvent, + type MatrixReactionSummary, + type ReactionEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function listMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { limit?: number } = {}, +): Promise { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 100; + // @vector-im/matrix-bot-sdk uses doRequest for relations + const res = await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit }, + ) as { chunk: MatrixRawEvent[] }; + const summaries = new Map(); + for (const event of res.chunk) { + const content = event.content as ReactionEventContent; + const key = content["m.relates_to"]?.key; + if (!key) continue; + const sender = event.sender ?? ""; + const entry: MatrixReactionSummary = summaries.get(key) ?? { + key, + count: 0, + users: [], + }; + entry.count += 1; + if (sender && !entry.users.includes(sender)) { + entry.users.push(sender); + } + summaries.set(key, entry); + } + return Array.from(summaries.values()); + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function removeMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { emoji?: string } = {}, +): Promise<{ removed: number }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const res = await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit: 200 }, + ) as { chunk: MatrixRawEvent[] }; + const userId = await client.getUserId(); + if (!userId) return { removed: 0 }; + const targetEmoji = opts.emoji?.trim(); + const toRemove = res.chunk + .filter((event) => event.sender === userId) + .filter((event) => { + if (!targetEmoji) return true; + const content = event.content as ReactionEventContent; + return content["m.relates_to"]?.key === targetEmoji; + }) + .map((event) => event.event_id) + .filter((id): id is string => Boolean(id)); + if (toRemove.length === 0) return { removed: 0 }; + await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); + return { removed: toRemove.length }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/src/matrix/actions/room.ts b/src/matrix/actions/room.ts new file mode 100644 index 000000000..68cf9b0a0 --- /dev/null +++ b/src/matrix/actions/room.ts @@ -0,0 +1,88 @@ +import { EventType, type MatrixActionClientOpts } from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function getMatrixMemberInfo( + userId: string, + opts: MatrixActionClientOpts & { roomId?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; + // @vector-im/matrix-bot-sdk uses getUserProfile + const profile = await client.getUserProfile(userId); + // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk + // We'd need to fetch room state separately if needed + return { + userId, + profile: { + displayName: profile?.displayname ?? null, + avatarUrl: profile?.avatar_url ?? null, + }, + membership: null, // Would need separate room state query + powerLevel: null, // Would need separate power levels state query + displayName: profile?.displayname ?? null, + roomId: roomId ?? null, + }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function getMatrixRoomInfo( + roomId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + // @vector-im/matrix-bot-sdk uses getRoomState for state events + let name: string | null = null; + let topic: string | null = null; + let canonicalAlias: string | null = null; + let memberCount: number | null = null; + + try { + const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", ""); + name = nameState?.name ?? null; + } catch { + // ignore + } + + try { + const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, ""); + topic = topicState?.topic ?? null; + } catch { + // ignore + } + + try { + const aliasState = await client.getRoomStateEvent( + resolvedRoom, + "m.room.canonical_alias", + "", + ); + canonicalAlias = aliasState?.alias ?? null; + } catch { + // ignore + } + + try { + const members = await client.getJoinedRoomMembers(resolvedRoom); + memberCount = members.length; + } catch { + // ignore + } + + return { + roomId: resolvedRoom, + name, + topic, + canonicalAlias, + altAliases: [], // Would need separate query + memberCount, + }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/src/matrix/actions/summary.ts b/src/matrix/actions/summary.ts new file mode 100644 index 000000000..2fa2d27b3 --- /dev/null +++ b/src/matrix/actions/summary.ts @@ -0,0 +1,77 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import { + EventType, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, + type RoomPinnedEventsEventContent, +} from "./types.js"; + +export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary { + const content = event.content as RoomMessageEventContent; + const relates = content["m.relates_to"]; + let relType: string | undefined; + let eventId: string | undefined; + if (relates) { + if ("rel_type" in relates) { + relType = relates.rel_type; + eventId = relates.event_id; + } else if ("m.in_reply_to" in relates) { + eventId = relates["m.in_reply_to"]?.event_id; + } + } + const relatesTo = + relType || eventId + ? { + relType, + eventId, + } + : undefined; + return { + eventId: event.event_id, + sender: event.sender, + body: content.body, + msgtype: content.msgtype, + timestamp: event.origin_server_ts, + relatesTo, + }; +} + +export async function readPinnedEvents( + client: MatrixClient, + roomId: string, +): Promise { + try { + const content = (await client.getRoomStateEvent( + roomId, + EventType.RoomPinnedEvents, + "", + )) as RoomPinnedEventsEventContent; + const pinned = content.pinned; + return pinned.filter((id) => id.trim().length > 0); + } catch (err: unknown) { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + const httpStatus = errObj.statusCode; + const errcode = errObj.body?.errcode; + if (httpStatus === 404 || errcode === "M_NOT_FOUND") { + return []; + } + throw err; + } +} + +export async function fetchEventSummary( + client: MatrixClient, + roomId: string, + eventId: string, +): Promise { + try { + const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent; + if (raw.unsigned?.redacted_because) return null; + return summarizeMatrixRawEvent(raw); + } catch { + // Event not found, redacted, or inaccessible - return null + return null; + } +} diff --git a/src/matrix/actions/types.ts b/src/matrix/actions/types.ts new file mode 100644 index 000000000..75fddbd9c --- /dev/null +++ b/src/matrix/actions/types.ts @@ -0,0 +1,84 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +export const MsgType = { + Text: "m.text", +} as const; + +export const RelationType = { + Replace: "m.replace", + Annotation: "m.annotation", +} as const; + +export const EventType = { + RoomMessage: "m.room.message", + RoomPinnedEvents: "m.room.pinned_events", + RoomTopic: "m.room.topic", + Reaction: "m.reaction", +} as const; + +export type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.new_content"?: RoomMessageEventContent; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +}; + +export type RoomPinnedEventsEventContent = { + pinned: string[]; +}; + +export type RoomTopicEventContent = { + topic?: string; +}; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + redacted_because?: unknown; + }; +}; + +export type MatrixActionClientOpts = { + client?: MatrixClient; + timeoutMs?: number; +}; + +export type MatrixMessageSummary = { + eventId?: string; + sender?: string; + body?: string; + msgtype?: string; + timestamp?: number; + relatesTo?: { + relType?: string; + eventId?: string; + key?: string; + }; +}; + +export type MatrixReactionSummary = { + key: string; + count: number; + users: string[]; +}; + +export type MatrixActionClient = { + client: MatrixClient; + stopOnDone: boolean; +}; diff --git a/src/matrix/active-client.ts b/src/matrix/active-client.ts new file mode 100644 index 000000000..5ff540926 --- /dev/null +++ b/src/matrix/active-client.ts @@ -0,0 +1,11 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +let activeClient: MatrixClient | null = null; + +export function setActiveMatrixClient(client: MatrixClient | null): void { + activeClient = client; +} + +export function getActiveMatrixClient(): MatrixClient | null { + return activeClient; +} diff --git a/src/matrix/client.test.ts b/src/matrix/client.test.ts new file mode 100644 index 000000000..f806f9c81 --- /dev/null +++ b/src/matrix/client.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import type { CoreConfig } from "../types.js"; +import { resolveMatrixConfig } from "./client.js"; + +describe("resolveMatrixConfig", () => { + it("prefers config over env", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://cfg.example.org", + userId: "@cfg:example.org", + accessToken: "cfg-token", + password: "cfg-pass", + deviceName: "CfgDevice", + initialSyncLimit: 5, + }, + }, + } as CoreConfig; + const env = { + MATRIX_HOMESERVER: "https://env.example.org", + MATRIX_USER_ID: "@env:example.org", + MATRIX_ACCESS_TOKEN: "env-token", + MATRIX_PASSWORD: "env-pass", + MATRIX_DEVICE_NAME: "EnvDevice", + } as NodeJS.ProcessEnv; + const resolved = resolveMatrixConfig(cfg, env); + expect(resolved).toEqual({ + homeserver: "https://cfg.example.org", + userId: "@cfg:example.org", + accessToken: "cfg-token", + password: "cfg-pass", + deviceName: "CfgDevice", + initialSyncLimit: 5, + encryption: false, + }); + }); + + it("uses env when config is missing", () => { + const cfg = {} as CoreConfig; + const env = { + MATRIX_HOMESERVER: "https://env.example.org", + MATRIX_USER_ID: "@env:example.org", + MATRIX_ACCESS_TOKEN: "env-token", + MATRIX_PASSWORD: "env-pass", + MATRIX_DEVICE_NAME: "EnvDevice", + } as NodeJS.ProcessEnv; + const resolved = resolveMatrixConfig(cfg, env); + expect(resolved.homeserver).toBe("https://env.example.org"); + expect(resolved.userId).toBe("@env:example.org"); + expect(resolved.accessToken).toBe("env-token"); + expect(resolved.password).toBe("env-pass"); + expect(resolved.deviceName).toBe("EnvDevice"); + expect(resolved.initialSyncLimit).toBeUndefined(); + expect(resolved.encryption).toBe(false); + }); +}); diff --git a/src/matrix/client.ts b/src/matrix/client.ts new file mode 100644 index 000000000..82c734d15 --- /dev/null +++ b/src/matrix/client.ts @@ -0,0 +1,9 @@ +export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; +export { isBunRuntime } from "./client/runtime.js"; +export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js"; +export { createMatrixClient } from "./client/create-client.js"; +export { + resolveSharedMatrixClient, + waitForMatrixSync, + stopSharedClient, +} from "./client/shared.js"; diff --git a/src/matrix/client/config.ts b/src/matrix/client/config.ts new file mode 100644 index 000000000..cb9cc38b3 --- /dev/null +++ b/src/matrix/client/config.ts @@ -0,0 +1,216 @@ +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; + +import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; + +function clean(value?: string): string { + return value?.trim() ?? ""; +} + +/** + * Get account-specific config from channels.matrix.accounts[accountId] + */ +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): MatrixAccountConfig | undefined { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as MatrixAccountConfig | undefined; + if (direct) return direct; + + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find( + (key) => normalizeAccountId(key) === normalized + ); + return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined; +} + +/** + * Merge base matrix config with account-specific overrides + */ +function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig { + const base = cfg.channels?.matrix ?? {}; + const { accounts: _ignored, ...baseConfig } = base as MatrixConfig; + const accountConfig = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...baseConfig, ...accountConfig }; +} + +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, + accountId?: string, +): MatrixResolvedConfig { + const normalizedAccountId = normalizeAccountId(accountId); + const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default"; + + // Get merged config for this account + const merged = mergeMatrixAccountConfig(cfg, normalizedAccountId); + + // For default account, allow env var fallbacks + const homeserver = clean(merged.homeserver) || (isDefaultAccount ? clean(env.MATRIX_HOMESERVER) : ""); + const userId = clean(merged.userId) || (isDefaultAccount ? clean(env.MATRIX_USER_ID) : ""); + const accessToken = clean(merged.accessToken) || (isDefaultAccount ? clean(env.MATRIX_ACCESS_TOKEN) : "") || undefined; + const password = clean(merged.password) || (isDefaultAccount ? clean(env.MATRIX_PASSWORD) : "") || undefined; + const deviceName = clean(merged.deviceName) || (isDefaultAccount ? clean(env.MATRIX_DEVICE_NAME) : "") || undefined; + const initialSyncLimit = + typeof merged.initialSyncLimit === "number" + ? Math.max(0, Math.floor(merged.initialSyncLimit)) + : undefined; + const encryption = merged.encryption ?? false; + + return { + homeserver, + userId, + accessToken, + password, + deviceName, + initialSyncLimit, + encryption, + }; +} + +export async function resolveMatrixAuth(params?: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; + accountId?: string; +}): Promise { + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const env = params?.env ?? process.env; + const accountId = params?.accountId; + const resolved = resolveMatrixConfig(cfg, env, accountId); + + if (!resolved.homeserver) { + throw new Error(`Matrix homeserver is required for account ${accountId ?? "default"} (matrix.homeserver)`); + } + + const normalizedAccountId = normalizeAccountId(accountId); + const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default"; + + // Only use cached credentials for default account + const { + loadMatrixCredentials, + saveMatrixCredentials, + credentialsMatchConfig, + touchMatrixCredentials, + } = await import("../credentials.js"); + + const cached = isDefaultAccount ? loadMatrixCredentials(env) : null; + const cachedCredentials = + cached && + credentialsMatchConfig(cached, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + ? cached + : null; + + // If we have an access token, we can fetch userId via whoami if not provided + if (resolved.accessToken) { + let userId = resolved.userId; + if (!userId) { + // Fetch userId from access token via whoami + ensureMatrixSdkLoggingConfigured(); + const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); + const whoami = await tempClient.getUserId(); + userId = whoami; + // Only save credentials for default account + if (isDefaultAccount) { + saveMatrixCredentials({ + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }); + } + } else if (isDefaultAccount && cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + touchMatrixCredentials(env); + } + return { + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + // Try cached credentials (only for default account) + if (isDefaultAccount && cachedCredentials) { + touchMatrixCredentials(env); + return { + homeserver: cachedCredentials.homeserver, + userId: cachedCredentials.userId, + accessToken: cachedCredentials.accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + if (!resolved.userId) { + throw new Error( + `Matrix userId is required for account ${accountId ?? "default"} when no access token is configured`, + ); + } + + if (!resolved.password) { + throw new Error( + `Matrix password is required for account ${accountId ?? "default"} when no access token is configured`, + ); + } + + // Login with password using HTTP API + const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", + }), + }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + throw new Error(`Matrix login failed for account ${accountId ?? "default"}: ${errorText}`); + } + + const login = (await loginResponse.json()) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + + const accessToken = login.access_token?.trim(); + if (!accessToken) { + throw new Error(`Matrix login did not return an access token for account ${accountId ?? "default"}`); + } + + const auth: MatrixAuth = { + homeserver: resolved.homeserver, + userId: login.user_id ?? resolved.userId, + accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + + // Only save credentials for default account + if (isDefaultAccount) { + saveMatrixCredentials({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + deviceId: login.device_id, + }); + } + + return auth; +} diff --git a/src/matrix/client/create-client.ts b/src/matrix/client/create-client.ts new file mode 100644 index 000000000..874da7e92 --- /dev/null +++ b/src/matrix/client/create-client.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; + +import { + LogService, + MatrixClient, + SimpleFsStorageProvider, + RustSdkCryptoStorageProvider, +} from "@vector-im/matrix-bot-sdk"; +import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; + +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import { + maybeMigrateLegacyStorage, + resolveMatrixStoragePaths, + writeStorageMeta, +} from "./storage.js"; + +function sanitizeUserIdList(input: unknown, label: string): string[] { + if (input == null) return []; + if (!Array.isArray(input)) { + LogService.warn( + "MatrixClientLite", + `Expected ${label} list to be an array, got ${typeof input}`, + ); + return []; + } + const filtered = input.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); + if (filtered.length !== input.length) { + LogService.warn( + "MatrixClientLite", + `Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`, + ); + } + return filtered; +} + +export async function createMatrixClient(params: { + homeserver: string; + userId: string; + accessToken: string; + encryption?: boolean; + localTimeoutMs?: number; + accountId?: string | null; +}): Promise { + ensureMatrixSdkLoggingConfigured(); + const env = process.env; + + // Create storage provider + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + accountId: params.accountId, + env, + }); + maybeMigrateLegacyStorage({ storagePaths, env }); + fs.mkdirSync(storagePaths.rootDir, { recursive: true }); + const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath); + + // Create crypto storage if encryption is enabled + let cryptoStorage: ICryptoStorageProvider | undefined; + if (params.encryption) { + fs.mkdirSync(storagePaths.cryptoPath, { recursive: true }); + + try { + const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); + cryptoStorage = new RustSdkCryptoStorageProvider( + storagePaths.cryptoPath, + StoreType.Sqlite, + ); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err); + } + } + + writeStorageMeta({ + storagePaths, + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId, + }); + + const client = new MatrixClient( + params.homeserver, + params.accessToken, + storage, + cryptoStorage, + ); + + if (client.crypto) { + const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto); + client.crypto.updateSyncData = async ( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + changedDeviceLists, + leftDeviceLists, + ) => { + const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list"); + const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list"); + try { + return await originalUpdateSyncData( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + safeChanged, + safeLeft, + ); + } catch (err) { + const message = typeof err === "string" ? err : err instanceof Error ? err.message : ""; + if (message.includes("Expect value to be String")) { + LogService.warn( + "MatrixClientLite", + "Ignoring malformed device list entries during crypto sync", + message, + ); + return; + } + throw err; + } + }; + } + + return client; +} diff --git a/src/matrix/client/logging.ts b/src/matrix/client/logging.ts new file mode 100644 index 000000000..5a7180597 --- /dev/null +++ b/src/matrix/client/logging.ts @@ -0,0 +1,35 @@ +import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk"; + +let matrixSdkLoggingConfigured = false; +const matrixSdkBaseLogger = new ConsoleLogger(); + +function shouldSuppressMatrixHttpNotFound( + module: string, + messageOrObject: unknown[], +): boolean { + if (module !== "MatrixHttpClient") return false; + return messageOrObject.some((entry) => { + if (!entry || typeof entry !== "object") return false; + return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; + }); +} + +export function ensureMatrixSdkLoggingConfigured(): void { + if (matrixSdkLoggingConfigured) return; + matrixSdkLoggingConfigured = true; + + LogService.setLogger({ + trace: (module, ...messageOrObject) => + matrixSdkBaseLogger.trace(module, ...messageOrObject), + debug: (module, ...messageOrObject) => + matrixSdkBaseLogger.debug(module, ...messageOrObject), + info: (module, ...messageOrObject) => + matrixSdkBaseLogger.info(module, ...messageOrObject), + warn: (module, ...messageOrObject) => + matrixSdkBaseLogger.warn(module, ...messageOrObject), + error: (module, ...messageOrObject) => { + if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return; + matrixSdkBaseLogger.error(module, ...messageOrObject); + }, + }); +} diff --git a/src/matrix/client/runtime.ts b/src/matrix/client/runtime.ts new file mode 100644 index 000000000..4995eaf8d --- /dev/null +++ b/src/matrix/client/runtime.ts @@ -0,0 +1,4 @@ +export function isBunRuntime(): boolean { + const versions = process.versions as { bun?: string }; + return typeof versions.bun === "string"; +} diff --git a/src/matrix/client/shared.ts b/src/matrix/client/shared.ts new file mode 100644 index 000000000..e2163328c --- /dev/null +++ b/src/matrix/client/shared.ts @@ -0,0 +1,277 @@ +import { LogService } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import type { CoreConfig } from "../types.js"; +import { createMatrixClient } from "./create-client.js"; +import { resolveMatrixAuth } from "./config.js"; +import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; +import type { MatrixAuth } from "./types.js"; + +type SharedMatrixClientState = { + client: MatrixClient; + key: string; + started: boolean; + cryptoReady: boolean; +}; + +// Multi-account support: Map of accountKey -> client state +const sharedClients = new Map(); +const sharedClientPromises = new Map>(); +const sharedClientStartPromises = new Map>(); + +// Legacy single-client references (for backwards compatibility) +let sharedClientState: SharedMatrixClientState | null = null; +let sharedClientPromise: Promise | null = null; +let sharedClientStartPromise: Promise | null = null; + +function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { + return [ + auth.homeserver, + auth.userId, + auth.accessToken, + auth.encryption ? "e2ee" : "plain", + accountId ?? DEFAULT_ACCOUNT_KEY, + ].join("|"); +} + +function getAccountKey(accountId?: string | null): string { + return accountId ?? DEFAULT_ACCOUNT_KEY; +} + +async function createSharedMatrixClient(params: { + auth: MatrixAuth; + timeoutMs?: number; + accountId?: string; +}): Promise { + const client = await createMatrixClient({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + encryption: params.auth.encryption, + localTimeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + return { + client, + key: buildSharedClientKey(params.auth, params.accountId), + started: false, + cryptoReady: false, + }; +} + +async function ensureSharedClientStarted(params: { + state: SharedMatrixClientState; + timeoutMs?: number; + initialSyncLimit?: number; + encryption?: boolean; + accountId?: string | null; +}): Promise { + if (params.state.started) return; + + const accountKey = getAccountKey(params.accountId); + const existingPromise = sharedClientStartPromises.get(accountKey); + if (existingPromise) { + await existingPromise; + return; + } + + // Legacy compatibility + if (sharedClientStartPromise && !params.accountId) { + await sharedClientStartPromise; + return; + } + + const startPromise = (async () => { + const client = params.state.client; + + // Initialize crypto if enabled + if (params.encryption && !params.state.cryptoReady) { + try { + const joinedRooms = await client.getJoinedRooms(); + if (client.crypto) { + await client.crypto.prepare(joinedRooms); + params.state.cryptoReady = true; + } + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); + } + } + + await client.start(); + params.state.started = true; + })(); + + sharedClientStartPromises.set(accountKey, startPromise); + if (!params.accountId) { + sharedClientStartPromise = startPromise; + } + + try { + await startPromise; + } finally { + sharedClientStartPromises.delete(accountKey); + if (!params.accountId) { + sharedClientStartPromise = null; + } + } +} + +export async function resolveSharedMatrixClient( + params: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + auth?: MatrixAuth; + startClient?: boolean; + accountId?: string | null; + } = {}, +): Promise { + const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId ?? undefined })); + const key = buildSharedClientKey(auth, params.accountId); + const accountKey = getAccountKey(params.accountId); + const shouldStart = params.startClient !== false; + + // Check if we already have this client in the multi-account map + const existingClient = sharedClients.get(accountKey); + if (existingClient?.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: existingClient, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + accountId: params.accountId, + }); + } + // Update legacy reference for default account + if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) { + sharedClientState = existingClient; + } + return existingClient.client; + } + + // Legacy compatibility: check old single-client state + if (!params.accountId && sharedClientState?.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: sharedClientState, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + accountId: params.accountId, + }); + } + return sharedClientState.client; + } + + // Check for pending creation promise for this account + const pendingPromise = sharedClientPromises.get(accountKey); + if (pendingPromise) { + const pending = await pendingPromise; + if (pending.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + accountId: params.accountId, + }); + } + return pending.client; + } + // Key mismatch - stop old client + pending.client.stop(); + sharedClients.delete(accountKey); + } + + // Legacy: check old single-client promise + if (!params.accountId && sharedClientPromise) { + const pending = await sharedClientPromise; + if (pending.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + accountId: params.accountId, + }); + } + return pending.client; + } + pending.client.stop(); + sharedClientState = null; + sharedClientPromise = null; + } + + // Create new client + const createPromise = createSharedMatrixClient({ + auth, + timeoutMs: params.timeoutMs, + accountId: params.accountId ?? undefined, + }); + + sharedClientPromises.set(accountKey, createPromise); + if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) { + sharedClientPromise = createPromise; + } + + try { + const created = await createPromise; + sharedClients.set(accountKey, created); + if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) { + sharedClientState = created; + } + if (shouldStart) { + await ensureSharedClientStarted({ + state: created, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + accountId: params.accountId, + }); + } + return created.client; + } finally { + sharedClientPromises.delete(accountKey); + if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) { + sharedClientPromise = null; + } + } +} + +export async function waitForMatrixSync(_params: { + client: MatrixClient; + timeoutMs?: number; + abortSignal?: AbortSignal; +}): Promise { + // @vector-im/matrix-bot-sdk handles sync internally in start() + // This is kept for API compatibility but is essentially a no-op now +} + +export function stopSharedClient(accountId?: string | null): void { + if (accountId) { + // Stop specific account + const accountKey = getAccountKey(accountId); + const client = sharedClients.get(accountKey); + if (client) { + client.client.stop(); + sharedClients.delete(accountKey); + } + // Also clear legacy reference if it matches + if (sharedClientState?.key === client?.key) { + sharedClientState = null; + } + } else { + // Stop all clients (legacy behavior + all multi-account clients) + for (const [key, client] of sharedClients) { + client.client.stop(); + sharedClients.delete(key); + } + if (sharedClientState) { + sharedClientState.client.stop(); + sharedClientState = null; + } + } +} diff --git a/src/matrix/client/storage.ts b/src/matrix/client/storage.ts new file mode 100644 index 000000000..b5ddffe88 --- /dev/null +++ b/src/matrix/client/storage.ts @@ -0,0 +1,131 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getMatrixRuntime } from "../../runtime.js"; +import type { MatrixStoragePaths } from "./types.js"; + +export const DEFAULT_ACCOUNT_KEY = "default"; +const STORAGE_META_FILENAME = "storage-meta.json"; + +function sanitizePathSegment(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return cleaned || "unknown"; +} + +function resolveHomeserverKey(homeserver: string): string { + try { + const url = new URL(homeserver); + if (url.host) return sanitizePathSegment(url.host); + } catch { + // fall through + } + return sanitizePathSegment(homeserver); +} + +function hashAccessToken(accessToken: string): string { + return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); +} + +function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { + storagePath: string; + cryptoPath: string; +} { + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return { + storagePath: path.join(stateDir, "matrix", "bot-storage.json"), + cryptoPath: path.join(stateDir, "matrix", "crypto"), + }; +} + +export function resolveMatrixStoragePaths(params: { + homeserver: string; + userId: string; + accessToken: string; + accountId?: string | null; + env?: NodeJS.ProcessEnv; +}): MatrixStoragePaths { + const env = params.env ?? process.env; + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); + const userKey = sanitizePathSegment(params.userId); + const serverKey = resolveHomeserverKey(params.homeserver); + const tokenHash = hashAccessToken(params.accessToken); + const rootDir = path.join( + stateDir, + "matrix", + "accounts", + accountKey, + `${serverKey}__${userKey}`, + tokenHash, + ); + return { + rootDir, + storagePath: path.join(rootDir, "bot-storage.json"), + cryptoPath: path.join(rootDir, "crypto"), + metaPath: path.join(rootDir, STORAGE_META_FILENAME), + accountKey, + tokenHash, + }; +} + +export function maybeMigrateLegacyStorage(params: { + storagePaths: MatrixStoragePaths; + env?: NodeJS.ProcessEnv; +}): void { + const legacy = resolveLegacyStoragePaths(params.env); + const hasLegacyStorage = fs.existsSync(legacy.storagePath); + const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); + const hasNewStorage = + fs.existsSync(params.storagePaths.storagePath) || + fs.existsSync(params.storagePaths.cryptoPath); + + if (!hasLegacyStorage && !hasLegacyCrypto) return; + if (hasNewStorage) return; + + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + if (hasLegacyStorage) { + try { + fs.renameSync(legacy.storagePath, params.storagePaths.storagePath); + } catch { + // Ignore migration failures; new store will be created. + } + } + if (hasLegacyCrypto) { + try { + fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath); + } catch { + // Ignore migration failures; new store will be created. + } + } +} + +export function writeStorageMeta(params: { + storagePaths: MatrixStoragePaths; + homeserver: string; + userId: string; + accountId?: string | null; +}): void { + try { + const payload = { + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, + accessTokenHash: params.storagePaths.tokenHash, + createdAt: new Date().toISOString(), + }; + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + fs.writeFileSync( + params.storagePaths.metaPath, + JSON.stringify(payload, null, 2), + "utf-8", + ); + } catch { + // ignore meta write failures + } +} diff --git a/src/matrix/client/types.ts b/src/matrix/client/types.ts new file mode 100644 index 000000000..ec1b3002b --- /dev/null +++ b/src/matrix/client/types.ts @@ -0,0 +1,34 @@ +export type MatrixResolvedConfig = { + homeserver: string; + userId: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +/** + * Authenticated Matrix configuration. + * Note: deviceId is NOT included here because it's implicit in the accessToken. + * The crypto storage assumes the device ID (and thus access token) does not change + * between restarts. If the access token becomes invalid or crypto storage is lost, + * both will need to be recreated together. + */ +export type MatrixAuth = { + homeserver: string; + userId: string; + accessToken: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +export type MatrixStoragePaths = { + rootDir: string; + storagePath: string; + cryptoPath: string; + metaPath: string; + accountKey: string; + tokenHash: string; +}; diff --git a/src/matrix/credentials.ts b/src/matrix/credentials.ts new file mode 100644 index 000000000..45388462d --- /dev/null +++ b/src/matrix/credentials.ts @@ -0,0 +1,103 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getMatrixRuntime } from "../runtime.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; + createdAt: string; + lastUsedAt?: string; +}; + +const CREDENTIALS_FILENAME = "credentials.json"; + +export function resolveMatrixCredentialsDir( + env: NodeJS.ProcessEnv = process.env, + stateDir?: string, +): string { + const resolvedStateDir = + stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return path.join(resolvedStateDir, "credentials", "matrix"); +} + +export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string { + const dir = resolveMatrixCredentialsDir(env); + return path.join(dir, CREDENTIALS_FILENAME); +} + +export function loadMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, +): MatrixStoredCredentials | null { + const credPath = resolveMatrixCredentialsPath(env); + try { + if (!fs.existsSync(credPath)) return null; + const raw = fs.readFileSync(credPath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return parsed as MatrixStoredCredentials; + } catch { + return null; + } +} + +export function saveMatrixCredentials( + credentials: Omit, + env: NodeJS.ProcessEnv = process.env, +): void { + const dir = resolveMatrixCredentialsDir(env); + fs.mkdirSync(dir, { recursive: true }); + + const credPath = resolveMatrixCredentialsPath(env); + + const existing = loadMatrixCredentials(env); + const now = new Date().toISOString(); + + const toSave: MatrixStoredCredentials = { + ...credentials, + createdAt: existing?.createdAt ?? now, + lastUsedAt: now, + }; + + fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); +} + +export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { + const existing = loadMatrixCredentials(env); + if (!existing) return; + + existing.lastUsedAt = new Date().toISOString(); + const credPath = resolveMatrixCredentialsPath(env); + fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); +} + +export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { + const credPath = resolveMatrixCredentialsPath(env); + try { + if (fs.existsSync(credPath)) { + fs.unlinkSync(credPath); + } + } catch { + // ignore + } +} + +export function credentialsMatchConfig( + stored: MatrixStoredCredentials, + config: { homeserver: string; userId: string }, +): boolean { + // If userId is empty (token-based auth), only match homeserver + if (!config.userId) { + return stored.homeserver === config.homeserver; + } + return stored.homeserver === config.homeserver && stored.userId === config.userId; +} diff --git a/src/matrix/deps.ts b/src/matrix/deps.ts new file mode 100644 index 000000000..ddbe60d57 --- /dev/null +++ b/src/matrix/deps.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; + +const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; + +export function isMatrixSdkAvailable(): boolean { + try { + const req = createRequire(import.meta.url); + req.resolve(MATRIX_SDK_PACKAGE); + return true; + } catch { + return false; + } +} + +function resolvePluginRoot(): string { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(currentDir, "..", ".."); +} + +export async function ensureMatrixSdkInstalled(params: { + runtime: RuntimeEnv; + confirm?: (message: string) => Promise; +}): Promise { + if (isMatrixSdkAvailable()) return; + const confirm = params.confirm; + if (confirm) { + const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?"); + if (!ok) { + throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first)."); + } + } + + const root = resolvePluginRoot(); + const command = fs.existsSync(path.join(root, "pnpm-lock.yaml")) + ? ["pnpm", "install"] + : ["npm", "install", "--omit=dev", "--silent"]; + params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); + const result = await getMatrixRuntime().system.runCommandWithTimeout(command, { + cwd: root, + timeoutMs: 300_000, + env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, + }); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.", + ); + } + if (!isMatrixSdkAvailable()) { + throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing."); + } +} diff --git a/src/matrix/format.test.ts b/src/matrix/format.test.ts new file mode 100644 index 000000000..5ae98c97c --- /dev/null +++ b/src/matrix/format.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { markdownToMatrixHtml } from "./format.js"; + +describe("markdownToMatrixHtml", () => { + it("renders basic inline formatting", () => { + const html = markdownToMatrixHtml("hi _there_ **boss** `code`"); + expect(html).toContain("there"); + expect(html).toContain("boss"); + expect(html).toContain("code"); + }); + + it("renders links as HTML", () => { + const html = markdownToMatrixHtml("see [docs](https://example.com)"); + expect(html).toContain('docs'); + }); + + it("escapes raw HTML", () => { + const html = markdownToMatrixHtml("nope"); + expect(html).toContain("<b>nope</b>"); + expect(html).not.toContain("nope"); + }); + + it("flattens images into alt text", () => { + const html = markdownToMatrixHtml("![alt](https://example.com/img.png)"); + expect(html).toContain("alt"); + expect(html).not.toContain(" { + const html = markdownToMatrixHtml("line1\nline2"); + expect(html).toContain(" escapeHtml(tokens[idx]?.content ?? ""); + +md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); +md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); + +export function markdownToMatrixHtml(markdown: string): string { + const rendered = md.render(markdown ?? ""); + return rendered.trimEnd(); +} diff --git a/src/matrix/index.ts b/src/matrix/index.ts new file mode 100644 index 000000000..7cd75d8a1 --- /dev/null +++ b/src/matrix/index.ts @@ -0,0 +1,11 @@ +export { monitorMatrixProvider } from "./monitor/index.js"; +export { probeMatrix } from "./probe.js"; +export { + reactMatrixMessage, + resolveMatrixRoomId, + sendReadReceiptMatrix, + sendMessageMatrix, + sendPollMatrix, + sendTypingMatrix, +} from "./send.js"; +export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js"; diff --git a/src/matrix/monitor/allowlist.ts b/src/matrix/monitor/allowlist.ts new file mode 100644 index 000000000..373b48000 --- /dev/null +++ b/src/matrix/monitor/allowlist.ts @@ -0,0 +1,58 @@ +import type { AllowlistMatch } from "openclaw/plugin-sdk"; + +function normalizeAllowList(list?: Array) { + return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); +} + +export function normalizeAllowListLower(list?: Array) { + return normalizeAllowList(list).map((entry) => entry.toLowerCase()); +} + +function normalizeMatrixUser(raw?: string | null): string { + return (raw ?? "").trim().toLowerCase(); +} + +export type MatrixAllowListMatch = AllowlistMatch< + "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart" +>; + +export function resolveMatrixAllowListMatch(params: { + allowList: string[]; + userId?: string; + userName?: string; +}): MatrixAllowListMatch { + const allowList = params.allowList; + if (allowList.length === 0) return { allowed: false }; + if (allowList.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const userId = normalizeMatrixUser(params.userId); + const userName = normalizeMatrixUser(params.userName); + const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : ""; + const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [ + { value: userId, source: "id" }, + { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" }, + { value: userId ? `user:${userId}` : "", source: "prefixed-user" }, + { value: userName, source: "name" }, + { value: localPart, source: "localpart" }, + ]; + for (const candidate of candidates) { + if (!candidate.value) continue; + if (allowList.includes(candidate.value)) { + return { + allowed: true, + matchKey: candidate.value, + matchSource: candidate.source, + }; + } + } + return { allowed: false }; +} + +export function resolveMatrixAllowListMatches(params: { + allowList: string[]; + userId?: string; + userName?: string; +}) { + return resolveMatrixAllowListMatch(params).allowed; +} diff --git a/src/matrix/monitor/auto-join.ts b/src/matrix/monitor/auto-join.ts new file mode 100644 index 000000000..77d15296e --- /dev/null +++ b/src/matrix/monitor/auto-join.ts @@ -0,0 +1,68 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk"; + +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import type { CoreConfig } from "../../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export function registerMatrixAutoJoin(params: { + client: MatrixClient; + cfg: CoreConfig; + runtime: RuntimeEnv; +}) { + const { client, cfg, runtime } = params; + const core = getMatrixRuntime(); + const logVerbose = (message: string) => { + if (!core.logging.shouldLogVerbose()) return; + runtime.log?.(message); + }; + const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; + const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; + + if (autoJoin === "off") { + return; + } + + if (autoJoin === "always") { + // Use the built-in autojoin mixin for "always" mode + AutojoinRoomsMixin.setupOnClient(client); + logVerbose("matrix: auto-join enabled for all invites"); + return; + } + + // For "allowlist" mode, handle invites manually + client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { + if (autoJoin !== "allowlist") return; + + // Get room alias if available + let alias: string | undefined; + let altAliases: string[] = []; + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + alias = aliasState?.alias; + altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : []; + } catch { + // Ignore errors + } + + const allowed = + autoJoinAllowlist.includes("*") || + autoJoinAllowlist.includes(roomId) || + (alias ? autoJoinAllowlist.includes(alias) : false) || + altAliases.some((value) => autoJoinAllowlist.includes(value)); + + if (!allowed) { + logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); + return; + } + + try { + await client.joinRoom(roomId); + logVerbose(`matrix: joined room ${roomId}`); + } catch (err) { + runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`); + } + }); +} diff --git a/src/matrix/monitor/direct.ts b/src/matrix/monitor/direct.ts new file mode 100644 index 000000000..cd2234fdd --- /dev/null +++ b/src/matrix/monitor/direct.ts @@ -0,0 +1,105 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +type DirectMessageCheck = { + roomId: string; + senderId?: string; + selfUserId?: string; +}; + +type DirectRoomTrackerOptions = { + log?: (message: string) => void; +}; + +const DM_CACHE_TTL_MS = 30_000; + +export function createDirectRoomTracker( + client: MatrixClient, + opts: DirectRoomTrackerOptions = {}, +) { + const log = opts.log ?? (() => {}); + let lastDmUpdateMs = 0; + let cachedSelfUserId: string | null = null; + const memberCountCache = new Map(); + + const ensureSelfUserId = async (): Promise => { + if (cachedSelfUserId) return cachedSelfUserId; + try { + cachedSelfUserId = await client.getUserId(); + } catch { + cachedSelfUserId = null; + } + return cachedSelfUserId; + }; + + const refreshDmCache = async (): Promise => { + const now = Date.now(); + if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return; + lastDmUpdateMs = now; + try { + await client.dms.update(); + } catch (err) { + log(`matrix: dm cache refresh failed (${String(err)})`); + } + }; + + const resolveMemberCount = async (roomId: string): Promise => { + const cached = memberCountCache.get(roomId); + const now = Date.now(); + if (cached && now - cached.ts < DM_CACHE_TTL_MS) { + return cached.count; + } + try { + const members = await client.getJoinedRoomMembers(roomId); + const count = members.length; + memberCountCache.set(roomId, { count, ts: now }); + return count; + } catch (err) { + log(`matrix: dm member count failed room=${roomId} (${String(err)})`); + return null; + } + }; + + const hasDirectFlag = async (roomId: string, userId?: string): Promise => { + const target = userId?.trim(); + if (!target) return false; + try { + const state = await client.getRoomStateEvent(roomId, "m.room.member", target); + return state?.is_direct === true; + } catch { + return false; + } + }; + + return { + isDirectMessage: async (params: DirectMessageCheck): Promise => { + const { roomId, senderId } = params; + await refreshDmCache(); + + if (client.dms.isDm(roomId)) { + log(`matrix: dm detected via m.direct room=${roomId}`); + return true; + } + + const memberCount = await resolveMemberCount(roomId); + if (memberCount === 2) { + log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`); + return true; + } + + const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); + const directViaState = + (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? "")); + if (directViaState) { + log(`matrix: dm detected via member state room=${roomId}`); + return true; + } + + log( + `matrix: dm check room=${roomId} result=group members=${ + memberCount ?? "unknown" + }`, + ); + return false; + }, + }; +} diff --git a/src/matrix/monitor/events.ts b/src/matrix/monitor/events.ts new file mode 100644 index 000000000..d8c273e10 --- /dev/null +++ b/src/matrix/monitor/events.ts @@ -0,0 +1,103 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +import type { MatrixAuth } from "../client.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +export function registerMatrixMonitorEvents(params: { + client: MatrixClient; + auth: MatrixAuth; + logVerboseMessage: (message: string) => void; + warnedEncryptedRooms: Set; + warnedCryptoMissingRooms: Set; + logger: { warn: (meta: Record, message: string) => void }; + formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; + onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; +}): void { + const { + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint, + onRoomMessage, + } = params; + + client.on("room.message", onRoomMessage); + + client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on( + "room.failed_decryption", + async (roomId: string, event: MatrixRawEvent, error: Error) => { + logger.warn( + { roomId, eventId: event.event_id, error: error.message }, + "Failed to decrypt message", + ); + logVerboseMessage( + `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, + ); + }, + ); + + client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const sender = event?.sender ?? "unknown"; + const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; + logVerboseMessage( + `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, + ); + }); + + client.on("room.join", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`); + }); + + client.on("room.event", (roomId: string, event: MatrixRawEvent) => { + const eventType = event?.type ?? "unknown"; + if (eventType === EventType.RoomMessageEncrypted) { + logVerboseMessage( + `matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`, + ); + if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { + warnedEncryptedRooms.add(roomId); + const warning = + "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; + logger.warn({ roomId }, warning); + } + if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { + warnedCryptoMissingRooms.add(roomId); + const hint = formatNativeDependencyHint({ + packageName: "@matrix-org/matrix-sdk-crypto-nodejs", + manager: "pnpm", + downloadCommand: + "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", + }); + const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; + logger.warn({ roomId }, warning); + } + return; + } + if (eventType === EventType.RoomMember) { + const membership = (event?.content as { membership?: string } | undefined)?.membership; + const stateKey = (event as { state_key?: string }).state_key ?? ""; + logVerboseMessage( + `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, + ); + } + }); +} diff --git a/src/matrix/monitor/handler.ts b/src/matrix/monitor/handler.ts new file mode 100644 index 000000000..cd631f351 --- /dev/null +++ b/src/matrix/monitor/handler.ts @@ -0,0 +1,645 @@ +import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import { + createReplyPrefixContext, + createTypingCallbacks, + formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, + resolveControlCommandGate, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; +import { + formatPollAsText, + isPollStartType, + parsePollStartContent, + type PollStartContent, +} from "../poll-types.js"; +import { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js"; +import { + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, + normalizeAllowListLower, +} from "./allowlist.js"; +import { downloadMatrixMedia } from "./media.js"; +import { resolveMentions } from "./mentions.js"; +import { deliverMatrixReplies } from "./replies.js"; +import { resolveMatrixRoomConfig } from "./rooms.js"; +import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; +import { EventType, RelationType } from "./types.js"; + +export type MatrixMonitorHandlerParams = { + client: MatrixClient; + core: { + logging: { + shouldLogVerbose: () => boolean; + }; + channel: typeof import("openclaw/plugin-sdk")["channel"]; + system: { + enqueueSystemEvent: ( + text: string, + meta: { sessionKey?: string | null; contextKey?: string | null }, + ) => void; + }; + }; + cfg: CoreConfig; + runtime: RuntimeEnv; + logger: { + info: (message: string | Record, ...meta: unknown[]) => void; + warn: (meta: Record, message: string) => void; + }; + logVerboseMessage: (message: string) => void; + allowFrom: string[]; + roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig } + ? MatrixConfig extends { groups?: infer Groups } + ? Groups + : Record | undefined + : Record | undefined; + mentionRegexes: ReturnType< + typeof import("openclaw/plugin-sdk")["channel"]["mentions"]["buildMentionRegexes"] + >; + groupPolicy: "open" | "allowlist" | "disabled"; + replyToMode: ReplyToMode; + threadReplies: "off" | "inbound" | "always"; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + textLimit: number; + mediaMaxBytes: number; + startupMs: number; + startupGraceMs: number; + directTracker: { + isDirectMessage: (params: { + roomId: string; + senderId: string; + selfUserId: string; + }) => Promise; + }; + getRoomInfo: (roomId: string) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; + getMemberDisplayName: (roomId: string, userId: string) => Promise; +}; + +export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { + const { + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + } = params; + + return async (roomId: string, event: MatrixRawEvent) => { + try { + const eventType = event.type; + if (eventType === EventType.RoomMessageEncrypted) { + // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled + return; + } + + const isPollEvent = isPollStartType(eventType); + const locationContent = event.content as LocationMessageEventContent; + const isLocationEvent = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && + locationContent.msgtype === EventType.Location); + if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return; + logVerboseMessage( + `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, + ); + if (event.unsigned?.redacted_because) return; + const senderId = event.sender; + if (!senderId) return; + const selfUserId = await client.getUserId(); + if (senderId === selfUserId) return; + const eventTs = event.origin_server_ts; + const eventAge = event.unsigned?.age; + if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { + return; + } + if ( + typeof eventTs !== "number" && + typeof eventAge === "number" && + eventAge > startupGraceMs + ) { + return; + } + + const roomInfo = await getRoomInfo(roomId); + const roomName = roomInfo.name; + const roomAliases = [ + roomInfo.canonicalAlias ?? "", + ...roomInfo.altAliases, + ].filter(Boolean); + + let content = event.content as RoomMessageEventContent; + if (isPollEvent) { + const pollStartContent = event.content as PollStartContent; + const pollSummary = parsePollStartContent(pollStartContent); + if (pollSummary) { + pollSummary.eventId = event.event_id ?? ""; + pollSummary.roomId = roomId; + pollSummary.sender = senderId; + const senderDisplayName = await getMemberDisplayName(roomId, senderId); + pollSummary.senderName = senderDisplayName; + const pollText = formatPollAsText(pollSummary); + content = { + msgtype: "m.text", + body: pollText, + } as unknown as RoomMessageEventContent; + } else { + return; + } + } + + const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ + eventType, + content: content as LocationMessageEventContent, + }); + + const relates = content["m.relates_to"]; + if (relates && "rel_type" in relates) { + if (relates.rel_type === RelationType.Replace) return; + } + + const isDirectMessage = await directTracker.isDirectMessage({ + roomId, + senderId, + selfUserId, + }); + const isRoom = !isDirectMessage; + + if (isRoom && groupPolicy === "disabled") return; + + const roomConfigInfo = isRoom + ? resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliases, + name: roomName, + }) + : undefined; + const roomConfig = roomConfigInfo?.config; + const roomMatchMeta = roomConfigInfo + ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ + roomConfigInfo.matchSource ?? "none" + }` + : "matchKey=none matchSource=none"; + + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + return; + } + if (isRoom && groupPolicy === "allowlist") { + if (!roomConfigInfo?.allowlistConfigured) { + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + return; + } + if (!roomConfig) { + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + return; + } + } + + const senderName = await getMemberDisplayName(roomId, senderId); + const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); + const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); + const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; + const effectiveGroupAllowFrom = normalizeAllowListLower([ + ...groupAllowFrom, + ...storeAllowFrom, + ]); + const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; + + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") return; + if (dmPolicy !== "open") { + const allowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveAllowFrom, + userId: senderId, + userName: senderName, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (!allowMatch.allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + meta: { name: senderName }, + }); + if (created) { + logVerboseMessage( + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + { client }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } + if (dmPolicy !== "pairing") { + logVerboseMessage( + `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + } + return; + } + } + } + + const roomUsers = roomConfig?.users ?? []; + if (isRoom && roomUsers.length > 0) { + const userMatch = resolveMatrixAllowListMatch({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }); + if (!userMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + userMatch, + )})`, + ); + return; + } + } + if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + const groupAllowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }); + if (!groupAllowMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + groupAllowMatch, + )})`, + ); + return; + } + } + if (isRoom) { + logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); + } + + const rawBody = locationPayload?.text + ?? (typeof content.body === "string" ? content.body.trim() : ""); + let media: { + path: string; + contentType?: string; + placeholder: string; + } | null = null; + const contentUrl = + "url" in content && typeof content.url === "string" ? content.url : undefined; + const contentFile = + "file" in content && content.file && typeof content.file === "object" + ? content.file + : undefined; + const mediaUrl = contentUrl ?? contentFile?.url; + if (!rawBody && !mediaUrl) { + return; + } + + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) + : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = + typeof contentInfo?.size === "number" ? contentInfo.size : undefined; + if (mediaUrl?.startsWith("mxc://")) { + try { + media = await downloadMatrixMedia({ + client, + mxcUrl: mediaUrl, + contentType, + sizeBytes: contentSize, + maxBytes: mediaMaxBytes, + file: contentFile, + }); + } catch (err) { + logVerboseMessage(`matrix: media download failed: ${String(err)}`); + } + } + + const bodyText = rawBody || media?.placeholder || ""; + if (!bodyText) return; + + const { wasMentioned, hasExplicitMention } = resolveMentions({ + content, + userId: selfUserId, + text: bodyText, + mentionRegexes, + }); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "matrix", + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveMatrixAllowListMatches({ + allowList: effectiveAllowFrom, + userId: senderId, + userName: senderName, + }); + const senderAllowedForGroup = groupAllowConfigured + ? resolveMatrixAllowListMatches({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }) + : false; + const senderAllowedForRoomUsers = + isRoom && roomUsers.length > 0 + ? resolveMatrixAllowListMatches({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }) + : false; + const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, + { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, + ], + allowTextCommands, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = commandGate.commandAuthorized; + if (isRoom && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "matrix", + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + const shouldRequireMention = isRoom + ? roomConfig?.autoReply === true + ? false + : roomConfig?.autoReply === false + ? true + : typeof roomConfig?.requireMention === "boolean" + ? roomConfig?.requireMention + : true + : false; + const shouldBypassMention = + allowTextCommands && + isRoom && + shouldRequireMention && + !wasMentioned && + !hasExplicitMention && + commandAuthorized && + hasControlCommandInMessage; + const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; + if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { + logger.info({ roomId, reason: "no-mention" }, "skipping room message"); + return; + } + + const messageId = event.event_id ?? ""; + const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; + const threadRootId = resolveMatrixThreadRootId({ event, content }); + const threadTarget = resolveMatrixThreadTarget({ + threadReplies, + messageId, + threadRootId, + isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available + }); + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix", + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); + const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); + const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Matrix", + from: envelopeFrom, + timestamp: eventTs ?? undefined, + previousTimestamp, + envelope: envelopeOptions, + body: textWithId, + }); + + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: bodyText, + CommandBody: bodyText, + From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, + To: `room:${roomId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: envelopeFrom, + SenderName: senderName, + SenderId: senderId, + SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), + GroupSubject: isRoom ? (roomName ?? roomId) : undefined, + GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, + GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, + Provider: "matrix" as const, + Surface: "matrix" as const, + WasMentioned: isRoom ? wasMentioned : undefined, + MessageSid: messageId, + ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), + MessageThreadId: threadTarget, + Timestamp: eventTs ?? undefined, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + ...(locationPayload?.context ?? {}), + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + OriginatingChannel: "matrix" as const, + OriginatingTo: `room:${roomId}`, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logger.warn( + { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, + "failed updating session meta", + ); + }, + }); + + const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const shouldAckReaction = () => + Boolean( + ackReaction && + core.channel.reactions.shouldAckReaction({ + scope: ackScope, + isDirect: isDirectMessage, + isGroup: isRoom, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned: wasMentioned || shouldBypassMention, + shouldBypassMention, + }), + ); + if (shouldAckReaction() && messageId) { + reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { + logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); + }); + } + + const replyTarget = ctxPayload.To; + if (!replyTarget) { + runtime.error?.("matrix: missing reply target"); + return; + } + + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + + let didSendReply = false; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: route.accountId, + }); + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingMatrix(roomId, true, undefined, client), + stop: () => sendTypingMatrix(roomId, false, undefined, client), + onStartError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "start", + target: roomId, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "stop", + target: roomId, + error: err, + }); + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + await deliverMatrixReplies({ + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + accountId: route.accountId, + tableMode, + }); + didSendReply = true; + }, + onError: (err, info) => { + runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + }); + + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected: prefixContext.onModelSelected, + }, + }); + markDispatchIdle(); + if (!queuedFinal) return; + didSendReply = true; + const finalCount = counts.final; + logVerboseMessage( + `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, + ); + if (didSendReply) { + const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160); + core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, { + sessionKey: route.sessionKey, + contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, + }); + } + } catch (err) { + runtime.error?.(`matrix handler failed: ${String(err)}`); + } + }; +} diff --git a/src/matrix/monitor/index.ts b/src/matrix/monitor/index.ts new file mode 100644 index 000000000..2b18a8114 --- /dev/null +++ b/src/matrix/monitor/index.ts @@ -0,0 +1,279 @@ +import { format } from "node:util"; + +import { + mergeAllowlist, + summarizeMapping, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; +import { setActiveMatrixClient } from "../active-client.js"; +import { + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, + stopSharedClient, +} from "../client.js"; +import { registerMatrixAutoJoin } from "./auto-join.js"; +import { createDirectRoomTracker } from "./direct.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { createMatrixRoomInfoResolver } from "./room-info.js"; +import { resolveMatrixTargets } from "../../resolve-targets.js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export type MonitorMatrixOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + mediaMaxMb?: number; + initialSyncLimit?: number; + replyToMode?: ReplyToMode; + accountId?: string | null; +}; + +const DEFAULT_MEDIA_MAX_MB = 20; + +export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise { + if (isBunRuntime()) { + throw new Error("Matrix provider requires Node (bun runtime not supported)"); + } + const core = getMatrixRuntime(); + let cfg = core.config.loadConfig() as CoreConfig; + if (cfg.channels?.matrix?.enabled === false) return; + + const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => { + logger.info(formatRuntimeMessage(...args)); + }, + error: (...args) => { + logger.error(formatRuntimeMessage(...args)); + }, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + const logVerboseMessage = (message: string) => { + if (!core.logging.shouldLogVerbose()) return; + logger.debug(message); + }; + + const normalizeUserEntry = (raw: string) => + raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim(); + const normalizeRoomEntry = (raw: string) => + raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim(); + const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; + let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms; + + if (allowFrom.length > 0) { + const entries = allowFrom + .map((entry) => normalizeUserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (entries.length > 0) { + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + const pending: string[] = []; + for (const entry of entries) { + if (isMatrixUserId(entry)) { + additions.push(entry); + continue; + } + pending.push(entry); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + runtime, + }); + for (const entry of resolved) { + if (entry.resolved && entry.id) { + additions.push(entry.id); + mapping.push(`${entry.input}→${entry.id}`); + } else { + unresolved.push(entry.input); + } + } + } + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + summarizeMapping("matrix users", mapping, unresolved, runtime); + } + } + + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const entries = Object.keys(roomsConfig).filter((key) => key !== "*"); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextRooms = { ...roomsConfig }; + const pending: Array<{ input: string; query: string }> = []; + for (const entry of entries) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const cleaned = normalizeRoomEntry(trimmed); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + if (!nextRooms[cleaned]) { + nextRooms[cleaned] = roomsConfig[entry]; + } + mapping.push(`${entry}→${cleaned}`); + continue; + } + pending.push({ input: entry, query: trimmed }); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending.map((entry) => entry.query), + kind: "group", + runtime, + }); + resolved.forEach((entry, index) => { + const source = pending[index]; + if (!source) return; + if (entry.resolved && entry.id) { + if (!nextRooms[entry.id]) { + nextRooms[entry.id] = roomsConfig[source.input]; + } + mapping.push(`${source.input}→${entry.id}`); + } else { + unresolved.push(source.input); + } + }); + } + roomsConfig = nextRooms; + summarizeMapping("matrix rooms", mapping, unresolved, runtime); + } + + cfg = { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + allowFrom, + }, + ...(roomsConfig ? { groups: roomsConfig } : {}), + }, + }, + }; + + const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId }); + const resolvedInitialSyncLimit = + typeof opts.initialSyncLimit === "number" + ? Math.max(0, Math.floor(opts.initialSyncLimit)) + : auth.initialSyncLimit; + const authWithLimit = + resolvedInitialSyncLimit === auth.initialSyncLimit + ? auth + : { ...auth, initialSyncLimit: resolvedInitialSyncLimit }; + const client = await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + startClient: false, + accountId: opts.accountId, + }); + setActiveMatrixClient(client); + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; + const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off"; + const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound"; + const dmConfig = cfg.channels?.matrix?.dm; + const dmEnabled = dmConfig?.enabled ?? true; + const dmPolicyRaw = dmConfig?.policy ?? "pairing"; + const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); + const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; + const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; + const startupMs = Date.now(); + const startupGraceMs = 0; + const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); + registerMatrixAutoJoin({ client, cfg, runtime }); + const warnedEncryptedRooms = new Set(); + const warnedCryptoMissingRooms = new Set(); + + const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); + const handleRoomMessage = createMatrixRoomMessageHandler({ + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + }); + + registerMatrixMonitorEvents({ + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint: core.system.formatNativeDependencyHint, + onRoomMessage: handleRoomMessage, + }); + + logVerboseMessage("matrix: starting client"); + await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + accountId: opts.accountId, + }); + logVerboseMessage("matrix: client started"); + + // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient + logger.info(`matrix: logged in as ${auth.userId}`); + + // If E2EE is enabled, trigger device verification + if (auth.encryption && client.crypto) { + try { + // Request verification from other sessions + const verificationRequest = await client.crypto.requestOwnUserVerification(); + if (verificationRequest) { + logger.info("matrix: device verification requested - please verify in another client"); + } + } catch (err) { + logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)"); + } + } + + await new Promise((resolve) => { + const onAbort = () => { + try { + logVerboseMessage(`matrix: stopping client for account ${opts.accountId ?? "default"}`); + stopSharedClient(opts.accountId); + } finally { + setActiveMatrixClient(null); + resolve(); + } + }; + if (opts.abortSignal?.aborted) { + onAbort(); + return; + } + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/matrix/monitor/location.ts b/src/matrix/monitor/location.ts new file mode 100644 index 000000000..8d7aecc13 --- /dev/null +++ b/src/matrix/monitor/location.ts @@ -0,0 +1,83 @@ +import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk"; + +import { + formatLocationText, + toLocationContext, + type NormalizedLocation, +} from "openclaw/plugin-sdk"; +import { EventType } from "./types.js"; + +export type MatrixLocationPayload = { + text: string; + context: ReturnType; +}; + +type GeoUriParams = { + latitude: number; + longitude: number; + accuracy?: number; +}; + +function parseGeoUri(value: string): GeoUriParams | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!trimmed.toLowerCase().startsWith("geo:")) return null; + const payload = trimmed.slice(4); + const [coordsPart, ...paramParts] = payload.split(";"); + const coords = coordsPart.split(","); + if (coords.length < 2) return null; + const latitude = Number.parseFloat(coords[0] ?? ""); + const longitude = Number.parseFloat(coords[1] ?? ""); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + + const params = new Map(); + for (const part of paramParts) { + const segment = part.trim(); + if (!segment) continue; + const eqIndex = segment.indexOf("="); + const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex); + const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1); + const key = rawKey.trim().toLowerCase(); + if (!key) continue; + const valuePart = rawValue.trim(); + params.set(key, valuePart ? decodeURIComponent(valuePart) : ""); + } + + const accuracyRaw = params.get("u"); + const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined; + + return { + latitude, + longitude, + accuracy: Number.isFinite(accuracy) ? accuracy : undefined, + }; +} + +export function resolveMatrixLocation(params: { + eventType: string; + content: LocationMessageEventContent; +}): MatrixLocationPayload | null { + const { eventType, content } = params; + const isLocation = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && content.msgtype === EventType.Location); + if (!isLocation) return null; + const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : ""; + if (!geoUri) return null; + const parsed = parseGeoUri(geoUri); + if (!parsed) return null; + const caption = typeof content.body === "string" ? content.body.trim() : ""; + const location: NormalizedLocation = { + latitude: parsed.latitude, + longitude: parsed.longitude, + accuracy: parsed.accuracy, + caption: caption || undefined, + source: "pin", + isLive: false, + }; + + return { + text: formatLocationText(location), + context: toLocationContext(location), + }; +} diff --git a/src/matrix/monitor/media.test.ts b/src/matrix/monitor/media.test.ts new file mode 100644 index 000000000..eabbc0d4c --- /dev/null +++ b/src/matrix/monitor/media.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { setMatrixRuntime } from "../../runtime.js"; +import { downloadMatrixMedia } from "./media.js"; + +describe("downloadMatrixMedia", () => { + const saveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/media", + contentType: "image/png", + }); + + const runtimeStub = { + channel: { + media: { + saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + }, + }, + } as unknown as PluginRuntime; + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("decrypts encrypted media when file payloads are present", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + const result = await downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + maxBytes: 1024, + file, + }); + + // decryptMedia should be called with just the file object (it handles download internally) + expect(decryptMedia).toHaveBeenCalledWith(file); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("decrypted"), + "image/png", + "inbound", + 1024, + ); + expect(result?.path).toBe("/tmp/media"); + }); + + it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + await expect( + downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + sizeBytes: 2048, + maxBytes: 1024, + file, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + + expect(decryptMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/matrix/monitor/media.ts b/src/matrix/monitor/media.ts new file mode 100644 index 000000000..0b33cca53 --- /dev/null +++ b/src/matrix/monitor/media.ts @@ -0,0 +1,113 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import { getMatrixRuntime } from "../../runtime.js"; + +// Type for encrypted file info +type EncryptedFile = { + url: string; + key: { + kty: string; + key_ops: string[]; + alg: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: Record; + v: string; +}; + +async function fetchMatrixMediaBuffer(params: { + client: MatrixClient; + mxcUrl: string; + maxBytes: number; +}): Promise<{ buffer: Buffer; headerType?: string } | null> { + // @vector-im/matrix-bot-sdk provides mxcToHttp helper + const url = params.client.mxcToHttp(params.mxcUrl); + if (!url) return null; + + // Use the client's download method which handles auth + try { + const buffer = await params.client.downloadContent(params.mxcUrl); + if (buffer.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + return { buffer: Buffer.from(buffer) }; + } catch (err) { + throw new Error(`Matrix media download failed: ${String(err)}`); + } +} + +/** + * Download and decrypt encrypted media from a Matrix room. + * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption. + */ +async function fetchEncryptedMediaBuffer(params: { + client: MatrixClient; + file: EncryptedFile; + maxBytes: number; +}): Promise<{ buffer: Buffer } | null> { + if (!params.client.crypto) { + throw new Error("Cannot decrypt media: crypto not enabled"); + } + + // decryptMedia handles downloading and decrypting the encrypted content internally + const decrypted = await params.client.crypto.decryptMedia(params.file); + + if (decrypted.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + + return { buffer: decrypted }; +} + +export async function downloadMatrixMedia(params: { + client: MatrixClient; + mxcUrl: string; + contentType?: string; + sizeBytes?: number; + maxBytes: number; + file?: EncryptedFile; +}): Promise<{ + path: string; + contentType?: string; + placeholder: string; +} | null> { + let fetched: { buffer: Buffer; headerType?: string } | null; + if ( + typeof params.sizeBytes === "number" && + params.sizeBytes > params.maxBytes + ) { + throw new Error("Matrix media exceeds configured size limit"); + } + + if (params.file) { + // Encrypted media + fetched = await fetchEncryptedMediaBuffer({ + client: params.client, + file: params.file, + maxBytes: params.maxBytes, + }); + } else { + // Unencrypted media + fetched = await fetchMatrixMediaBuffer({ + client: params.client, + mxcUrl: params.mxcUrl, + maxBytes: params.maxBytes, + }); + } + + if (!fetched) return null; + const headerType = fetched.headerType ?? params.contentType ?? undefined; + const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( + fetched.buffer, + headerType, + "inbound", + params.maxBytes, + ); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "[matrix media]", + }; +} diff --git a/src/matrix/monitor/mentions.ts b/src/matrix/monitor/mentions.ts new file mode 100644 index 000000000..1053b3fa1 --- /dev/null +++ b/src/matrix/monitor/mentions.ts @@ -0,0 +1,31 @@ +import { getMatrixRuntime } from "../../runtime.js"; + +// Type for room message content with mentions +type MessageContentWithMentions = { + msgtype: string; + body: string; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; +}; + +export function resolveMentions(params: { + content: MessageContentWithMentions; + userId?: string | null; + text?: string; + mentionRegexes: RegExp[]; +}) { + const mentions = params.content["m.mentions"]; + const mentionedUsers = Array.isArray(mentions?.user_ids) + ? new Set(mentions.user_ids) + : new Set(); + const wasMentioned = + Boolean(mentions?.room) || + (params.userId ? mentionedUsers.has(params.userId) : false) || + getMatrixRuntime().channel.mentions.matchesMentionPatterns( + params.text ?? "", + params.mentionRegexes, + ); + return { wasMentioned, hasExplicitMention: Boolean(mentions) }; +} diff --git a/src/matrix/monitor/replies.ts b/src/matrix/monitor/replies.ts new file mode 100644 index 000000000..a4640f3cc --- /dev/null +++ b/src/matrix/monitor/replies.ts @@ -0,0 +1,96 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk"; +import { sendMessageMatrix } from "../send.js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export async function deliverMatrixReplies(params: { + replies: ReplyPayload[]; + roomId: string; + client: MatrixClient; + runtime: RuntimeEnv; + textLimit: number; + replyToMode: "off" | "first" | "all"; + threadId?: string; + accountId?: string; + tableMode?: MarkdownTableMode; +}): Promise { + const core = getMatrixRuntime(); + const cfg = core.config.loadConfig(); + const tableMode = + params.tableMode ?? + core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: params.accountId, + }); + const logVerbose = (message: string) => { + if (core.logging.shouldLogVerbose()) { + params.runtime.log?.(message); + } + }; + const chunkLimit = Math.min(params.textLimit, 4000); + const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId); + let hasReplied = false; + for (const reply of params.replies) { + const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; + if (!reply?.text && !hasMedia) { + if (reply?.audioAsVoice) { + logVerbose("matrix reply has audioAsVoice without media/text; skipping"); + continue; + } + params.runtime.error?.("matrix reply missing text/media"); + continue; + } + const replyToIdRaw = reply.replyToId?.trim(); + const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); + const mediaList = reply.mediaUrls?.length + ? reply.mediaUrls + : reply.mediaUrl + ? [reply.mediaUrl] + : []; + + const shouldIncludeReply = (id?: string) => + Boolean(id) && (params.replyToMode === "all" || !hasReplied); + + if (mediaList.length === 0) { + for (const chunk of core.channel.text.chunkMarkdownTextWithMode( + text, + chunkLimit, + chunkMode, + )) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + await sendMessageMatrix(params.roomId, trimmed, { + client: params.client, + replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, + threadId: params.threadId, + accountId: params.accountId, + }); + if (shouldIncludeReply(replyToId)) { + hasReplied = true; + } + } + continue; + } + + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : ""; + await sendMessageMatrix(params.roomId, caption, { + client: params.client, + mediaUrl, + replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + if (shouldIncludeReply(replyToId)) { + hasReplied = true; + } + first = false; + } + } +} diff --git a/src/matrix/monitor/room-info.ts b/src/matrix/monitor/room-info.ts new file mode 100644 index 000000000..cad377e1a --- /dev/null +++ b/src/matrix/monitor/room-info.ts @@ -0,0 +1,58 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +export type MatrixRoomInfo = { + name?: string; + canonicalAlias?: string; + altAliases: string[]; +}; + +export function createMatrixRoomInfoResolver(client: MatrixClient) { + const roomInfoCache = new Map(); + + const getRoomInfo = async (roomId: string): Promise => { + const cached = roomInfoCache.get(roomId); + if (cached) return cached; + let name: string | undefined; + let canonicalAlias: string | undefined; + let altAliases: string[] = []; + try { + const nameState = await client + .getRoomStateEvent(roomId, "m.room.name", "") + .catch(() => null); + name = nameState?.name; + } catch { + // ignore + } + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + canonicalAlias = aliasState?.alias; + altAliases = aliasState?.alt_aliases ?? []; + } catch { + // ignore + } + const info = { name, canonicalAlias, altAliases }; + roomInfoCache.set(roomId, info); + return info; + }; + + const getMemberDisplayName = async ( + roomId: string, + userId: string, + ): Promise => { + try { + const memberState = await client + .getRoomStateEvent(roomId, "m.room.member", userId) + .catch(() => null); + return memberState?.displayname ?? userId; + } catch { + return userId; + } + }; + + return { + getRoomInfo, + getMemberDisplayName, + }; +} diff --git a/src/matrix/monitor/rooms.ts b/src/matrix/monitor/rooms.ts new file mode 100644 index 000000000..086048a76 --- /dev/null +++ b/src/matrix/monitor/rooms.ts @@ -0,0 +1,43 @@ +import type { MatrixRoomConfig } from "../../types.js"; +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk"; + +export type MatrixRoomConfigResolved = { + allowed: boolean; + allowlistConfigured: boolean; + config?: MatrixRoomConfig; + matchKey?: string; + matchSource?: "direct" | "wildcard"; +}; + +export function resolveMatrixRoomConfig(params: { + rooms?: Record; + roomId: string; + aliases: string[]; + name?: string | null; +}): MatrixRoomConfigResolved { + const rooms = params.rooms ?? {}; + const keys = Object.keys(rooms); + const allowlistConfigured = keys.length > 0; + const candidates = buildChannelKeyCandidates( + params.roomId, + `room:${params.roomId}`, + ...params.aliases, + params.name ?? "", + ); + const { entry: matched, key: matchedKey, wildcardEntry, wildcardKey } = resolveChannelEntryMatch({ + entries: rooms, + keys: candidates, + wildcardKey: "*", + }); + const resolved = matched ?? wildcardEntry; + const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false; + const matchKey = matchedKey ?? wildcardKey; + const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined; + return { + allowed, + allowlistConfigured, + config: resolved, + matchKey, + matchSource, + }; +} diff --git a/src/matrix/monitor/threads.ts b/src/matrix/monitor/threads.ts new file mode 100644 index 000000000..4d618f329 --- /dev/null +++ b/src/matrix/monitor/threads.ts @@ -0,0 +1,64 @@ +// Type for raw Matrix event from @vector-im/matrix-bot-sdk +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; +}; + +type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +const RelationType = { + Thread: "m.thread", +} as const; + +export function resolveMatrixThreadTarget(params: { + threadReplies: "off" | "inbound" | "always"; + messageId: string; + threadRootId?: string; + isThreadRoot?: boolean; +}): string | undefined { + const { threadReplies, messageId, threadRootId } = params; + if (threadReplies === "off") return undefined; + const isThreadRoot = params.isThreadRoot === true; + const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot); + if (threadReplies === "inbound") { + return hasInboundThread ? threadRootId : undefined; + } + if (threadReplies === "always") { + return threadRootId ?? messageId; + } + return undefined; +} + +export function resolveMatrixThreadRootId(params: { + event: MatrixRawEvent; + content: RoomMessageEventContent; +}): string | undefined { + const relates = params.content["m.relates_to"]; + if (!relates || typeof relates !== "object") return undefined; + if ("rel_type" in relates && relates.rel_type === RelationType.Thread) { + if ("event_id" in relates && typeof relates.event_id === "string") { + return relates.event_id; + } + if ( + "m.in_reply_to" in relates && + typeof relates["m.in_reply_to"] === "object" && + relates["m.in_reply_to"] && + "event_id" in relates["m.in_reply_to"] && + typeof relates["m.in_reply_to"].event_id === "string" + ) { + return relates["m.in_reply_to"].event_id; + } + } + return undefined; +} diff --git a/src/matrix/monitor/types.ts b/src/matrix/monitor/types.ts new file mode 100644 index 000000000..c910f931f --- /dev/null +++ b/src/matrix/monitor/types.ts @@ -0,0 +1,39 @@ +import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk"; + +export const EventType = { + RoomMessage: "m.room.message", + RoomMessageEncrypted: "m.room.encrypted", + RoomMember: "m.room.member", + Location: "m.location", +} as const; + +export const RelationType = { + Replace: "m.replace", + Thread: "m.thread", +} as const; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; +}; + +export type RoomMessageEventContent = MessageEventContent & { + url?: string; + file?: EncryptedFile; + info?: { + mimetype?: string; + size?: number; + }; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; diff --git a/src/matrix/poll-types.test.ts b/src/matrix/poll-types.test.ts new file mode 100644 index 000000000..f2d885622 --- /dev/null +++ b/src/matrix/poll-types.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { parsePollStartContent } from "./poll-types.js"; + +describe("parsePollStartContent", () => { + it("parses legacy m.poll payloads", () => { + const summary = parsePollStartContent({ + "m.poll": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "answer1", "m.text": "Yes" }, + { id: "answer2", "m.text": "No" }, + ], + }, + }); + + expect(summary?.question).toBe("Lunch?"); + expect(summary?.answers).toEqual(["Yes", "No"]); + }); +}); diff --git a/src/matrix/poll-types.ts b/src/matrix/poll-types.ts new file mode 100644 index 000000000..cd641ebb4 --- /dev/null +++ b/src/matrix/poll-types.ts @@ -0,0 +1,157 @@ +/** + * Matrix Poll Types (MSC3381) + * + * Defines types for Matrix poll events: + * - m.poll.start - Creates a new poll + * - m.poll.response - Records a vote + * - m.poll.end - Closes a poll + */ + +import type { PollInput } from "openclaw/plugin-sdk"; + +export const M_POLL_START = "m.poll.start" as const; +export const M_POLL_RESPONSE = "m.poll.response" as const; +export const M_POLL_END = "m.poll.end" as const; + +export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const; +export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const; +export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const; + +export const POLL_EVENT_TYPES = [ + M_POLL_START, + M_POLL_RESPONSE, + M_POLL_END, + ORG_POLL_START, + ORG_POLL_RESPONSE, + ORG_POLL_END, +]; + +export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START]; +export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE]; +export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END]; + +export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; + +export type TextContent = { + "m.text"?: string; + "org.matrix.msc1767.text"?: string; + body?: string; +}; + +export type PollAnswer = { + id: string; +} & TextContent; + +export type PollStartSubtype = { + question: TextContent; + kind?: PollKind; + max_selections?: number; + answers: PollAnswer[]; +}; + +export type LegacyPollStartContent = { + "m.poll"?: PollStartSubtype; +}; + +export type PollStartContent = { + [M_POLL_START]?: PollStartSubtype; + [ORG_POLL_START]?: PollStartSubtype; + "m.poll"?: PollStartSubtype; + "m.text"?: string; + "org.matrix.msc1767.text"?: string; +}; + +export type PollSummary = { + eventId: string; + roomId: string; + sender: string; + senderName: string; + question: string; + answers: string[]; + kind: PollKind; + maxSelections: number; +}; + +export function isPollStartType(eventType: string): boolean { + return POLL_START_TYPES.includes(eventType); +} + +export function getTextContent(text?: TextContent): string { + if (!text) return ""; + return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; +} + +export function parsePollStartContent(content: PollStartContent): PollSummary | null { + const poll = (content as Record)[M_POLL_START] + ?? (content as Record)[ORG_POLL_START] + ?? (content as Record)["m.poll"]; + if (!poll) return null; + + const question = getTextContent(poll.question); + if (!question) return null; + + const answers = poll.answers + .map((answer) => getTextContent(answer)) + .filter((a) => a.trim().length > 0); + + return { + eventId: "", + roomId: "", + sender: "", + senderName: "", + question, + answers, + kind: poll.kind ?? "m.poll.disclosed", + maxSelections: poll.max_selections ?? 1, + }; +} + +export function formatPollAsText(summary: PollSummary): string { + const lines = [ + "[Poll]", + summary.question, + "", + ...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`), + ]; + return lines.join("\n"); +} + +function buildTextContent(body: string): TextContent { + return { + "m.text": body, + "org.matrix.msc1767.text": body, + }; +} + +function buildPollFallbackText(question: string, answers: string[]): string { + if (answers.length === 0) return question; + return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`; +} + +export function buildPollStartContent(poll: PollInput): PollStartContent { + const question = poll.question.trim(); + const answers = poll.options + .map((option) => option.trim()) + .filter((option) => option.length > 0) + .map((option, idx) => ({ + id: `answer${idx + 1}`, + ...buildTextContent(option), + })); + + const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1; + const fallbackText = buildPollFallbackText( + question, + answers.map((answer) => getTextContent(answer)), + ); + + return { + [M_POLL_START]: { + question: buildTextContent(question), + kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed", + max_selections: maxSelections, + answers, + }, + "m.text": fallbackText, + "org.matrix.msc1767.text": fallbackText, + }; +} diff --git a/src/matrix/probe.ts b/src/matrix/probe.ts new file mode 100644 index 000000000..7bd54bdc4 --- /dev/null +++ b/src/matrix/probe.ts @@ -0,0 +1,70 @@ +import { createMatrixClient, isBunRuntime } from "./client.js"; + +export type MatrixProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs: number; + userId?: string | null; +}; + +export async function probeMatrix(params: { + homeserver: string; + accessToken: string; + userId?: string; + timeoutMs: number; +}): Promise { + const started = Date.now(); + const result: MatrixProbe = { + ok: false, + status: null, + error: null, + elapsedMs: 0, + }; + if (isBunRuntime()) { + return { + ...result, + error: "Matrix probe requires Node (bun runtime not supported)", + elapsedMs: Date.now() - started, + }; + } + if (!params.homeserver?.trim()) { + return { + ...result, + error: "missing homeserver", + elapsedMs: Date.now() - started, + }; + } + if (!params.accessToken?.trim()) { + return { + ...result, + error: "missing access token", + elapsedMs: Date.now() - started, + }; + } + try { + const client = await createMatrixClient({ + homeserver: params.homeserver, + userId: params.userId ?? "", + accessToken: params.accessToken, + localTimeoutMs: params.timeoutMs, + }); + // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally + const userId = await client.getUserId(); + result.ok = true; + result.userId = userId ?? null; + + result.elapsedMs = Date.now() - started; + return result; + } catch (err) { + return { + ...result, + status: + typeof err === "object" && err && "statusCode" in err + ? Number((err as { statusCode?: number }).statusCode) + : result.status, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - started, + }; + } +} diff --git a/src/matrix/send.test.ts b/src/matrix/send.test.ts new file mode 100644 index 000000000..2bba70e6c --- /dev/null +++ b/src/matrix/send.test.ts @@ -0,0 +1,172 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { setMatrixRuntime } from "../runtime.js"; + +vi.mock("@vector-im/matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + LogService: { + setLogger: vi.fn(), + }, + MatrixClient: vi.fn(), + SimpleFsStorageProvider: vi.fn(), + RustSdkCryptoStorageProvider: vi.fn(), +})); + +const loadWebMediaMock = vi.fn().mockResolvedValue({ + buffer: Buffer.from("media"), + fileName: "photo.png", + contentType: "image/png", + kind: "image", +}); +const getImageMetadataMock = vi.fn().mockResolvedValue(null); +const resizeToJpegMock = vi.fn(); + +const runtimeStub = { + config: { + loadConfig: () => ({}), + }, + media: { + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), + mediaKindFromMime: () => "image", + isVoiceCompatibleAudio: () => false, + getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), + resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), + }, + channel: { + text: { + resolveTextChunkLimit: () => 4000, + resolveChunkMode: () => "length", + chunkMarkdownText: (text: string) => (text ? [text] : []), + chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, + }, + }, +} as unknown as PluginRuntime; + +let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; + +const makeClient = () => { + const sendMessage = vi.fn().mockResolvedValue("evt1"); + const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); + const client = { + sendMessage, + uploadContent, + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + return { client, sendMessage, uploadContent }; +}; + +describe("sendMessageMatrix media", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("uploads media with url payloads", async () => { + const { client, sendMessage, uploadContent } = makeClient(); + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + const uploadArg = uploadContent.mock.calls[0]?.[0]; + expect(Buffer.isBuffer(uploadArg)).toBe(true); + + const content = sendMessage.mock.calls[0]?.[1] as { + url?: string; + msgtype?: string; + format?: string; + formatted_body?: string; + }; + expect(content.msgtype).toBe("m.image"); + expect(content.format).toBe("org.matrix.custom.html"); + expect(content.formatted_body).toContain("caption"); + expect(content.url).toBe("mxc://example/file"); + }); + + it("uploads encrypted media with file payloads", async () => { + const { client, sendMessage, uploadContent } = makeClient(); + (client as { crypto?: object }).crypto = { + isRoomEncrypted: vi.fn().mockResolvedValue(true), + encryptMedia: vi.fn().mockResolvedValue({ + buffer: Buffer.from("encrypted"), + file: { + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + }), + }; + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined; + expect(uploadArg?.toString()).toBe("encrypted"); + + const content = sendMessage.mock.calls[0]?.[1] as { + url?: string; + file?: { url?: string }; + }; + expect(content.url).toBeUndefined(); + expect(content.file?.url).toBe("mxc://example/file"); + }); +}); + +describe("sendMessageMatrix threads", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("includes thread relation metadata when threadId is set", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello thread", { + client, + threadId: "$thread", + }); + + const content = sendMessage.mock.calls[0]?.[1] as { + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; + }; + + expect(content["m.relates_to"]).toMatchObject({ + rel_type: "m.thread", + event_id: "$thread", + "m.in_reply_to": { event_id: "$thread" }, + }); + }); +}); diff --git a/src/matrix/send.ts b/src/matrix/send.ts new file mode 100644 index 000000000..8f853ff2a --- /dev/null +++ b/src/matrix/send.ts @@ -0,0 +1,255 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import type { PollInput } from "openclaw/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; +import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; +import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; +import { + buildReplyRelation, + buildTextContent, + buildThreadRelation, + resolveMatrixMsgType, + resolveMatrixVoiceDecision, +} from "./send/formatting.js"; +import { + buildMediaContent, + prepareImageInfo, + resolveMediaDurationMs, + uploadMediaMaybeEncrypted, +} from "./send/media.js"; +import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; +import { + EventType, + MsgType, + RelationType, + type MatrixOutboundContent, + type MatrixSendOpts, + type MatrixSendResult, + type ReactionEventContent, +} from "./send/types.js"; + +const MATRIX_TEXT_LIMIT = 4000; +const getCore = () => getMatrixRuntime(); + +export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; +export { resolveMatrixRoomId } from "./send/targets.js"; + +export async function sendMessageMatrix( + to: string, + message: string, + opts: MatrixSendOpts = {}, +): Promise { + const trimmedMessage = message?.trim() ?? ""; + if (!trimmedMessage && !opts.mediaUrl) { + throw new Error("Matrix send requires text or media"); + } + const { client, stopOnDone } = await resolveMatrixClient({ + client: opts.client, + timeoutMs: opts.timeoutMs, + }); + try { + const roomId = await resolveMatrixRoomId(client, to); + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + // @vector-im/matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind, + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) continue; + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) continue; + const content = buildTextContent(text, relation); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + } + } + + return { + messageId: lastMessageId || "unknown", + roomId, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function sendPollMatrix( + to: string, + poll: PollInput, + opts: MatrixSendOpts = {}, +): Promise<{ eventId: string; roomId: string }> { + if (!poll.question?.trim()) { + throw new Error("Matrix poll requires a question"); + } + if (!poll.options?.length) { + throw new Error("Matrix poll requires options"); + } + const { client, stopOnDone } = await resolveMatrixClient({ + client: opts.client, + timeoutMs: opts.timeoutMs, + }); + + try { + const roomId = await resolveMatrixRoomId(client, to); + const pollContent = buildPollStartContent(poll); + const threadId = normalizeThreadId(opts.threadId); + const pollPayload = threadId + ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } + : pollContent; + // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly + const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); + + return { + eventId: eventId ?? "unknown", + roomId, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function sendTypingMatrix( + roomId: string, + typing: boolean, + timeoutMs?: number, + client?: MatrixClient, +): Promise { + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + timeoutMs, + }); + try { + const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; + await resolved.setTyping(roomId, typing, resolvedTimeoutMs); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} + +export async function sendReadReceiptMatrix( + roomId: string, + eventId: string, + client?: MatrixClient, +): Promise { + if (!eventId?.trim()) return; + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + }); + try { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + await resolved.sendReadReceipt(resolvedRoom, eventId.trim()); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} + +export async function reactMatrixMessage( + roomId: string, + messageId: string, + emoji: string, + client?: MatrixClient, +): Promise { + if (!emoji.trim()) { + throw new Error("Matrix reaction requires an emoji"); + } + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + }); + try { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + const reaction: ReactionEventContent = { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: messageId, + key: emoji, + }, + }; + await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} diff --git a/src/matrix/send/client.ts b/src/matrix/send/client.ts new file mode 100644 index 000000000..296a790fe --- /dev/null +++ b/src/matrix/send/client.ts @@ -0,0 +1,63 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { CoreConfig } from "../types.js"; + +const getCore = () => getMatrixRuntime(); + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export function resolveMediaMaxBytes(): number | undefined { + const cfg = getCore().config.loadConfig() as CoreConfig; + if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { + return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; + } + return undefined; +} + +export async function resolveMatrixClient(opts: { + client?: MatrixClient; + timeoutMs?: number; +}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { + ensureNodeRuntime(); + if (opts.client) return { client: opts.client, stopOnDone: false }; + const active = getActiveMatrixClient(); + if (active) return { client: active, stopOnDone: false }; + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth(); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off sends; normal sync will retry. + } + } + // @vector-im/matrix-bot-sdk uses start() instead of startClient() + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/src/matrix/send/formatting.ts b/src/matrix/send/formatting.ts new file mode 100644 index 000000000..ef2edeaf4 --- /dev/null +++ b/src/matrix/send/formatting.ts @@ -0,0 +1,92 @@ +import { markdownToMatrixHtml } from "../format.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { + MsgType, + RelationType, + type MatrixFormattedContent, + type MatrixMediaMsgType, + type MatrixRelation, + type MatrixReplyRelation, + type MatrixTextContent, + type MatrixThreadRelation, +} from "./types.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildTextContent( + body: string, + relation?: MatrixRelation, +): MatrixTextContent { + const content: MatrixTextContent = relation + ? { + msgtype: MsgType.Text, + body, + "m.relates_to": relation, + } + : { + msgtype: MsgType.Text, + body, + }; + applyMatrixFormatting(content, body); + return content; +} + +export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { + const formatted = markdownToMatrixHtml(body ?? ""); + if (!formatted) return; + content.format = "org.matrix.custom.html"; + content.formatted_body = formatted; +} + +export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { + const trimmed = replyToId?.trim(); + if (!trimmed) return undefined; + return { "m.in_reply_to": { event_id: trimmed } }; +} + +export function buildThreadRelation( + threadId: string, + replyToId?: string, +): MatrixThreadRelation { + const trimmed = threadId.trim(); + return { + rel_type: RelationType.Thread, + event_id: trimmed, + is_falling_back: true, + "m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) }, + }; +} + +export function resolveMatrixMsgType( + contentType?: string, + _fileName?: string, +): MatrixMediaMsgType { + const kind = getCore().media.mediaKindFromMime(contentType ?? ""); + switch (kind) { + case "image": + return MsgType.Image; + case "audio": + return MsgType.Audio; + case "video": + return MsgType.Video; + default: + return MsgType.File; + } +} + +export function resolveMatrixVoiceDecision(opts: { + wantsVoice: boolean; + contentType?: string; + fileName?: string; +}): { useVoice: boolean } { + if (!opts.wantsVoice) return { useVoice: false }; + if ( + getCore().media.isVoiceCompatibleAudio({ + contentType: opts.contentType, + fileName: opts.fileName, + }) + ) { + return { useVoice: true }; + } + return { useVoice: false }; +} diff --git a/src/matrix/send/media.ts b/src/matrix/send/media.ts new file mode 100644 index 000000000..8c564bddb --- /dev/null +++ b/src/matrix/send/media.ts @@ -0,0 +1,220 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MatrixClient, + TimedFileInfo, + VideoFileInfo, +} from "@vector-im/matrix-bot-sdk"; +import { parseBuffer, type IFileInfo } from "music-metadata"; + +import { getMatrixRuntime } from "../../runtime.js"; +import { + type MatrixMediaContent, + type MatrixMediaInfo, + type MatrixMediaMsgType, + type MatrixRelation, + type MediaKind, +} from "./types.js"; +import { applyMatrixFormatting } from "./formatting.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildMatrixMediaInfo(params: { + size: number; + mimetype?: string; + durationMs?: number; + imageInfo?: DimensionalFileInfo; +}): MatrixMediaInfo | undefined { + const base: FileWithThumbnailInfo = {}; + if (Number.isFinite(params.size)) { + base.size = params.size; + } + if (params.mimetype) { + base.mimetype = params.mimetype; + } + if (params.imageInfo) { + const dimensional: DimensionalFileInfo = { + ...base, + ...params.imageInfo, + }; + if (typeof params.durationMs === "number") { + const videoInfo: VideoFileInfo = { + ...dimensional, + duration: params.durationMs, + }; + return videoInfo; + } + return dimensional; + } + if (typeof params.durationMs === "number") { + const timedInfo: TimedFileInfo = { + ...base, + duration: params.durationMs, + }; + return timedInfo; + } + if (Object.keys(base).length === 0) return undefined; + return base; +} + +export function buildMediaContent(params: { + msgtype: MatrixMediaMsgType; + body: string; + url?: string; + filename?: string; + mimetype?: string; + size: number; + relation?: MatrixRelation; + isVoice?: boolean; + durationMs?: number; + imageInfo?: DimensionalFileInfo; + file?: EncryptedFile; +}): MatrixMediaContent { + const info = buildMatrixMediaInfo({ + size: params.size, + mimetype: params.mimetype, + durationMs: params.durationMs, + imageInfo: params.imageInfo, + }); + const base: MatrixMediaContent = { + msgtype: params.msgtype, + body: params.body, + filename: params.filename, + info: info ?? undefined, + }; + // Encrypted media should only include the "file" payload, not top-level "url". + if (!params.file && params.url) { + base.url = params.url; + } + // For encrypted files, add the file object + if (params.file) { + base.file = params.file; + } + if (params.isVoice) { + base["org.matrix.msc3245.voice"] = {}; + if (typeof params.durationMs === "number") { + base["org.matrix.msc1767.audio"] = { + duration: params.durationMs, + }; + } + } + if (params.relation) { + base["m.relates_to"] = params.relation; + } + applyMatrixFormatting(base, params.body); + return base; +} + +const THUMBNAIL_MAX_SIDE = 800; +const THUMBNAIL_QUALITY = 80; + +export async function prepareImageInfo(params: { + buffer: Buffer; + client: MatrixClient; +}): Promise { + const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null); + if (!meta) return undefined; + const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; + const maxDim = Math.max(meta.width, meta.height); + if (maxDim > THUMBNAIL_MAX_SIDE) { + try { + const thumbBuffer = await getCore().media.resizeToJpeg({ + buffer: params.buffer, + maxSide: THUMBNAIL_MAX_SIDE, + quality: THUMBNAIL_QUALITY, + withoutEnlargement: true, + }); + const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null); + const thumbUri = await params.client.uploadContent( + thumbBuffer, + "image/jpeg", + "thumbnail.jpg", + ); + imageInfo.thumbnail_url = thumbUri; + if (thumbMeta) { + imageInfo.thumbnail_info = { + w: thumbMeta.width, + h: thumbMeta.height, + mimetype: "image/jpeg", + size: thumbBuffer.byteLength, + }; + } + } catch { + // Thumbnail generation failed, continue without it + } + } + return imageInfo; +} + +export async function resolveMediaDurationMs(params: { + buffer: Buffer; + contentType?: string; + fileName?: string; + kind: MediaKind; +}): Promise { + if (params.kind !== "audio" && params.kind !== "video") return undefined; + try { + const fileInfo: IFileInfo | string | undefined = + params.contentType || params.fileName + ? { + mimeType: params.contentType, + size: params.buffer.byteLength, + path: params.fileName, + } + : undefined; + const metadata = await parseBuffer(params.buffer, fileInfo, { + duration: true, + skipCovers: true, + }); + const durationSeconds = metadata.format.duration; + if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) { + return Math.max(0, Math.round(durationSeconds * 1000)); + } + } catch { + // Duration is optional; ignore parse failures. + } + return undefined; +} + +async function uploadFile( + client: MatrixClient, + file: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise { + return await client.uploadContent(file, params.contentType, params.filename); +} + +/** + * Upload media with optional encryption for E2EE rooms. + */ +export async function uploadMediaMaybeEncrypted( + client: MatrixClient, + roomId: string, + buffer: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise<{ url: string; file?: EncryptedFile }> { + // Check if room is encrypted and crypto is available + const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId); + + if (isEncrypted && client.crypto) { + // Encrypt the media before uploading + const encrypted = await client.crypto.encryptMedia(buffer); + const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename); + const file: EncryptedFile = { url: mxc, ...encrypted.file }; + return { + url: mxc, + file, + }; + } + + // Upload unencrypted + const mxc = await uploadFile(client, buffer, params); + return { url: mxc }; +} diff --git a/src/matrix/send/targets.test.ts b/src/matrix/send/targets.test.ts new file mode 100644 index 000000000..7173b1cf6 --- /dev/null +++ b/src/matrix/send/targets.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { EventType } from "./types.js"; + +let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; +let normalizeThreadId: typeof import("./targets.js").normalizeThreadId; + +beforeEach(async () => { + vi.resetModules(); + ({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js")); +}); + +describe("resolveMatrixRoomId", () => { + it("uses m.direct when available", async () => { + const userId = "@user:example.org"; + const client = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: ["!room:example.org"], + }), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn(), + setAccountData: vi.fn(), + } as unknown as MatrixClient; + + const roomId = await resolveMatrixRoomId(client, userId); + + expect(roomId).toBe("!room:example.org"); + expect(client.getJoinedRooms).not.toHaveBeenCalled(); + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("falls back to joined rooms and persists m.direct", async () => { + const userId = "@fallback:example.org"; + const roomId = "!room:example.org"; + const setAccountData = vi.fn().mockResolvedValue(undefined); + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue([roomId]), + getJoinedRoomMembers: vi.fn().mockResolvedValue([ + "@bot:example.org", + userId, + ]), + setAccountData, + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + expect(setAccountData).toHaveBeenCalledWith( + EventType.Direct, + expect.objectContaining({ [userId]: [roomId] }), + ); + }); + + it("continues when a room member lookup fails", async () => { + const userId = "@continue:example.org"; + const roomId = "!good:example.org"; + const setAccountData = vi.fn().mockResolvedValue(undefined); + const getJoinedRoomMembers = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(["@bot:example.org", userId]); + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]), + getJoinedRoomMembers, + setAccountData, + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + expect(setAccountData).toHaveBeenCalled(); + }); + + it("allows larger rooms when no 1:1 match exists", async () => { + const userId = "@group:example.org"; + const roomId = "!group:example.org"; + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue([roomId]), + getJoinedRoomMembers: vi.fn().mockResolvedValue([ + "@bot:example.org", + userId, + "@extra:example.org", + ]), + setAccountData: vi.fn().mockResolvedValue(undefined), + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + }); +}); + +describe("normalizeThreadId", () => { + it("returns null for empty thread ids", () => { + expect(normalizeThreadId(" ")).toBeNull(); + expect(normalizeThreadId("$thread")).toBe("$thread"); + }); +}); diff --git a/src/matrix/send/targets.ts b/src/matrix/send/targets.ts new file mode 100644 index 000000000..6ec6ad6d7 --- /dev/null +++ b/src/matrix/send/targets.ts @@ -0,0 +1,144 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +import { EventType, type MatrixDirectAccountData } from "./types.js"; + +function normalizeTarget(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Matrix target is required (room: or #alias)"); + } + return trimmed; +} + +export function normalizeThreadId(raw?: string | number | null): string | null { + if (raw === undefined || raw === null) return null; + const trimmed = String(raw).trim(); + return trimmed ? trimmed : null; +} + +const directRoomCache = new Map(); + +async function persistDirectRoom( + client: MatrixClient, + userId: string, + roomId: string, +): Promise { + let directContent: MatrixDirectAccountData | null = null; + try { + directContent = (await client.getAccountData( + EventType.Direct, + )) as MatrixDirectAccountData | null; + } catch { + // Ignore fetch errors and fall back to an empty map. + } + const existing = + directContent && !Array.isArray(directContent) ? directContent : {}; + const current = Array.isArray(existing[userId]) ? existing[userId] : []; + if (current[0] === roomId) return; + const next = [roomId, ...current.filter((id) => id !== roomId)]; + try { + await client.setAccountData(EventType.Direct, { + ...existing, + [userId]: next, + }); + } catch { + // Ignore persistence errors. + } +} + +async function resolveDirectRoomId( + client: MatrixClient, + userId: string, +): Promise { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + throw new Error( + `Matrix user IDs must be fully qualified (got "${trimmed}")`, + ); + } + + const cached = directRoomCache.get(trimmed); + if (cached) return cached; + + // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot). + try { + const directContent = (await client.getAccountData( + EventType.Direct, + )) as MatrixDirectAccountData | null; + const list = Array.isArray(directContent?.[trimmed]) + ? directContent[trimmed] + : []; + if (list.length > 0) { + directRoomCache.set(trimmed, list[0]); + return list[0]; + } + } catch { + // Ignore and fall back. + } + + // 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. + // Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. + let fallbackRoom: string | null = null; + try { + const rooms = await client.getJoinedRooms(); + for (const roomId of rooms) { + let members: string[]; + try { + members = await client.getJoinedRoomMembers(roomId); + } catch { + continue; + } + if (!members.includes(trimmed)) continue; + // Prefer classic 1:1 rooms, but allow larger rooms if requested. + if (members.length === 2) { + directRoomCache.set(trimmed, roomId); + await persistDirectRoom(client, trimmed, roomId); + return roomId; + } + if (!fallbackRoom) { + fallbackRoom = roomId; + } + } + } catch { + // Ignore and fall back. + } + + if (fallbackRoom) { + directRoomCache.set(trimmed, fallbackRoom); + await persistDirectRoom(client, trimmed, fallbackRoom); + return fallbackRoom; + } + + throw new Error(`No direct room found for ${trimmed} (m.direct missing)`); +} + +export async function resolveMatrixRoomId( + client: MatrixClient, + raw: string, +): Promise { + const target = normalizeTarget(raw); + const lowered = target.toLowerCase(); + if (lowered.startsWith("matrix:")) { + return await resolveMatrixRoomId(client, target.slice("matrix:".length)); + } + if (lowered.startsWith("room:")) { + return await resolveMatrixRoomId(client, target.slice("room:".length)); + } + if (lowered.startsWith("channel:")) { + return await resolveMatrixRoomId(client, target.slice("channel:".length)); + } + if (lowered.startsWith("user:")) { + return await resolveDirectRoomId(client, target.slice("user:".length)); + } + if (target.startsWith("@")) { + return await resolveDirectRoomId(client, target); + } + if (target.startsWith("#")) { + const resolved = await client.resolveRoom(target); + if (!resolved) { + throw new Error(`Matrix alias ${target} could not be resolved`); + } + return resolved; + } + return target; +} diff --git a/src/matrix/send/types.ts b/src/matrix/send/types.ts new file mode 100644 index 000000000..2b91327aa --- /dev/null +++ b/src/matrix/send/types.ts @@ -0,0 +1,109 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MessageEventContent, + TextualMessageEventContent, + TimedFileInfo, + VideoFileInfo, +} from "@vector-im/matrix-bot-sdk"; + +// Message types +export const MsgType = { + Text: "m.text", + Image: "m.image", + Audio: "m.audio", + Video: "m.video", + File: "m.file", + Notice: "m.notice", +} as const; + +// Relation types +export const RelationType = { + Annotation: "m.annotation", + Replace: "m.replace", + Thread: "m.thread", +} as const; + +// Event types +export const EventType = { + Direct: "m.direct", + Reaction: "m.reaction", + RoomMessage: "m.room.message", +} as const; + +export type MatrixDirectAccountData = Record; + +export type MatrixReplyRelation = { + "m.in_reply_to": { event_id: string }; +}; + +export type MatrixThreadRelation = { + rel_type: typeof RelationType.Thread; + event_id: string; + is_falling_back?: boolean; + "m.in_reply_to"?: { event_id: string }; +}; + +export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation; + +export type MatrixReplyMeta = { + "m.relates_to"?: MatrixRelation; +}; + +export type MatrixMediaInfo = + | FileWithThumbnailInfo + | DimensionalFileInfo + | TimedFileInfo + | VideoFileInfo; + +export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta; + +export type MatrixMediaContent = MessageEventContent & + MatrixReplyMeta & { + info?: MatrixMediaInfo; + url?: string; + file?: EncryptedFile; + filename?: string; + "org.matrix.msc3245.voice"?: Record; + "org.matrix.msc1767.audio"?: { duration: number }; + }; + +export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: typeof RelationType.Annotation; + event_id: string; + key: string; + }; +}; + +export type MatrixSendResult = { + messageId: string; + roomId: string; +}; + +export type MatrixSendOpts = { + client?: import("@vector-im/matrix-bot-sdk").MatrixClient; + mediaUrl?: string; + accountId?: string; + replyToId?: string; + threadId?: string | number | null; + timeoutMs?: number; + /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ + audioAsVoice?: boolean; +}; + +export type MatrixMediaMsgType = + | typeof MsgType.Image + | typeof MsgType.Audio + | typeof MsgType.Video + | typeof MsgType.File; + +export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +export type MatrixFormattedContent = MessageEventContent & { + format?: string; + formatted_body?: string; +}; diff --git a/src/onboarding.ts b/src/onboarding.ts new file mode 100644 index 000000000..dbd4e6027 --- /dev/null +++ b/src/onboarding.ts @@ -0,0 +1,432 @@ +import { + addWildcardAllowFrom, + formatDocsLink, + promptChannelAccessConfig, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; + +const channel = "matrix" as const; + +function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { + const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Matrix requires a homeserver URL.", + "Use an access token (recommended) or a password (logs in and stores a token).", + "With access token: user ID is fetched automatically.", + "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", + `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, + ].join("\n"), + "Matrix setup", + ); +} + +async function promptMatrixAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; +}): Promise { + const { cfg, prompter } = params; + const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + const account = resolveMatrixAccount({ cfg }); + const canResolve = Boolean(account.configured); + + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (username or user id)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + let unresolved: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + const results = await listMatrixDirectoryPeersLive({ + cfg, + query: part, + limit: 5, + }).catch(() => []); + const match = results.find((result) => result.id); + if (match?.id) { + resolvedIds.push(match.id); + if (results.length > 1) { + await prompter.note( + `Multiple matches for "${part}", using ${match.id}.`, + "Matrix allowlist", + ); + } + } else { + unresolved.push(part); + } + } + + if (unresolved.length > 0) { + await prompter.note( + `Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = [ + ...new Set([ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + ...resolvedIds, + ]), + ]; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + dm: { + ...cfg.channels?.matrix?.dm, + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; + } +} + +function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + groupPolicy, + }, + }, + }; +} + +function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { + const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + groups, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Matrix", + channel, + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: promptMatrixAllowFrom, +}; + +export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); + const configured = account.configured; + const sdkReady = isMatrixSdkAvailable(); + return { + channel, + configured, + statusLines: [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ], + selectionHint: !sdkReady + ? "install @vector-im/matrix-bot-sdk" + : configured + ? "configured" + : "needs auth", + }; + }, + configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { + let next = cfg as CoreConfig; + await ensureMatrixSdkInstalled({ + runtime, + confirm: async (message) => + await prompter.confirm({ + message, + initialValue: true, + }), + }); + const existing = next.channels?.matrix ?? {}; + const account = resolveMatrixAccount({ cfg: next }); + if (!account.configured) { + await noteMatrixAuthHelp(prompter); + } + + const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); + const envUserId = process.env.MATRIX_USER_ID?.trim(); + const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); + const envPassword = process.env.MATRIX_PASSWORD?.trim(); + const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const useEnv = await prompter.confirm({ + message: "Matrix env vars detected. Use env values?", + initialValue: true, + }); + if (useEnv) { + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + }; + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + return { cfg: next }; + } + } + + const homeserver = String( + await prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + if (!/^https?:\/\//i.test(raw)) return "Use a full URL (https://...)"; + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = existing.password ?? ""; + let userId = existing.userId ?? ""; + + if (accessToken || password) { + const keep = await prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + } + } + + if (!accessToken && !password) { + // Ask auth method FIRST before asking for user ID + const authMode = (await prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + ], + })) as "token" | "password"; + + if (authMode === "token") { + accessToken = String( + await prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + // With access token, we can fetch the userId automatically - don't prompt for it + // The client.ts will use whoami() to get it + userId = ""; + } else { + // Password auth requires user ID upfront + userId = String( + await prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + if (!raw.startsWith("@")) return "Matrix user IDs should start with @"; + if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)"; + return undefined; + }, + }), + ).trim(); + password = String( + await prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const deviceName = String( + await prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + // Ask about E2EE encryption + const enableEncryption = await prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + homeserver, + userId: userId || undefined, + accessToken: accessToken || undefined, + password: password || undefined, + deviceName: deviceName || undefined, + encryption: enableEncryption || undefined, + }, + }, + }; + + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + + const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [ + ...resolvedIds, + ...unresolved.map((entry) => entry.trim()).filter(Boolean), + ]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixGroupRooms(next, roomKeys); + } + } + + return { cfg: next }; + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false }, + }, + }), +}; diff --git a/src/outbound.ts b/src/outbound.ts new file mode 100644 index 000000000..91a6ced80 --- /dev/null +++ b/src/outbound.ts @@ -0,0 +1,53 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; + +import { getMatrixRuntime } from "./runtime.js"; +import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; + +export const matrixOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ to, text, deps, replyToId, threadId }) => { + const send = deps?.sendMatrix ?? sendMessageMatrix; + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await send(to, text, { + replyToId: replyToId ?? undefined, + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.messageId, + roomId: result.roomId, + }; + }, + sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId }) => { + const send = deps?.sendMatrix ?? sendMessageMatrix; + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await send(to, text, { + mediaUrl, + replyToId: replyToId ?? undefined, + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.messageId, + roomId: result.roomId, + }; + }, + sendPoll: async ({ to, poll, threadId }) => { + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await sendPollMatrix(to, poll, { + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.eventId, + roomId: result.roomId, + pollId: result.eventId, + }; + }, +}; diff --git a/src/resolve-targets.ts b/src/resolve-targets.ts new file mode 100644 index 000000000..4e776da53 --- /dev/null +++ b/src/resolve-targets.ts @@ -0,0 +1,89 @@ +import type { + ChannelDirectoryEntry, + ChannelResolveKind, + ChannelResolveResult, + RuntimeEnv, +} from "openclaw/plugin-sdk"; + +import { + listMatrixDirectoryGroupsLive, + listMatrixDirectoryPeersLive, +} from "./directory-live.js"; + +function pickBestGroupMatch( + matches: ChannelDirectoryEntry[], + query: string, +): ChannelDirectoryEntry | undefined { + if (matches.length === 0) return undefined; + const normalized = query.trim().toLowerCase(); + if (normalized) { + const exact = matches.find((match) => { + const name = match.name?.trim().toLowerCase(); + const handle = match.handle?.trim().toLowerCase(); + const id = match.id.trim().toLowerCase(); + return name === normalized || handle === normalized || id === normalized; + }); + if (exact) return exact; + } + return matches[0]; +} + +export async function resolveMatrixTargets(params: { + cfg: unknown; + inputs: string[]; + kind: ChannelResolveKind; + runtime?: RuntimeEnv; +}): Promise { + const results: ChannelResolveResult[] = []; + for (const input of params.inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (params.kind === "user") { + if (trimmed.startsWith("@") && trimmed.includes(":")) { + results.push({ input, resolved: true, id: trimmed }); + continue; + } + try { + const matches = await listMatrixDirectoryPeersLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + continue; + } + try { + const matches = await listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + const best = pickBestGroupMatch(matches, trimmed); + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 000000000..62eff71ad --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setMatrixRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMatrixRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Matrix runtime not initialized"); + } + return runtime; +} diff --git a/src/tool-actions.ts b/src/tool-actions.ts new file mode 100644 index 000000000..cdc704c42 --- /dev/null +++ b/src/tool-actions.ts @@ -0,0 +1,160 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; + +import type { CoreConfig } from "./types.js"; +import { + deleteMatrixMessage, + editMatrixMessage, + getMatrixMemberInfo, + getMatrixRoomInfo, + listMatrixPins, + listMatrixReactions, + pinMatrixMessage, + readMatrixMessages, + removeMatrixReactions, + sendMatrixMessage, + unpinMatrixMessage, +} from "./matrix/actions.js"; +import { reactMatrixMessage } from "./matrix/send.js"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "openclaw/plugin-sdk"; + +const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); +const reactionActions = new Set(["react", "reactions"]); +const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); + +function readRoomId(params: Record, required = true): string { + const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + if (direct) return direct; + if (!required) return readStringParam(params, "to") ?? ""; + return readStringParam(params, "to", { required: true }); +} + +export async function handleMatrixAction( + params: Record, + cfg: CoreConfig, +): Promise> { + const action = readStringParam(params, "action", { required: true }); + const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions); + + if (reactionActions.has(action)) { + if (!isActionEnabled("reactions")) { + throw new Error("Matrix reactions are disabled."); + } + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + if (action === "react") { + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Matrix reaction.", + }); + if (remove || isEmpty) { + const result = await removeMatrixReactions(roomId, messageId, { + emoji: remove ? emoji : undefined, + }); + return jsonResult({ ok: true, removed: result.removed }); + } + await reactMatrixMessage(roomId, messageId, emoji); + return jsonResult({ ok: true, added: emoji }); + } + const reactions = await listMatrixReactions(roomId, messageId); + return jsonResult({ ok: true, reactions }); + } + + if (messageActions.has(action)) { + if (!isActionEnabled("messages")) { + throw new Error("Matrix messages are disabled."); + } + switch (action) { + case "sendMessage": { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "mediaUrl"); + const replyToId = readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + const result = await sendMatrixMessage(to, content, { + mediaUrl: mediaUrl ?? undefined, + replyToId: replyToId ?? undefined, + threadId: threadId ?? undefined, + }); + return jsonResult({ ok: true, result }); + } + case "editMessage": { + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "content", { required: true }); + const result = await editMatrixMessage(roomId, messageId, content); + return jsonResult({ ok: true, result }); + } + case "deleteMessage": { + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + const reason = readStringParam(params, "reason"); + await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined }); + return jsonResult({ ok: true, deleted: true }); + } + case "readMessages": { + const roomId = readRoomId(params); + const limit = readNumberParam(params, "limit", { integer: true }); + const before = readStringParam(params, "before"); + const after = readStringParam(params, "after"); + const result = await readMatrixMessages(roomId, { + limit: limit ?? undefined, + before: before ?? undefined, + after: after ?? undefined, + }); + return jsonResult({ ok: true, ...result }); + } + default: + break; + } + } + + if (pinActions.has(action)) { + if (!isActionEnabled("pins")) { + throw new Error("Matrix pins are disabled."); + } + const roomId = readRoomId(params); + if (action === "pinMessage") { + const messageId = readStringParam(params, "messageId", { required: true }); + const result = await pinMatrixMessage(roomId, messageId); + return jsonResult({ ok: true, pinned: result.pinned }); + } + if (action === "unpinMessage") { + const messageId = readStringParam(params, "messageId", { required: true }); + const result = await unpinMatrixMessage(roomId, messageId); + return jsonResult({ ok: true, pinned: result.pinned }); + } + const result = await listMatrixPins(roomId); + return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); + } + + if (action === "memberInfo") { + if (!isActionEnabled("memberInfo")) { + throw new Error("Matrix member info is disabled."); + } + const userId = readStringParam(params, "userId", { required: true }); + const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + const result = await getMatrixMemberInfo(userId, { + roomId: roomId ?? undefined, + }); + return jsonResult({ ok: true, member: result }); + } + + if (action === "channelInfo") { + if (!isActionEnabled("channelInfo")) { + throw new Error("Matrix room info is disabled."); + } + const roomId = readRoomId(params); + const result = await getMatrixRoomInfo(roomId); + return jsonResult({ ok: true, room: result }); + } + + throw new Error(`Unsupported Matrix action: ${action}`); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..17ff35a7a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,108 @@ +export type ReplyToMode = "off" | "first" | "all"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; + +export type MatrixDmConfig = { + /** If false, ignore all incoming Matrix DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */ + allowFrom?: Array; +}; + +export type MatrixRoomConfig = { + /** If false, disable the bot in this room (alias for allow: false). */ + enabled?: boolean; + /** Legacy room allow toggle; prefer enabled. */ + allow?: boolean; + /** Require mentioning the bot to trigger replies. */ + requireMention?: boolean; + /** Optional tool policy overrides for this room. */ + tools?: { allow?: string[]; deny?: string[] }; + /** If true, reply without mention requirements. */ + autoReply?: boolean; + /** Optional allowlist for room senders (user IDs or localparts). */ + users?: Array; + /** Optional skill filter for this room. */ + skills?: string[]; + /** Optional system prompt snippet for this room. */ + systemPrompt?: string; +}; + +export type MatrixActionConfig = { + reactions?: boolean; + messages?: boolean; + pins?: boolean; + memberInfo?: boolean; + channelInfo?: boolean; +}; + +export type MatrixAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this account. Default: true. */ + enabled?: boolean; + /** Matrix homeserver URL (https://matrix.example.org). */ + homeserver?: string; + /** Matrix user id (@user:server). */ + userId?: string; + /** Matrix access token. */ + accessToken?: string; + /** Matrix password (used only to fetch access token). */ + password?: string; + /** Optional device name when logging in via password. */ + deviceName?: string; + /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */ + initialSyncLimit?: number; + /** Enable end-to-end encryption (E2EE). Default: false. */ + encryption?: boolean; + /** If true, enforce allowlists for groups + DMs regardless of policy. */ + allowlistOnly?: boolean; + /** Group message policy (default: allowlist). */ + groupPolicy?: GroupPolicy; + /** Allowlist for group senders (user IDs or localparts). */ + groupAllowFrom?: Array; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + /** How to handle thread replies (off|inbound|always). */ + threadReplies?: "off" | "inbound" | "always"; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; + /** Auto-join invites (always|allowlist|off). Default: always. */ + autoJoin?: "always" | "allowlist" | "off"; + /** Allowlist for auto-join invites (room IDs, aliases). */ + autoJoinAllowlist?: Array; + /** Direct message policy + allowlist overrides. */ + dm?: MatrixDmConfig; + /** Room config allowlist keyed by room ID, alias, or name. */ + groups?: Record; + /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */ + rooms?: Record; + /** Per-action tool gating (default: true for all). */ + actions?: MatrixActionConfig; +}; + +export type MatrixConfig = { + /** Optional per-account Matrix configuration (multi-account). */ + accounts?: Record; +} & MatrixAccountConfig; + +export type CoreConfig = { + channels?: { + matrix?: MatrixConfig; + }; + bindings?: Array<{ + agentId?: string; + match?: { + channel?: string; + accountId?: string; + peer?: { kind?: string; id?: string }; + }; + }>; + [key: string]: unknown; +}; diff --git a/tsconfig.check.json b/tsconfig.check.json new file mode 100644 index 000000000..20aadbbeb --- /dev/null +++ b/tsconfig.check.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}