From d3fd2d32157f0ca3aa9cb80cc90259d071fb3fb0 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 07:29:22 -0700 Subject: [PATCH 1/4] Align Linq messaging with channel runtime SDK --- openclaw.plugin.json | 4 +- package-lock.json | 428 ++++++++++++++++++++---------- package.json | 2 +- src/channel.ts | 19 +- src/linq/config.test.ts | 50 +++- src/linq/config.ts | 55 ++-- src/linq/gateway.ts | 182 +++++++++++++ src/linq/inbound.test.ts | 280 ++++++++++++++++++++ src/linq/inbound.ts | 257 ++++++++++++++++++ src/linq/ingress.ts | 93 +++++++ src/linq/message.test.ts | 46 ++++ src/linq/message.ts | 87 +++++++ src/linq/monitor.test.ts | 49 ---- src/linq/monitor.ts | 457 --------------------------------- src/linq/session-route.test.ts | 47 ++++ src/linq/session-route.ts | 33 +++ 16 files changed, 1425 insertions(+), 664 deletions(-) create mode 100644 src/linq/gateway.ts create mode 100644 src/linq/inbound.test.ts create mode 100644 src/linq/inbound.ts create mode 100644 src/linq/ingress.ts create mode 100644 src/linq/message.test.ts create mode 100644 src/linq/message.ts delete mode 100644 src/linq/monitor.test.ts delete mode 100644 src/linq/monitor.ts create mode 100644 src/linq/session-route.test.ts create mode 100644 src/linq/session-route.ts diff --git a/openclaw.plugin.json b/openclaw.plugin.json index fb605cb..c907137 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -27,7 +27,7 @@ "default": "/linq-webhook" }, "webhookHost": { "type": "string", "minLength": 1 }, - "accounts": { "type": "object", "additionalProperties": true }, + "accounts": { "type": "object", "additionalProperties": { "$ref": "#" } }, "defaultAccount": { "type": "string", "minLength": 1 } }, "$defs": { @@ -71,7 +71,7 @@ "default": "/linq-webhook" }, "webhookHost": { "type": "string", "minLength": 1 }, - "accounts": { "type": "object", "additionalProperties": true }, + "accounts": { "type": "object", "additionalProperties": { "$ref": "#" } }, "defaultAccount": { "type": "string", "minLength": 1 } }, "$defs": { diff --git a/package-lock.json b/package-lock.json index 457f90c..563f5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "vitest": "^3.2.2" }, "peerDependencies": { - "openclaw": ">=2026.6.0" + "openclaw": ">=2026.6.1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1236,41 +1236,42 @@ } }, "node_modules/openclaw": { - "version": "2026.6.1", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.6.1.tgz", - "integrity": "sha512-rGSwhIo8N37cQQ5puG8vmWZESE8q/ych5VFpzOQNcf49TF/rvCYyxiNAyot11qbUZF5wfLh8bsvofapnOEh0BQ==", + "version": "2026.7.1", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", + "integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==", "hasInstallScript": true, "hasShrinkwrap": true, "license": "MIT", "peer": true, "dependencies": { - "@agentclientprotocol/sdk": "0.22.1", - "@anthropic-ai/sdk": "0.100.1", - "@clack/core": "1.3.1", - "@clack/prompts": "1.4.0", - "@earendil-works/pi-tui": "0.78.0", - "@google/genai": "2.7.0", + "@agentclientprotocol/sdk": "1.1.0", + "@anthropic-ai/sdk": "0.109.1", + "@clack/core": "1.4.2", + "@clack/prompts": "1.6.0", + "@earendil-works/pi-tui": "0.80.3", + "@google/genai": "2.10.0", "@grammyjs/runner": "2.0.3", "@grammyjs/transformer-throttler": "1.2.1", "@homebridge/ciao": "1.3.9", "@lydell/node-pty": "1.2.0-beta.12", - "@mistralai/mistralai": "2.2.5", + "@mistralai/mistralai": "2.4.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", - "@openclaw/fs-safe": "0.3.0", + "@openclaw/ai": "2026.7.1", + "@openclaw/fs-safe": "0.4.1", "@openclaw/proxyline": "0.3.3", + "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "chokidar": "5.0.0", "clawpdf": "0.3.0", - "commander": "14.0.3", + "commander": "15.0.0", "croner": "10.0.1", - "cross-spawn": "7.0.6", "diff": "9.0.0", "dotenv": "17.4.2", "express": "5.2.1", "file-type": "22.0.1", "glob": "13.0.6", - "grammy": "1.43.0", + "grammy": "1.44.0", "highlight.js": "11.11.1", "hosted-git-info": "10.1.1", "ignore": "7.0.5", @@ -1281,21 +1282,21 @@ "linkedom": "0.18.12", "minimatch": "10.2.5", "node-edge-tts": "1.2.10", - "openai": "6.39.1", + "openai": "6.45.0", "partial-json": "0.1.7", - "playwright-core": "1.60.0", + "playwright-core": "1.61.1", "proper-lockfile": "4.1.2", "qrcode": "1.5.4", - "quickjs-wasi": "3.0.0", + "quickjs-wasi": "3.0.2", "rastermill": "0.3.1", - "tar": "7.5.15", + "tar": "7.5.19", "tree-sitter-bash": "0.25.1", "tslog": "4.10.2", - "typebox": "1.1.39", + "typebox": "1.3.3", "typescript": "6.0.3", - "undici": "8.3.0", + "undici": "8.5.0", "web-push": "3.6.7", - "web-tree-sitter": "0.26.9", + "web-tree-sitter": "0.26.10", "ws": "8.21.0", "yaml": "2.9.0", "zod": "4.4.3" @@ -1304,16 +1305,16 @@ "openclaw": "openclaw.mjs" }, "engines": { - "node": ">=22.19.0" + "node": ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0" }, "optionalDependencies": { "sqlite-vec": "0.1.9" } }, "node_modules/openclaw/node_modules/@agentclientprotocol/sdk": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.22.1.tgz", - "integrity": "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", "license": "Apache-2.0", "peer": true, "peerDependencies": { @@ -1321,9 +1322,9 @@ } }, "node_modules/openclaw/node_modules/@anthropic-ai/sdk": { - "version": "0.100.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz", - "integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==", + "version": "0.109.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz", + "integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==", "license": "MIT", "peer": true, "dependencies": { @@ -1364,9 +1365,9 @@ } }, "node_modules/openclaw/node_modules/@clack/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", - "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz", + "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==", "license": "MIT", "peer": true, "dependencies": { @@ -1378,13 +1379,13 @@ } }, "node_modules/openclaw/node_modules/@clack/prompts": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", - "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz", + "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==", "license": "MIT", "peer": true, "dependencies": { - "@clack/core": "1.3.1", + "@clack/core": "1.4.2", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -1394,23 +1395,23 @@ } }, "node_modules/openclaw/node_modules/@earendil-works/pi-tui": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.0.tgz", - "integrity": "sha512-3a705FnsVVUhAyceShNB3kS2rpxcxLcx+hqB0u6MMMpHwQGbW+m++MqA6r7eOzq/8FLx5e3vDh38h/SVTk2qzw==", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz", + "integrity": "sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==", "license": "MIT", "peer": true, "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" } }, "node_modules/openclaw/node_modules/@google/genai": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.7.0.tgz", - "integrity": "sha512-tv0DRtcndt2oEhBYy+5mA0TaXH98+L1Gt0AP9unBfH7DP20KhB7+O3QqAN1Lz+laMARGTHS7BFQSNpLbl4gm1g==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz", + "integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==", "hasInstallScript": true, "license": "Apache-2.0", "peer": true, @@ -1465,9 +1466,9 @@ } }, "node_modules/openclaw/node_modules/@grammyjs/types": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.27.3.tgz", - "integrity": "sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz", + "integrity": "sha512-4JvXCdxRZHCje0M4gHzLwtB4bLno3WD28xd8CNfk4POWIu73BFnSvGeW6OQ5gPem4eYTEwkD9yDaXssixl6tMQ==", "license": "MIT", "peer": true }, @@ -1613,15 +1614,24 @@ "peer": true }, "node_modules/openclaw/node_modules/@mistralai/mistralai": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.5.tgz", - "integrity": "sha512-ATbWzKkNzNAZ+gtw9MI/c/ULTMG80tKUiRNIbQFfg4OP0uEZZpTfXZeBCNfs5Dq0uqMQ/tQWc4o6RRJQtMrpDA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.4.0.tgz", + "integrity": "sha512-t6hCx242MTGolB76CI+17jDtPIe/bzLsMdUTMMoMn9Qo1h02N2G5jQYHmKDGU3X//OgR2wvngTD7tO6tPp5poQ==", "license": "Apache-2.0", "peer": true, "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/openclaw/node_modules/@modelcontextprotocol/sdk": { @@ -1675,18 +1685,36 @@ "node": ">=14.0.0" } }, + "node_modules/openclaw/node_modules/@openclaw/ai": { + "version": "2026.7.1", + "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1.tgz", + "integrity": "sha512-FsKy5DXSHf4qyN8Huoz/10HZRgoEwLF4uk8UWaCafaIler+q5Fsl51HcrIqIrEe0S38OT7LOaxnR++MOshAlmw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.109.1", + "@google/genai": "2.10.0", + "@mistralai/mistralai": "2.4.0", + "openai": "6.45.0", + "partial-json": "0.1.7", + "typebox": "1.3.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/openclaw/node_modules/@openclaw/fs-safe": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.3.0.tgz", - "integrity": "sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.4.1.tgz", + "integrity": "sha512-hQi+BxO10KdRFlYUot1syC+hTaUnGeQNdqX5kwkKJig8CFq1tKsYJLPm+zkiiGsSKOprPAquQl/txejEhpKPgg==", "license": "MIT", "peer": true, "engines": { - "node": ">=20.11" + "node": ">=22" }, "optionalDependencies": { "jszip": "^3.10.1", - "tar": "7.5.13" + "tar": "7.5.19" } }, "node_modules/openclaw/node_modules/@openclaw/proxyline": { @@ -1702,6 +1730,89 @@ "undici": ">=8.3.0 <9" } }, + "node_modules/openclaw/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/openclaw/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/openclaw/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/openclaw/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/openclaw/node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", @@ -1741,6 +1852,16 @@ "license": "MIT", "peer": true }, + "node_modules/openclaw/node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/openclaw/node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -1901,28 +2022,28 @@ } }, "node_modules/openclaw/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", + "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", "license": "MIT", "peer": true }, "node_modules/openclaw/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1932,6 +2053,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/openclaw/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/openclaw/node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1947,9 +2082,9 @@ "peer": true }, "node_modules/openclaw/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "peer": true, "dependencies": { @@ -2112,13 +2247,13 @@ "peer": true }, "node_modules/openclaw/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "peer": true, "engines": { - "node": ">=20" + "node": ">=22.12.0" } }, "node_modules/openclaw/node_modules/content-disposition": { @@ -2805,9 +2940,9 @@ } }, "node_modules/openclaw/node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2915,9 +3050,9 @@ } }, "node_modules/openclaw/node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2963,13 +3098,13 @@ "peer": true }, "node_modules/openclaw/node_modules/grammy": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.43.0.tgz", - "integrity": "sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.44.0.tgz", + "integrity": "sha512-gGVykS5+c5f1tPV97LuU6IDRMawE2NzpwM9pNz58HQ35IZXDnYL3VOLvNzYognPSeBIOSzQXRu5w96V0aY8y8A==", "license": "MIT", "peer": true, "dependencies": { - "@grammyjs/types": "3.27.3", + "@grammyjs/types": "3.28.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" @@ -3036,9 +3171,9 @@ } }, "node_modules/openclaw/node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "peer": true, "engines": { @@ -3439,16 +3574,16 @@ } }, "node_modules/openclaw/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "peer": true, "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/openclaw/node_modules/math-intrinsics": { @@ -3585,9 +3720,9 @@ } }, "node_modules/openclaw/node_modules/node-addon-api": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", - "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", "license": "MIT", "peer": true, "engines": { @@ -3711,19 +3846,28 @@ } }, "node_modules/openclaw/node_modules/openai": { - "version": "6.39.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", - "integrity": "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==", + "version": "6.45.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", "license": "Apache-2.0", "peer": true, - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, "ws": { "optional": true }, @@ -3868,9 +4012,9 @@ } }, "node_modules/openclaw/node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", "peer": true, "bin": { @@ -3920,13 +4064,24 @@ } }, "node_modules/openclaw/node_modules/protobufjs": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.0.tgz", - "integrity": "sha512-iriNhQ57SYA5Jbdi+41AyPdx6jPPkFO7DODzkOBmqFhgYn/JzX2HxgxYPY18eQAs3CP/AWqtPvkWn8rclRAxdQ==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", + "integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", "hasInstallScript": true, "license": "BSD-3-Clause", "peer": true, "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", "long": "^5.3.2" }, "engines": { @@ -4053,20 +4208,24 @@ } }, "node_modules/openclaw/node_modules/quickjs-wasi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.0.0.tgz", - "integrity": "sha512-X7ouKC4ZVf9bXQ8rsE7+L6TeBbesejAJH61x16xRaGAQGfBHHRcniWgzJZZVtHc8rS9yVsY+Tvk8/usAosg4bg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.0.2.tgz", + "integrity": "sha512-SyfPzlrfz67/kv0SogmQgW4c2I1klkLcbvj9Y2gc1h7+VylmvuGevFljLXibGKajKJKiJV29d4S6FQLA6Sc80A==", "license": "MIT", "peer": true }, "node_modules/openclaw/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==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "peer": true, "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/openclaw/node_modules/rastermill": { @@ -4309,15 +4468,15 @@ } }, "node_modules/openclaw/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==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "peer": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4588,9 +4747,9 @@ } }, "node_modules/openclaw/node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "license": "BlueOak-1.0.0", "peer": true, "dependencies": { @@ -4721,9 +4880,9 @@ } }, "node_modules/openclaw/node_modules/typebox": { - "version": "1.1.39", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.39.tgz", - "integrity": "sha512-vj0afVtOfLQvv0GR0VxVagYxsXN64btL7Z9XoaG0ZggH3mruMMkOO6hXdgMsjCY3shZgEvooAWVeznQVs5c43w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz", + "integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==", "license": "MIT", "peer": true }, @@ -4762,15 +4921,22 @@ } }, "node_modules/openclaw/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "peer": true, "engines": { "node": ">=22.19.0" } }, + "node_modules/openclaw/node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, "node_modules/openclaw/node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4829,9 +4995,9 @@ } }, "node_modules/openclaw/node_modules/web-tree-sitter": { - "version": "0.26.9", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.9.tgz", - "integrity": "sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA==", + "version": "0.26.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.10.tgz", + "integrity": "sha512-vengBGYS7FpAerkR3o04oBL4L8MkVmjawK50AFBu7v0HZBkmF9ZavPGKoXLSSmRhp7T/YgsJ7joAS3yAxHPEqQ==", "license": "MIT", "peer": true }, @@ -4960,9 +5126,9 @@ } }, "node_modules/openclaw/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "peer": true, "dependencies": { diff --git a/package.json b/package.json index 5b5f486..d76b80d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "messaging" ], "peerDependencies": { - "openclaw": ">=2026.6.0" + "openclaw": ">=2026.6.1" }, "dependencies": { "zod": "^4.4.3" diff --git a/src/channel.ts b/src/channel.ts index 4559a36..fab354f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -4,7 +4,6 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, - buildChannelConfigSchema, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, @@ -13,7 +12,7 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedLinqAccount } from "./linq/accounts.js"; import type { LinqProbe } from "./linq/types.js"; -import { LinqConfigSchema } from "./linq/config.js"; +import { LinqChannelConfigSchema } from "./linq/config.js"; import { listLinqAccountIds, resolveDefaultLinqAccountId, @@ -22,8 +21,10 @@ import { } from "./linq/accounts.js"; import { probeLinq } from "./linq/probe.js"; import { sendMessageLinq } from "./linq/send.js"; -import { parseLinqTarget } from "./linq/targets.js"; -import { monitorLinqProvider } from "./linq/monitor.js"; +import { formatLinqTarget, parseLinqTarget } from "./linq/targets.js"; +import { monitorLinqProvider } from "./linq/gateway.js"; +import { linqMessageAdapter, toLinqOutboundDeliveryResult } from "./linq/message.js"; +import { resolveLinqOutboundSessionRoute } from "./linq/session-route.js"; import { linqOnboardingAdapter } from "./onboarding.js"; import { getLinqRuntime } from "./runtime.js"; import { @@ -56,7 +57,7 @@ export const linqPlugin: ChannelPlugin = { media: true, }, reload: { configPrefixes: ["channels.linq"] }, - configSchema: buildChannelConfigSchema(LinqConfigSchema), + configSchema: LinqChannelConfigSchema, config: { listAccountIds: (cfg) => listLinqAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveLinqAccountForStatus({ cfg, accountId }), @@ -120,11 +121,12 @@ export const linqPlugin: ChannelPlugin = { return ""; } try { - return parseLinqTarget(raw).raw; + return formatLinqTarget(parseLinqTarget(raw)); } catch { return raw; } }, + resolveOutboundSessionRoute: (params) => resolveLinqOutboundSessionRoute(params), targetResolver: { looksLikeId: (id) => { try { @@ -227,7 +229,7 @@ export const linqPlugin: ChannelPlugin = { accountId: accountId ?? undefined, config: cfg, }); - return { channel: "linq", ...result }; + return toLinqOutboundDeliveryResult(result, "text"); }, sendMedia: async ({ to, text, mediaUrl, accountId }) => { const cfg = getLinqRuntime().config.current() as OpenClawConfig; @@ -236,9 +238,10 @@ export const linqPlugin: ChannelPlugin = { accountId: accountId ?? undefined, config: cfg, }); - return { channel: "linq", ...result }; + return toLinqOutboundDeliveryResult(result, "media"); }, }, + message: linqMessageAdapter, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, diff --git a/src/linq/config.test.ts b/src/linq/config.test.ts index 879e95d..3438f97 100644 --- a/src/linq/config.test.ts +++ b/src/linq/config.test.ts @@ -1,5 +1,6 @@ +import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; -import { LinqConfigSchema } from "./config.js"; +import { LinqChannelConfigSchema, LinqConfigSchema } from "./config.js"; import { resolveLinqAccount, resolveLinqAccountForStatus } from "./accounts.js"; describe("LinqConfigSchema", () => { @@ -26,6 +27,53 @@ describe("LinqConfigSchema", () => { }); expect(invalid.success).toBe(false); }); + + it("exposes matching public JSON-schema and runtime validation", () => { + expect(LinqChannelConfigSchema.schema).toMatchObject({ + type: "object", + additionalProperties: false, + properties: { + accounts: { + additionalProperties: { $ref: "#" }, + }, + }, + }); + expect( + LinqChannelConfigSchema.runtime?.safeParse({ + apiToken: "token", + fromPhone: "+15556667777", + }), + ).toMatchObject({ success: true, data: { dmPolicy: "open" } }); + expect( + LinqChannelConfigSchema.runtime?.safeParse({ + apiToken: "token", + webhookMaxBytes: 2048, + }), + ).toMatchObject({ success: false }); + expect( + LinqChannelConfigSchema.runtime?.safeParse({ + accounts: { sales: { apiToken: "token", webhookMaxBytes: 2048 } }, + }), + ).toMatchObject({ success: false }); + }); + + it("keeps manifest channel schemas strict for nested accounts", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"), + ) as { + configSchema: { properties: { accounts: unknown } }; + channelConfigs: { linq: { schema: { properties: { accounts: unknown } } } }; + }; + + expect(manifest.configSchema.properties.accounts).toEqual({ + type: "object", + additionalProperties: { $ref: "#" }, + }); + expect(manifest.channelConfigs.linq.schema.properties.accounts).toEqual({ + type: "object", + additionalProperties: { $ref: "#" }, + }); + }); }); describe("resolveLinqAccount", () => { diff --git a/src/linq/config.ts b/src/linq/config.ts index fdbf7ca..d26e7cd 100644 --- a/src/linq/config.ts +++ b/src/linq/config.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; const e164PhoneSchema = z.string().regex(/^\+[1-9]\d{6,14}$/u, "expected E.164 phone number"); const allowFromEntrySchema = z.union([z.string().min(1), z.number()]); @@ -41,24 +42,26 @@ export const LinqAccountConfigSchema: z.ZodType> = z.laz export const LinqConfigSchema = LinqAccountConfigSchema; +const linqAccountJsonSchemaProperties = { + enabled: { type: "boolean" }, + name: { type: "string", minLength: 1 }, + apiToken: { anyOf: [{ type: "string", minLength: 1 }, { $ref: "#/$defs/secretRef" }] }, + tokenFile: { type: "string", minLength: 1 }, + fromPhone: { type: "string", pattern: "^\\+[1-9]\\d{6,14}$" }, + dmPolicy: { enum: ["pairing", "open", "disabled"], default: "open" }, + allowFrom: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + webhookUrl: { type: "string", format: "uri" }, + webhookSecret: { anyOf: [{ type: "string", minLength: 1 }, { $ref: "#/$defs/secretRef" }] }, + webhookPath: { type: "string", pattern: "^/[A-Za-z0-9/_-]*$", default: "/linq-webhook" }, + webhookHost: { type: "string", minLength: 1 }, + accounts: { type: "object", additionalProperties: { $ref: "#" } }, + defaultAccount: { type: "string", minLength: 1 }, +}; + export const LinqConfigJsonSchema = { type: "object", additionalProperties: false, - properties: { - enabled: { type: "boolean" }, - name: { type: "string", minLength: 1 }, - apiToken: { anyOf: [{ type: "string", minLength: 1 }, { $ref: "#/$defs/secretRef" }] }, - tokenFile: { type: "string", minLength: 1 }, - fromPhone: { type: "string", pattern: "^\\+[1-9]\\d{6,14}$" }, - dmPolicy: { enum: ["pairing", "open", "disabled"], default: "open" }, - allowFrom: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, - webhookUrl: { type: "string", format: "uri" }, - webhookSecret: { anyOf: [{ type: "string", minLength: 1 }, { $ref: "#/$defs/secretRef" }] }, - webhookPath: { type: "string", pattern: "^/[A-Za-z0-9/_-]*$", default: "/linq-webhook" }, - webhookHost: { type: "string", minLength: 1 }, - accounts: { type: "object", additionalProperties: true }, - defaultAccount: { type: "string", minLength: 1 }, - }, + properties: linqAccountJsonSchemaProperties, $defs: { secretRef: { type: "object", @@ -72,3 +75,25 @@ export const LinqConfigJsonSchema = { }, }, }; + +export const LinqChannelConfigSchema = buildJsonChannelConfigSchema(LinqConfigJsonSchema, { + cacheKey: "linq-channel-config", + runtime: { + safeParse: (value) => { + const result = LinqConfigSchema.safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + return { + success: false, + issues: result.error.issues.map((issue) => ({ + path: issue.path.filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ), + message: issue.message, + })), + }; + }, + }, +}); diff --git a/src/linq/gateway.ts b/src/linq/gateway.ts new file mode 100644 index 0000000..a80a41e --- /dev/null +++ b/src/linq/gateway.ts @@ -0,0 +1,182 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound"; +import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; +import { getLinqRuntime } from "../runtime.js"; +import { resolveLinqAccount } from "./accounts.js"; +import { dispatchLinqInboundEvent, extractLinqText, normalizeLinqMessageReceivedData } from "./inbound.js"; +import type { LinqMessageReceivedData, LinqReactionReceivedData, LinqWebhookEvent } from "./types.js"; +import { createLinqWebhookHandler, createMemoryLinqWebhookDedupeStore } from "./webhook.js"; + +const LINQ_WEBHOOK_MAX_BYTES = 1024 * 1024; +const LINQ_WEBHOOK_REPLAY_WINDOW_SECONDS = 300; +const LINQ_WEBHOOK_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000; +const activeWebhookPaths = new Map(); + +export type MonitorLinqOpts = { + accountId?: string; + config?: OpenClawConfig; + runtime?: { + info: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + }; + abortSignal?: AbortSignal; +}; + +export async function monitorLinqProvider(opts: MonitorLinqOpts = {}): Promise { + const rt = getLinqRuntime(); + const logVerbose = (message: string) => { + if (rt.logging.shouldLogVerbose()) { + opts.runtime?.info(message); + } + }; + const cfg = opts.config ?? rt.config.loadConfig(); + const account = resolveLinqAccount({ cfg, accountId: opts.accountId }); + if (!account.token) { + throw new Error("Linq API token not configured"); + } + + const webhookPath = account.config.webhookPath?.trim() || "/linq-webhook"; + const inboundDebounceMs = rt.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "linq", + }); + const inboundDebouncer = rt.channel.debounce.createInboundDebouncer<{ + event: LinqMessageReceivedData; + }>({ + debounceMs: inboundDebounceMs, + buildKey: ({ event }) => { + const sender = event.from?.trim(); + return sender ? `linq:${account.accountId}:${event.chat_id}:${sender}` : null; + }, + shouldDebounce: ({ event }) => { + const text = extractLinqText( + event.message.parts as Array<{ type: string; value?: string }>, + ); + return Boolean(text.trim()) && !rt.channel.text.hasControlCommand(text, cfg); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + const event = + entries.length === 1 + ? last.event + : { + ...last.event, + message: { + ...last.event.message, + parts: [ + { + type: "text" as const, + value: entries + .map(({ event }) => + extractLinqText( + event.message.parts as Array<{ type: string; value?: string }>, + ), + ) + .filter(Boolean) + .join("\n"), + }, + ], + }, + }; + await dispatchLinqInboundEvent({ + cfg, + account, + message: event, + channelRuntime: rt.channel, + log: { info: logVerbose, warn: opts.runtime?.warn ?? opts.runtime?.error }, + }); + }, + onError: (err) => { + opts.runtime?.error?.(`linq debounce flush failed: ${String(err)}`); + }, + }); + + const webhookHandler = createLinqWebhookHandler({ + path: webhookPath, + secret: account.webhookSecret, + maxBytes: LINQ_WEBHOOK_MAX_BYTES, + replayWindowSeconds: LINQ_WEBHOOK_REPLAY_WINDOW_SECONDS, + dedupeTtlMs: LINQ_WEBHOOK_DEDUPE_TTL_MS, + dedupeStore: createMemoryLinqWebhookDedupeStore(), + }); + const currentPathOwner = activeWebhookPaths.get(webhookPath); + if (currentPathOwner && currentPathOwner !== account.accountId) { + throw new Error( + `Linq webhook path ${webhookPath} is already registered by account ${currentPathOwner}; configure a distinct webhookPath for account ${account.accountId}.`, + ); + } + + const unregister = registerPluginHttpRoute({ + path: webhookPath, + auth: "plugin", + pluginId: "linq", + accountId: account.accountId, + replaceExisting: true, + log: (message) => opts.runtime?.info(message), + handler: async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > LINQ_WEBHOOK_MAX_BYTES) { + res.writeHead(413); + res.end(); + return; + } + chunks.push(chunk as Buffer); + } + const result = await webhookHandler({ + method: req.method ?? "GET", + path: url.pathname, + headers: req.headers, + body: Buffer.concat(chunks), + }); + res.writeHead(result.status, { "Content-Type": "application/json" }); + res.end(result.body); + if (!result.event || result.duplicate) { + return; + } + + try { + const event = result.event as LinqWebhookEvent; + if (event.event_type === "message.received") { + const message = normalizeLinqMessageReceivedData(event.data); + if (!message) { + logVerbose(`linq webhook ignored malformed message.received event ${event.event_id}`); + return; + } + await inboundDebouncer.enqueue({ event: message }); + } else if (event.event_type === "reaction.received") { + const reaction = event.data as LinqReactionReceivedData; + if (!reaction.is_from_me && reaction.reaction) { + logVerbose( + `linq reaction: ${reaction.reaction.operation} ${reaction.reaction.type} from=${reaction.from} msg=${reaction.message_id}`, + ); + } + } else if (event.event_type === "message.delivery_status") { + const delivery = event.data as { status?: string; message_id?: string }; + logVerbose(`linq delivery: ${delivery.status} msg=${delivery.message_id}`); + } + } catch (err) { + opts.runtime?.error?.(`linq webhook parse error: ${String(err)}`); + } + }, + }); + + activeWebhookPaths.set(webhookPath, account.accountId); + opts.runtime?.info( + `linq: registered webhook route ${webhookPath} for account ${account.accountId}`, + ); + await waitUntilAbort(opts.abortSignal, () => { + unregister(); + if (activeWebhookPaths.get(webhookPath) === account.accountId) { + activeWebhookPaths.delete(webhookPath); + } + }); +} diff --git a/src/linq/inbound.test.ts b/src/linq/inbound.test.ts new file mode 100644 index 0000000..42aa8b3 --- /dev/null +++ b/src/linq/inbound.test.ts @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { ResolvedLinqAccount } from "./accounts.js"; +import { + dispatchLinqInboundEvent, + normalizeLinqMessageReceivedData, +} from "./inbound.js"; +import type { LinqChannelRuntime } from "./ingress.js"; +import type { LinqMessageReceivedData } from "./types.js"; + +const sendMessageLinq = vi.hoisted(() => + vi.fn(async () => ({ messageId: "msg_reply", chatId: "chat_1", target: "linq:chat:chat_1" })), +); +const markAsReadLinq = vi.hoisted(() => vi.fn(async () => true)); +const startTypingLinq = vi.hoisted(() => vi.fn(async () => true)); +const stopTypingLinq = vi.hoisted(() => vi.fn(async () => true)); + +vi.mock("./send.js", () => ({ + sendMessageLinq, + markAsReadLinq, + startTypingLinq, + stopTypingLinq, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createAccount( + config: ResolvedLinqAccount["config"] = {}, +): ResolvedLinqAccount { + return { + accountId: "default", + enabled: true, + token: "token", + tokenSource: "config", + webhookSecret: "secret", + webhookSecretSource: "config", + fromPhone: "+15557654321", + config, + }; +} + +function createMessage(): LinqMessageReceivedData { + return { + chat_id: "chat_1", + from: "+15551234567", + recipient_phone: "+15557654321", + received_at: "2026-07-13T12:00:00.000Z", + is_from_me: false, + service: "iMessage", + message: { + id: "msg_inbound", + parts: [{ type: "text", value: "hello" }], + }, + }; +} + +function createRuntime() { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const upsertPairingRequest = vi.fn(async () => ({ code: "PAIR123", created: true })); + const resolveAgentRoute = vi.fn(() => ({ + agentId: "main", + accountId: "default", + sessionKey: "agent:main:linq:direct:+15551234567", + mainSessionKey: "agent:main:main", + })); + const run = vi.fn(async () => undefined); + const buildContext = vi.fn(() => ({ + SessionKey: "agent:main:linq:direct:+15551234567", + })); + const resolveStorePath = vi.fn(() => "/tmp/openclaw-sessions"); + const runtime = { + pairing: { readAllowFromStore, upsertPairingRequest }, + routing: { resolveAgentRoute }, + inbound: { run, buildContext }, + session: { resolveStorePath, recordInboundSession: vi.fn() }, + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + } as unknown as LinqChannelRuntime; + return { + runtime, + readAllowFromStore, + upsertPairingRequest, + resolveAgentRoute, + run, + buildContext, + resolveStorePath, + }; +} + +describe("normalizeLinqMessageReceivedData", () => { + it("keeps the legacy 2025 message.received shape", () => { + const data = { + chat_id: "chat_1", + from: "+12025550100", + recipient_phone: "+12025550101", + received_at: "2026-02-05T19:31:13.736Z", + is_from_me: false, + service: "SMS", + message: { + id: "msg_1", + parts: [{ type: "text", value: "hello" }], + }, + }; + + expect(normalizeLinqMessageReceivedData(data)).toEqual(data); + }); + + it("normalizes the current 2026 message.received shape", () => { + expect( + normalizeLinqMessageReceivedData({ + chat: { + id: "chat_2", + owner_handle: { handle: "+12025550101" }, + }, + id: "msg_2", + direction: "inbound", + sender_handle: { handle: "+12025550100", is_me: false }, + parts: [{ type: "text", value: "hello" }], + sent_at: "2026-02-05T19:31:13.074Z", + service: "SMS", + }), + ).toEqual({ + chat_id: "chat_2", + from: "+12025550100", + recipient_phone: "+12025550101", + received_at: "2026-02-05T19:31:13.074Z", + is_from_me: false, + service: "SMS", + message: { + id: "msg_2", + parts: [{ type: "text", value: "hello" }], + }, + }); + }); +}); + +describe("dispatchLinqInboundEvent", () => { + it("does not block dispatch on best-effort presence calls", async () => { + const { runtime, run } = createRuntime(); + markAsReadLinq.mockImplementationOnce(() => new Promise(() => {})); + startTypingLinq.mockImplementationOnce(() => new Promise(() => {})); + + const outcome = await Promise.race([ + dispatchLinqInboundEvent({ + cfg: {}, + account: createAccount({ dmPolicy: "open" }), + message: createMessage(), + channelRuntime: runtime, + }).then(() => "dispatched"), + new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)), + ]); + + expect(outcome).toBe("dispatched"); + expect(run).toHaveBeenCalledOnce(); + expect(stopTypingLinq).toHaveBeenCalledOnce(); + }); + + it("preserves the legacy open policy without requiring an explicit wildcard", async () => { + const { runtime, run } = createRuntime(); + + await dispatchLinqInboundEvent({ + cfg: {}, + account: createAccount({ dmPolicy: "open" }), + message: createMessage(), + channelRuntime: runtime, + }); + + expect(run).toHaveBeenCalledOnce(); + }); + + it("creates an account-scoped pairing challenge for unknown senders", async () => { + const { runtime, readAllowFromStore, upsertPairingRequest, run } = createRuntime(); + + await dispatchLinqInboundEvent({ + cfg: {}, + account: createAccount({ dmPolicy: "pairing" }), + message: createMessage(), + channelRuntime: runtime, + }); + + expect(readAllowFromStore).toHaveBeenCalledWith({ channel: "linq", accountId: "default" }); + expect(upsertPairingRequest).toHaveBeenCalledWith({ + channel: "linq", + accountId: "default", + id: "+15551234567", + meta: { sender: "+15551234567", chatId: "chat_1" }, + }); + expect(sendMessageLinq).toHaveBeenCalledWith( + "linq:chat:chat_1", + expect.stringContaining("PAIR123"), + expect.objectContaining({ account: expect.objectContaining({ accountId: "default" }) }), + ); + expect(run).not.toHaveBeenCalled(); + expect(startTypingLinq).not.toHaveBeenCalled(); + }); + + it("runs authorized messages on the phone session and persists the chat reply target", async () => { + const { runtime, resolveAgentRoute, run, buildContext, resolveStorePath } = createRuntime(); + const cfg: OpenClawConfig = { session: { dmScope: "per-channel-peer" } }; + const message = createMessage(); + + await dispatchLinqInboundEvent({ + cfg, + account: createAccount({ + dmPolicy: "allowlist", + allowFrom: ["+1 (555) 123-4567"], + }), + message, + channelRuntime: runtime, + }); + + expect(resolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ peer: { kind: "direct", id: "+15551234567" } }), + ); + expect(markAsReadLinq).toHaveBeenCalledWith("chat_1", "token"); + expect(startTypingLinq).toHaveBeenCalledWith("chat_1", "token"); + expect(stopTypingLinq).toHaveBeenCalledWith("chat_1", "token"); + + const runParams = run.mock.calls[0]?.[0] as { + adapter: { + ingest: (raw: LinqMessageReceivedData) => unknown; + resolveTurn: (input: unknown) => Promise<{ + routeSessionKey: string; + record: { + updateLastRoute: { + sessionKey: string; + channel: string; + to: string; + accountId: string; + }; + }; + delivery: { + durable: () => { to: string }; + deliver: (payload: { text?: string; mediaUrl?: string }) => Promise<{ + visibleReplySent: boolean; + }>; + }; + replyPipeline: Record; + }>; + }; + }; + const input = runParams.adapter.ingest(message); + const turn = await runParams.adapter.resolveTurn(input); + + expect(buildContext).toHaveBeenCalledWith( + expect.objectContaining({ + from: "linq:+15551234567", + conversation: expect.objectContaining({ id: "+15551234567" }), + route: expect.objectContaining({ + routeSessionKey: "agent:main:linq:direct:+15551234567", + dispatchSessionKey: "agent:main:linq:direct:+15551234567", + }), + reply: { + to: "linq:chat:chat_1", + originatingTo: "linq:chat:chat_1", + }, + }), + ); + expect(resolveStorePath).toHaveBeenCalledWith(cfg.session?.store, { agentId: "main" }); + expect(turn.routeSessionKey).toBe("agent:main:linq:direct:+15551234567"); + expect(turn.record.updateLastRoute).toEqual({ + sessionKey: "agent:main:main", + channel: "linq", + to: "linq:chat:chat_1", + accountId: "default", + }); + expect(turn.delivery.durable()).toEqual({ to: "linq:chat:chat_1" }); + expect(turn.replyPipeline).toEqual({}); + + await expect(turn.delivery.deliver({ text: "reply" })).resolves.toEqual({ + visibleReplySent: true, + }); + expect(sendMessageLinq).toHaveBeenLastCalledWith( + "linq:chat:chat_1", + "reply", + expect.objectContaining({ account: expect.objectContaining({ accountId: "default" }) }), + ); + }); +}); diff --git a/src/linq/inbound.ts b/src/linq/inbound.ts new file mode 100644 index 0000000..2a70370 --- /dev/null +++ b/src/linq/inbound.ts @@ -0,0 +1,257 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { ResolvedLinqAccount } from "./accounts.js"; +import { authorizeLinqSender, issueLinqPairingChallenge, type LinqChannelRuntime } from "./ingress.js"; +import { markAsReadLinq, sendMessageLinq, startTypingLinq, stopTypingLinq } from "./send.js"; +import type { LinqMediaPart, LinqMessageReceivedData, LinqTextPart } from "./types.js"; + +const CHANNEL_ID = "linq"; + +type LinqLog = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +export function extractLinqText(parts: Array<{ type: string; value?: string }>): string { + return parts + .filter((part): part is LinqTextPart => part.type === "text") + .map((part) => part.value) + .join("\n"); +} + +function extractLinqMedia( + parts: Array<{ type: string; url?: string; mime_type?: string }>, +): Array<{ url: string; mimeType: string }> { + return parts + .filter( + (part): part is LinqMediaPart & { url: string; mime_type: string } => + part.type === "media" && Boolean(part.url) && Boolean(part.mime_type), + ) + .map((part) => ({ url: part.url, mimeType: part.mime_type })); +} + +export function normalizeLinqMessageReceivedData(raw: unknown): LinqMessageReceivedData | null { + if (!raw || typeof raw !== "object") { + return null; + } + const data = raw as Record; + const legacyMessage = data.message as { id?: unknown; parts?: unknown; reply_to?: unknown } | undefined; + if ( + typeof data.chat_id === "string" && + typeof data.from === "string" && + legacyMessage && + typeof legacyMessage.id === "string" && + Array.isArray(legacyMessage.parts) + ) { + return data as LinqMessageReceivedData; + } + + const chat = data.chat as + | { id?: unknown; owner_handle?: { handle?: unknown } } + | undefined; + const senderHandle = data.sender_handle as { handle?: unknown; is_me?: unknown } | undefined; + if ( + typeof chat?.id !== "string" || + typeof senderHandle?.handle !== "string" || + !Array.isArray(data.parts) || + typeof data.id !== "string" + ) { + return null; + } + + return { + chat_id: chat.id, + from: senderHandle.handle, + recipient_phone: typeof chat.owner_handle?.handle === "string" ? chat.owner_handle.handle : "", + received_at: + typeof data.sent_at === "string" + ? data.sent_at + : typeof data.created_at === "string" + ? data.created_at + : "", + is_from_me: data.direction === "outbound" || senderHandle.is_me === true, + service: + data.service === "SMS" || data.service === "RCS" || data.service === "iMessage" + ? data.service + : "iMessage", + message: { + id: data.id, + parts: data.parts as LinqMessageReceivedData["message"]["parts"], + reply_to: data.reply_to as LinqMessageReceivedData["message"]["reply_to"], + }, + }; +} + +function parseLinqTimestamp(value: string): number | undefined { + if (!value) { + return undefined; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +export async function dispatchLinqInboundEvent(params: { + cfg: OpenClawConfig; + account: ResolvedLinqAccount; + message: LinqMessageReceivedData; + channelRuntime: LinqChannelRuntime; + log?: LinqLog; +}): Promise { + const sender = params.message.from?.trim(); + if (!sender || params.message.is_from_me) { + return; + } + if (params.account.fromPhone && params.message.recipient_phone !== params.account.fromPhone) { + params.log?.info?.( + `linq: skipping message to ${params.message.recipient_phone} (not ${params.account.fromPhone})`, + ); + return; + } + + const text = extractLinqText( + params.message.message.parts as Array<{ type: string; value?: string }>, + ); + const media = extractLinqMedia( + params.message.message.parts as Array<{ type: string; url?: string; mime_type?: string }>, + ); + if (!text.trim() && media.length === 0) { + return; + } + + const authorization = await authorizeLinqSender({ + cfg: params.cfg, + account: params.account, + channelRuntime: params.channelRuntime, + sender, + }); + if (!authorization.senderAccess.allowed) { + if (authorization.senderAccess.decision === "pairing") { + await issueLinqPairingChallenge({ + account: params.account, + channelRuntime: params.channelRuntime, + sender, + chatId: params.message.chat_id, + log: params.log, + }); + } else { + params.log?.warn?.( + `linq sender ${sender} is not authorized (${authorization.senderAccess.reasonCode})`, + ); + } + return; + } + + const route = params.channelRuntime.routing.resolveAgentRoute({ + cfg: params.cfg, + channel: CHANNEL_ID, + accountId: params.account.accountId, + peer: { kind: "direct", id: sender }, + }); + const replyTarget = `linq:chat:${params.message.chat_id}`; + const timestamp = parseLinqTimestamp(params.message.received_at); + const bodyText = text.trim() || ""; + + void markAsReadLinq(params.message.chat_id, params.account.token); + void startTypingLinq(params.message.chat_id, params.account.token); + + try { + await params.channelRuntime.inbound.run({ + channel: CHANNEL_ID, + accountId: params.account.accountId, + raw: params.message, + adapter: { + ingest: (message) => ({ + id: message.message.id, + timestamp, + rawText: bodyText, + textForAgent: bodyText, + textForCommands: bodyText, + raw: message, + }), + resolveTurn: async (input) => { + const ctxPayload = params.channelRuntime.inbound.buildContext({ + channel: CHANNEL_ID, + accountId: route.accountId, + messageId: params.message.message.id, + timestamp: input.timestamp, + from: `linq:${sender}`, + sender: { id: sender, name: sender }, + conversation: { kind: "direct", id: sender, label: sender }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + dispatchSessionKey: route.sessionKey, + mainSessionKey: route.mainSessionKey, + }, + reply: { to: replyTarget, originatingTo: replyTarget }, + message: { + rawBody: input.rawText, + commandBody: input.textForCommands, + bodyForAgent: input.textForAgent, + }, + media: + media.length > 0 + ? media.map((item) => ({ url: item.url, contentType: item.mimeType })) + : undefined, + extra: { + ReplyToId: params.message.message.reply_to?.message_id, + Service: params.message.service, + WasMentioned: true, + CommandAuthorized: true, + }, + }); + const storePath = params.channelRuntime.session.resolveStorePath( + params.cfg.session?.store, + { agentId: route.agentId }, + ); + return { + cfg: params.cfg, + channel: CHANNEL_ID, + accountId: route.accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: params.channelRuntime.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher, + record: { + updateLastRoute: { + sessionKey: route.mainSessionKey, + channel: CHANNEL_ID, + to: replyTarget, + accountId: route.accountId, + }, + onRecordError: (err) => { + params.log?.warn?.(`linq failed updating session metadata: ${String(err)}`); + }, + }, + delivery: { + durable: () => ({ to: replyTarget }), + deliver: async (payload) => { + const replyText = payload.text ?? ""; + const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0]; + if (!replyText && !mediaUrl) { + return { visibleReplySent: false }; + } + await sendMessageLinq(replyTarget, replyText, { + account: params.account, + mediaUrl, + }); + return { visibleReplySent: true }; + }, + }, + replyPipeline: {}, + dispatcherOptions: { + onReplyStart: () => { + params.log?.info?.(`linq reply started for ${sender}`); + }, + }, + }; + }, + }, + }); + } finally { + void stopTypingLinq(params.message.chat_id, params.account.token); + } +} diff --git a/src/linq/ingress.ts b/src/linq/ingress.ts new file mode 100644 index 0000000..1795696 --- /dev/null +++ b/src/linq/ingress.ts @@ -0,0 +1,93 @@ +import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; +import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import type { ResolvedLinqAccount } from "./accounts.js"; +import { sendMessageLinq } from "./send.js"; + +const CHANNEL_ID = "linq"; + +export type LinqChannelRuntime = Pick< + PluginRuntime["channel"], + "inbound" | "pairing" | "reply" | "routing" | "session" +>; + +type LinqLog = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +function normalizeLinqSender(value: string): string { + return value.replace(/[\s().-]/gu, "").toLowerCase(); +} + +export async function authorizeLinqSender(params: { + cfg: OpenClawConfig; + account: ResolvedLinqAccount; + channelRuntime: LinqChannelRuntime; + sender: string; +}) { + const dmPolicy = params.account.config.dmPolicy ?? "open"; + const configuredAllowFrom = params.account.config.allowFrom ?? []; + // Linq historically treated dmPolicy=open as unconditional access. + const allowFrom = dmPolicy === "open" ? [...configuredAllowFrom, "*"] : configuredAllowFrom; + return await resolveStableChannelMessageIngress({ + channelId: CHANNEL_ID, + accountId: params.account.accountId, + cfg: params.cfg, + identity: { + key: "phone", + entryIdPrefix: "linq-entry", + normalize: normalizeLinqSender, + }, + readStoreAllowFrom: async () => + await params.channelRuntime.pairing.readAllowFromStore({ + channel: CHANNEL_ID, + accountId: params.account.accountId, + }), + subject: { stableId: params.sender }, + conversation: { kind: "direct", id: params.sender }, + event: { mayPair: true }, + dmPolicy, + allowFrom, + }); +} + +export async function issueLinqPairingChallenge(params: { + account: ResolvedLinqAccount; + channelRuntime: LinqChannelRuntime; + sender: string; + chatId: string; + log?: LinqLog; +}): Promise { + // OpenClaw 2026.7.1+ forwards accountId to pairing hooks; older hosts ignore it. + const pairingIssuerOptions: Parameters[0] & { + accountId: string; + } = { + channel: CHANNEL_ID, + accountId: params.account.accountId, + upsertPairingRequest: async (input) => + await params.channelRuntime.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + accountId: params.account.accountId, + ...input, + }), + }; + const issueChallenge = createChannelPairingChallengeIssuer(pairingIssuerOptions); + + await issueChallenge({ + senderId: params.sender, + senderIdLine: `Your phone number: ${params.sender}`, + meta: { sender: params.sender, chatId: params.chatId }, + sendPairingReply: async (text) => { + await sendMessageLinq(`linq:chat:${params.chatId}`, text, { + account: params.account, + }); + }, + onCreated: () => { + params.log?.info?.(`linq pairing request created for ${params.sender}`); + }, + onReplyError: (err) => { + params.log?.warn?.(`linq pairing reply failed for ${params.sender}: ${String(err)}`); + }, + }); +} diff --git a/src/linq/message.test.ts b/src/linq/message.test.ts new file mode 100644 index 0000000..abced94 --- /dev/null +++ b/src/linq/message.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { toLinqOutboundDeliveryResult } from "./message.js"; + +describe("Linq delivery receipts", () => { + it("preserves provider message and conversation identity", () => { + const result = toLinqOutboundDeliveryResult( + { + messageId: "msg_1", + chatId: "chat_1", + target: "linq:chat:chat_1", + accountId: "default", + fromPhone: "+15557654321", + traceId: "trace_1", + }, + "text", + ); + + expect(result).toMatchObject({ + channel: "linq", + messageId: "msg_1", + chatId: "chat_1", + receipt: { + primaryPlatformMessageId: "msg_1", + platformMessageIds: ["msg_1"], + parts: [{ kind: "text", raw: { conversationId: "chat_1" } }], + }, + }); + }); + + it("does not record the legacy unknown sentinel as a platform message id", () => { + const result = toLinqOutboundDeliveryResult( + { + messageId: "unknown", + chatId: "chat_1", + target: "linq:+15551234567", + }, + "text", + ); + + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "chat_1", + platformMessageIds: ["chat_1"], + }); + expect(result.receipt.platformMessageIds).not.toContain("unknown"); + }); +}); diff --git a/src/linq/message.ts b/src/linq/message.ts new file mode 100644 index 0000000..3d48cd5 --- /dev/null +++ b/src/linq/message.ts @@ -0,0 +1,87 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, +} from "openclaw/plugin-sdk/channel-outbound"; +import { sendMessageLinq } from "./send.js"; +import type { LinqSendResult } from "./types.js"; + +const CHANNEL_ID = "linq"; + +export function toLinqOutboundDeliveryResult( + result: LinqSendResult, + kind: "text" | "media", + replyToId?: string | null, +) { + const receipt = createMessageReceiptFromOutboundResults({ + results: [ + { + channel: CHANNEL_ID, + messageId: result.messageId === "unknown" ? undefined : result.messageId, + chatId: result.chatId, + conversationId: result.chatId, + meta: { + target: result.target, + ...(result.fromPhone ? { fromPhone: result.fromPhone } : {}), + ...(result.traceId ? { traceId: result.traceId } : {}), + }, + }, + ], + kind, + ...(replyToId ? { replyToId } : {}), + }); + return { + channel: CHANNEL_ID, + ...result, + receipt, + }; +} + +async function sendLinqMessage(params: { + cfg: OpenClawConfig; + to: string; + text: string; + accountId?: string | null; + mediaUrl?: string; + replyToId?: string | null; +}) { + const result = await sendMessageLinq(params.to, params.text, { + config: params.cfg, + accountId: params.accountId ?? undefined, + mediaUrl: params.mediaUrl, + replyToMessageId: params.replyToId ?? undefined, + }); + return toLinqOutboundDeliveryResult( + result, + params.mediaUrl ? "media" : "text", + params.replyToId, + ); +} + +export const linqMessageAdapter = defineChannelMessageAdapter({ + id: CHANNEL_ID, + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => { + const result = await sendLinqMessage(ctx); + return { + messageId: result.receipt.primaryPlatformMessageId, + receipt: result.receipt, + }; + }, + media: async (ctx) => { + const result = await sendLinqMessage({ ...ctx, mediaUrl: ctx.mediaUrl }); + return { + messageId: result.receipt.primaryPlatformMessageId, + receipt: result.receipt, + }; + }, + }, +}); diff --git a/src/linq/monitor.test.ts b/src/linq/monitor.test.ts deleted file mode 100644 index e724764..0000000 --- a/src/linq/monitor.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeLinqMessageReceivedData } from "./monitor.js"; - -describe("normalizeLinqMessageReceivedData", () => { - it("keeps the legacy 2025 message.received shape", () => { - const data = { - chat_id: "chat_1", - from: "+12025550100", - recipient_phone: "+12025550101", - received_at: "2026-02-05T19:31:13.736Z", - is_from_me: false, - service: "SMS", - message: { - id: "msg_1", - parts: [{ type: "text", value: "hello" }], - }, - }; - - expect(normalizeLinqMessageReceivedData(data)).toEqual(data); - }); - - it("normalizes the current 2026 message.received shape", () => { - expect( - normalizeLinqMessageReceivedData({ - chat: { - id: "chat_2", - owner_handle: { handle: "+12025550101" }, - }, - id: "msg_2", - direction: "inbound", - sender_handle: { handle: "+12025550100", is_me: false }, - parts: [{ type: "text", value: "hello" }], - sent_at: "2026-02-05T19:31:13.074Z", - service: "SMS", - }), - ).toEqual({ - chat_id: "chat_2", - from: "+12025550100", - recipient_phone: "+12025550101", - received_at: "2026-02-05T19:31:13.074Z", - is_from_me: false, - service: "SMS", - message: { - id: "msg_2", - parts: [{ type: "text", value: "hello" }], - }, - }); - }); -}); diff --git a/src/linq/monitor.ts b/src/linq/monitor.ts deleted file mode 100644 index a107fbe..0000000 --- a/src/linq/monitor.ts +++ /dev/null @@ -1,457 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { - LinqMediaPart, - LinqMessageReceivedData, - LinqReactionReceivedData, - LinqTextPart, - LinqWebhookEvent, -} from "./types.js"; -import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions, waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound"; -import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; -import { resolveLinqAccount } from "./accounts.js"; -import { markAsReadLinq, sendMessageLinq, startTypingLinq } from "./send.js"; -import { getLinqRuntime } from "../runtime.js"; -import { createLinqWebhookHandler, createMemoryLinqWebhookDedupeStore } from "./webhook.js"; - -const LINQ_WEBHOOK_MAX_BYTES = 1024 * 1024; -const LINQ_WEBHOOK_REPLAY_WINDOW_SECONDS = 300; -const LINQ_WEBHOOK_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000; -const activeWebhookPaths = new Map(); - -export type MonitorLinqOpts = { - accountId?: string; - config?: OpenClawConfig; - runtime?: { info: (msg: string) => void; error?: (msg: string) => void }; - abortSignal?: AbortSignal; -}; - -function normalizeAllowList(raw?: Array): string[] { - if (!raw || !Array.isArray(raw)) { - return []; - } - return raw.map((v) => String(v).trim()).filter(Boolean); -} - -function extractTextContent(parts: Array<{ type: string; value?: string }>): string { - return parts - .filter((p): p is LinqTextPart => p.type === "text") - .map((p) => p.value) - .join("\n"); -} - -function extractMediaUrls( - parts: Array<{ type: string; url?: string; mime_type?: string }>, -): Array<{ url: string; mimeType: string }> { - return parts - .filter( - (p): p is LinqMediaPart & { url: string; mime_type: string } => - p.type === "media" && Boolean(p.url) && Boolean(p.mime_type), - ) - .map((p) => ({ url: p.url, mimeType: p.mime_type })); -} - -export function normalizeLinqMessageReceivedData(raw: unknown): LinqMessageReceivedData | null { - if (!raw || typeof raw !== "object") { - return null; - } - const data = raw as Record; - const legacyMessage = data.message as { id?: unknown; parts?: unknown; reply_to?: unknown } | undefined; - if ( - typeof data.chat_id === "string" && - typeof data.from === "string" && - legacyMessage && - typeof legacyMessage.id === "string" && - Array.isArray(legacyMessage.parts) - ) { - return data as LinqMessageReceivedData; - } - - const chat = data.chat as - | { - id?: unknown; - owner_handle?: { handle?: unknown }; - } - | undefined; - const senderHandle = data.sender_handle as { handle?: unknown; is_me?: unknown } | undefined; - if ( - typeof chat?.id !== "string" || - typeof senderHandle?.handle !== "string" || - !Array.isArray(data.parts) || - typeof data.id !== "string" - ) { - return null; - } - - return { - chat_id: chat.id, - from: senderHandle.handle, - recipient_phone: - typeof chat.owner_handle?.handle === "string" ? chat.owner_handle.handle : "", - received_at: - typeof data.sent_at === "string" - ? data.sent_at - : typeof data.created_at === "string" - ? data.created_at - : "", - is_from_me: - data.direction === "outbound" || - senderHandle.is_me === true, - service: - data.service === "SMS" || data.service === "RCS" || data.service === "iMessage" - ? data.service - : "iMessage", - message: { - id: data.id, - parts: data.parts as LinqMessageReceivedData["message"]["parts"], - reply_to: data.reply_to as LinqMessageReceivedData["message"]["reply_to"], - }, - }; -} - -function isAllowedLinqSender(allowFrom: string[], sender: string): boolean { - if (allowFrom.includes("*")) { - return true; - } - const normalized = sender.replace(/[\s()-]/g, "").toLowerCase(); - return allowFrom.some((entry) => { - const norm = entry.replace(/[\s()-]/g, "").toLowerCase(); - return norm === normalized; - }); -} - -export async function monitorLinqProvider(opts: MonitorLinqOpts = {}): Promise { - const rt = getLinqRuntime(); - const logVerbose = (msg: string) => { - if (rt.logging.shouldLogVerbose()) { - opts.runtime?.info(msg); - } - }; - const cfg = opts.config ?? rt.config.loadConfig(); - const accountInfo = resolveLinqAccount({ cfg, accountId: opts.accountId }); - const linqCfg = accountInfo.config; - const token = accountInfo.token; - - if (!token) { - throw new Error("Linq API token not configured"); - } - - const allowFrom = normalizeAllowList(linqCfg.allowFrom); - const dmPolicy = linqCfg.dmPolicy ?? "open"; - const webhookSecret = accountInfo.webhookSecret; - const webhookPath = linqCfg.webhookPath?.trim() || "/linq-webhook"; - const fromPhone = accountInfo.fromPhone; - - const inboundDebounceMs = rt.channel.debounce.resolveInboundDebounceMs({ cfg, channel: "linq" }); - const inboundDebouncer = rt.channel.debounce.createInboundDebouncer<{ event: LinqMessageReceivedData }>({ - debounceMs: inboundDebounceMs, - buildKey: (entry) => { - const sender = entry.event.from?.trim(); - if (!sender) { - return null; - } - return `linq:${accountInfo.accountId}:${entry.event.chat_id}:${sender}`; - }, - shouldDebounce: (entry) => { - const text = extractTextContent( - entry.event.message.parts as Array<{ type: string; value?: string }>, - ); - if (!text.trim()) { - return false; - } - return !rt.channel.text.hasControlCommand(text, cfg); - }, - onFlush: async (entries) => { - const last = entries.at(-1); - if (!last) { - return; - } - if (entries.length === 1) { - await handleMessage(last.event); - return; - } - const combinedText = entries - .map((e) => - extractTextContent(e.event.message.parts as Array<{ type: string; value?: string }>), - ) - .filter(Boolean) - .join("\n"); - const syntheticEvent: LinqMessageReceivedData = { - ...last.event, - message: { - ...last.event.message, - parts: [{ type: "text" as const, value: combinedText }], - }, - }; - await handleMessage(syntheticEvent); - }, - onError: (err) => { - opts.runtime?.error?.(`linq debounce flush failed: ${String(err)}`); - }, - }); - - async function handleMessage(data: LinqMessageReceivedData) { - const sender = data.from?.trim(); - if (!sender) { - return; - } - if (data.is_from_me) { - return; - } - - if (fromPhone && data.recipient_phone !== fromPhone) { - logVerbose(`linq: skipping message to ${data.recipient_phone} (not ${fromPhone})`); - return; - } - - const chatId = data.chat_id; - const text = extractTextContent(data.message.parts as Array<{ type: string; value?: string }>); - const media = extractMediaUrls( - data.message.parts as Array<{ type: string; url?: string; mime_type?: string }>, - ); - - if (!text.trim() && media.length === 0) { - return; - } - - markAsReadLinq(chatId, token); - startTypingLinq(chatId, token); - - const storeAllowFrom = await rt.channel.pairing - .readAllowFromStore({ channel: "linq", accountId: accountInfo.accountId }) - .catch(() => []); - const effectiveDmAllowFrom = Array.from(new Set([...allowFrom, ...storeAllowFrom])) - .map((v) => String(v).trim()) - .filter(Boolean); - - const dmHasWildcard = effectiveDmAllowFrom.includes("*"); - const dmAuthorized = - dmPolicy === "open" - ? true - : dmHasWildcard || - (effectiveDmAllowFrom.length > 0 && isAllowedLinqSender(effectiveDmAllowFrom, sender)); - - if (dmPolicy === "disabled") { - return; - } - if (!dmAuthorized) { - if (dmPolicy === "pairing") { - const { code, created } = await rt.channel.pairing.upsertPairingRequest({ - channel: "linq", - id: sender, - accountId: accountInfo.accountId, - meta: { sender, chatId }, - }); - if (created) { - logVerbose(`linq pairing request sender=${sender}`); - try { - await sendMessageLinq( - `linq:chat:${chatId}`, - rt.channel.pairing.buildPairingReply({ - channel: "linq", - idLine: `Your phone number: ${sender}`, - code, - }), - { token, accountId: accountInfo.accountId }, - ); - } catch (err) { - logVerbose(`linq pairing reply failed for ${sender}: ${String(err)}`); - } - } - } else { - logVerbose(`Blocked linq sender ${sender} (dmPolicy=${dmPolicy})`); - } - return; - } - - const route = rt.channel.routing.resolveAgentRoute({ - cfg, - channel: "linq", - accountId: accountInfo.accountId, - peer: { kind: "direct", id: sender }, - }); - const bodyText = text.trim() || (media.length > 0 ? "" : ""); - if (!bodyText) { - return; - } - - const replyContext = data.message.reply_to ? { id: data.message.reply_to.message_id } : null; - const createdAt = data.received_at ? Date.parse(data.received_at) : undefined; - - const fromLabel = sender; - const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId }); - const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = rt.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - - const replySuffix = replyContext?.id ? `\n\n[Replying to message ${replyContext.id}]` : ""; - const body = rt.channel.reply.formatInboundEnvelope({ - channel: "Linq iMessage", - from: fromLabel, - timestamp: createdAt, - body: `${bodyText}${replySuffix}`, - chatType: "direct", - sender: { name: sender, id: sender }, - previousTimestamp, - envelope: envelopeOptions, - }); - - const linqTo = chatId; - const ctxPayload = rt.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: bodyText, - RawBody: bodyText, - CommandBody: bodyText, - From: `linq:${sender}`, - To: linqTo, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: "direct", - ConversationLabel: fromLabel, - SenderName: sender, - SenderId: sender, - Provider: "linq", - Surface: "linq", - MessageSid: data.message.id, - ReplyToId: replyContext?.id, - Timestamp: createdAt, - MediaUrl: media[0]?.url, - MediaType: media[0]?.mimeType, - MediaUrls: media.length > 0 ? media.map((m) => m.url) : undefined, - MediaTypes: media.length > 0 ? media.map((m) => m.mimeType) : undefined, - WasMentioned: true, - CommandAuthorized: dmAuthorized, - OriginatingChannel: "linq" as const, - OriginatingTo: linqTo, - }); - - await rt.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - updateLastRoute: { - sessionKey: route.mainSessionKey, - channel: "linq", - to: linqTo, - accountId: route.accountId, - }, - onRecordError: (err) => { - logVerbose(`linq: failed updating session meta: ${String(err)}`); - }, - }); - - logVerbose( - `linq inbound: chatId=${chatId} from=${sender} len=${body.length}`, - ); - - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg, - agentId: route.agentId, - channel: "linq", - accountId: route.accountId, - }); - - await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...prefixOptions, - deliver: async (payload) => { - const replyText = typeof payload === "string" ? payload : (payload.text ?? ""); - if (replyText) { - await sendMessageLinq(`linq:chat:${chatId}`, replyText, { - token, - accountId: accountInfo.accountId, - }); - } - }, - }, - }); - } - - const webhookHandler = createLinqWebhookHandler({ - path: webhookPath, - secret: webhookSecret, - maxBytes: LINQ_WEBHOOK_MAX_BYTES, - replayWindowSeconds: LINQ_WEBHOOK_REPLAY_WINDOW_SECONDS, - dedupeTtlMs: LINQ_WEBHOOK_DEDUPE_TTL_MS, - dedupeStore: createMemoryLinqWebhookDedupeStore(), - }); - - const currentPathOwner = activeWebhookPaths.get(webhookPath); - if (currentPathOwner && currentPathOwner !== accountInfo.accountId) { - throw new Error( - `Linq webhook path ${webhookPath} is already registered by account ${currentPathOwner}; configure a distinct webhookPath for account ${accountInfo.accountId}.`, - ); - } - - const unregister = registerPluginHttpRoute({ - path: webhookPath, - auth: "plugin", - pluginId: "linq", - accountId: accountInfo.accountId, - replaceExisting: true, - log: (msg) => opts.runtime?.info(msg), - handler: async (req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url || "/", `http://${req.headers.host}`); - const chunks: Buffer[] = []; - let size = 0; - const maxPayloadBytes = LINQ_WEBHOOK_MAX_BYTES; - for await (const chunk of req) { - size += (chunk as Buffer).length; - if (size > maxPayloadBytes) { - res.writeHead(413); - res.end(); - return; - } - chunks.push(chunk as Buffer); - } - const result = await webhookHandler({ - method: req.method ?? "GET", - path: url.pathname, - headers: req.headers, - body: Buffer.concat(chunks), - }); - - res.writeHead(result.status, { "Content-Type": "application/json" }); - res.end(result.body); - - if (!result.event || result.duplicate) { - return; - } - - try { - const event = result.event as LinqWebhookEvent; - if (event.event_type === "message.received") { - const data = normalizeLinqMessageReceivedData(event.data); - if (!data) { - logVerbose(`linq webhook ignored malformed message.received event ${event.event_id}`); - return; - } - await inboundDebouncer.enqueue({ event: data }); - } else if (event.event_type === "reaction.received") { - const data = event.data as LinqReactionReceivedData; - if (!data.is_from_me && data.reaction) { - logVerbose( - `linq reaction: ${data.reaction.operation} ${data.reaction.type} from=${data.from} msg=${data.message_id}`, - ); - } - } else if (event.event_type === "message.delivery_status") { - logVerbose(`linq delivery: ${(event.data as { status?: string })?.status} msg=${(event.data as { message_id?: string })?.message_id}`); - } - } catch (err) { - opts.runtime?.error?.(`linq webhook parse error: ${String(err)}`); - } - }, - }); - activeWebhookPaths.set(webhookPath, accountInfo.accountId); - opts.runtime?.info(`linq: registered webhook route ${webhookPath} for account ${accountInfo.accountId}`); - await waitUntilAbort(opts.abortSignal, () => { - unregister(); - if (activeWebhookPaths.get(webhookPath) === accountInfo.accountId) { - activeWebhookPaths.delete(webhookPath); - } - }); -} diff --git a/src/linq/session-route.test.ts b/src/linq/session-route.test.ts new file mode 100644 index 0000000..0c535fa --- /dev/null +++ b/src/linq/session-route.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { resolveLinqOutboundSessionRoute } from "./session-route.js"; + +describe("Linq outbound session routing", () => { + it("maps explicit phone sends to the canonical inbound phone session", () => { + const route = resolveLinqOutboundSessionRoute({ + cfg: { session: { dmScope: "per-channel-peer" } }, + agentId: "main", + target: "linq:+1 (555) 123-4567", + }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:linq:direct:+15551234567", + recipientSessionExact: true, + peer: { kind: "direct", id: "+15551234567" }, + from: "linq:+15551234567", + to: "linq:+15551234567", + }); + }); + + it("keeps explicit chat targets account-scoped", () => { + const route = resolveLinqOutboundSessionRoute({ + cfg: { session: { dmScope: "per-channel-peer" } }, + agentId: "main", + accountId: "sales", + target: "linq:chat:chat_1", + }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:linq:direct:chat_1", + peer: { kind: "direct", id: "chat_1" }, + from: "linq:sales:chat:chat_1", + to: "linq:sales:chat:chat_1", + }); + expect(route).not.toHaveProperty("recipientSessionExact"); + }); + + it("rejects unsupported group targets", () => { + expect( + resolveLinqOutboundSessionRoute({ + cfg: {}, + agentId: "main", + target: "linq:group:group_1", + }), + ).toBeNull(); + }); +}); diff --git a/src/linq/session-route.ts b/src/linq/session-route.ts new file mode 100644 index 0000000..60961cf --- /dev/null +++ b/src/linq/session-route.ts @@ -0,0 +1,33 @@ +import { + buildChannelOutboundSessionRoute, + type ChannelOutboundSessionRouteParams, +} from "openclaw/plugin-sdk/channel-core"; +import { formatLinqTarget, parseLinqTarget } from "./targets.js"; + +export function resolveLinqOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { + try { + const parsed = parseLinqTarget( + params.resolvedTarget?.to ?? params.target, + params.accountId ?? undefined, + ); + if (parsed.kind === "group") { + return null; + } + const target = formatLinqTarget(parsed); + const peerId = parsed.kind === "phone" ? parsed.phone : parsed.chatId; + const route = buildChannelOutboundSessionRoute({ + cfg: params.cfg, + agentId: params.agentId, + channel: "linq", + accountId: parsed.accountId ?? params.accountId, + peer: { kind: "direct", id: peerId }, + chatType: "direct", + from: target, + to: target, + }); + // OpenClaw 2026.7.1+ consumes this hint; older compatible hosts ignore it. + return parsed.kind === "phone" ? { ...route, recipientSessionExact: true as const } : route; + } catch { + return null; + } +} From a1dcbbf0e99ea13c3d1649baccd70059896be5a1 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 15 Jul 2026 12:31:23 -0700 Subject: [PATCH 2/4] Split Linq setup from runtime channel --- setup-entry.ts | 4 +- src/channel-base.ts | 184 +++++++++++++++++++++++++++++++++++++++ src/channel.setup.ts | 3 + src/channel.test.ts | 12 ++- src/channel.ts | 185 ++-------------------------------------- src/onboarding.ts | 15 ++-- src/setup-entry.test.ts | 23 +++++ 7 files changed, 234 insertions(+), 192 deletions(-) create mode 100644 src/channel-base.ts create mode 100644 src/channel.setup.ts create mode 100644 src/setup-entry.test.ts diff --git a/setup-entry.ts b/setup-entry.ts index 67a4e9d..eed08c0 100644 --- a/setup-entry.ts +++ b/setup-entry.ts @@ -1,4 +1,4 @@ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; -import { linqPlugin } from "./src/channel.js"; +import { linqSetupPlugin } from "./src/channel.setup.js"; -export default defineSetupPluginEntry(linqPlugin); +export default defineSetupPluginEntry(linqSetupPlugin); diff --git a/src/channel-base.ts b/src/channel-base.ts new file mode 100644 index 0000000..f892c98 --- /dev/null +++ b/src/channel-base.ts @@ -0,0 +1,184 @@ +import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk/core"; +import { + listLinqAccountIds, + resolveDefaultLinqAccountId, + resolveLinqAccountForStatus, + type ResolvedLinqAccount, +} from "./linq/accounts.js"; +import { LinqChannelConfigSchema } from "./linq/config.js"; +import { + collectLinqRuntimeConfigAssignments, + linqSecretTargetRegistryEntries, +} from "./linq/secret-contract.js"; +import type { LinqProbe } from "./linq/types.js"; +import { linqOnboardingAdapter } from "./onboarding.js"; + +const meta = getChatChannelMeta("linq"); + +export function createLinqPluginBase(): ChannelPlugin { + return { + id: "linq", + meta: { + ...meta, + aliases: ["linq-imessage"], + }, + setupWizard: linqOnboardingAdapter, + capabilities: { + chatTypes: ["direct"], + reactions: false, + media: true, + }, + reload: { configPrefixes: ["channels.linq"] }, + configSchema: LinqChannelConfigSchema, + config: { + listAccountIds: (cfg) => listLinqAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveLinqAccountForStatus({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultLinqAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "linq", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "linq", + accountId, + clearBaseFields: ["apiToken", "tokenFile", "fromPhone", "name"], + }), + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + webhookSecretSource: account.webhookSecretSource, + fromPhone: account.fromPhone, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveLinqAccountForStatus({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const linqSection = (cfg.channels as Record | undefined)?.linq as + | Record + | undefined; + const useAccountPath = Boolean( + (linqSection?.accounts as Record | undefined)?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.linq.accounts.${resolvedAccountId}.` + : "channels.linq."; + return { + policy: account.config.dmPolicy ?? "open", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("linq"), + }; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "linq", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINQ_API_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Linq requires an API token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "linq", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "linq" }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + linq: { + ...((next.channels as Record | undefined)?.linq as + | Record + | undefined), + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { apiToken: input.token } + : {}), + }, + }, + }; + } + const linqSection = (next.channels as Record | undefined)?.linq as + | Record + | undefined; + return { + ...next, + channels: { + ...next.channels, + linq: { + ...linqSection, + enabled: true, + accounts: { + ...(linqSection?.accounts as Record | undefined), + [accountId]: { + ...((linqSection?.accounts as Record | undefined)?.[ + accountId + ] as Record | undefined), + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { apiToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, + }, + secrets: { + secretTargetRegistryEntries: linqSecretTargetRegistryEntries, + collectRuntimeConfigAssignments: collectLinqRuntimeConfigAssignments, + }, + }; +} diff --git a/src/channel.setup.ts b/src/channel.setup.ts new file mode 100644 index 0000000..a1fbb43 --- /dev/null +++ b/src/channel.setup.ts @@ -0,0 +1,3 @@ +import { createLinqPluginBase } from "./channel-base.js"; + +export const linqSetupPlugin = createLinqPluginBase(); diff --git a/src/channel.test.ts b/src/channel.test.ts index 80fb7ce..ddb76ab 100644 --- a/src/channel.test.ts +++ b/src/channel.test.ts @@ -2,10 +2,18 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import linqSetupEntry from "../setup-entry.js"; import { linqPlugin } from "./channel.js"; +import { linqSetupPlugin } from "./channel.setup.js"; describe("Linq channel discovery", () => { - it("exposes the channel plugin to setup-only discovery", () => { - expect(linqSetupEntry.plugin).toBe(linqPlugin); + it("exposes a runtime-free channel plugin to setup-only discovery", () => { + expect(linqSetupEntry.plugin).toBe(linqSetupPlugin); + expect(linqSetupEntry.plugin).not.toBe(linqPlugin); + expect(linqSetupPlugin.setupWizard).toBe(linqPlugin.setupWizard); + expect(linqSetupPlugin.setup).toBeDefined(); + expect(linqSetupPlugin.configSchema).toBe(linqPlugin.configSchema); + expect(linqSetupPlugin).not.toHaveProperty("gateway"); + expect(linqSetupPlugin).not.toHaveProperty("outbound"); + expect(linqSetupPlugin).not.toHaveProperty("message"); }); it("declares the setup entry and channel catalog metadata", async () => { diff --git a/src/channel.ts b/src/channel.ts index fab354f..45cbdff 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1,46 +1,19 @@ -import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatPairingApproveHint, - getChatChannelMeta, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - setAccountEnabledInConfigSection, - type ChannelPlugin, -} from "openclaw/plugin-sdk/core"; +import { DEFAULT_ACCOUNT_ID, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedLinqAccount } from "./linq/accounts.js"; import type { LinqProbe } from "./linq/types.js"; -import { LinqChannelConfigSchema } from "./linq/config.js"; -import { - listLinqAccountIds, - resolveDefaultLinqAccountId, - resolveLinqAccount, - resolveLinqAccountForStatus, -} from "./linq/accounts.js"; +import { resolveLinqAccount } from "./linq/accounts.js"; import { probeLinq } from "./linq/probe.js"; import { sendMessageLinq } from "./linq/send.js"; import { formatLinqTarget, parseLinqTarget } from "./linq/targets.js"; import { monitorLinqProvider } from "./linq/gateway.js"; import { linqMessageAdapter, toLinqOutboundDeliveryResult } from "./linq/message.js"; import { resolveLinqOutboundSessionRoute } from "./linq/session-route.js"; -import { linqOnboardingAdapter } from "./onboarding.js"; import { getLinqRuntime } from "./runtime.js"; -import { - collectLinqRuntimeConfigAssignments, - linqSecretTargetRegistryEntries, -} from "./linq/secret-contract.js"; - -const meta = getChatChannelMeta("linq"); +import { createLinqPluginBase } from "./channel-base.js"; export const linqPlugin: ChannelPlugin = { - id: "linq", - meta: { - ...meta, - aliases: ["linq-imessage"], - }, - setupWizard: linqOnboardingAdapter as never, + ...createLinqPluginBase(), pairing: { idLabel: "phoneNumber", notifyApproval: async ({ id, accountId, cfg }) => { @@ -51,70 +24,6 @@ export const linqPlugin: ChannelPlugin = { }); }, }, - capabilities: { - chatTypes: ["direct"], - reactions: false, - media: true, - }, - reload: { configPrefixes: ["channels.linq"] }, - configSchema: LinqChannelConfigSchema, - config: { - listAccountIds: (cfg) => listLinqAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveLinqAccountForStatus({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultLinqAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "linq", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "linq", - accountId, - clearBaseFields: ["apiToken", "tokenFile", "fromPhone", "name"], - }), - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - webhookSecretSource: account.webhookSecretSource, - fromPhone: account.fromPhone, - }), - resolveAllowFrom: ({ cfg, accountId }) => - (resolveLinqAccountForStatus({ cfg, accountId }).config.allowFrom ?? []).map((entry) => - String(entry), - ), - formatAllowFrom: ({ allowFrom }) => - allowFrom.map((entry) => String(entry).trim()).filter(Boolean), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; - const linqSection = (cfg.channels as Record | undefined)?.linq as - | Record - | undefined; - const useAccountPath = Boolean( - (linqSection?.accounts as Record | undefined)?.[resolvedAccountId], - ); - const basePath = useAccountPath - ? `channels.linq.accounts.${resolvedAccountId}.` - : "channels.linq."; - return { - policy: account.config.dmPolicy ?? "open", - allowFrom: account.config.allowFrom ?? [], - policyPath: `${basePath}dmPolicy`, - allowFromPath: basePath, - approveHint: formatPairingApproveHint("linq"), - }; - }, - }, messaging: { normalizeTarget: (raw: string) => { if (!raw) { @@ -139,85 +48,6 @@ export const linqPlugin: ChannelPlugin = { hint: "linq:+15556667777 | linq:chat: | linq::+15556667777", }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "linq", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINQ_API_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Linq requires an API token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "linq", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "linq" }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - linq: { - ...((next.channels as Record | undefined)?.linq as - | Record - | undefined), - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { apiToken: input.token } - : {}), - }, - }, - }; - } - const linqSection = (next.channels as Record | undefined)?.linq as - | Record - | undefined; - return { - ...next, - channels: { - ...next.channels, - linq: { - ...linqSection, - enabled: true, - accounts: { - ...(linqSection?.accounts as Record | undefined), - [accountId]: { - ...((linqSection?.accounts as Record | undefined)?.[accountId] as - | Record - | undefined), - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { apiToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, - }, outbound: { deliveryMode: "direct", chunker: (text, limit) => getLinqRuntime().channel.text.chunkText(text, limit), @@ -277,8 +107,7 @@ export const linqPlugin: ChannelPlugin = { probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - probeAccount: async ({ account, timeoutMs }) => - probeLinq(account.token, timeoutMs), + probeAccount: async ({ account, timeoutMs }) => probeLinq(account.token, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, name: account.name, @@ -296,10 +125,6 @@ export const linqPlugin: ChannelPlugin = { lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, - secrets: { - secretTargetRegistryEntries: linqSecretTargetRegistryEntries, - collectRuntimeConfigAssignments: collectLinqRuntimeConfigAssignments, - }, gateway: { startAccount: async (ctx) => { const account = ctx.account; diff --git a/src/onboarding.ts b/src/onboarding.ts index 35bc39d..93fed2d 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,8 +1,6 @@ -// @ts-nocheck - Legacy setup wizard compatibility surface; the concrete setup -// adapter lives on the channel plugin's `setup` property. import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { - ChannelSetupAdapter as ChannelOnboardingAdapter, + ChannelSetupWizardAdapter as ChannelOnboardingAdapter, ChannelSetupDmPolicy as ChannelOnboardingDmPolicy, DmPolicy, WizardPrompter, @@ -158,7 +156,8 @@ async function selectLinqPhone(params: { return String( await prompter.select({ message: "Linq sender phone number", - initialValue: existingPhone && phoneNumbers.includes(existingPhone) ? existingPhone : phoneNumbers[0], + initialValue: + existingPhone && phoneNumbers.includes(existingPhone) ? existingPhone : phoneNumbers[0], options: phoneNumbers.map((phone) => ({ value: phone, label: phone })), }), ); @@ -268,13 +267,13 @@ async function maybeCreateLinqWebhookSubscription(params: { targetUrl: webhookUrl, phoneNumber: fromPhone, }); + await prompter.note(`Created Linq webhook subscription ${subscription.id}.`, "Linq webhook"); + return subscription.signing_secret?.trim() || null; + } catch (err) { await prompter.note( - `Created Linq webhook subscription ${subscription.id}.`, + `Could not configure Linq webhook subscription: ${String(err)}`, "Linq webhook", ); - return subscription.signing_secret?.trim() || null; - } catch (err) { - await prompter.note(`Could not configure Linq webhook subscription: ${String(err)}`, "Linq webhook"); return null; } } diff --git a/src/setup-entry.test.ts b/src/setup-entry.test.ts new file mode 100644 index 0000000..0b8bab5 --- /dev/null +++ b/src/setup-entry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./linq/gateway.js", () => { + throw new Error("setup discovery must not load the Linq gateway runtime"); +}); +vi.mock("./linq/send.js", () => { + throw new Error("setup discovery must not load Linq outbound delivery"); +}); +vi.mock("./runtime.js", () => { + throw new Error("setup discovery must not load the full plugin runtime"); +}); + +describe("Linq setup entry import boundary", () => { + it("loads setup/config surfaces without runtime-only modules", async () => { + const { default: entry } = await import("../setup-entry.js"); + + expect(entry.plugin.id).toBe("linq"); + expect(entry.plugin.setupWizard).toBeDefined(); + expect(entry.plugin.setup).toBeDefined(); + expect(entry.plugin).not.toHaveProperty("gateway"); + expect(entry.plugin).not.toHaveProperty("outbound"); + }); +}); From 16d68521873b691893ab5078208dbbafa0a111be Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 15 Jul 2026 12:17:03 -0700 Subject: [PATCH 3/4] Fix Linq public webhook onboarding --- .npmignore | 11 + README.md | 68 ++++- openclaw.plugin.json | 8 +- src/linq/config.test.ts | 44 ++- src/linq/config.ts | 15 +- src/linq/onboarding-ingress.test.ts | 77 +++++ src/linq/onboarding-ingress.ts | 160 ++++++++++ src/linq/onboarding-webhook.ts | 434 ++++++++++++++++++++++++++++ src/onboarding.test.ts | 347 ++++++++++++++++++++++ src/onboarding.ts | 245 ++++------------ 10 files changed, 1205 insertions(+), 204 deletions(-) create mode 100644 .npmignore create mode 100644 src/linq/onboarding-ingress.test.ts create mode 100644 src/linq/onboarding-ingress.ts create mode 100644 src/linq/onboarding-webhook.ts create mode 100644 src/onboarding.test.ts diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..34225fb --- /dev/null +++ b/.npmignore @@ -0,0 +1,11 @@ +.env +.env.* +.worktrees/ +.local/ +dist/ +node_modules/ +tmp/ +*.tsbuildinfo +**/*.test.ts +LINQ-PARITY.md +LINQ-PLAN.md diff --git a/README.md b/README.md index 77dfff1..8c76b01 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ The wizard will walk you through: 1. **API token** — sign up at [linqapp.com](https://linqapp.com) and copy your token from the dashboard 2. **Phone number** — the Linq phone number shown in your dashboard (E.164 format, e.g. `+15551234567`) -3. **Webhook config** — URL for inbound message delivery; the local route path is derived from the URL path (defaults to `http://localhost:3100/linq-webhook`) +3. **Local webhook path** — the dedicated path served by the OpenClaw Gateway (defaults to `/linq-webhook`) +4. **Inbound delivery** — an existing public HTTPS URL, a path-scoped Tailscale Funnel, a Cloudflare Tunnel/reverse proxy, or explicit outbound-only mode +5. **Readiness summary** — local route, public target, subscription, phone filter, signing-secret state, and the next verification action Prefer a SecretRef for credentials. `LINQ_API_TOKEN` is still supported as a default-account setup convenience. @@ -48,13 +50,73 @@ After running the wizard, your `openclaw.json` will contain: "apiToken": { "source": "env", "id": "LINQ_API_TOKEN" }, "fromPhone": "+15551234567", "dmPolicy": "open", - "webhookUrl": "http://localhost:3100/linq-webhook", + "webhookUrl": "https://messages.example.com/linq-webhook", "webhookPath": "/linq-webhook" } } } ``` +`webhookUrl` is the stable public HTTPS target registered with Linq. `webhookPath` is the matching route on the local OpenClaw Gateway. Outbound-only configurations omit `webhookUrl` and `webhookSecret`. + +### Public ingress + +The webhook route is served by the OpenClaw Gateway on `gateway.port` (default `18789`). Publish only the configured `webhookPath`; do not expose the Gateway root or Control UI. Linq requires a stable public HTTPS URL before the wizard will offer to create a subscription. + +Start the Gateway before testing ingress: + +```bash +openclaw gateway run +``` + +#### Tailscale Funnel + +For a Gateway on port `18789`, publish only the Linq path: + +```bash +tailscale funnel --bg --https=443 --set-path=/linq-webhook http://127.0.0.1:18789/linq-webhook +``` + +Enter the HTTPS hostname printed by Tailscale with `/linq-webhook` appended. The wizard detects the `tailscale` binary but never installs it or runs Funnel for you. Remove the route with: + +```bash +tailscale funnel --https=443 --set-path=/linq-webhook off +``` + +See the [Tailscale Funnel CLI reference](https://tailscale.com/docs/reference/tailscale-cli/funnel) for installation and account requirements. + +#### Cloudflare Tunnel + +Use a named tunnel with a path matcher and a catch-all `404`; a quick tunnel pointed at the Gateway would expose more than the webhook route. + +```yaml +tunnel: +credentials-file: + +ingress: + - hostname: messages.example.com + path: ^/linq-webhook$ + service: http://127.0.0.1:18789 + - service: http_status:404 +``` + +Validate and run the configuration yourself: + +```bash +cloudflared tunnel --config ingress validate +cloudflared tunnel --config run +``` + +Stop the foreground process with `Ctrl-C`. If the tunnel was dedicated to a temporary Linq setup, remove it after stopping it and remove its DNS record: + +```bash +cloudflared tunnel delete +``` + +See Cloudflare's [configuration-file reference](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/) for tunnel creation, credentials, DNS routing, and service installation. + +Other reverse proxies are supported when the public HTTPS path maps exactly to `http://127.0.0.1:` and all unrelated paths are rejected. + ### Multi-account Multiple Linq accounts are supported via the `accounts` field: @@ -86,7 +148,7 @@ Multiple Linq accounts are supported via the `accounts` field: Control who can message your agent: - `"open"` (default in this version) — anyone can message -- `"pairing"` — new senders must enter a pairing code once durable pairing setup is enabled +- `"pairing"` — new senders must complete the OpenClaw pairing flow - `"disabled"` — no inbound DMs ### Webhook security diff --git a/openclaw.plugin.json b/openclaw.plugin.json index c907137..4058a84 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -17,13 +17,13 @@ "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } }, - "webhookUrl": { "type": "string", "format": "uri" }, + "webhookUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, "webhookSecret": { "anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }] }, "webhookPath": { "type": "string", - "pattern": "^/[A-Za-z0-9/_-]*$", + "pattern": "^/[A-Za-z0-9/_-]+$", "default": "/linq-webhook" }, "webhookHost": { "type": "string", "minLength": 1 }, @@ -61,13 +61,13 @@ "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } }, - "webhookUrl": { "type": "string", "format": "uri" }, + "webhookUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, "webhookSecret": { "anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }] }, "webhookPath": { "type": "string", - "pattern": "^/[A-Za-z0-9/_-]*$", + "pattern": "^/[A-Za-z0-9/_-]+$", "default": "/linq-webhook" }, "webhookHost": { "type": "string", "minLength": 1 }, diff --git a/src/linq/config.test.ts b/src/linq/config.test.ts index 3438f97..783d6b7 100644 --- a/src/linq/config.test.ts +++ b/src/linq/config.test.ts @@ -28,6 +28,19 @@ describe("LinqConfigSchema", () => { expect(invalid.success).toBe(false); }); + it("rejects non-HTTPS webhook targets and the Gateway root path", () => { + expect( + LinqConfigSchema.safeParse({ webhookUrl: "http://localhost:3100/linq-webhook" }).success, + ).toBe(false); + expect(LinqConfigSchema.safeParse({ webhookPath: "/" }).success).toBe(false); + expect( + LinqConfigSchema.safeParse({ + webhookUrl: "https://messages.example.com/linq-webhook", + webhookPath: "/linq-webhook", + }).success, + ).toBe(true); + }); + it("exposes matching public JSON-schema and runtime validation", () => { expect(LinqChannelConfigSchema.schema).toMatchObject({ type: "object", @@ -61,18 +74,33 @@ describe("LinqConfigSchema", () => { const manifest = JSON.parse( readFileSync(new URL("../../openclaw.plugin.json", import.meta.url), "utf8"), ) as { - configSchema: { properties: { accounts: unknown } }; - channelConfigs: { linq: { schema: { properties: { accounts: unknown } } } }; + configSchema: { + properties: { accounts: unknown; webhookPath: unknown; webhookUrl: unknown }; + }; + channelConfigs: { + linq: { + schema: { + properties: { accounts: unknown; webhookPath: unknown; webhookUrl: unknown }; + }; + }; + }; }; - expect(manifest.configSchema.properties.accounts).toEqual({ + const expectedAccounts = { type: "object", additionalProperties: { $ref: "#" }, - }); - expect(manifest.channelConfigs.linq.schema.properties.accounts).toEqual({ - type: "object", - additionalProperties: { $ref: "#" }, - }); + }; + const expectedWebhookUrl = { type: "string", format: "uri", pattern: "^https://" }; + const expectedWebhookPath = { + type: "string", + pattern: "^/[A-Za-z0-9/_-]+$", + default: "/linq-webhook", + }; + for (const schema of [manifest.configSchema, manifest.channelConfigs.linq.schema]) { + expect(schema.properties.accounts).toEqual(expectedAccounts); + expect(schema.properties.webhookUrl).toEqual(expectedWebhookUrl); + expect(schema.properties.webhookPath).toEqual(expectedWebhookPath); + } }); }); diff --git a/src/linq/config.ts b/src/linq/config.ts index d26e7cd..ce1c5a6 100644 --- a/src/linq/config.ts +++ b/src/linq/config.ts @@ -2,6 +2,13 @@ import { z } from "zod"; import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; const e164PhoneSchema = z.string().regex(/^\+[1-9]\d{6,14}$/u, "expected E.164 phone number"); +const publicWebhookUrlSchema = z + .string() + .url() + .refine((value) => value.startsWith("https://"), "expected public HTTPS webhook URL"); +const webhookPathSchema = z + .string() + .regex(/^\/[A-Za-z0-9/_-]+$/u, "expected dedicated webhook path"); const allowFromEntrySchema = z.union([z.string().min(1), z.number()]); const secretRefSchema = z.object({ source: z.enum(["env", "file", "exec"]), @@ -20,9 +27,9 @@ export const LinqAccountConfigSchema: z.ZodType> = z.laz // TODO: default to "pairing" once durable Linq pairing setup is supported. dmPolicy: z.enum(["pairing", "open", "disabled"]).default("open").optional(), allowFrom: z.array(allowFromEntrySchema).optional(), - webhookUrl: z.string().url().optional(), + webhookUrl: publicWebhookUrlSchema.optional(), webhookSecret: z.union([z.string().min(1), secretRefSchema]).optional(), - webhookPath: z.string().regex(/^\/[A-Za-z0-9/_-]*$/u).default("/linq-webhook").optional(), + webhookPath: webhookPathSchema.default("/linq-webhook").optional(), webhookHost: z.string().min(1).optional(), accounts: z.record(z.string(), LinqAccountConfigSchema).optional(), defaultAccount: z.string().min(1).optional(), @@ -50,9 +57,9 @@ const linqAccountJsonSchemaProperties = { fromPhone: { type: "string", pattern: "^\\+[1-9]\\d{6,14}$" }, dmPolicy: { enum: ["pairing", "open", "disabled"], default: "open" }, allowFrom: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, - webhookUrl: { type: "string", format: "uri" }, + webhookUrl: { type: "string", format: "uri", pattern: "^https://" }, webhookSecret: { anyOf: [{ type: "string", minLength: 1 }, { $ref: "#/$defs/secretRef" }] }, - webhookPath: { type: "string", pattern: "^/[A-Za-z0-9/_-]*$", default: "/linq-webhook" }, + webhookPath: { type: "string", pattern: "^/[A-Za-z0-9/_-]+$", default: "/linq-webhook" }, webhookHost: { type: "string", minLength: 1 }, accounts: { type: "object", additionalProperties: { $ref: "#" } }, defaultAccount: { type: "string", minLength: 1 }, diff --git a/src/linq/onboarding-ingress.test.ts b/src/linq/onboarding-ingress.test.ts new file mode 100644 index 0000000..9533d63 --- /dev/null +++ b/src/linq/onboarding-ingress.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudflareTunnelInstructions, + buildLocalWebhookUrl, + buildTailscaleFunnelInstructions, + resolveConfiguredWebhookPath, + validatePublicWebhookUrl, + validateWebhookPath, +} from "./onboarding-ingress.js"; + +describe("Linq onboarding ingress", () => { + it("requires a dedicated local webhook path", () => { + expect(validateWebhookPath("/linq-webhook")).toBeUndefined(); + expect(validateWebhookPath("/hooks/linq_1")).toBeUndefined(); + expect(validateWebhookPath("/")).toContain("dedicated path"); + expect(validateWebhookPath("linq-webhook")).toContain("Use a path"); + }); + + it("accepts only public HTTPS URLs on the configured path", () => { + const path = "/linq-webhook"; + expect( + validatePublicWebhookUrl("https://messages.example.com/linq-webhook", path), + ).toBeUndefined(); + expect(validatePublicWebhookUrl("http://messages.example.com/linq-webhook", path)).toContain( + "require HTTPS", + ); + expect(validatePublicWebhookUrl("https://localhost/linq-webhook", path)).toContain( + "public hostname", + ); + expect(validatePublicWebhookUrl("https://localhost./linq-webhook", path)).toContain( + "public hostname", + ); + expect(validatePublicWebhookUrl("https://192.168.1.2/linq-webhook", path)).toContain( + "public hostname", + ); + expect(validatePublicWebhookUrl("https://messages.example.com/wrong", path)).toContain(path); + expect( + validatePublicWebhookUrl("https://messages.example.com/linq-webhook?token=no", path), + ).toContain("query parameters"); + }); + + it("uses explicit config, then the public URL path, then the safe default", () => { + expect( + resolveConfiguredWebhookPath({ + webhookPath: "/configured", + webhookUrl: "https://messages.example.com/from-url", + }), + ).toBe("/configured"); + expect( + resolveConfiguredWebhookPath({ webhookUrl: "https://messages.example.com/from-url" }), + ).toBe("/from-url"); + expect(resolveConfiguredWebhookPath({})).toBe("/linq-webhook"); + }); + + it("builds path-scoped tunnel and cleanup instructions", () => { + expect(buildLocalWebhookUrl(19001, "/linq-webhook")).toBe( + "http://127.0.0.1:19001/linq-webhook", + ); + expect( + buildTailscaleFunnelInstructions({ gatewayPort: 19001, webhookPath: "/linq-webhook" }), + ).toContain( + "tailscale funnel --bg --https=443 --set-path=/linq-webhook http://127.0.0.1:19001/linq-webhook", + ); + expect( + buildTailscaleFunnelInstructions({ gatewayPort: 19001, webhookPath: "/linq-webhook" }), + ).toContain("tailscale funnel --https=443 --set-path=/linq-webhook off"); + + const cloudflare = buildCloudflareTunnelInstructions({ + gatewayPort: 19001, + webhookPath: "/linq-webhook", + }); + expect(cloudflare).toContain("path: ^/linq-webhook$"); + expect(cloudflare).toContain("service: http://127.0.0.1:19001"); + expect(cloudflare).toContain("service: http_status:404"); + expect(cloudflare).toContain("cloudflared tunnel delete "); + }); +}); diff --git a/src/linq/onboarding-ingress.ts b/src/linq/onboarding-ingress.ts new file mode 100644 index 0000000..b5022be --- /dev/null +++ b/src/linq/onboarding-ingress.ts @@ -0,0 +1,160 @@ +import { isIP } from "node:net"; + +export const DEFAULT_LINQ_WEBHOOK_PATH = "/linq-webhook"; + +const webhookPathPattern = /^\/[A-Za-z0-9/_-]*$/u; + +export function parseWebhookUrl(value: string | undefined): URL | null { + const trimmed = value?.trim() ?? ""; + if (!trimmed) { + return null; + } + try { + return new URL(trimmed); + } catch { + return null; + } +} + +export function validateWebhookPath(value: string | undefined): string | undefined { + const trimmed = value?.trim() ?? ""; + if (!trimmed) { + return "Required"; + } + if (!webhookPathPattern.test(trimmed)) { + return "Use a path like /linq-webhook with letters, numbers, _, -, or /."; + } + if (trimmed === "/") { + return "Use a dedicated path instead of exposing the Gateway root."; + } + return undefined; +} + +function isPrivateIpv4(hostname: string): boolean { + const octets = hostname.split(".").map(Number); + const [first, second] = octets; + if (octets.length !== 4 || first === undefined || second === undefined) { + return true; + } + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + first >= 224 + ); +} + +function isPrivateIpv6(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/gu, "").toLowerCase(); + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("::ffff:") || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("ff") || + /^fe[89a-f]/u.test(normalized) + ); +} + +function isClearlyLocalHostname(hostname: string): boolean { + const normalized = hostname + .replace(/^\[|\]$/gu, "") + .replace(/\.$/u, "") + .toLowerCase(); + if ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") + ) { + return true; + } + const ipVersion = isIP(normalized); + return ipVersion === 4 + ? isPrivateIpv4(normalized) + : ipVersion === 6 + ? isPrivateIpv6(normalized) + : false; +} + +export function validatePublicWebhookUrl( + value: string | undefined, + webhookPath: string, +): string | undefined { + const url = parseWebhookUrl(value); + if (!url) { + return "Enter a valid public HTTPS URL."; + } + if (url.protocol !== "https:") { + return "Linq webhook subscriptions require HTTPS."; + } + if (!url.hostname || isClearlyLocalHostname(url.hostname)) { + return "Use a public hostname or publicly routable IP address."; + } + if (url.username || url.password || url.search || url.hash) { + return "Do not include credentials, query parameters, or a fragment."; + } + if (url.pathname !== webhookPath) { + return `The public URL path must be ${webhookPath}.`; + } + return undefined; +} + +export function resolveConfiguredWebhookPath(params: { + webhookPath?: string; + webhookUrl?: string; +}): string { + const configuredPath = params.webhookPath?.trim(); + if (configuredPath && !validateWebhookPath(configuredPath)) { + return configuredPath; + } + const urlPath = parseWebhookUrl(params.webhookUrl)?.pathname; + if (urlPath && !validateWebhookPath(urlPath)) { + return urlPath; + } + return DEFAULT_LINQ_WEBHOOK_PATH; +} + +export function buildLocalWebhookUrl(gatewayPort: number, webhookPath: string): string { + return `http://127.0.0.1:${gatewayPort}${webhookPath}`; +} + +export function buildTailscaleFunnelInstructions(params: { + gatewayPort: number; + webhookPath: string; +}): string { + const target = buildLocalWebhookUrl(params.gatewayPort, params.webhookPath); + return [ + "Run this yourself after the Gateway is listening:", + `tailscale funnel --bg --https=443 --set-path=${params.webhookPath} ${target}`, + "Use the HTTPS URL printed by Tailscale with the same path.", + "Cleanup:", + `tailscale funnel --https=443 --set-path=${params.webhookPath} off`, + ].join("\n"); +} + +export function buildCloudflareTunnelInstructions(params: { + gatewayPort: number; + webhookPath: string; +}): string { + return [ + "Add a path-limited ingress rule to a named Cloudflare Tunnel:", + "tunnel: ", + "credentials-file: ", + "ingress:", + " - hostname: ", + ` path: ^${params.webhookPath}$`, + ` service: http://127.0.0.1:${params.gatewayPort}`, + " - service: http_status:404", + "Validate and run it yourself:", + "cloudflared tunnel --config ingress validate", + "cloudflared tunnel --config run ", + "Cleanup a dedicated temporary tunnel after stopping it:", + "cloudflared tunnel delete ", + ].join("\n"); +} diff --git a/src/linq/onboarding-webhook.ts b/src/linq/onboarding-webhook.ts new file mode 100644 index 0000000..d9d7ec2 --- /dev/null +++ b/src/linq/onboarding-webhook.ts @@ -0,0 +1,434 @@ +import { detectBinary, type WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { + createLinqWebhookSubscription, + deleteLinqWebhookSubscription, + findLinqWebhookSubscription, + findReplaceableLinqWebhookSubscriptions, + listLinqWebhookSubscriptions, + type LinqWebhookSubscription, +} from "./subscriptions.js"; +import { + buildCloudflareTunnelInstructions, + buildLocalWebhookUrl, + buildTailscaleFunnelInstructions, + resolveConfiguredWebhookPath, + validatePublicWebhookUrl, + validateWebhookPath, +} from "./onboarding-ingress.js"; + +type LinqIngressMode = "existing" | "tailscale" | "cloudflare" | "outbound-only"; + +type LinqSubscriptionSetupResult = { + state: "ready" | "incomplete" | "error"; + detail: string; + subscription?: LinqWebhookSubscription; + signingSecret?: string; + clearSigningSecret?: boolean; +}; + +export type LinqWebhookOnboardingResult = + | { + mode: "inbound"; + webhookPath: string; + webhookUrl: string; + signingSecret?: string; + clearSigningSecret?: boolean; + } + | { mode: "outbound-only"; webhookPath: string } + | { mode: "preserved" }; + +async function setupLinqWebhookSubscription(params: { + prompter: WizardPrompter; + token: string; + webhookUrl: string; + previousWebhookUrl?: string; + fromPhone: string; + previousFromPhone?: string; + hasWebhookSecret: boolean; +}): Promise { + const { + prompter, + token, + webhookUrl, + previousWebhookUrl, + fromPhone, + previousFromPhone, + hasWebhookSecret, + } = params; + if (!token.trim()) { + return { state: "error", detail: "API token is unavailable." }; + } + let clearSigningSecret = false; + try { + const subscriptions = await listLinqWebhookSubscriptions(token); + const existing = findLinqWebhookSubscription(subscriptions, webhookUrl, fromPhone); + if (existing) { + await prompter.note( + `Found Linq webhook subscription ${existing.id} for ${webhookUrl}.`, + "Linq webhook", + ); + if (hasWebhookSecret) { + return { + state: "ready", + detail: "Reused existing subscription.", + subscription: existing, + }; + } + const recreateForSecret = await prompter.confirm({ + message: "Recreate the existing Linq webhook subscription to store its signing secret?", + initialValue: true, + }); + if (!recreateForSecret) { + await prompter.note( + "Linq only returns the signing secret when creating a subscription. Delete/recreate the subscription or enter the secret manually if inbound signatures fail.", + "Linq webhook secret", + ); + return { + state: "incomplete", + detail: "Existing subscription has no locally configured signing secret.", + subscription: existing, + }; + } + await deleteLinqWebhookSubscription({ token, subscriptionId: existing.id }); + clearSigningSecret = true; + } + + const replaceable = findReplaceableLinqWebhookSubscriptions(subscriptions, { + targetUrl: webhookUrl, + phoneNumber: fromPhone, + previousTargetUrl: previousWebhookUrl, + previousPhoneNumber: previousFromPhone, + }); + if (replaceable.length > 0) { + const replace = await prompter.confirm({ + message: `Replace ${replaceable.length} stale Linq webhook subscription${replaceable.length === 1 ? "" : "s"} before creating the current one?`, + initialValue: true, + }); + if (!replace) { + return { + state: "incomplete", + detail: "A stale subscription was left in place and the current target was not created.", + }; + } + for (const subscription of replaceable) { + await deleteLinqWebhookSubscription({ token, subscriptionId: subscription.id }); + if (subscription.target_url === previousWebhookUrl) { + clearSigningSecret = true; + } + } + } + + const create = await prompter.confirm({ + message: "Create Linq webhook subscription for inbound messages?", + initialValue: true, + }); + if (!create) { + return { + state: "incomplete", + detail: "Subscription creation was declined.", + ...(clearSigningSecret ? { clearSigningSecret: true } : {}), + }; + } + const subscription = await createLinqWebhookSubscription({ + token, + targetUrl: webhookUrl, + phoneNumber: fromPhone, + }); + await prompter.note(`Created Linq webhook subscription ${subscription.id}.`, "Linq webhook"); + const signingSecret = subscription.signing_secret?.trim(); + if (!signingSecret) { + return { + state: "incomplete", + detail: "Subscription was created but Linq did not return a signing secret.", + subscription, + clearSigningSecret: true, + }; + } + return { + state: "ready", + detail: "Created subscription and captured its signing secret.", + subscription, + signingSecret, + }; + } catch (err) { + await prompter.note( + `Could not configure Linq webhook subscription: ${String(err)}`, + "Linq webhook", + ); + return { + state: "error", + detail: String(err), + ...(clearSigningSecret ? { clearSigningSecret: true } : {}), + }; + } +} + +async function selectLinqIngressMode(params: { + prompter: WizardPrompter; + hasPublicWebhookUrl: boolean; +}): Promise { + const [hasTailscale, hasCloudflared] = await Promise.all([ + detectBinary("tailscale"), + detectBinary("cloudflared"), + ]); + return await params.prompter.select({ + message: "Inbound message delivery", + initialValue: params.hasPublicWebhookUrl ? "existing" : hasTailscale ? "tailscale" : "existing", + options: [ + { + value: "existing", + label: "Existing public HTTPS URL", + hint: "Use a reverse proxy or ingress you already operate", + }, + { + value: "tailscale", + label: "Tailscale Funnel", + hint: hasTailscale ? "tailscale detected" : "tailscale not detected", + }, + { + value: "cloudflare", + label: "Cloudflare Tunnel", + hint: hasCloudflared ? "cloudflared detected" : "cloudflared not detected", + }, + { + value: "outbound-only", + label: "Outbound only", + hint: "Skip inbound messages and provider subscription", + }, + ], + }); +} + +async function promptPublicWebhookUrl(params: { + prompter: WizardPrompter; + mode: Exclude; + gatewayPort: number; + webhookPath: string; + existingWebhookUrl?: string; +}): Promise { + const { prompter, mode, gatewayPort, webhookPath } = params; + if (mode === "tailscale") { + await prompter.note( + buildTailscaleFunnelInstructions({ gatewayPort, webhookPath }), + "Path-scoped Tailscale Funnel", + ); + } else if (mode === "cloudflare") { + await prompter.note( + buildCloudflareTunnelInstructions({ gatewayPort, webhookPath }), + "Path-scoped Cloudflare Tunnel", + ); + } else { + await prompter.note( + [ + `Route only ${webhookPath} to ${buildLocalWebhookUrl(gatewayPort, webhookPath)}.`, + "Do not expose the Gateway root or Control UI.", + ].join("\n"), + "Public ingress", + ); + } + + const existingWebhookUrl = params.existingWebhookUrl?.trim(); + return String( + await prompter.text({ + message: "Public webhook URL", + initialValue: + existingWebhookUrl && !validatePublicWebhookUrl(existingWebhookUrl, webhookPath) + ? existingWebhookUrl + : undefined, + placeholder: `https://messages.example.com${webhookPath}`, + validate: (value) => validatePublicWebhookUrl(value, webhookPath), + }), + ).trim(); +} + +async function disableExistingInbound(params: { + prompter: WizardPrompter; + token: string; + webhookUrl?: string; + fromPhone: string; +}): Promise<{ disabled: boolean; detail: string }> { + const webhookUrl = params.webhookUrl?.trim(); + if (!webhookUrl || !params.token.trim()) { + return { disabled: true, detail: "No inbound subscription was configured." }; + } + try { + const subscriptions = await listLinqWebhookSubscriptions(params.token); + const existing = findLinqWebhookSubscription(subscriptions, webhookUrl, params.fromPhone); + if (!existing) { + return { disabled: true, detail: "No matching active subscription was found." }; + } + const unfiltered = (existing.phone_numbers ?? []).length === 0; + const shouldDelete = await params.prompter.confirm({ + message: unfiltered + ? `Delete unfiltered Linq webhook subscription ${existing.id}? It may receive messages for other lines on this Linq account.` + : `Delete Linq webhook subscription ${existing.id} to switch to outbound-only?`, + initialValue: true, + }); + if (!shouldDelete) { + return { + disabled: false, + detail: + "Existing inbound configuration was preserved because subscription deletion was declined.", + }; + } + await deleteLinqWebhookSubscription({ token: params.token, subscriptionId: existing.id }); + return { disabled: true, detail: `Deleted subscription ${existing.id}.` }; + } catch (err) { + await params.prompter.note( + `Could not disable the existing Linq subscription: ${String(err)}`, + "Linq outbound-only setup", + ); + return { + disabled: false, + detail: "Existing inbound configuration was preserved because provider cleanup failed.", + }; + } +} + +function formatPhoneFilter(subscription: LinqWebhookSubscription | undefined): string { + if (!subscription) { + return "not configured"; + } + const phoneNumbers = subscription.phone_numbers ?? []; + return phoneNumbers.length > 0 ? phoneNumbers.join(", ") : "all account phone numbers"; +} + +async function noteLinqWebhookSummary(params: { + prompter: WizardPrompter; + localUrl: string; + publicUrl?: string; + subscriptionResult?: LinqSubscriptionSetupResult; + hasWebhookSecret: boolean; + outboundDetail?: string; +}): Promise { + if (!params.publicUrl) { + await params.prompter.note( + [ + `Local route: ${params.localUrl}`, + "Public target: not configured", + "Subscription: not configured", + "Inbound readiness: OUTBOUND ONLY", + params.outboundDetail ?? "Inbound setup was skipped.", + "Next: rerun channel setup when you are ready to add public HTTPS ingress.", + ].join("\n"), + "Linq setup summary", + ); + return; + } + + const result = params.subscriptionResult; + const ready = result?.state === "ready" && params.hasWebhookSecret; + await params.prompter.note( + [ + `Local route: ${params.localUrl}`, + `Public target: ${params.publicUrl}`, + `Subscription: ${result?.subscription?.id ?? "not configured"}`, + `Phone filter: ${formatPhoneFilter(result?.subscription)}`, + `Signing secret: ${params.hasWebhookSecret ? "configured" : "missing"}`, + `Inbound readiness: ${ready ? "READY" : "INCOMPLETE"}`, + result?.detail ?? "Subscription setup did not run.", + ready + ? "Next: start the Gateway, then text the selected Linq number and confirm its reply." + : "Next: rerun setup and complete the missing subscription or signing-secret step.", + ].join("\n"), + "Linq setup summary", + ); +} + +export async function configureLinqWebhookOnboarding(params: { + prompter: WizardPrompter; + token: string; + fromPhone: string; + previousFromPhone?: string; + existingWebhookUrl?: string; + existingWebhookPath?: string; + hasWebhookSecret: boolean; + gatewayPort: number; +}): Promise { + const previousWebhookUrl = params.existingWebhookUrl?.trim(); + const existingWebhookPath = resolveConfiguredWebhookPath({ + webhookPath: params.existingWebhookPath, + webhookUrl: previousWebhookUrl, + }); + const webhookPath = String( + await params.prompter.text({ + message: "Local webhook path", + initialValue: existingWebhookPath, + validate: validateWebhookPath, + }), + ).trim(); + const localWebhookUrl = buildLocalWebhookUrl(params.gatewayPort, webhookPath); + await params.prompter.note( + `The OpenClaw Gateway will receive Linq webhooks at ${localWebhookUrl}.`, + "Local Linq route", + ); + + const ingressMode = await selectLinqIngressMode({ + prompter: params.prompter, + hasPublicWebhookUrl: Boolean( + previousWebhookUrl && !validatePublicWebhookUrl(previousWebhookUrl, webhookPath), + ), + }); + if (ingressMode === "outbound-only") { + const outbound = await disableExistingInbound({ + prompter: params.prompter, + token: params.token, + webhookUrl: previousWebhookUrl, + fromPhone: params.fromPhone, + }); + if (!outbound.disabled) { + await params.prompter.note( + [ + `Local route: ${buildLocalWebhookUrl(params.gatewayPort, existingWebhookPath)}`, + `Public target: ${previousWebhookUrl ?? "unknown"}`, + "Inbound readiness: EXISTING CONFIGURATION PRESERVED", + outbound.detail, + ].join("\n"), + "Linq setup summary", + ); + return { mode: "preserved" }; + } + await noteLinqWebhookSummary({ + prompter: params.prompter, + localUrl: localWebhookUrl, + outboundDetail: outbound.detail, + hasWebhookSecret: false, + }); + return { mode: "outbound-only", webhookPath }; + } + + const webhookUrl = await promptPublicWebhookUrl({ + prompter: params.prompter, + mode: ingressMode, + gatewayPort: params.gatewayPort, + webhookPath, + existingWebhookUrl: previousWebhookUrl, + }); + const subscriptionResult = await setupLinqWebhookSubscription({ + prompter: params.prompter, + token: params.token, + webhookUrl, + previousWebhookUrl, + fromPhone: params.fromPhone, + previousFromPhone: params.previousFromPhone, + hasWebhookSecret: params.hasWebhookSecret, + }); + await noteLinqWebhookSummary({ + prompter: params.prompter, + localUrl: localWebhookUrl, + publicUrl: webhookUrl, + subscriptionResult, + hasWebhookSecret: + Boolean(subscriptionResult.signingSecret) || + (params.hasWebhookSecret && !subscriptionResult.clearSigningSecret), + }); + return { + mode: "inbound", + webhookPath, + webhookUrl, + ...(subscriptionResult.signingSecret + ? { signingSecret: subscriptionResult.signingSecret } + : {}), + ...(subscriptionResult.clearSigningSecret ? { clearSigningSecret: true } : {}), + }; +} diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts new file mode 100644 index 0000000..ff226d4 --- /dev/null +++ b/src/onboarding.test.ts @@ -0,0 +1,347 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { linqOnboardingAdapter } from "./onboarding.js"; + +const phone = "+15551112222"; + +function createPrompter(params: { + ingressMode: "existing" | "tailscale" | "cloudflare" | "outbound-only"; + webhookPath?: string; + webhookUrl?: string; + confirm?: (message: string) => boolean; +}) { + const notes: Array<{ message: string; title?: string }> = []; + const textMessages: string[] = []; + const prompter = { + note: vi.fn(async (message: string, title?: string) => { + notes.push({ message, title }); + }), + confirm: vi.fn(async ({ message }: { message: string }) => params.confirm?.(message) ?? true), + select: vi.fn(async ({ message }: { message: string }) => { + return message === "Linq sender phone number" ? phone : params.ingressMode; + }), + text: vi.fn(async ({ message }: { message: string }) => { + textMessages.push(message); + if (message === "Local webhook path") { + return params.webhookPath ?? "/linq-webhook"; + } + if (message === "Public webhook URL") { + return params.webhookUrl ?? "https://messages.example.com/linq-webhook"; + } + throw new Error(`Unexpected text prompt: ${message}`); + }), + } as unknown as WizardPrompter; + return { prompter, notes, textMessages }; +} + +function configuredAccount(overrides: Record = {}): OpenClawConfig { + return { + gateway: { port: 19001 }, + channels: { + linq: { + enabled: true, + apiToken: "test-token", + fromPhone: phone, + dmPolicy: "open", + allowFrom: ["*"], + ...overrides, + }, + }, + } as OpenClawConfig; +} + +function stubPhoneProbeAndSubscriptionCreation() { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/phone_numbers")) { + return new Response(JSON.stringify({ phone_numbers: [{ phone_number: phone }] }), { + status: 200, + }); + } + if (url.endsWith("/webhook-subscriptions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + id: "sub_ready", + is_active: true, + subscribed_events: ["message.received"], + target_url: "https://messages.example.com/hooks/linq", + phone_numbers: [phone], + signing_secret: "signing-secret", + }), + { status: 201 }, + ); + } + if (url.endsWith("/webhook-subscriptions")) { + return new Response(JSON.stringify({ subscriptions: [] }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("Linq onboarding wizard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("creates a ready, path-scoped inbound configuration", async () => { + const fetchMock = stubPhoneProbeAndSubscriptionCreation(); + const { prompter, notes } = createPrompter({ + ingressMode: "cloudflare", + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount(), + prompter, + runtime: {} as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + + expect(linq.webhookPath).toBe("/hooks/linq"); + expect(linq.webhookUrl).toBe("https://messages.example.com/hooks/linq"); + expect(linq.webhookSecret).toBe("signing-secret"); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.linqapp.com/api/partner/v3/webhook-subscriptions", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + subscribed_events: ["message.received"], + target_url: "https://messages.example.com/hooks/linq", + phone_numbers: [phone], + }), + }), + ); + expect(notes.some((note) => note.message.includes("path: ^/hooks/linq$"))).toBe(true); + expect( + notes.some( + (note) => + note.title === "Linq setup summary" && + note.message.includes("Local route: http://127.0.0.1:19001/hooks/linq") && + note.message.includes("Inbound readiness: READY"), + ), + ).toBe(true); + }); + + it("makes outbound-only explicit without storing a fake webhook URL", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/phone_numbers")) { + return new Response(JSON.stringify({ phone_numbers: [{ phone_number: phone }] }), { + status: 200, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { prompter, notes, textMessages } = createPrompter({ + ingressMode: "outbound-only", + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount(), + prompter, + runtime: {} as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + + expect(linq.webhookPath).toBe("/linq-webhook"); + expect(linq).not.toHaveProperty("webhookUrl"); + expect(linq).not.toHaveProperty("webhookSecret"); + expect(textMessages).not.toContain("Public webhook URL"); + expect(notes.some((note) => note.message.includes("Inbound readiness: OUTBOUND ONLY"))).toBe( + true, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stores named-account ingress under that account", async () => { + stubPhoneProbeAndSubscriptionCreation(); + const { prompter } = createPrompter({ + ingressMode: "existing", + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + }); + const cfg = { + gateway: { port: 19001 }, + channels: { + linq: { + enabled: true, + accounts: { + work: { + enabled: true, + apiToken: "test-token", + fromPhone: phone, + }, + }, + }, + }, + } as OpenClawConfig; + + const result = await linqOnboardingAdapter.configure({ + cfg, + prompter, + runtime: {} as never, + accountOverrides: { linq: "work" }, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + + expect(linq).not.toHaveProperty("webhookUrl"); + expect(linq.accounts.work).toMatchObject({ + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + webhookSecret: "signing-secret", + }); + }); + + it("preserves existing inbound config when outbound-only cleanup is declined", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/phone_numbers")) { + return new Response(JSON.stringify({ phone_numbers: [{ phone_number: phone }] }), { + status: 200, + }); + } + if (url.endsWith("/webhook-subscriptions")) { + return new Response( + JSON.stringify({ + subscriptions: [ + { + id: "sub_shared", + is_active: true, + subscribed_events: ["message.received"], + target_url: "https://messages.example.com/original", + phone_numbers: null, + }, + ], + }), + { status: 200 }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { prompter, notes } = createPrompter({ + ingressMode: "outbound-only", + webhookPath: "/changed-but-not-saved", + confirm: (message) => !message.startsWith("Delete unfiltered Linq webhook subscription"), + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount({ + webhookUrl: "https://messages.example.com/original", + webhookPath: "/original", + webhookSecret: "existing-secret", + }), + prompter, + runtime: {} as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + + expect(linq).toMatchObject({ + webhookUrl: "https://messages.example.com/original", + webhookPath: "/original", + webhookSecret: "existing-secret", + }); + expect(prompter.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("may receive messages for other lines"), + }), + ); + expect( + notes.some( + (note) => + note.title === "Linq setup summary" && + note.message.includes("Local route: http://127.0.0.1:19001/original") && + note.message.includes("EXISTING CONFIGURATION PRESERVED"), + ), + ).toBe(true); + }); + + it("clears a stale secret when a replacement subscription returns no secret", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/phone_numbers")) { + return new Response(JSON.stringify({ phone_numbers: [{ phone_number: phone }] }), { + status: 200, + }); + } + if (url.endsWith("/webhook-subscriptions/sub_old") && init?.method === "DELETE") { + return new Response(null, { status: 204 }); + } + if (url.endsWith("/webhook-subscriptions") && init?.method === "POST") { + return new Response( + JSON.stringify({ + id: "sub_new", + is_active: true, + subscribed_events: ["message.received"], + target_url: "https://new.example.com/linq-webhook", + phone_numbers: [phone], + }), + { status: 201 }, + ); + } + if (url.endsWith("/webhook-subscriptions")) { + return new Response( + JSON.stringify({ + subscriptions: [ + { + id: "sub_old", + is_active: true, + subscribed_events: ["message.received"], + target_url: "https://old.example.com/linq-webhook", + phone_numbers: [phone], + }, + ], + }), + { status: 200 }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { prompter, notes } = createPrompter({ + ingressMode: "existing", + webhookUrl: "https://new.example.com/linq-webhook", + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount({ + webhookUrl: "https://old.example.com/linq-webhook", + webhookPath: "/linq-webhook", + webhookSecret: "old-secret", + }), + prompter, + runtime: {} as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + + expect(linq.webhookUrl).toBe("https://new.example.com/linq-webhook"); + expect(linq).not.toHaveProperty("webhookSecret"); + expect( + notes.some( + (note) => + note.title === "Linq setup summary" && + note.message.includes("Signing secret: missing") && + note.message.includes("Inbound readiness: INCOMPLETE"), + ), + ).toBe(true); + }); +}); diff --git a/src/onboarding.ts b/src/onboarding.ts index 93fed2d..78d454e 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveGatewayPort } from "openclaw/plugin-sdk/core"; import type { ChannelSetupWizardAdapter as ChannelOnboardingAdapter, ChannelSetupDmPolicy as ChannelOnboardingDmPolicy, @@ -18,49 +19,9 @@ import { resolveLinqAccountForStatus, } from "./linq/accounts.js"; import { probeLinq } from "./linq/probe.js"; -import { - createLinqWebhookSubscription, - deleteLinqWebhookSubscription, - findLinqWebhookSubscription, - findReplaceableLinqWebhookSubscriptions, - listLinqWebhookSubscriptions, -} from "./linq/subscriptions.js"; +import { configureLinqWebhookOnboarding } from "./linq/onboarding-webhook.js"; const channel = "linq" as const; -const webhookPathPattern = /^\/[A-Za-z0-9/_-]*$/u; - -function validateWebhookPath(value: string | undefined): string | undefined { - const trimmed = value?.trim() ?? ""; - if (!trimmed) { - return "Required"; - } - return webhookPathPattern.test(trimmed) - ? undefined - : "Use a path like /linq-webhook with letters, numbers, _, -, or /."; -} - -function parseWebhookUrl(value: string | undefined): URL | null { - const trimmed = value?.trim() ?? ""; - if (!trimmed) { - return null; - } - try { - return new URL(trimmed); - } catch { - return null; - } -} - -function validateWebhookUrl(value: string | undefined): string | undefined { - const url = parseWebhookUrl(value); - if (!url) { - return "Enter a valid URL."; - } - if (url.pathname === "/") { - return "Include a webhook path, for example /linq-webhook."; - } - return validateWebhookPath(url.pathname); -} function setLinqAccountPatch( cfg: OpenClawConfig, @@ -98,6 +59,42 @@ function setLinqAccountPatch( }; } +function clearLinqAccountFields( + cfg: OpenClawConfig, + accountId: string, + fields: readonly string[], +): OpenClawConfig { + const clear = (value: unknown): Record => { + const next = { ...(value as Record | undefined) }; + for (const field of fields) { + delete next[field]; + } + return next; + }; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + linq: clear(cfg.channels?.linq), + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + linq: { + ...cfg.channels?.linq, + accounts: { + ...cfg.channels?.linq?.accounts, + [accountId]: clear(cfg.channels?.linq?.accounts?.[accountId]), + }, + }, + }, + }; +} + function setLinqDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.linq?.allowFrom) : undefined; @@ -173,111 +170,6 @@ async function selectLinqPhone(params: { ).trim(); } -async function maybeCreateLinqWebhookSubscription(params: { - prompter: WizardPrompter; - token: string; - webhookUrl: string; - previousWebhookUrl?: string; - fromPhone: string; - previousFromPhone?: string; - hasWebhookSecret: boolean; -}): Promise { - const { - prompter, - token, - webhookUrl, - previousWebhookUrl, - fromPhone, - previousFromPhone, - hasWebhookSecret, - } = params; - if (!token?.trim()) { - return null; - } - try { - const subscriptions = await listLinqWebhookSubscriptions(token); - const parsedWebhookUrl = parseWebhookUrl(webhookUrl); - const isPublicHttps = parsedWebhookUrl?.protocol === "https:"; - const existing = findLinqWebhookSubscription(subscriptions, webhookUrl, fromPhone); - if (existing) { - await prompter.note( - `Found Linq webhook subscription ${existing.id} for ${webhookUrl}.`, - "Linq webhook", - ); - if (hasWebhookSecret) { - return null; - } - if (!isPublicHttps) { - await prompter.note( - "Skipping Linq webhook subscription recreation because the webhook URL is not HTTPS.", - "Linq webhook", - ); - return null; - } - const recreateForSecret = await prompter.confirm({ - message: "Recreate the existing Linq webhook subscription to store its signing secret?", - initialValue: true, - }); - if (!recreateForSecret) { - await prompter.note( - "Linq only returns the signing secret when creating a subscription. Delete/recreate the subscription or enter the secret manually if inbound signatures fail.", - "Linq webhook secret", - ); - return null; - } - await deleteLinqWebhookSubscription({ token, subscriptionId: existing.id }); - } - - if (!isPublicHttps) { - await prompter.note( - "Skipping Linq webhook subscription creation because the webhook URL is not HTTPS.", - "Linq webhook", - ); - return null; - } - - const replaceable = findReplaceableLinqWebhookSubscriptions(subscriptions, { - targetUrl: webhookUrl, - phoneNumber: fromPhone, - previousTargetUrl: previousWebhookUrl, - previousPhoneNumber: previousFromPhone, - }); - if (replaceable.length > 0) { - const replace = await prompter.confirm({ - message: `Replace ${replaceable.length} stale Linq webhook subscription${replaceable.length === 1 ? "" : "s"} before creating the current one?`, - initialValue: true, - }); - if (!replace) { - return null; - } - for (const subscription of replaceable) { - await deleteLinqWebhookSubscription({ token, subscriptionId: subscription.id }); - } - } - - const create = await prompter.confirm({ - message: "Create Linq webhook subscription for inbound messages?", - initialValue: true, - }); - if (!create) { - return null; - } - const subscription = await createLinqWebhookSubscription({ - token, - targetUrl: webhookUrl, - phoneNumber: fromPhone, - }); - await prompter.note(`Created Linq webhook subscription ${subscription.id}.`, "Linq webhook"); - return subscription.signing_secret?.trim() || null; - } catch (err) { - await prompter.note( - `Could not configure Linq webhook subscription: ${String(err)}`, - "Linq webhook", - ); - return null; - } -} - const dmPolicy: ChannelOnboardingDmPolicy = { label: "Linq", channel, @@ -401,49 +293,32 @@ export const linqOnboardingAdapter: ChannelOnboardingAdapter = { next = setLinqAccountPatch(next, linqAccountId, { fromPhone }); - // --- Webhook config --- - const linqSection = (next.channels as Record | undefined)?.linq as - | Record - | undefined; - const existingWebhookPath = (linqSection?.webhookPath as string) ?? "/linq-webhook"; - const existingWebhookUrl = - (linqSection?.webhookUrl as string) ?? `http://localhost:3100${existingWebhookPath}`; - const previousWebhookUrl = - typeof linqSection?.webhookUrl === "string" ? linqSection.webhookUrl : undefined; - const webhookUrl = String( - await prompter.text({ - message: "Webhook URL", - initialValue: existingWebhookUrl, - validate: validateWebhookUrl, - }), - ).trim(); - - const webhookPath = parseWebhookUrl(webhookUrl)?.pathname ?? existingWebhookPath; - - next = { - ...next, - channels: { - ...next.channels, - linq: { - ...next.channels?.linq, - webhookUrl, - webhookPath, - }, - }, - }; - - const accountAfterWebhook = resolveLinqAccount({ cfg: next, accountId: linqAccountId }); - const webhookSecret = await maybeCreateLinqWebhookSubscription({ + // --- Public webhook ingress --- + const accountBeforeWebhook = resolveLinqAccount({ cfg: next, accountId: linqAccountId }); + const webhookResult = await configureLinqWebhookOnboarding({ prompter, - token: accountAfterWebhook.token, - webhookUrl, - previousWebhookUrl, + token: accountBeforeWebhook.token, fromPhone, previousFromPhone, - hasWebhookSecret: Boolean(accountAfterWebhook.webhookSecret), + existingWebhookUrl: accountBeforeWebhook.config.webhookUrl, + existingWebhookPath: accountBeforeWebhook.config.webhookPath, + hasWebhookSecret: Boolean(accountBeforeWebhook.webhookSecret), + gatewayPort: resolveGatewayPort(next), }); - if (webhookSecret) { - next = setLinqAccountPatch(next, linqAccountId, { webhookSecret }); + if (webhookResult.mode === "inbound") { + next = setLinqAccountPatch(next, linqAccountId, { + webhookUrl: webhookResult.webhookUrl, + webhookPath: webhookResult.webhookPath, + ...(webhookResult.signingSecret ? { webhookSecret: webhookResult.signingSecret } : {}), + }); + if (webhookResult.clearSigningSecret) { + next = clearLinqAccountFields(next, linqAccountId, ["webhookSecret"]); + } + } else if (webhookResult.mode === "outbound-only") { + next = setLinqAccountPatch(next, linqAccountId, { + webhookPath: webhookResult.webhookPath, + }); + next = clearLinqAccountFields(next, linqAccountId, ["webhookUrl", "webhookSecret"]); } if (!next.channels?.linq?.dmPolicy) { From 787a7a769785e0bcad7dcb985083709b75a7e597 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 15 Jul 2026 12:40:19 -0700 Subject: [PATCH 4/4] Harden Linq setup credentials and mutations --- src/linq/onboarding-webhook.ts | 32 ++++++ src/onboarding.test.ts | 173 ++++++++++++++++++++++++++++++++- src/onboarding.ts | 113 ++++++++++----------- 3 files changed, 254 insertions(+), 64 deletions(-) diff --git a/src/linq/onboarding-webhook.ts b/src/linq/onboarding-webhook.ts index d9d7ec2..c55797e 100644 --- a/src/linq/onboarding-webhook.ts +++ b/src/linq/onboarding-webhook.ts @@ -26,6 +26,23 @@ type LinqSubscriptionSetupResult = { clearSigningSecret?: boolean; }; +class PersistentEffectGuardError extends Error { + constructor(readonly originalError: unknown) { + super("Linq setup persistent-effect guard failed"); + this.name = "PersistentEffectGuardError"; + } +} + +async function guardPersistentEffect( + beforePersistentEffect: (() => Promise) | undefined, +): Promise { + try { + await beforePersistentEffect?.(); + } catch (err) { + throw new PersistentEffectGuardError(err); + } +} + export type LinqWebhookOnboardingResult = | { mode: "inbound"; @@ -45,6 +62,7 @@ async function setupLinqWebhookSubscription(params: { fromPhone: string; previousFromPhone?: string; hasWebhookSecret: boolean; + beforePersistentEffect?: () => Promise; }): Promise { const { prompter, @@ -89,6 +107,7 @@ async function setupLinqWebhookSubscription(params: { subscription: existing, }; } + await guardPersistentEffect(params.beforePersistentEffect); await deleteLinqWebhookSubscription({ token, subscriptionId: existing.id }); clearSigningSecret = true; } @@ -111,6 +130,7 @@ async function setupLinqWebhookSubscription(params: { }; } for (const subscription of replaceable) { + await guardPersistentEffect(params.beforePersistentEffect); await deleteLinqWebhookSubscription({ token, subscriptionId: subscription.id }); if (subscription.target_url === previousWebhookUrl) { clearSigningSecret = true; @@ -129,6 +149,7 @@ async function setupLinqWebhookSubscription(params: { ...(clearSigningSecret ? { clearSigningSecret: true } : {}), }; } + await guardPersistentEffect(params.beforePersistentEffect); const subscription = await createLinqWebhookSubscription({ token, targetUrl: webhookUrl, @@ -151,6 +172,9 @@ async function setupLinqWebhookSubscription(params: { signingSecret, }; } catch (err) { + if (err instanceof PersistentEffectGuardError) { + throw err.originalError; + } await prompter.note( `Could not configure Linq webhook subscription: ${String(err)}`, "Linq webhook", @@ -246,6 +270,7 @@ async function disableExistingInbound(params: { token: string; webhookUrl?: string; fromPhone: string; + beforePersistentEffect?: () => Promise; }): Promise<{ disabled: boolean; detail: string }> { const webhookUrl = params.webhookUrl?.trim(); if (!webhookUrl || !params.token.trim()) { @@ -271,9 +296,13 @@ async function disableExistingInbound(params: { "Existing inbound configuration was preserved because subscription deletion was declined.", }; } + await guardPersistentEffect(params.beforePersistentEffect); await deleteLinqWebhookSubscription({ token: params.token, subscriptionId: existing.id }); return { disabled: true, detail: `Deleted subscription ${existing.id}.` }; } catch (err) { + if (err instanceof PersistentEffectGuardError) { + throw err.originalError; + } await params.prompter.note( `Could not disable the existing Linq subscription: ${String(err)}`, "Linq outbound-only setup", @@ -344,6 +373,7 @@ export async function configureLinqWebhookOnboarding(params: { existingWebhookPath?: string; hasWebhookSecret: boolean; gatewayPort: number; + beforePersistentEffect?: () => Promise; }): Promise { const previousWebhookUrl = params.existingWebhookUrl?.trim(); const existingWebhookPath = resolveConfiguredWebhookPath({ @@ -375,6 +405,7 @@ export async function configureLinqWebhookOnboarding(params: { token: params.token, webhookUrl: previousWebhookUrl, fromPhone: params.fromPhone, + beforePersistentEffect: params.beforePersistentEffect, }); if (!outbound.disabled) { await params.prompter.note( @@ -412,6 +443,7 @@ export async function configureLinqWebhookOnboarding(params: { fromPhone: params.fromPhone, previousFromPhone: params.previousFromPhone, hasWebhookSecret: params.hasWebhookSecret, + beforePersistentEffect: params.beforePersistentEffect, }); await noteLinqWebhookSummary({ prompter: params.prompter, diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index ff226d4..c45a1c8 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -10,19 +10,40 @@ function createPrompter(params: { webhookPath?: string; webhookUrl?: string; confirm?: (message: string) => boolean; + tokenInput?: string; + secretInputMode?: "plaintext" | "ref"; + secretEnvVar?: string; }) { const notes: Array<{ message: string; title?: string }> = []; const textMessages: string[] = []; + const textPrompts: Array<{ message: string; sensitive?: boolean }> = []; const prompter = { note: vi.fn(async (message: string, title?: string) => { notes.push({ message, title }); }), confirm: vi.fn(async ({ message }: { message: string }) => params.confirm?.(message) ?? true), select: vi.fn(async ({ message }: { message: string }) => { - return message === "Linq sender phone number" ? phone : params.ingressMode; + if (message === "Linq sender phone number") { + return phone; + } + if (message.startsWith("How do you want to provide")) { + return params.secretInputMode ?? "plaintext"; + } + if (message.startsWith("Where is this Linq API token stored?")) { + return "env"; + } + return params.ingressMode; }), - text: vi.fn(async ({ message }: { message: string }) => { + text: vi.fn(async (prompt: { message: string; sensitive?: boolean }) => { + const { message } = prompt; textMessages.push(message); + textPrompts.push(prompt); + if (message === "Enter Linq API token") { + return params.tokenInput ?? "test-token"; + } + if (message === "Environment variable name") { + return params.secretEnvVar ?? "LINQ_API_TOKEN"; + } if (message === "Local webhook path") { return params.webhookPath ?? "/linq-webhook"; } @@ -32,7 +53,7 @@ function createPrompter(params: { throw new Error(`Unexpected text prompt: ${message}`); }), } as unknown as WizardPrompter; - return { prompter, notes, textMessages }; + return { prompter, notes, textMessages, textPrompts }; } function configuredAccount(overrides: Record = {}): OpenClawConfig { @@ -84,10 +105,12 @@ function stubPhoneProbeAndSubscriptionCreation() { describe("Linq onboarding wizard", () => { afterEach(() => { vi.unstubAllGlobals(); + delete process.env.LINQ_TOKEN_REF_TEST; }); it("creates a ready, path-scoped inbound configuration", async () => { const fetchMock = stubPhoneProbeAndSubscriptionCreation(); + const beforePersistentEffect = vi.fn(async () => {}); const { prompter, notes } = createPrompter({ ingressMode: "cloudflare", webhookPath: "/hooks/linq", @@ -98,6 +121,7 @@ describe("Linq onboarding wizard", () => { cfg: configuredAccount(), prompter, runtime: {} as never, + options: { beforePersistentEffect } as never, accountOverrides: {}, shouldPromptAccountIds: false, forceAllowFrom: false, @@ -107,6 +131,12 @@ describe("Linq onboarding wizard", () => { expect(linq.webhookPath).toBe("/hooks/linq"); expect(linq.webhookUrl).toBe("https://messages.example.com/hooks/linq"); expect(linq.webhookSecret).toBe("signing-secret"); + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + const createCallIndex = fetchMock.mock.calls.findIndex(([, init]) => init?.method === "POST"); + expect(createCallIndex).toBeGreaterThanOrEqual(0); + expect(beforePersistentEffect.mock.invocationCallOrder[0]).toBeLessThan( + fetchMock.mock.invocationCallOrder[createCallIndex]!, + ); expect(fetchMock).toHaveBeenCalledWith( "https://api.linqapp.com/api/partner/v3/webhook-subscriptions", expect.objectContaining({ @@ -129,6 +159,86 @@ describe("Linq onboarding wizard", () => { ).toBe(true); }); + it("masks plaintext API-token input", async () => { + stubPhoneProbeAndSubscriptionCreation(); + const { prompter, textPrompts } = createPrompter({ + ingressMode: "existing", + secretInputMode: "plaintext", + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount({ apiToken: undefined }), + prompter, + runtime: {} as never, + options: { secretInputMode: "plaintext" }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect((result.cfg.channels?.linq as Record).apiToken).toBe("test-token"); + expect(textPrompts).toContainEqual( + expect.objectContaining({ message: "Enter Linq API token", sensitive: true }), + ); + }); + + it("stores an API-token SecretRef while using its resolved value for setup", async () => { + process.env.LINQ_TOKEN_REF_TEST = "test-token"; + stubPhoneProbeAndSubscriptionCreation(); + const { prompter } = createPrompter({ + ingressMode: "existing", + secretInputMode: "ref", + secretEnvVar: "LINQ_TOKEN_REF_TEST", + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount({ apiToken: undefined }), + prompter, + runtime: {} as never, + options: { secretInputMode: "ref" }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect((result.cfg.channels?.linq as Record).apiToken).toEqual({ + source: "env", + provider: "default", + id: "LINQ_TOKEN_REF_TEST", + }); + }); + + it("propagates a persistent-effect guard failure before subscription creation", async () => { + const guardError = new Error("setup authority changed"); + const beforePersistentEffect = vi.fn(async () => { + throw guardError; + }); + const fetchMock = stubPhoneProbeAndSubscriptionCreation(); + const { prompter } = createPrompter({ + ingressMode: "existing", + webhookPath: "/hooks/linq", + webhookUrl: "https://messages.example.com/hooks/linq", + }); + + await expect( + linqOnboardingAdapter.configure({ + cfg: configuredAccount(), + prompter, + runtime: {} as never, + options: { beforePersistentEffect } as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }), + ).rejects.toBe(guardError); + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === "POST")).toBe(false); + }); + it("makes outbound-only explicit without storing a fake webhook URL", async () => { const fetchMock = vi.fn(async (input: string | URL | Request) => { const url = String(input); @@ -164,6 +274,63 @@ describe("Linq onboarding wizard", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("guards provider deletion when switching existing inbound setup to outbound-only", async () => { + const beforePersistentEffect = vi.fn(async () => {}); + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/phone_numbers")) { + return new Response(JSON.stringify({ phone_numbers: [{ phone_number: phone }] }), { + status: 200, + }); + } + if (url.endsWith("/webhook-subscriptions/sub_existing") && init?.method === "DELETE") { + return new Response(null, { status: 204 }); + } + if (url.endsWith("/webhook-subscriptions")) { + return new Response( + JSON.stringify({ + subscriptions: [ + { + id: "sub_existing", + is_active: true, + subscribed_events: ["message.received"], + target_url: "https://messages.example.com/linq-webhook", + phone_numbers: [phone], + }, + ], + }), + { status: 200 }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { prompter } = createPrompter({ ingressMode: "outbound-only" }); + + const result = await linqOnboardingAdapter.configure({ + cfg: configuredAccount({ + webhookUrl: "https://messages.example.com/linq-webhook", + webhookPath: "/linq-webhook", + webhookSecret: "existing-secret", + }), + prompter, + runtime: {} as never, + options: { beforePersistentEffect } as never, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + const linq = result.cfg.channels?.linq as Record; + const deleteCallIndex = fetchMock.mock.calls.findIndex(([, init]) => init?.method === "DELETE"); + + expect(linq).not.toHaveProperty("webhookUrl"); + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + expect(deleteCallIndex).toBeGreaterThanOrEqual(0); + expect(beforePersistentEffect.mock.invocationCallOrder[0]).toBeLessThan( + fetchMock.mock.invocationCallOrder[deleteCallIndex]!, + ); + }); + it("stores named-account ingress under that account", async () => { stubPhoneProbeAndSubscriptionCreation(); const { prompter } = createPrompter({ diff --git a/src/onboarding.ts b/src/onboarding.ts index 78d454e..1c401e6 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -11,11 +11,11 @@ import { normalizeAccountId, addWildcardAllowFrom, promptAccountId, + runSingleChannelSecretStep, } from "openclaw/plugin-sdk/setup"; import { listLinqAccountIds, resolveDefaultLinqAccountId, - resolveLinqAccount, resolveLinqAccountForStatus, } from "./linq/accounts.js"; import { probeLinq } from "./linq/probe.js"; @@ -198,10 +198,14 @@ export const linqOnboardingAdapter: ChannelOnboardingAdapter = { configure: async ({ cfg, prompter, + options, accountOverrides, shouldPromptAccountIds, forceAllowFrom, }) => { + const beforePersistentEffect = ( + options as (typeof options & { beforePersistentEffect?: () => Promise }) | undefined + )?.beforePersistentEffect; const linqOverride = accountOverrides.linq?.trim(); const defaultLinqAccountId = resolveDefaultLinqAccountId(cfg); let linqAccountId = linqOverride ? normalizeAccountId(linqOverride) : defaultLinqAccountId; @@ -217,93 +221,80 @@ export const linqOnboardingAdapter: ChannelOnboardingAdapter = { } let next = cfg; - const resolvedAccount = resolveLinqAccount({ + const resolvedAccount = resolveLinqAccountForStatus({ cfg: next, accountId: linqAccountId, }); - const accountConfigured = Boolean(resolvedAccount.token); const allowEnv = linqAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = allowEnv && Boolean(process.env.LINQ_API_TOKEN?.trim()); const hasConfigToken = Boolean( resolvedAccount.config.apiToken || resolvedAccount.config.tokenFile, ); - - let token: string | null = null; - if (!accountConfigured) { - await noteLinqTokenHelp(prompter); - } - if (canUseEnv && !resolvedAccount.config.apiToken) { - const keepEnv = await prompter.confirm({ - message: "LINQ_API_TOKEN detected. Use env var?", - initialValue: true, - }); - if (keepEnv) { - next = { - ...next, - channels: { - ...next.channels, - linq: { - ...next.channels?.linq, - enabled: true, - }, - }, - }; - } else { - token = String( - await prompter.text({ - message: "Enter Linq API token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else if (hasConfigToken) { - const keep = await prompter.confirm({ - message: "Linq token already configured. Keep it?", - initialValue: true, - }); - if (!keep) { - token = String( - await prompter.text({ - message: "Enter Linq API token", - validate: (value) => (value?.trim() ? undefined : "Required"), + const tokenStep = await runSingleChannelSecretStep({ + cfg: next, + prompter: { + confirm: prompter.confirm, + select: prompter.select, + note: prompter.note, + text: (params) => + prompter.text({ + ...params, + ...(params.message === "Enter Linq API token" ? { sensitive: true } : {}), }), - ).trim(); - } - } else { - token = String( - await prompter.text({ - message: "Enter Linq API token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - - if (token) { - next = setLinqAccountPatch(next, linqAccountId, { enabled: true, apiToken: token }); - } + }, + providerHint: channel, + credentialLabel: "Linq API token", + secretInputMode: options?.secretInputMode, + accountConfigured: Boolean(resolvedAccount.token) || hasConfigToken, + hasConfigToken, + allowEnv, + envValue: allowEnv ? process.env.LINQ_API_TOKEN?.trim() : undefined, + envPrompt: "LINQ_API_TOKEN detected. Use env var?", + keepPrompt: "Linq token already configured. Keep it?", + inputPrompt: "Enter Linq API token", + preferredEnvVar: "LINQ_API_TOKEN", + onMissingConfigured: async () => await noteLinqTokenHelp(prompter), + applyUseEnv: (currentCfg) => + setLinqAccountPatch(currentCfg, linqAccountId, { enabled: true }), + applySet: (currentCfg, value) => { + const cleared = clearLinqAccountFields(currentCfg, linqAccountId, ["tokenFile"]); + return setLinqAccountPatch(cleared, linqAccountId, { + enabled: true, + apiToken: value, + }); + }, + }); + next = tokenStep.cfg; // --- fromPhone --- - const accountAfterToken = resolveLinqAccount({ cfg: next, accountId: linqAccountId }); + const accountAfterToken = resolveLinqAccountForStatus({ + cfg: next, + accountId: linqAccountId, + }); + const tokenForSetup = tokenStep.resolvedValue ?? accountAfterToken.token; const previousFromPhone = accountAfterToken.fromPhone; const fromPhone = await selectLinqPhone({ prompter, - token: accountAfterToken.token, + token: tokenForSetup, existingPhone: accountAfterToken.fromPhone, }); next = setLinqAccountPatch(next, linqAccountId, { fromPhone }); // --- Public webhook ingress --- - const accountBeforeWebhook = resolveLinqAccount({ cfg: next, accountId: linqAccountId }); + const accountBeforeWebhook = resolveLinqAccountForStatus({ + cfg: next, + accountId: linqAccountId, + }); const webhookResult = await configureLinqWebhookOnboarding({ prompter, - token: accountBeforeWebhook.token, + token: tokenForSetup, fromPhone, previousFromPhone, existingWebhookUrl: accountBeforeWebhook.config.webhookUrl, existingWebhookPath: accountBeforeWebhook.config.webhookPath, hasWebhookSecret: Boolean(accountBeforeWebhook.webhookSecret), gatewayPort: resolveGatewayPort(next), + beforePersistentEffect, }); if (webhookResult.mode === "inbound") { next = setLinqAccountPatch(next, linqAccountId, {