chore: consolidate heicode workspace into single repository
Import cc-haha and new-api into a single repository layout for unified delivery. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.62.1",
|
||||
"grammy": "^1.42.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@grammyjs/types": ["@grammyjs/types@3.26.0", "", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
|
||||
|
||||
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.62.1", "", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-o9oAjv5Ffnp/6iXIJLHrO6N0US/r2ZZy3xmO6ylGegjuVSC05cx0fADA38Dc1h0FV8T9BDK+ariWk84TNMGbKg=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"grammy": ["grammy@1.42.0", "", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"lodash.identity": ["lodash.identity@3.0.0", "", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lodash.pickby": ["lodash.pickby@4.6.0", "", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
desktop
|
||||
docs
|
||||
release-notes
|
||||
fixtures/__pycache__
|
||||
runtime/__pycache__
|
||||
*.log
|
||||
.tmp-*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-assets/
|
||||
!.docker-assets/bun-linux-aarch64-musl.zip
|
||||
.DS_Store
|
||||
build-artifacts
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,53 @@
|
||||
# ============================================================
|
||||
# MiniMax(直连 Anthropic 兼容接口)
|
||||
# 海外用户: ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
||||
# 国内用户: ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
|
||||
# 可用模型: MiniMax-M2.7(默认)、MiniMax-M2.7-highspeed(更快)
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=your_minimax_api_key_here
|
||||
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
||||
# ANTHROPIC_MODEL=MiniMax-M2.7
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M2.7
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M2.7-highspeed
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M2.7
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# OpenAI(通过 LiteLLM 代理)
|
||||
# 先启动: litellm --config litellm_config.yaml --port 4000
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
||||
# ANTHROPIC_BASE_URL=http://localhost:4000
|
||||
# ANTHROPIC_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-4o
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-4o
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# DeepSeek(通过 LiteLLM 代理)
|
||||
# 先启动: litellm --config litellm_config.yaml --port 4000
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-anything
|
||||
# ANTHROPIC_BASE_URL=http://localhost:4000
|
||||
# ANTHROPIC_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-chat
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-chat
|
||||
# API_TIMEOUT_MS=3000000
|
||||
|
||||
# ============================================================
|
||||
# OpenRouter(直连 Anthropic 兼容接口)
|
||||
# ============================================================
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-or-v1-xxx
|
||||
# ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
||||
# ANTHROPIC_MODEL=openai/gpt-4o
|
||||
# ANTHROPIC_DEFAULT_SONNET_MODEL=openai/gpt-4o
|
||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL=openai/gpt-4o-mini
|
||||
# ANTHROPIC_DEFAULT_OPUS_MODEL=openai/gpt-4o
|
||||
|
||||
# ============================================================
|
||||
# 通用设置(建议始终开启)
|
||||
# ============================================================
|
||||
DISABLE_TELEMETRY=1
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
github: NanmiCoder
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 提交桌面端或 CLI 的 Bug 报告
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 提交前确认
|
||||
<!-- 请在提交 issue 前确认以下事项 -->
|
||||
|
||||
- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题)
|
||||
- [ ] 我已经升级到最新版本后复现过这个问题
|
||||
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
|
||||
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
|
||||
|
||||
## 问题描述
|
||||
<!-- 请用 1-3 句话说明实际发生了什么、你原本期望发生什么 -->
|
||||
|
||||
|
||||
## 运行环境
|
||||
<!-- 桌面端问题请优先填写前 4 项;CLI 问题再补充 Bun / Node 信息 -->
|
||||
|
||||
- 使用方式: 桌面端 / CLI / 其他:
|
||||
- 操作系统: Windows / macOS / Linux:
|
||||
- 系统版本: 例如 Windows 11 23H2 / macOS 15.4:
|
||||
- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看):
|
||||
- 安装来源: GitHub Release / 源码本地构建 / 其他:
|
||||
- Bun 版本(CLI 问题填写,`bun --version`):
|
||||
- Node 版本(CLI 问题填写,`node --version`):
|
||||
|
||||
## Provider / 模型配置
|
||||
<!-- 请尽量提供配置截图;务必隐藏 API Key、Token 等敏感信息 -->
|
||||
|
||||
- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama:
|
||||
- Base URL:
|
||||
- 使用的模型:
|
||||
- 相关配置截图:
|
||||
|
||||
## 复现步骤
|
||||
<!-- 请写到别人可以照着操作复现;如果偶发,请说明大约出现频率 -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
是否稳定复现: 是 / 否 / 不确定
|
||||
|
||||
## 错误信息与日志
|
||||
<!--
|
||||
请粘贴完整错误信息。桌面端启动失败时,请复制错误弹窗中的 Recent server logs;
|
||||
如果是对话、配置或模型调用失败,请粘贴页面上的错误、终端输出或相关日志。
|
||||
-->
|
||||
|
||||
```shell
|
||||
在此粘贴错误信息和日志
|
||||
```
|
||||
|
||||
## 截图或录屏
|
||||
<!-- 请提供错误截图、配置截图或复现录屏;截图前请隐藏敏感信息 -->
|
||||
|
||||
|
||||
## 补充信息
|
||||
<!-- 最近是否升级过版本、换过 provider、改过配置,或是否有旧版本可以正常使用 -->
|
||||
|
||||
|
||||
---
|
||||
<!-- 🤖 Dosu 机器人将自动响应此 issue -->
|
||||
@dosubot
|
||||
+1
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: 使用问题咨询
|
||||
about: 咨询桌面端或 CLI 使用过程中遇到的问题
|
||||
title: '[问题] '
|
||||
labels: question
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 提交前确认
|
||||
<!-- 请确认以下事项 -->
|
||||
|
||||
- [ ] 我已经阅读了 [README 常见问题](https://github.com/NanmiCoder/cc-haha#常见问题)
|
||||
- [ ] 我已经阅读了[第三方模型使用指南](https://github.com/NanmiCoder/cc-haha/blob/main/docs/guide/third-party-models.md)(如使用第三方 provider)
|
||||
- [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题
|
||||
- [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息
|
||||
|
||||
## 问题描述
|
||||
<!-- 清晰简洁地描述你想做什么、现在卡在哪里 -->
|
||||
|
||||
|
||||
## 使用环境
|
||||
<!-- 桌面端问题请优先填写前 4 项;CLI 问题再补充 Bun / Node 信息 -->
|
||||
|
||||
- 使用方式: 桌面端 / CLI / 其他:
|
||||
- 操作系统: Windows / macOS / Linux:
|
||||
- 系统版本: 例如 Windows 11 23H2 / macOS 15.4:
|
||||
- 桌面端版本: 例如 v0.1.8(设置页 -> 关于 中可查看):
|
||||
- 安装来源: GitHub Release / 源码本地构建 / 其他:
|
||||
- Bun 版本(CLI 问题填写,`bun --version`):
|
||||
- Node 版本(CLI 问题填写,`node --version`):
|
||||
|
||||
## 使用场景
|
||||
<!-- 描述你在使用哪个功能时遇到的问题 -->
|
||||
|
||||
- 使用功能: 例如会话对话 / Provider 配置 / 模型调用 / MCP / Skills / 远程控制:
|
||||
- API 提供商: 例如 Anthropic 官方 / OpenRouter / MiniMax / Kimi / Ollama:
|
||||
- 使用的模型:
|
||||
|
||||
## 配置截图
|
||||
<!-- 请尽量提供 Provider / 模型 / Base URL 等相关配置截图;务必隐藏 API Key、Token 等敏感信息 -->
|
||||
|
||||
|
||||
## 操作步骤
|
||||
<!-- 请说明你是怎么操作到这个问题的 -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## 错误信息与日志
|
||||
<!-- 如果页面、弹窗或终端里有错误,请粘贴完整内容 -->
|
||||
|
||||
```shell
|
||||
在此粘贴错误信息和日志
|
||||
```
|
||||
|
||||
## 截图或录屏
|
||||
<!-- 如有,请提供截图或录屏;截图前请隐藏敏感信息 -->
|
||||
|
||||
|
||||
---
|
||||
<!-- 🤖 Dosu 机器人将自动响应此 issue -->
|
||||
@dosubot
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
name: Build Desktop (Dev)
|
||||
|
||||
# 开发阶段构建 — 不创建 Release,只上传 Artifacts 供下载测试
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
description: 'Build platforms'
|
||||
required: true
|
||||
default: 'all'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- macos-arm64
|
||||
- macos-x64
|
||||
- windows-x64
|
||||
- linux-x64
|
||||
- linux-arm64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-desktop-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── 动态生成构建矩阵 ──────────────────────────────────────
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- id: set-matrix
|
||||
run: |
|
||||
ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64","asset_platform":"macos","asset_arch":"arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64","asset_platform":"macos","asset_arch":"x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"msi","label":"Windows-x64","artifact_name":"windows-x64","asset_platform":"windows","asset_arch":"x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64","asset_platform":"linux","asset_arch":"x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64","asset_platform":"linux","asset_arch":"arm64"}]'
|
||||
|
||||
SELECTED="${{ inputs.platforms }}"
|
||||
if [ "$SELECTED" = "all" ]; then
|
||||
MATRIX=$(echo "$ALL" | jq -c '{include: .}')
|
||||
else
|
||||
MATRIX=$(echo "$ALL" | jq -c --arg sel "$SELECTED" '{include: [.[] | select(
|
||||
($sel == "macos-arm64" and .label == "macOS-ARM64") or
|
||||
($sel == "macos-x64" and .label == "macOS-x64") or
|
||||
($sel == "windows-x64" and .label == "Windows-x64") or
|
||||
($sel == "linux-x64" and .label == "Linux-x64") or
|
||||
($sel == "linux-arm64" and .label == "Linux-ARM64")
|
||||
)]}')
|
||||
fi
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 构建 ────────────────────────────────────────────────────
|
||||
build:
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
name: Build (${{ matrix.label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── 读取版本号 ───────────────────────────────────────────
|
||||
- name: Read version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' desktop/src-tauri/tauri.conf.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 系统依赖 (Linux) ─────────────────────────────────────
|
||||
# libfuse2 是 AppImage 运行时依赖:linuxdeploy 工具链本身是 AppImage 格式,
|
||||
# 运行时需要 libfuse2,Ubuntu 22.04 默认未安装。
|
||||
- name: Install Linux dependencies
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential curl wget file \
|
||||
libxdo-dev libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev patchelf \
|
||||
libfuse2
|
||||
|
||||
# ── Bun ──────────────────────────────────────────────────
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# ── Node.js ──────────────────────────────────────────────
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# ── Rust ─────────────────────────────────────────────────
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
shared-key: ${{ matrix.rust_target }}
|
||||
|
||||
# ── 安装依赖 ─────────────────────────────────────────────
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
# ── 构建 sidecars ────────────────────────────────────────
|
||||
- name: Build sidecars
|
||||
working-directory: desktop
|
||||
env:
|
||||
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
|
||||
run: bun run build:sidecars
|
||||
|
||||
# ── 构建 Tauri (无签名、无 updater) ──────────────────────
|
||||
- name: Build Tauri app
|
||||
working-directory: desktop
|
||||
env:
|
||||
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
# 开发构建: 禁用 updater artifacts (不需要签名密钥)
|
||||
echo '{"bundle":{"createUpdaterArtifacts":false}}' > /tmp/dev-build.json
|
||||
|
||||
ARGS=(bunx tauri build --bundles ${{ matrix.bundles }} --ci --config /tmp/dev-build.json)
|
||||
|
||||
if [ -n "${{ matrix.tauri_args }}" ]; then
|
||||
ARGS+=(${{ matrix.tauri_args }})
|
||||
fi
|
||||
|
||||
"${ARGS[@]}"
|
||||
shell: bash
|
||||
|
||||
# ── 收集产物 ─────────────────────────────────────────────
|
||||
- name: Collect artifacts
|
||||
id: collect
|
||||
shell: bash
|
||||
run: |
|
||||
BUNDLE_DIR="desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
|
||||
FALLBACK_DIR="desktop/src-tauri/target/release/bundle"
|
||||
|
||||
# 优先使用带 target 的路径
|
||||
if [ -d "$BUNDLE_DIR" ]; then
|
||||
SEARCH_DIR="$BUNDLE_DIR"
|
||||
else
|
||||
SEARCH_DIR="$FALLBACK_DIR"
|
||||
fi
|
||||
|
||||
STAGING="desktop/build-artifacts/ci-Claude-Code-Haha-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}"
|
||||
mkdir -p "$STAGING"
|
||||
|
||||
echo "Searching for artifacts in: $SEARCH_DIR"
|
||||
if [ -d "$SEARCH_DIR" ]; then
|
||||
find "$SEARCH_DIR" \
|
||||
\( -type f \( \
|
||||
-name "*.dmg" -o -name "*.msi" -o \
|
||||
-name "*.deb" -o -name "*.AppImage" -o -name "*.zip" \
|
||||
\) \) -o \
|
||||
\( -type d -name "*.app" \) | while read -r f; do
|
||||
base="$(basename "$f")"
|
||||
bundle="artifact"
|
||||
ext=""
|
||||
|
||||
case "$base" in
|
||||
*.app)
|
||||
bundle="app"
|
||||
ext=".app"
|
||||
;;
|
||||
*.dmg)
|
||||
bundle="dmg"
|
||||
ext=".dmg"
|
||||
;;
|
||||
*.msi)
|
||||
bundle="msi"
|
||||
ext=".msi"
|
||||
;;
|
||||
*.deb)
|
||||
bundle="deb"
|
||||
ext=".deb"
|
||||
;;
|
||||
*.AppImage)
|
||||
bundle="appimage"
|
||||
ext=".AppImage"
|
||||
;;
|
||||
*.zip)
|
||||
bundle="zip"
|
||||
ext=".zip"
|
||||
;;
|
||||
*)
|
||||
ext=""
|
||||
;;
|
||||
esac
|
||||
|
||||
target="Claude-Code-Haha_${{ steps.version.outputs.version }}_${{ matrix.asset_platform }}_${{ matrix.asset_arch }}_${bundle}${ext}"
|
||||
echo " Copying: ${base} -> ${target}"
|
||||
cp -R "$f" "$STAGING/$target"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "staging_dir=$STAGING" >> "$GITHUB_OUTPUT"
|
||||
echo "=== Collected artifacts ==="
|
||||
ls -lh "$STAGING/" 2>/dev/null || echo "(empty)"
|
||||
|
||||
# ── 上传为 GitHub Artifact ───────────────────────────────
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Claude-Code-Haha-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}
|
||||
path: ${{ steps.collect.outputs.staging_dir }}
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# ── 构建完成汇总 ────────────────────────────────────────────
|
||||
summary:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- name: Build summary
|
||||
run: |
|
||||
echo "## Desktop Dev Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 Artifacts are available for download from the **Artifacts** section above." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Artifacts will be retained for **7 days**." >> $GITHUB_STEP_SUMMARY
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
name: Deploy VitePress Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build with VitePress
|
||||
run: npm run docs:build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: 'Create as draft release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# macOS Apple Silicon
|
||||
- platform: macos-latest
|
||||
rust_target: aarch64-apple-darwin
|
||||
tauri_args: '--target aarch64-apple-darwin'
|
||||
label: macOS-ARM64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_arm64_[bundle][ext]'
|
||||
|
||||
# macOS Intel
|
||||
- platform: macos-latest
|
||||
rust_target: x86_64-apple-darwin
|
||||
tauri_args: '--target x86_64-apple-darwin'
|
||||
label: macOS-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_x64_[bundle][ext]'
|
||||
|
||||
# Linux x64
|
||||
- platform: ubuntu-22.04
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
tauri_args: '--bundles deb'
|
||||
label: Linux-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_x64_[bundle][ext]'
|
||||
|
||||
# Linux ARM64
|
||||
- platform: ubuntu-22.04-arm
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
tauri_args: '--bundles deb'
|
||||
label: Linux-ARM64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_arm64_[bundle][ext]'
|
||||
|
||||
# Windows x64
|
||||
- platform: windows-latest
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
tauri_args: '--bundles nsis'
|
||||
label: Windows-x64
|
||||
asset_name_pattern: 'Claude-Code-Haha_[version]_windows_x64_[bundle][ext]'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
name: Build (${{ matrix.label }})
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' desktop/src-tauri/tauri.conf.json)
|
||||
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate tag matches version
|
||||
if: github.event_name == 'push'
|
||||
shell: bash
|
||||
run: |
|
||||
EXPECTED_TAG="v${{ steps.version.outputs.value }}"
|
||||
if [ "${GITHUB_REF_NAME}" != "$EXPECTED_TAG" ]; then
|
||||
echo "::error::Tag ${GITHUB_REF_NAME} does not match app version ${EXPECTED_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Load release notes
|
||||
id: release_notes
|
||||
shell: bash
|
||||
run: |
|
||||
NOTES_FILE="release-notes/v${{ steps.version.outputs.value }}.md"
|
||||
if [ ! -f "$NOTES_FILE" ]; then
|
||||
echo "::error::Missing release notes file: $NOTES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo 'body<<__RELEASE_NOTES__'
|
||||
cat "$NOTES_FILE"
|
||||
echo
|
||||
echo '__RELEASE_NOTES__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── System dependencies (Linux) ──────────────────────────
|
||||
- name: Install Linux dependencies
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libfuse2
|
||||
|
||||
# ── Bun ──────────────────────────────────────────────────
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# ── Node.js (for tauri-action compatibility) ─────────────
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# ── Rust ─────────────────────────────────────────────────
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
shared-key: ${{ matrix.rust_target }}
|
||||
|
||||
# ── Install dependencies ─────────────────────────────────
|
||||
- name: Install root dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: bun install
|
||||
|
||||
- name: Install adapter dependencies
|
||||
working-directory: adapters
|
||||
run: bun install
|
||||
|
||||
# ── Build sidecars ───────────────────────────────────────
|
||||
- name: Build sidecars
|
||||
working-directory: desktop
|
||||
env:
|
||||
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
|
||||
run: bun run build:sidecars
|
||||
|
||||
# ── Build Tauri app ──────────────────────────────────────
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tauriScript: bunx tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'Claude Code Haha v__VERSION__'
|
||||
releaseBody: ${{ steps.release_notes.outputs.body }}
|
||||
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
|
||||
prerelease: false
|
||||
updaterJsonPreferNsis: true
|
||||
releaseAssetNamePattern: ${{ matrix.asset_name_pattern }}
|
||||
args: ${{ matrix.tauri_args }} --config src-tauri/tauri.release-ci.json
|
||||
@@ -0,0 +1,67 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
.DS_Store
|
||||
|
||||
# Computer Use runtime (auto-generated)
|
||||
.runtime/
|
||||
extracted-natives/
|
||||
|
||||
# Adapter attachment cache (created at runtime by AttachmentStore when
|
||||
# running adapter tests or the feishu/telegram adapters locally)
|
||||
.lark-attachments/
|
||||
.tg-attachments/
|
||||
|
||||
# Playwright MCP snapshots & logs
|
||||
.playwright-mcp/
|
||||
|
||||
# OMX state/logs
|
||||
.omx/
|
||||
|
||||
# E2E screenshots
|
||||
e2e-*.png
|
||||
|
||||
# Desktop build output
|
||||
desktop/dist/
|
||||
desktop/build-artifacts/
|
||||
desktop/src-tauri/binaries/
|
||||
desktop/src-tauri/target/
|
||||
desktop/src-tauri/gen/
|
||||
desktop/package-lock.json
|
||||
|
||||
# Desktop brand asset candidates (keep only selected ones in public/)
|
||||
desktop/brand-assets/
|
||||
|
||||
# Claude local config & auto-generated
|
||||
.claude/
|
||||
|
||||
# Superpowers plans & specs (local only)
|
||||
docs/superpowers/
|
||||
|
||||
# Temp server/web logs
|
||||
.tmp-*.log
|
||||
|
||||
# Debug / temp screenshots (root dir only)
|
||||
/*.png
|
||||
bug_attachments/
|
||||
|
||||
# Private keys & credentials
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
credentials*.json
|
||||
|
||||
# TS build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Temp markdown snapshots
|
||||
sidebar-snapshot.md
|
||||
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
runtime/__pycache__
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
@@ -0,0 +1,37 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
The root package is the Bun-based CLI and local server. Main code lives in `src/`: `entrypoints/` for startup paths, `screens/` and `components/` for the Ink TUI, `commands/` for slash commands, `services/` for API/MCP/OAuth logic, and `tools/` for agent tool implementations. `bin/claude-haha` is the executable entrypoint. The desktop app is isolated in `desktop/` with React UI code in `desktop/src/` and Tauri glue in `desktop/src-tauri/`. Documentation is in `docs/` and builds with VitePress. Treat root screenshots and `docs/images/` as reference assets, not source code.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Install root dependencies with `bun install`, then install desktop dependencies in `desktop/` if you are touching the app UI.
|
||||
|
||||
- `./bin/claude-haha` or `bun run start`: run the CLI locally.
|
||||
- `SERVER_PORT=3456 bun run src/server/index.ts`: start the local API/WebSocket server used by `desktop/`.
|
||||
- `bun run docs:dev` / `bun run docs:build`: preview or build the VitePress docs.
|
||||
- `cd desktop && bun run dev`: run the desktop frontend in Vite.
|
||||
- `cd desktop && bun run build`: type-check and produce a production web build.
|
||||
- `cd desktop && bun run test`: run Vitest suites.
|
||||
- `cd desktop && bun run lint`: run TypeScript no-emit checks.
|
||||
|
||||
## Desktop Release Workflow
|
||||
- Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts.
|
||||
- The release workflow is `.github/workflows/release-desktop.yml`; it triggers automatically on `push` of tags matching `v*.*.*`.
|
||||
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename aligned with the version/tag exactly.
|
||||
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates version files, refreshes `desktop/src-tauri/Cargo.lock`, requires the matching `release-notes/vX.Y.Z.md`, commits it, and creates the annotated tag.
|
||||
- The normal release push is `git push origin main --tags`. If the tag, app version, or release-notes filename do not match, the workflow is designed to fail fast instead of publishing the wrong release.
|
||||
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, and outputs land under `desktop/build-artifacts/macos-arm64/`.
|
||||
|
||||
## Docs Workflow Notes
|
||||
- The docs workflow is `.github/workflows/deploy-docs.yml` and uses `npm ci`, not Bun. When root `package.json` dependencies change, keep `package-lock.json` in the same commit or the docs build will fail.
|
||||
- The docs workflow currently runs on Node 22; avoid reintroducing older Node assumptions there without checking dependency engine requirements.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match the existing code. Prefer `PascalCase` for React components, `camelCase` for functions, hooks, and stores, and descriptive file names like `teamWatcher.ts` or `AgentTranscript.tsx`. Keep shared UI in `desktop/src/components/`, API clients in `desktop/src/api/`, and avoid adding new dependencies unless the existing utilities cannot cover the change.
|
||||
|
||||
## Testing Guidelines
|
||||
Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep subjects imperative and scoped to one change. PRs should explain the user-visible impact, list verification steps, link related issues, and include screenshots for desktop or docs UI changes. Keep diffs reviewable and call out any follow-up work or known gaps.
|
||||
Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository.
|
||||
@@ -0,0 +1,59 @@
|
||||
# HeiCode Server — local API/WebSocket server in a container.
|
||||
#
|
||||
# NOTE:
|
||||
# We intentionally do NOT use `FROM oven/bun:*` here because some environments
|
||||
# have intermittent Docker Hub TLS proxy issues when pulling that image.
|
||||
# Instead we use alpine (already commonly cached) and download Bun directly
|
||||
# from GitHub release assets during build.
|
||||
#
|
||||
# Build: docker build -t heicode-server -f Dockerfile .
|
||||
# Run: see docker-compose.yml at the repo root.
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
# Bun on Alpine still needs libstdc++/libgcc at runtime.
|
||||
# Network to Alpine mirrors can be flaky in this environment, so retry.
|
||||
RUN set -eux; \
|
||||
for i in 1 2 3 4 5 6 7 8; do \
|
||||
apk add --no-cache libstdc++ libgcc && break; \
|
||||
echo "apk add failed (attempt ${i}), retrying..."; \
|
||||
sleep 3; \
|
||||
done
|
||||
|
||||
# Install Bun (musl build for Alpine on arm64) from a local build asset.
|
||||
# This avoids flaky outbound TLS during `docker build` in restricted networks.
|
||||
COPY .docker-assets/bun-linux-aarch64-musl.zip /tmp/bun.zip
|
||||
RUN busybox unzip -q /tmp/bun.zip -d /tmp \
|
||||
&& mv /tmp/bun-linux-aarch64-musl/bun /usr/local/bin/bun \
|
||||
&& chmod +x /usr/local/bin/bun \
|
||||
&& rm -rf /tmp/bun.zip /tmp/bun-linux-aarch64-musl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies in a separate layer to maximise cache hits across
|
||||
# code-only changes.
|
||||
COPY package.json bun.lock bunfig.toml preload.ts ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source. We deliberately *don't* copy the desktop/
|
||||
# Tauri tree because the server doesn't need it; it dramatically speeds up
|
||||
# the build and keeps the image lean. The `desktop/` folder lives in the
|
||||
# repo only as a sibling project.
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY adapters ./adapters
|
||||
COPY runtime ./runtime
|
||||
COPY stubs ./stubs
|
||||
COPY fixtures ./fixtures
|
||||
COPY bin ./bin
|
||||
|
||||
# Default port the server listens on.
|
||||
ENV SERVER_PORT=3456
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
# Bind to /data so cc-haha's ~/.claude config persists across container restarts.
|
||||
ENV CLAUDE_CONFIG_DIR=/data/.claude
|
||||
RUN mkdir -p /data/.claude
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["bun", "run", "src/server/index.ts"]
|
||||
@@ -0,0 +1,38 @@
|
||||
DISCLAIMER AND LICENSE
|
||||
|
||||
Copyright (c) 2026 Anthropic, PBC. All rights reserved for the original source code.
|
||||
|
||||
This repository is based on the Claude Code source code leaked from the Anthropic
|
||||
npm registry on 2026-03-31. All original source code copyrights belong to Anthropic.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED FOR EDUCATIONAL AND RESEARCH PURPOSES ONLY.
|
||||
|
||||
The purpose of this repository is to facilitate the study and research of AI Agent
|
||||
engineering, including but not limited to:
|
||||
- Agent architecture and design patterns
|
||||
- Tool use and function calling mechanisms
|
||||
- Multi-agent orchestration
|
||||
- Terminal UI (TUI) implementation for AI applications
|
||||
- MCP (Model Context Protocol) integration
|
||||
|
||||
RESTRICTIONS:
|
||||
|
||||
1. This software SHALL NOT be used for any commercial purposes.
|
||||
2. This software SHALL NOT be used to compete with or replicate Anthropic's
|
||||
commercial products or services.
|
||||
3. This software SHALL NOT be redistributed, sublicensed, or sold in any form.
|
||||
4. Users SHALL comply with all applicable laws and regulations in their jurisdiction.
|
||||
|
||||
NO WARRANTY:
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
By using this software, you acknowledge that:
|
||||
- You understand this is leaked source code and all original rights belong to Anthropic.
|
||||
- You will use it solely for learning and research of Agent engineering.
|
||||
- You assume all legal risks and responsibilities arising from the use of this software.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/NanmiCoder/cc-haha/stargazers)
|
||||
[](https://github.com/NanmiCoder/cc-haha/network/members)
|
||||
[](https://github.com/NanmiCoder/cc-haha/issues)
|
||||
[](https://github.com/NanmiCoder/cc-haha/pulls)
|
||||
[](https://github.com/NanmiCoder/cc-haha/blob/main/LICENSE)
|
||||
[](README.md)
|
||||
[](README.en.md)
|
||||
[](https://claudecode-haha.relakkesyang.org)
|
||||
|
||||
</div>
|
||||
|
||||
A **locally runnable version** repaired from the leaked Claude Code source, with support for any Anthropic-compatible API endpoint (MiniMax, OpenRouter, etc.). Beyond the full TUI, we've also completed Computer Use (macOS / Windows), built a GUI **desktop app**, and enabled **full remote control** via Telegram / Feishu.
|
||||
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> · <a href="#desktop-preview">Desktop</a> · <a href="#architecture-overview">Architecture</a> · <a href="#quick-start">Quick Start</a> · <a href="docs/en/guide/env-vars.md">Env Vars</a> · <a href="docs/en/guide/faq.md">FAQ</a> · <a href="docs/en/guide/global-usage.md">Global Usage</a> · <a href="#more-documentation">More Docs</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Full Ink TUI experience (matching the official Claude Code interface)
|
||||
- `--print` headless mode for scripts and CI
|
||||
- MCP server, plugin, and Skills support
|
||||
- Custom API endpoint and model support ([Third-Party Models Guide](docs/en/guide/third-party-models.md))
|
||||
- **Memory System** (cross-session persistent memory) — [Usage Guide](docs/memory/01-usage-guide.md)
|
||||
- **Multi-Agent System** (agent orchestration, parallel tasks, Teams collaboration) — [Usage Guide](docs/agent/01-usage-guide.md) | [Implementation](docs/agent/02-implementation.md)
|
||||
- **Skills System** (extensible capability plugins, custom workflows) — [Usage Guide](docs/skills/01-usage-guide.md) | [Implementation](docs/skills/02-implementation.md)
|
||||
- **Channel System** (remote Agent control via Telegram/Feishu/Discord IM platforms) — [Architecture](docs/en/channel/01-channel-system.md)
|
||||
- **Computer Use desktop control** — [Guide](docs/en/features/computer-use.md) | [Architecture](docs/en/features/computer-use-architecture.md)
|
||||
- **Desktop App** (Tauri 2 + React GUI client, multi-tab multi-session) — [Docs](docs/desktop/)
|
||||
- Fallback Recovery CLI mode (`CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha`)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="docs/images/01-overall-architecture.png" alt="Overall architecture"><br><b>Overall architecture</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/02-request-lifecycle.png" alt="Request lifecycle"><br><b>Request lifecycle</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/03-tool-system.png" alt="Tool system"><br><b>Tool system</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/04-multi-agent.png" alt="Multi-agent architecture"><br><b>Multi-agent architecture</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="docs/images/05-terminal-ui.png" alt="Terminal UI"><br><b>Terminal UI</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/06-permission-security.png" alt="Permissions and security"><br><b>Permissions and security</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/07-services-layer.png" alt="Services layer"><br><b>Services layer</b></td>
|
||||
<td align="center" width="25%"><img src="docs/images/08-state-data-flow.png" alt="State and data flow"><br><b>State and data flow</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Desktop Preview
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NanmiCoder/cc-haha/releases"><img src="https://img.shields.io/badge/⬇_Download_Desktop-macOS_%7C_Windows-D97757?style=for-the-badge" alt="Download Desktop"></a>
|
||||
|
||||
<a href="docs/desktop/04-installation.md"><img src="https://img.shields.io/badge/📖_Install_Guide-Guide-gray?style=for-the-badge" alt="Install Guide"></a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/01_full_ui.png" alt="Main UI"><br><b>Main Interface</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/02_edit_code.png" alt="Code Editing"><br><b>Code Editing & Diff View</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/03_ask_question_and_permission.png" alt="Permission Control"><br><b>Permission Control & AI Questions</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/05_settings.png" alt="Provider Settings"><br><b>Multi-Provider Management</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/08_scheduled_task.png" alt="Scheduled Tasks"><br><b>Scheduled Tasks</b></td>
|
||||
<td align="center" width="33%"><img src="docs/images/desktop_ui/07_im.png" alt="IM Adapters"><br><b>IM Adapters (Telegram / Feishu)</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Bun
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# macOS (Homebrew)
|
||||
brew install bun
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
> On minimal Linux images, if you see `unzip is required`, run `apt update && apt install -y unzip` first.
|
||||
|
||||
### 2. Install Dependencies and Configure
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env
|
||||
# Edit .env with your API key — see docs/en/guide/env-vars.md for details
|
||||
```
|
||||
|
||||
### 3. Start
|
||||
|
||||
#### macOS / Linux
|
||||
|
||||
```bash
|
||||
./bin/claude-haha # Interactive TUI mode
|
||||
./bin/claude-haha -p "your prompt here" # Headless mode
|
||||
./bin/claude-haha --help # Show all options
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
> **Prerequisite**: [Git for Windows](https://git-scm.com/download/win) must be installed.
|
||||
|
||||
```powershell
|
||||
# PowerShell / cmd — call Bun directly
|
||||
bun --env-file=.env ./src/entrypoints/cli.tsx
|
||||
|
||||
# Or run inside Git Bash
|
||||
./bin/claude-haha
|
||||
```
|
||||
|
||||
### 4. Global Usage (Optional)
|
||||
|
||||
Add `bin/` to your PATH to run from any directory. See [Global Usage Guide](docs/en/guide/global-usage.md):
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
|
||||
```
|
||||
|
||||
### 5. Desktop Development
|
||||
|
||||
If you are developing or testing the `desktop/` frontend, start both the API server and the desktop frontend.
|
||||
|
||||
#### 5.1 Start the API server
|
||||
|
||||
```bash
|
||||
cd /Users/nanmi/workspace/myself_code/claude-code-haha
|
||||
SERVER_PORT=3456 bun run src/server/index.ts
|
||||
```
|
||||
|
||||
Optional health check:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/health
|
||||
```
|
||||
|
||||
#### 5.2 Start the desktop frontend
|
||||
|
||||
```bash
|
||||
cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop
|
||||
bun run dev --host 127.0.0.1 --port 2024
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:2024
|
||||
```
|
||||
|
||||
#### 5.3 Notes
|
||||
|
||||
- If port `3456` is already occupied by an old server process, run `lsof -nP -iTCP:3456 -sTCP:LISTEN`, find the PID, then `kill <PID>`.
|
||||
- For chat testing, create a fresh session and re-select a real working directory.
|
||||
- If an old session points to a deleted directory, the server will return `Working directory does not exist`. That is separate from whether the API server is running.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
|------|------|
|
||||
| Runtime | [Bun](https://bun.sh) |
|
||||
| Language | TypeScript |
|
||||
| Terminal UI | React + [Ink](https://github.com/vadimdemedes/ink) |
|
||||
| CLI parsing | Commander.js |
|
||||
| API | Anthropic SDK |
|
||||
| Protocols | MCP, LSP |
|
||||
|
||||
---
|
||||
|
||||
## More Documentation
|
||||
|
||||
| Document | Description |
|
||||
|------|------|
|
||||
| [Environment Variables](docs/en/guide/env-vars.md) | Full env var reference and configuration methods |
|
||||
| [Third-Party Models](docs/en/guide/third-party-models.md) | Using OpenAI / DeepSeek / Ollama and other non-Anthropic models |
|
||||
| [Memory System](docs/memory/01-usage-guide.md) | Cross-session persistent memory usage and implementation |
|
||||
| [Multi-Agent System](docs/agent/01-usage-guide.md) | Agent orchestration, parallel tasks and Teams collaboration |
|
||||
| [Skills System](docs/skills/01-usage-guide.md) | Extensible capability plugins, custom workflows and conditional activation |
|
||||
| [Channel System](docs/en/channel/01-channel-system.md) | Remote Agent control via Telegram/Feishu/Discord IM platforms |
|
||||
| [Computer Use](docs/en/features/computer-use.md) | Desktop control (screenshots, mouse, keyboard) — [Architecture](docs/en/features/computer-use-architecture.md) |
|
||||
| [Desktop App](docs/desktop/) | Tauri 2 + React GUI client — [Quick Start](docs/desktop/01-quick-start.md) \| [Architecture](docs/desktop/02-architecture.md) \| [Installation](docs/desktop/04-installation.md) |
|
||||
| [Global Usage](docs/en/guide/global-usage.md) | Run claude-haha from any directory |
|
||||
| [FAQ](docs/en/guide/faq.md) | Common error troubleshooting |
|
||||
| [Source Fixes](docs/en/reference/fixes.md) | Fixes compared with the original leaked source |
|
||||
| [Project Structure](docs/en/reference/project-structure.md) | Code directory structure |
|
||||
|
||||
---
|
||||
|
||||
## Sponsorship & Partnership
|
||||
|
||||
This project is maintained in the author's spare time. Corporate or individual sponsorships are welcome to support ongoing development. Custom features, integrations, and business partnerships are also open for discussion.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="220">Sponsor</th>
|
||||
<th align="left">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
|
||||
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="JieKou AI"><br>
|
||||
<strong>接口AI</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://jiekou.ai/referral?invited_code=OBNU3K">JieKou AI</a> for sponsoring this project. JieKou AI provides official model resources with stable, high-performance API access. Subscription bundles are priced at 20% off the official rate; new users who register through <a href="https://jiekou.ai/referral?invited_code=OBNU3K">this link</a> and bind GitHub can claim a $3 coupon.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
|
||||
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="ShengSuanYun">
|
||||
</a>
|
||||
</td>
|
||||
<td valign="middle">
|
||||
Thanks to <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">ShengSuanYun</a> for sponsoring this project. ShengSuanYun is an industrial-grade AI task parallel execution platform for AI Native Teams, aggregating Claude, ChatGPT, Gemini, and other LLM, image, and video model capacity through direct, non-reverse-engineered access. Its platform SLA reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">service status</a> available online. It also supports dedicated enterprise gateways, cost and permission controls, smart routing, security protection, BYOK, usage-based billing, upcoming tokens plans, and invoicing. New users registering through <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">this link</a> can receive 10 yuan in model credits plus a 10% first top-up bonus.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
📧 **Contact**: relakkes@gmail.com
|
||||
|
||||
---
|
||||
|
||||
## ☕ Buy Me a Coffee
|
||||
|
||||
If this project helps you, consider buying me a coffee — every bit of support keeps this project going ❤️
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<img src="docs/images/donate/wechat_pay.jpeg" width="250" alt="WeChat Pay"><br>
|
||||
<b>WeChat Pay</b>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<img src="docs/images/donate/zfb_pay.png" width="250" alt="Alipay"><br>
|
||||
<b>Alipay</b>
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<a href="https://buymeacoffee.com/relakkes" target="_blank">
|
||||
<img src="docs/images/donate/bmc_button.png" width="250" alt="Buy Me a Coffee">
|
||||
</a><br>
|
||||
<b>Buy Me a Coffee</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following open-source projects and community practices for reference and inspiration:
|
||||
|
||||
- [React](https://github.com/facebook/react): frontend engineering and component-based UI ecosystem.
|
||||
- [Tauri](https://github.com/tauri-apps/tauri): cross-platform desktop app capabilities and engineering practices.
|
||||
- [cc-switch](https://github.com/farion1231/cc-switch): reference for model provider configuration.
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha.
|
||||
|
||||
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only.
|
||||
@@ -0,0 +1,157 @@
|
||||
# HeiCode
|
||||
|
||||
> 基于 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) 二次开发的 Claude Code 客户端,登录后通过 **TaijiAICloud** 或 **ClawdRouter** 直接使用各家主流大模型,零配置开箱即用。
|
||||
|
||||
---
|
||||
|
||||
## 当前形态
|
||||
|
||||
- **登录方式**:用户启动 HeiCode 后只看到两个登录入口
|
||||
- **TaijiAICloud**(自建网关,基于 [`new-api`](https://github.com/Calcium-Ion/new-api))
|
||||
- **ClawdRouter**(聚合网关)
|
||||
- **支持模式**:
|
||||
- 浏览器跳转 OAuth(推荐 / 平台支持后启用)
|
||||
- 复制 API Key 粘贴登录(兼容入口 / 立即可用)
|
||||
- **协议**:两个平台均原生支持 Anthropic `/v1/messages`,HeiCode 直接调用,无需中台代理。
|
||||
- **模型矩阵**:登录后自动从平台 `/v1/models` 拉取,覆盖 GPT / Claude / Gemini 全家桶。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
heicode/
|
||||
├── bin/ # CLI 入口(heicode / claude-haha 兼容别名)
|
||||
├── src/ # CLI + Server(Bun + TypeScript + Ink)
|
||||
│ ├── server/ # 桌面端用的本地 HTTP/WS Server
|
||||
│ │ ├── config/providerPresets.json # ★ 仅保留 TaijiAICloud / ClawdRouter
|
||||
│ │ ├── api/heicode-auth.ts # ★ 双 Provider 登录入口
|
||||
│ │ └── api/providers.ts # ★ 加了 /v1/models 探活
|
||||
│ └── ...
|
||||
├── desktop/ # Tauri 2 + React 桌面端
|
||||
└── docs/ # 文档(含 HEICODE-PLAN.md 路线图)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 准备依赖
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
### 2. 安装项目
|
||||
|
||||
```bash
|
||||
cd heicode
|
||||
bun install
|
||||
```
|
||||
|
||||
### 3. 启动 CLI(终端版)
|
||||
|
||||
```bash
|
||||
./bin/heicode # 交互 TUI
|
||||
./bin/heicode -p "your prompt" # 无头模式
|
||||
```
|
||||
|
||||
### 4. 启动桌面端
|
||||
|
||||
```bash
|
||||
# Terminal A — 本地 Server
|
||||
SERVER_PORT=3456 bun run src/server/index.ts
|
||||
|
||||
# Terminal B — 桌面前端
|
||||
cd desktop && bun run dev --host 127.0.0.1 --port 2024
|
||||
```
|
||||
|
||||
浏览器打开 `http://127.0.0.1:2024`。
|
||||
|
||||
---
|
||||
|
||||
## HeiCode Auth API
|
||||
|
||||
供桌面端 / CLI 调用的双 Provider 登录入口。
|
||||
|
||||
| Method · Path | 用途 |
|
||||
|---|---|
|
||||
| `GET /api/heicode-auth/providers` | 列出 2 个登录入口(TaijiAICloud / ClawdRouter)+ OAuth 是否就绪 |
|
||||
| `POST /api/heicode-auth/login` | 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活 |
|
||||
| `POST /api/heicode-auth/oauth/start` | OAuth 启动(**当前为占位**,等平台支持) |
|
||||
| `GET /api/heicode-auth/oauth/callback` | OAuth 回跳(**当前为占位**,等平台支持) |
|
||||
| `GET /api/heicode-auth/status` | 当前登录状态 |
|
||||
| `POST /api/heicode-auth/logout` | 登出 |
|
||||
|
||||
### 粘贴 API Key 登录示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}'
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"provider": {
|
||||
"id": "...",
|
||||
"presetId": "taijiaicloud",
|
||||
"name": "TaijiAICloud",
|
||||
"baseUrl": "https://api.taijiaicloud.com",
|
||||
"apiFormat": "anthropic",
|
||||
"models": { "main": "claude-sonnet-4-6", "haiku": "...", "sonnet": "...", "opus": "..." }
|
||||
},
|
||||
"availableModels": [{ "id": "..." }, ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 给两个平台的对接清单
|
||||
|
||||
详见 [`docs/HEICODE-PLAN.md`](./docs/HEICODE-PLAN.md)。简版:
|
||||
|
||||
### TaijiAICloud(基于 new-api)
|
||||
|
||||
- 必备:`/v1/messages`(Anthropic 原生协议) · `/v1/models` · API Key 子分组管理
|
||||
- 推荐:OAuth2 Authorization Code + PKCE 端点;Webhook(额度告警)
|
||||
|
||||
### ClawdRouter
|
||||
|
||||
- 必备:`/v1/messages` · `/v1/models`(已支持)
|
||||
- 推荐:管理面 API(颁发短期 token);OAuth2 端点
|
||||
|
||||
---
|
||||
|
||||
## 路线图
|
||||
|
||||
- [x] **S0 品牌剥离**:包名 / Tauri / Cargo / 安装钩子改为 HeiCode
|
||||
- [x] **S0 双 Provider 预设**:providerPresets.json 仅保留 TaijiAICloud / ClawdRouter
|
||||
- [x] **S0 模型自动发现**:`/v1/models` 探活 + 默认模型自动选取
|
||||
- [x] **S0 双 Provider 登录后端**:`/api/heicode-auth/*` 完整就绪(OAuth 占位)
|
||||
- [ ] **S1 桌面端登录页**:替换现有 Settings 的 Provider 列表为 2 卡片登录
|
||||
- [ ] **S1 CLI Onboarding**:原 `Onboarding.tsx` / `ConsoleOAuthFlow` 改为 HeiCode 双卡片
|
||||
- [ ] **S1 OAuth 真实接入**:等 TaijiAICloud / ClawdRouter 提供 OAuth endpoints 后补全
|
||||
- [ ] **S2 模型选择器 UX**:登录后 `/api/providers/:id/models` 渲染下拉选择默认模型
|
||||
- [ ] **S2 配额状态栏**:拉平台 `/v1/usage` 实时展示余量
|
||||
- [ ] **S3 Taiji Agent 工具融合**:MCP 自动挂载 / Skills 同步 / Agent 导入
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
继承上游 cc-haha 的 License。再往上游溯源是 Anthropic Claude Code 泄露源码,**仅供学习研究使用**。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- 上游 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) — 提供基础工程
|
||||
- [`new-api`](https://github.com/Calcium-Ion/new-api) — 模型网关后端
|
||||
@@ -0,0 +1,87 @@
|
||||
# Claude Code IM Adapters
|
||||
|
||||
当前目录只放 IM Adapter 运行时代码。
|
||||
|
||||
用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
|
||||
|
||||
- `docs/im/index.md`
|
||||
- `docs/im/telegram.md`
|
||||
- `docs/im/feishu.md`
|
||||
|
||||
## 当前方案摘要
|
||||
|
||||
当前真实链路是:
|
||||
|
||||
```text
|
||||
Desktop Webapp Settings
|
||||
-> /api/adapters
|
||||
-> ~/.claude/adapters.json
|
||||
-> adapters/<platform>/index.ts
|
||||
-> /api/sessions + /ws/:sessionId
|
||||
-> Claude Code session
|
||||
```
|
||||
|
||||
注意两点:
|
||||
|
||||
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
|
||||
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu`
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd adapters
|
||||
bun install
|
||||
bun run telegram
|
||||
# 或
|
||||
bun run feishu
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
cd adapters
|
||||
bun test
|
||||
bun test common/
|
||||
bun test telegram/
|
||||
bun test feishu/
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```text
|
||||
adapters/
|
||||
├── common/
|
||||
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
|
||||
├── telegram/
|
||||
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
|
||||
├── feishu/
|
||||
│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
|
||||
│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 附件收发
|
||||
|
||||
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
|
||||
|
||||
**入站(用户 → Claude):**
|
||||
|
||||
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
|
||||
- Telegram: photo、document、video、audio、voice
|
||||
|
||||
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
|
||||
|
||||
**出站(Claude → 用户):**
|
||||
|
||||
Agent 流式文本里的 markdown 图片引用 `` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
|
||||
|
||||
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
|
||||
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
|
||||
|
||||
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
|
||||
|
||||
设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "claude-code-im-adapters",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.60.0",
|
||||
"grammy": "^1.42.0",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="],
|
||||
|
||||
"@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.4.tgz", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"grammy": ["grammy@1.42.0", "https://registry.npmmirror.com/grammy/-/grammy-1.42.0.tgz", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lodash.pickby": ["lodash.pickby@4.6.0", "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
|
||||
|
||||
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { enqueue } from '../chat-queue.js'
|
||||
|
||||
describe('ChatQueue', () => {
|
||||
it('executes tasks for the same chatId serially', async () => {
|
||||
const order: number[] = []
|
||||
|
||||
await Promise.all([
|
||||
enqueue('chat-1', async () => {
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
order.push(1)
|
||||
}),
|
||||
enqueue('chat-1', async () => {
|
||||
order.push(2)
|
||||
}),
|
||||
enqueue('chat-1', async () => {
|
||||
order.push(3)
|
||||
}),
|
||||
])
|
||||
|
||||
// Wait for all to complete
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('executes tasks for different chatIds in parallel', async () => {
|
||||
const order: string[] = []
|
||||
|
||||
const p1 = enqueue('chat-a', async () => {
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
order.push('a')
|
||||
})
|
||||
|
||||
const p2 = enqueue('chat-b', async () => {
|
||||
order.push('b') // should run immediately, not wait for chat-a
|
||||
})
|
||||
|
||||
await Promise.all([p1, p2])
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// 'b' should appear before 'a' since chat-a has a delay
|
||||
expect(order[0]).toBe('b')
|
||||
expect(order[1]).toBe('a')
|
||||
})
|
||||
|
||||
it('continues processing after a task fails', async () => {
|
||||
const order: number[] = []
|
||||
|
||||
await enqueue('chat-err', async () => {
|
||||
order.push(1)
|
||||
throw new Error('task failed')
|
||||
})
|
||||
|
||||
await enqueue('chat-err', async () => {
|
||||
order.push(2) // should still run
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
formatImHelp,
|
||||
formatImStatus,
|
||||
splitMessage,
|
||||
formatToolUse,
|
||||
formatPermissionRequest,
|
||||
truncateInput,
|
||||
escapeMarkdownV2,
|
||||
} from '../format.js'
|
||||
|
||||
describe('splitMessage', () => {
|
||||
it('returns single chunk for short text', () => {
|
||||
expect(splitMessage('hello', 100)).toEqual(['hello'])
|
||||
})
|
||||
|
||||
it('splits at paragraph boundary', () => {
|
||||
const text = 'First paragraph.\n\nSecond paragraph.'
|
||||
const chunks = splitMessage(text, 20)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
|
||||
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
|
||||
})
|
||||
|
||||
it('splits at newline if no paragraph break', () => {
|
||||
const text = 'Line one\nLine two\nLine three\nLine four'
|
||||
const chunks = splitMessage(text, 20)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('hard-splits at limit if no natural break', () => {
|
||||
const text = 'a'.repeat(50)
|
||||
const chunks = splitMessage(text, 20)
|
||||
expect(chunks.length).toBe(3) // 20 + 20 + 10
|
||||
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves all content after splitting', () => {
|
||||
const text = 'Hello world. This is a test. Foo bar baz.'
|
||||
const chunks = splitMessage(text, 15)
|
||||
const joined = chunks.join(' ')
|
||||
// All words should be present
|
||||
expect(joined).toContain('Hello')
|
||||
expect(joined).toContain('test')
|
||||
expect(joined).toContain('baz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatToolUse', () => {
|
||||
it('includes tool name and input preview', () => {
|
||||
const result = formatToolUse('Bash', { command: 'npm test' })
|
||||
expect(result).toContain('🔧 Bash')
|
||||
expect(result).toContain('npm test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPermissionRequest', () => {
|
||||
it('includes tool name, input preview, and request ID', () => {
|
||||
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
|
||||
expect(result).toContain('🔐')
|
||||
expect(result).toContain('Bash')
|
||||
expect(result).toContain('abcde')
|
||||
expect(result).toContain('rm -rf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateInput', () => {
|
||||
it('returns short input as-is', () => {
|
||||
expect(truncateInput('hello', 100)).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates long input with ellipsis', () => {
|
||||
const long = 'x'.repeat(300)
|
||||
const result = truncateInput(long, 100)
|
||||
expect(result.length).toBe(101) // 100 chars + '…'
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles objects by stringifying', () => {
|
||||
const result = truncateInput({ key: 'value' }, 100)
|
||||
expect(result).toContain('key')
|
||||
expect(result).toContain('value')
|
||||
})
|
||||
|
||||
it('handles unserializable input', () => {
|
||||
const circular: any = {}
|
||||
circular.self = circular
|
||||
expect(truncateInput(circular, 100)).toBe('(unserializable)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeMarkdownV2', () => {
|
||||
it('escapes special characters', () => {
|
||||
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
||||
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
|
||||
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
|
||||
})
|
||||
|
||||
it('leaves plain text unchanged', () => {
|
||||
expect(escapeMarkdownV2('hello world')).toBe('hello world')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatImHelp', () => {
|
||||
it('lists the lightweight IM commands', () => {
|
||||
const text = formatImHelp()
|
||||
expect(text).toContain('/new')
|
||||
expect(text).toContain('/projects')
|
||||
expect(text).toContain('/status')
|
||||
expect(text).toContain('/clear')
|
||||
expect(text).toContain('/stop')
|
||||
expect(text).toContain('/help')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatImStatus', () => {
|
||||
it('formats an active session summary for mobile reading', () => {
|
||||
const text = formatImStatus({
|
||||
sessionId: 'abc1234567890',
|
||||
projectName: 'claude-code-haha',
|
||||
branch: 'main',
|
||||
model: 'claude-sonnet',
|
||||
state: 'tool_executing',
|
||||
verb: 'Running tests',
|
||||
pendingPermissionCount: 1,
|
||||
taskCounts: {
|
||||
total: 4,
|
||||
pending: 1,
|
||||
inProgress: 2,
|
||||
completed: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(text).toContain('项目: claude-code-haha (main)')
|
||||
expect(text).toContain('会话: abc12345…')
|
||||
expect(text).toContain('模型: claude-sonnet')
|
||||
expect(text).toContain('状态: 执行工具中 (Running tests)')
|
||||
expect(text).toContain('审批: 1 个待确认')
|
||||
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
|
||||
})
|
||||
|
||||
it('returns a friendly empty-session message when nothing is active', () => {
|
||||
const text = formatImStatus(null)
|
||||
expect(text).toContain('当前没有活动会话')
|
||||
expect(text).toContain('/new')
|
||||
expect(text).toContain('/projects')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import { AdapterHttpClient } from '../http-client.js'
|
||||
|
||||
describe('AdapterHttpClient', () => {
|
||||
let client: AdapterHttpClient
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
client = new AdapterHttpClient('ws://127.0.0.1:3456')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('derives HTTP URL from WS URL', () => {
|
||||
expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456')
|
||||
|
||||
const secure = new AdapterHttpClient('wss://example.com:443')
|
||||
expect(secure.httpBaseUrl).toBe('https://example.com:443')
|
||||
})
|
||||
|
||||
it('createSession calls POST /api/sessions', async () => {
|
||||
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const sessionId = await client.createSession('/path/to/project')
|
||||
expect(sessionId).toBe(mockSessionId)
|
||||
|
||||
const call = (globalThis.fetch as any).mock.calls[0]
|
||||
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
|
||||
const body = JSON.parse(call[1].body)
|
||||
expect(body.workDir).toBe('/path/to/project')
|
||||
})
|
||||
|
||||
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
|
||||
const mockProjects = [
|
||||
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
|
||||
]
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const projects = await client.listRecentProjects()
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0].projectName).toBe('my-app')
|
||||
})
|
||||
|
||||
it('createSession throws on server error', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
expect(client.createSession('')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
branch: 'main',
|
||||
repoName: 'claude-code-haha',
|
||||
workDir: '/repo/claude-code-haha',
|
||||
changedFiles: 2,
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const gitInfo = await client.getGitInfo('session-123')
|
||||
expect(gitInfo.repoName).toBe('claude-code-haha')
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
'http://127.0.0.1:3456/api/sessions/session-123/git-info',
|
||||
)
|
||||
})
|
||||
|
||||
it('getTasksForSession calls GET /api/tasks/lists/:id', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({
|
||||
tasks: [
|
||||
{ id: '1', subject: 'Fix bug', status: 'in_progress' },
|
||||
{ id: '2', subject: 'Write docs', status: 'pending' },
|
||||
],
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
) as any
|
||||
|
||||
const tasks = await client.getTasksForSession('session-123')
|
||||
expect(tasks).toHaveLength(2)
|
||||
expect(tasks[0]?.status).toBe('in_progress')
|
||||
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
|
||||
'http://127.0.0.1:3456/api/tasks/lists/session-123',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach } from 'bun:test'
|
||||
import { MessageBuffer } from '../message-buffer.js'
|
||||
|
||||
describe('MessageBuffer', () => {
|
||||
it('accumulates text and flushes on complete', async () => {
|
||||
const flushed: Array<{ text: string; isComplete: boolean }> = []
|
||||
const buf = new MessageBuffer(
|
||||
(text, isComplete) => { flushed.push({ text, isComplete }) },
|
||||
500, // 500ms interval
|
||||
1000, // 1000 char threshold
|
||||
)
|
||||
|
||||
buf.append('Hello ')
|
||||
buf.append('World')
|
||||
await buf.complete()
|
||||
|
||||
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
||||
const allText = flushed.map((f) => f.text).join('')
|
||||
expect(allText).toBe('Hello World')
|
||||
// Last flush should be marked complete
|
||||
expect(flushed[flushed.length - 1]!.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes when character threshold is reached', async () => {
|
||||
const flushed: string[] = []
|
||||
const buf = new MessageBuffer(
|
||||
(text) => { flushed.push(text) },
|
||||
10000, // very long interval (won't trigger)
|
||||
10, // 10 char threshold
|
||||
)
|
||||
|
||||
buf.append('12345678901') // 11 chars > threshold
|
||||
|
||||
// Wait for microtask
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(flushed.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
buf.reset()
|
||||
})
|
||||
|
||||
it('flushes on timer interval', async () => {
|
||||
const flushed: string[] = []
|
||||
const buf = new MessageBuffer(
|
||||
(text) => { flushed.push(text) },
|
||||
50, // 50ms interval
|
||||
1000,
|
||||
)
|
||||
|
||||
buf.append('hi')
|
||||
|
||||
// Wait for timer
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
expect(flushed).toContain('hi')
|
||||
|
||||
buf.reset()
|
||||
})
|
||||
|
||||
it('does not flush empty buffer on complete', async () => {
|
||||
const flushed: string[] = []
|
||||
const buf = new MessageBuffer(
|
||||
(text) => { flushed.push(text) },
|
||||
)
|
||||
|
||||
await buf.complete()
|
||||
expect(flushed.length).toBe(0)
|
||||
})
|
||||
|
||||
it('resets properly between messages', async () => {
|
||||
const flushed: string[] = []
|
||||
const buf = new MessageBuffer(
|
||||
(text) => { flushed.push(text) },
|
||||
500,
|
||||
1000,
|
||||
)
|
||||
|
||||
buf.append('first')
|
||||
buf.reset()
|
||||
buf.append('second')
|
||||
await buf.complete()
|
||||
|
||||
const allText = flushed.map((f) => f).join('')
|
||||
expect(allText).toBe('second')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { MessageDedup } from '../message-dedup.js'
|
||||
|
||||
describe('MessageDedup', () => {
|
||||
let dedup: MessageDedup
|
||||
|
||||
beforeEach(() => {
|
||||
dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dedup.destroy()
|
||||
})
|
||||
|
||||
it('returns true for new messages', () => {
|
||||
expect(dedup.tryRecord('msg-1')).toBe(true)
|
||||
expect(dedup.tryRecord('msg-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for duplicate messages', () => {
|
||||
expect(dedup.tryRecord('msg-1')).toBe(true)
|
||||
expect(dedup.tryRecord('msg-1')).toBe(false)
|
||||
expect(dedup.tryRecord('msg-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows same ID after TTL expires', async () => {
|
||||
const shortDedup = new MessageDedup(50, 100) // 50ms TTL
|
||||
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
||||
expect(shortDedup.tryRecord('msg-1')).toBe(false)
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
expect(shortDedup.tryRecord('msg-1')).toBe(true)
|
||||
shortDedup.destroy()
|
||||
})
|
||||
|
||||
it('evicts oldest entry when at capacity', () => {
|
||||
const smallDedup = new MessageDedup(60_000, 3) // max 3 entries
|
||||
expect(smallDedup.tryRecord('a')).toBe(true)
|
||||
expect(smallDedup.tryRecord('b')).toBe(true)
|
||||
expect(smallDedup.tryRecord('c')).toBe(true)
|
||||
// Adding 4th should evict 'a'
|
||||
expect(smallDedup.tryRecord('d')).toBe(true)
|
||||
// 'a' was evicted, should be treated as new
|
||||
expect(smallDedup.tryRecord('a')).toBe(true)
|
||||
// Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted
|
||||
// 'c' should still be deduped (was not evicted)
|
||||
expect(smallDedup.tryRecord('c')).toBe(false)
|
||||
smallDedup.destroy()
|
||||
})
|
||||
|
||||
it('handles distinct messages independently', () => {
|
||||
expect(dedup.tryRecord('msg-1')).toBe(true)
|
||||
expect(dedup.tryRecord('msg-2')).toBe(true)
|
||||
expect(dedup.tryRecord('msg-1')).toBe(false)
|
||||
expect(dedup.tryRecord('msg-2')).toBe(false)
|
||||
expect(dedup.tryRecord('msg-3')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionStore } from '../session-store.js'
|
||||
|
||||
describe('SessionStore', () => {
|
||||
let tmpDir: string
|
||||
let store: SessionStore
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-'))
|
||||
store = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null for unknown chatId', () => {
|
||||
expect(store.get('unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('stores and retrieves a session', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path/to/project')
|
||||
const entry = store.get('chat-1')
|
||||
expect(entry).not.toBeNull()
|
||||
expect(entry!.sessionId).toBe('uuid-aaa')
|
||||
expect(entry!.workDir).toBe('/path/to/project')
|
||||
})
|
||||
|
||||
it('overwrites existing entry on set', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/old')
|
||||
store.set('chat-1', 'uuid-bbb', '/new')
|
||||
expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb')
|
||||
})
|
||||
|
||||
it('deletes an entry', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path')
|
||||
store.delete('chat-1')
|
||||
expect(store.get('chat-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists to disk and reloads', () => {
|
||||
store.set('chat-1', 'uuid-aaa', '/path')
|
||||
|
||||
const store2 = new SessionStore(path.join(tmpDir, 'sessions.json'))
|
||||
expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa')
|
||||
})
|
||||
|
||||
it('handles missing file gracefully', () => {
|
||||
const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json'))
|
||||
expect(store2.get('anything')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists all entries', () => {
|
||||
store.set('chat-1', 'uuid-1', '/a')
|
||||
store.set('chat-2', 'uuid-2', '/b')
|
||||
const all = store.listAll()
|
||||
expect(all).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { WsBridge } from '../ws-bridge.js'
|
||||
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
|
||||
|
||||
describe('WsBridge', () => {
|
||||
let bridge: WsBridge
|
||||
|
||||
beforeEach(() => {
|
||||
bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
it('connectSession connects with provided sessionId', () => {
|
||||
const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
|
||||
expect(result).toBe(true)
|
||||
expect(bridge.hasSession('chat-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('connectSession for different chatIds creates separate sessions', () => {
|
||||
bridge.connectSession('chat-1', 'uuid-1')
|
||||
bridge.connectSession('chat-2', 'uuid-2')
|
||||
expect(bridge.hasSession('chat-1')).toBe(true)
|
||||
expect(bridge.hasSession('chat-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('resetSession removes the session', () => {
|
||||
bridge.connectSession('chat-reset', 'uuid-reset')
|
||||
bridge.resetSession('chat-reset')
|
||||
expect(bridge.hasSession('chat-reset')).toBe(false)
|
||||
})
|
||||
|
||||
it('sendUserMessage returns false when no open connection', () => {
|
||||
bridge.connectSession('chat-offline', 'uuid-offline')
|
||||
expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
|
||||
})
|
||||
|
||||
it('sendPermissionResponse returns false when no open connection', () => {
|
||||
bridge.connectSession('chat-perm', 'uuid-perm')
|
||||
expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('sendStopGeneration returns false when no open connection', () => {
|
||||
bridge.connectSession('chat-stop', 'uuid-stop')
|
||||
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
|
||||
})
|
||||
|
||||
it('destroy cleans up all sessions', () => {
|
||||
bridge.connectSession('a', 'uuid-a')
|
||||
bridge.connectSession('b', 'uuid-b')
|
||||
bridge.destroy()
|
||||
expect(bridge.hasSession('a')).toBe(false)
|
||||
expect(bridge.hasSession('b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: per-chat handler serialization
|
||||
//
|
||||
// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
|
||||
// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
|
||||
// state msg 1's continuation is about to clear.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('WsBridge: handler serialization', () => {
|
||||
let server: WebSocketServer
|
||||
let port: number
|
||||
let connections: WsServerSocket[]
|
||||
let serverUrl: string
|
||||
|
||||
beforeEach(async () => {
|
||||
connections = []
|
||||
// port 0 → let the OS pick a free one
|
||||
server = new WebSocketServer({ port: 0 })
|
||||
server.on('connection', (ws) => {
|
||||
connections.push(ws)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
|
||||
port = (server.address() as { port: number }).port
|
||||
serverUrl = `ws://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Forcibly kill any server-side sockets (not graceful close) so
|
||||
// WebSocketServer.close() doesn't wait for client FIN.
|
||||
for (const ws of connections) {
|
||||
try { ws.terminate() } catch {}
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(() => resolve(), 500) // hard cap
|
||||
server.close(() => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('processes handler calls in strict FIFO order per chatId', async () => {
|
||||
const bridge = new WsBridge(serverUrl, 'test')
|
||||
const events: string[] = []
|
||||
|
||||
// The handler simulates an async side effect that takes varying time.
|
||||
// If handlers ran concurrently, fast msgs could finish before slow ones,
|
||||
// producing an out-of-order `events` array.
|
||||
bridge.onServerMessage('chat-1', async (msg: any) => {
|
||||
const tag = msg.tag as string
|
||||
const delay = msg.delay as number
|
||||
events.push(`start:${tag}`)
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
events.push(`end:${tag}`)
|
||||
})
|
||||
|
||||
bridge.connectSession('chat-1', 'sess-1')
|
||||
const ok = await bridge.waitForOpen('chat-1')
|
||||
expect(ok).toBe(true)
|
||||
expect(connections.length).toBe(1)
|
||||
const serverWs = connections[0]!
|
||||
|
||||
// Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
|
||||
// With serialization: start:1, end:1, start:2, end:2, start:3, end:3
|
||||
// Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
|
||||
serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
|
||||
serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
|
||||
serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
|
||||
|
||||
// Wait long enough for all three handlers to run serially
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
expect(events).toEqual([
|
||||
'start:1', 'end:1',
|
||||
'start:2', 'end:2',
|
||||
'start:3', 'end:3',
|
||||
])
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
it('handler error does not break the chain (subsequent messages still run)', async () => {
|
||||
const bridge = new WsBridge(serverUrl, 'test')
|
||||
const events: string[] = []
|
||||
|
||||
bridge.onServerMessage('chat-err', async (msg: any) => {
|
||||
if (msg.throw) {
|
||||
events.push('throwing')
|
||||
throw new Error('boom')
|
||||
}
|
||||
events.push(`ok:${msg.tag}`)
|
||||
})
|
||||
|
||||
bridge.connectSession('chat-err', 'sess-err')
|
||||
await bridge.waitForOpen('chat-err')
|
||||
const serverWs = connections[0]!
|
||||
|
||||
serverWs.send(JSON.stringify({ throw: true }))
|
||||
serverWs.send(JSON.stringify({ tag: 'after' }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
|
||||
expect(events).toEqual(['throwing', 'ok:after'])
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
it('resetSession clears the handler chain', async () => {
|
||||
const bridge = new WsBridge(serverUrl, 'test')
|
||||
bridge.onServerMessage('chat-reset', () => {})
|
||||
bridge.connectSession('chat-reset', 'sess-reset')
|
||||
await bridge.waitForOpen('chat-reset')
|
||||
|
||||
bridge.resetSession('chat-reset')
|
||||
expect(bridge.hasSession('chat-reset')).toBe(false)
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
checkAttachmentLimit,
|
||||
IMAGE_MAX_BYTES,
|
||||
FILE_MAX_BYTES,
|
||||
IMAGE_MIME_WHITELIST,
|
||||
} from '../attachment-limits.js'
|
||||
|
||||
describe('checkAttachmentLimit', () => {
|
||||
it('accepts a 1 MB PNG image', () => {
|
||||
const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an 11 MB image as too_large', () => {
|
||||
const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe('too_large')
|
||||
expect(result.hint).toContain('10')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unsupported image mime', () => {
|
||||
const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
||||
})
|
||||
|
||||
it('rejects image/heic (not supported by Claude API)', () => {
|
||||
const result = checkAttachmentLimit('image', 500_000, 'image/heic')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('unsupported_mime')
|
||||
})
|
||||
|
||||
it('accepts a 10 MB PDF file', () => {
|
||||
const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a 31 MB file as too_large', () => {
|
||||
const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('too_large')
|
||||
})
|
||||
|
||||
it('exposes the limits as exports', () => {
|
||||
expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
|
||||
expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
|
||||
expect(IMAGE_MIME_WHITELIST).toContain('image/png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as fsSync from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { AttachmentStore } from '../attachment-store.js'
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('AttachmentStore', () => {
|
||||
it('writes a buffer and returns the absolute path', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
|
||||
const written = await store.write(target, Buffer.from('PNGDATA'))
|
||||
expect(path.isAbsolute(written)).toBe(true)
|
||||
const content = await fs.readFile(written)
|
||||
expect(content.toString()).toBe('PNGDATA')
|
||||
})
|
||||
|
||||
it('writes under {root}/{platform}/{sessionId}/', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
|
||||
expect(target).toContain(path.join('telegram', 'sess-42'))
|
||||
expect(target.endsWith('foo.pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
|
||||
// The resulting target must still live inside the store root.
|
||||
const root = path.resolve(tmpRoot)
|
||||
expect(path.resolve(target).startsWith(root)).toBe(true)
|
||||
expect(path.basename(target)).not.toContain('..')
|
||||
expect(path.basename(target)).not.toContain('/')
|
||||
})
|
||||
|
||||
it('collapses name collisions by prefixing timestamps', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const a = store.resolvePath('feishu', 'sess-1', 'image.png')
|
||||
await store.write(a, Buffer.from('first'))
|
||||
const b = store.resolvePath('feishu', 'sess-1', 'image.png')
|
||||
expect(b).not.toBe(a)
|
||||
await store.write(b, Buffer.from('second'))
|
||||
const contentB = await fs.readFile(b)
|
||||
expect(contentB.toString()).toBe('second')
|
||||
})
|
||||
|
||||
it('gc() removes files older than retentionMs and reports counts', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
|
||||
const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
|
||||
await store.write(target, Buffer.from('STALE'))
|
||||
// Age the file manually
|
||||
const past = new Date(Date.now() - 10_000)
|
||||
await fs.utimes(target, past, past)
|
||||
const result = await store.gc()
|
||||
expect(result.removed).toBe(1)
|
||||
expect(result.bytes).toBe(5)
|
||||
await expect(fs.access(target)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('gc() keeps fresh files', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
|
||||
await store.write(target, Buffer.from('FRESH'))
|
||||
const result = await store.gc()
|
||||
expect(result.removed).toBe(0)
|
||||
await fs.access(target)
|
||||
})
|
||||
|
||||
it('resolvePath under heavy collision pressure returns unique paths', () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
// First create a file so subsequent resolves hit the collision branch
|
||||
const a = store.resolvePath('feishu', 'sess-1', 'race.png')
|
||||
fsSync.writeFileSync(a, 'first')
|
||||
// Collect 50 resolved paths in a tight loop — none should clash
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
|
||||
}
|
||||
expect(seen.size).toBe(50)
|
||||
for (const p of seen) {
|
||||
expect(p).not.toBe(a)
|
||||
}
|
||||
})
|
||||
|
||||
it('gc() cleans orphan .part files after a short grace period', async () => {
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
|
||||
// Simulate a crashed write — leave a .part tmp file behind
|
||||
const dir = path.join(tmpRoot, 'feishu', 'sess-1')
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const orphan = path.join(dir, 'image.png.1234.5678.part')
|
||||
await fs.writeFile(orphan, 'ORPHAN')
|
||||
// Age the orphan so gc considers it stale
|
||||
const past = new Date(Date.now() - 1000)
|
||||
await fs.utimes(orphan, past, past)
|
||||
const result = await store.gc()
|
||||
expect(result.removed).toBeGreaterThanOrEqual(1)
|
||||
await expect(fs.access(orphan)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { ImageBlockWatcher } from '../image-block-watcher.js'
|
||||
|
||||
describe('ImageBlockWatcher', () => {
|
||||
it('extracts a markdown image with http URL', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('Here is  an image.')
|
||||
expect(out.length).toBe(1)
|
||||
const source = out[0]!.source
|
||||
expect(source.kind).toBe('url')
|
||||
if (source.kind === 'url') {
|
||||
expect(source.url).toBe('https://example.com/foo.png')
|
||||
}
|
||||
expect(out[0]!.alt).toBe('alt')
|
||||
})
|
||||
|
||||
it('extracts a markdown image with absolute local path', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('')
|
||||
expect(out.length).toBe(1)
|
||||
const source = out[0]!.source
|
||||
expect(source.kind).toBe('path')
|
||||
if (source.kind === 'path') {
|
||||
expect(source.path).toBe('/tmp/cat.jpg')
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts a markdown image with file:// URL as path', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('')
|
||||
const source = out[0]!.source
|
||||
expect(source.kind).toBe('path')
|
||||
if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png')
|
||||
})
|
||||
|
||||
it('extracts a data URI as base64', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('')
|
||||
const source = out[0]!.source
|
||||
expect(source.kind).toBe('base64')
|
||||
if (source.kind === 'base64') {
|
||||
expect(source.mime).toBe('image/png')
|
||||
expect(source.data).toBe('AAAA')
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates the same image across multiple feeds', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const a = w.feed('')
|
||||
const b = w.feed(' repeated  again')
|
||||
expect(a.length).toBe(1)
|
||||
expect(b.length).toBe(0)
|
||||
})
|
||||
|
||||
it('handles images split across feed boundaries', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const a = w.feed('a  b')
|
||||
expect(a.length).toBe(0)
|
||||
expect(b.length).toBe(1)
|
||||
const source = b[0]!.source
|
||||
expect(source.kind).toBe('path')
|
||||
if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png')
|
||||
})
|
||||
|
||||
it('skips non-image markdown links', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('See [docs](https://example.com).')
|
||||
expect(out.length).toBe(0)
|
||||
})
|
||||
|
||||
it('drain() returns all accumulated uploads', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
w.feed('')
|
||||
w.feed(' and ')
|
||||
const all = w.drain()
|
||||
expect(all.length).toBe(2)
|
||||
})
|
||||
|
||||
it('reset() clears buffer, seen set, and accumulated list', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
w.feed('')
|
||||
w.reset()
|
||||
// After reset, drain() is empty
|
||||
expect(w.drain().length).toBe(0)
|
||||
// And re-feeding the same image yields a fresh emit (dedup state cleared)
|
||||
const out = w.feed('')
|
||||
expect(out.length).toBe(1)
|
||||
})
|
||||
|
||||
it('skips relative paths (cannot be resolved safely)', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed(' and ')
|
||||
expect(out.length).toBe(1)
|
||||
const source = out[0]!.source
|
||||
expect(source.kind).toBe('path')
|
||||
if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
|
||||
})
|
||||
|
||||
it('extracts multiple images from a single feed chunk in order', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('  ')
|
||||
expect(out.length).toBe(3)
|
||||
expect(out[0]!.source.kind).toBe('path')
|
||||
expect(out[1]!.source.kind).toBe('url')
|
||||
expect(out[2]!.source.kind).toBe('base64')
|
||||
})
|
||||
|
||||
it('rejects malformed data URI (not base64)', () => {
|
||||
const w = new ImageBlockWatcher()
|
||||
const out = w.feed('')
|
||||
// Not in `;base64,` form → classify returns null → skipped
|
||||
expect(out.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Size and MIME restrictions for IM attachments.
|
||||
*
|
||||
* Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file)
|
||||
* and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's
|
||||
* own image size bounds.
|
||||
*/
|
||||
|
||||
export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||
export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB
|
||||
|
||||
export const IMAGE_MIME_WHITELIST = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
] as const
|
||||
|
||||
export type LimitCheckResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string }
|
||||
|
||||
function formatMb(bytes: number): string {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
export function checkAttachmentLimit(
|
||||
kind: 'image' | 'file',
|
||||
size: number,
|
||||
mime?: string,
|
||||
): LimitCheckResult {
|
||||
if (kind === 'image') {
|
||||
if (size > IMAGE_MAX_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'too_large',
|
||||
hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`,
|
||||
}
|
||||
}
|
||||
if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unsupported_mime',
|
||||
hint: `📎 暂不支持此图片格式(${mime})`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
// kind === 'file'
|
||||
if (size > FILE_MAX_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'too_large',
|
||||
hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Local staging directory for IM-downloaded resources.
|
||||
*
|
||||
* Layout: {root}/{platform}/{sessionId}/{safeName}
|
||||
* Default root: ~/.claude/im-downloads
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Generate unique, safe paths from (platform, sessionId, originalName)
|
||||
* - Atomic write (tmp → rename) so concurrent downloads never corrupt each other
|
||||
* - GC files that haven't been touched for `retentionMs` (default 24h)
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as fsSync from 'node:fs'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import type { ImPlatform } from './attachment-types.js'
|
||||
|
||||
export interface AttachmentStoreConfig {
|
||||
root: string
|
||||
retentionMs: number
|
||||
/** Grace window before a `.part` orphan (left behind by a crashed writer)
|
||||
* is eligible for GC. Default 10 minutes. */
|
||||
orphanGraceMs: number
|
||||
}
|
||||
|
||||
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000
|
||||
const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000
|
||||
|
||||
function defaultRoot(): string {
|
||||
return path.join(os.homedir(), '.claude', 'im-downloads')
|
||||
}
|
||||
|
||||
/** Strip path separators / .. / control chars from a filename. */
|
||||
function sanitizeFilename(name: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '')
|
||||
const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_')
|
||||
return cleaned.trim() || 'unnamed'
|
||||
}
|
||||
|
||||
export class AttachmentStore {
|
||||
private readonly root: string
|
||||
private readonly retentionMs: number
|
||||
private readonly orphanGraceMs: number
|
||||
|
||||
constructor(config?: Partial<AttachmentStoreConfig>) {
|
||||
this.root = config?.root ?? defaultRoot()
|
||||
this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS
|
||||
this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS
|
||||
}
|
||||
|
||||
/** Compute the target path. Creates parent dirs on demand.
|
||||
* If a file with the same name already exists, prefix with a timestamp
|
||||
* to avoid clobbering. */
|
||||
resolvePath(platform: ImPlatform, sessionId: string, name: string): string {
|
||||
const safeSession = sanitizeFilename(sessionId)
|
||||
const dir = path.join(this.root, platform, safeSession)
|
||||
fsSync.mkdirSync(dir, { recursive: true })
|
||||
const safeName = sanitizeFilename(name)
|
||||
const candidate = path.join(dir, safeName)
|
||||
if (!fsSync.existsSync(candidate)) return candidate
|
||||
const { name: base, ext } = path.parse(safeName)
|
||||
// Collisions are rare in practice, but multiple downloads landing in the
|
||||
// same millisecond must still produce unique paths — append a random
|
||||
// suffix so the bare timestamp alone never clashes.
|
||||
const rand = Math.random().toString(36).slice(2, 8)
|
||||
return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`)
|
||||
}
|
||||
|
||||
/** Write atomically: stream to {target}.part, then rename. */
|
||||
async write(target: string, data: Buffer): Promise<string> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.part`
|
||||
await fs.writeFile(tmp, data)
|
||||
await fs.rename(tmp, target)
|
||||
return target
|
||||
}
|
||||
|
||||
/** Remove files older than retentionMs. Returns summary. */
|
||||
async gc(): Promise<{ removed: number; bytes: number }> {
|
||||
let removed = 0
|
||||
let bytes = 0
|
||||
const now = Date.now()
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: Dirent<string>[]
|
||||
try {
|
||||
// Pass encoding explicitly so Dirent stays string-typed under
|
||||
// newer @types/node where the Buffer overload becomes the default.
|
||||
entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full)
|
||||
} else if (entry.isFile()) {
|
||||
try {
|
||||
const stat = await fs.stat(full)
|
||||
const age = now - stat.mtimeMs
|
||||
const isOrphanPart = entry.name.endsWith('.part')
|
||||
const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs
|
||||
if (age > threshold) {
|
||||
bytes += stat.size
|
||||
await fs.unlink(full)
|
||||
removed++
|
||||
}
|
||||
} catch {
|
||||
// ignore races
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(this.root).catch(() => {})
|
||||
return { removed, bytes }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared attachment types for IM adapters.
|
||||
*/
|
||||
|
||||
import type { AttachmentRef } from '../ws-bridge.js'
|
||||
export type { AttachmentRef }
|
||||
|
||||
/** Platform tag — used for local staging subdir and telemetry. */
|
||||
export type ImPlatform = 'feishu' | 'telegram'
|
||||
|
||||
/** Result of downloading an IM resource into the local stage dir. */
|
||||
export interface LocalAttachment {
|
||||
kind: 'image' | 'file'
|
||||
name: string // original filename, or synthesized if none
|
||||
path: string // absolute path on disk (under ~/.claude/im-downloads)
|
||||
size: number // bytes
|
||||
mimeType: string // detected or provided
|
||||
buffer: Buffer // raw bytes (kept so caller can choose base64 vs path)
|
||||
}
|
||||
|
||||
/** Pending outbound media found in Agent stream output. */
|
||||
export interface PendingUpload {
|
||||
id: string // fingerprint, used for dedup
|
||||
source:
|
||||
| { kind: 'base64'; data: string; mime: string }
|
||||
| { kind: 'path'; path: string; mime?: string }
|
||||
| { kind: 'url'; url: string; mime?: string }
|
||||
alt?: string
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure, stateful extractor that watches a stream of assistant text for
|
||||
* markdown image references (``) and emits PendingUpload
|
||||
* records. Used by IM adapters to know which images to upload to IM.
|
||||
*
|
||||
* - Buffers input so an image marker split across multiple feed() calls
|
||||
* still gets detected.
|
||||
* - Dedups by fingerprint of the source so the same image is only emitted
|
||||
* once per watcher lifetime.
|
||||
*/
|
||||
|
||||
import type { PendingUpload } from './attachment-types.js'
|
||||
|
||||
// Matches a complete markdown image: 
|
||||
// `alt` may be empty; `target` stops at the first closing paren.
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
|
||||
|
||||
function fingerprint(raw: string): string {
|
||||
let h = 5381
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
h = ((h << 5) + h) ^ raw.charCodeAt(i)
|
||||
}
|
||||
return (h >>> 0).toString(16)
|
||||
}
|
||||
|
||||
function classify(target: string): PendingUpload['source'] | null {
|
||||
if (target.startsWith('data:')) {
|
||||
const m = /^data:([^;,]+);base64,(.+)$/.exec(target)
|
||||
if (!m) return null
|
||||
return { kind: 'base64', mime: m[1]!, data: m[2]! }
|
||||
}
|
||||
if (target.startsWith('file://')) {
|
||||
return { kind: 'path', path: target.slice('file://'.length) }
|
||||
}
|
||||
if (target.startsWith('http://') || target.startsWith('https://')) {
|
||||
return { kind: 'url', url: target }
|
||||
}
|
||||
if (target.startsWith('/')) {
|
||||
return { kind: 'path', path: target }
|
||||
}
|
||||
return null // relative paths — skip, we can't resolve them safely
|
||||
}
|
||||
|
||||
export class ImageBlockWatcher {
|
||||
private buffer = ''
|
||||
private seen = new Set<string>()
|
||||
private accumulated: PendingUpload[] = []
|
||||
|
||||
/** Feed a new chunk of streaming text; returns any NEW PendingUploads. */
|
||||
feed(chunk: string): PendingUpload[] {
|
||||
this.buffer += chunk
|
||||
const out: PendingUpload[] = []
|
||||
|
||||
IMAGE_RE.lastIndex = 0
|
||||
let lastConsumedEnd = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = IMAGE_RE.exec(this.buffer)) !== null) {
|
||||
const [, alt, target] = m
|
||||
const source = classify(target!)
|
||||
if (source) {
|
||||
const id = fingerprint(`${source.kind}:${target}`)
|
||||
if (!this.seen.has(id)) {
|
||||
this.seen.add(id)
|
||||
const pending: PendingUpload = { id, source, alt: alt || undefined }
|
||||
out.push(pending)
|
||||
this.accumulated.push(pending)
|
||||
}
|
||||
}
|
||||
lastConsumedEnd = m.index + m[0].length
|
||||
}
|
||||
|
||||
// Preserve tail that might contain a partially-received marker.
|
||||
if (lastConsumedEnd > 0) {
|
||||
this.buffer = this.buffer.slice(lastConsumedEnd)
|
||||
}
|
||||
if (this.buffer.length > 4096) {
|
||||
this.buffer = this.buffer.slice(-2048)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Return everything seen so far (for end-of-stream reconciliation). */
|
||||
drain(): PendingUpload[] {
|
||||
return [...this.accumulated]
|
||||
}
|
||||
|
||||
/** Reset watcher state (use at /clear or new session). */
|
||||
reset(): void {
|
||||
this.buffer = ''
|
||||
this.seen.clear()
|
||||
this.accumulated = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 会话串行队列
|
||||
*
|
||||
* 同一 chatId 的消息串行处理,防并发冲突。
|
||||
* 不同 chatId 之间互不影响。
|
||||
* 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。
|
||||
*/
|
||||
|
||||
const queues = new Map<string, Promise<void>>()
|
||||
|
||||
export async function enqueue(chatId: string, fn: () => Promise<void>): Promise<void> {
|
||||
const prev = queues.get(chatId) ?? Promise.resolve()
|
||||
const next = prev.then(fn, () => fn()).catch((err) => {
|
||||
console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err)
|
||||
})
|
||||
queues.set(chatId, next)
|
||||
// Clean up after completion to avoid memory leak for one-off chats
|
||||
next.finally(() => {
|
||||
if (queues.get(chatId) === next) {
|
||||
queues.delete(chatId)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Adapter 配置加载
|
||||
*
|
||||
* 优先级:环境变量 > ~/.claude/adapters.json > 默认值
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
export type PairedUser = {
|
||||
userId: string | number
|
||||
displayName: string
|
||||
pairedAt: number
|
||||
}
|
||||
|
||||
export type PairingState = {
|
||||
code: string | null
|
||||
expiresAt: number | null
|
||||
createdAt: number | null
|
||||
}
|
||||
|
||||
export type TelegramConfig = {
|
||||
botToken: string
|
||||
allowedUsers: number[]
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
}
|
||||
|
||||
export type FeishuConfig = {
|
||||
appId: string
|
||||
appSecret: string
|
||||
encryptKey: string
|
||||
verificationToken: string
|
||||
allowedUsers: string[]
|
||||
pairedUsers: PairedUser[]
|
||||
defaultWorkDir: string
|
||||
streamingCard: boolean
|
||||
}
|
||||
|
||||
export type AdapterConfig = {
|
||||
serverUrl: string
|
||||
defaultProjectDir: string
|
||||
pairing: PairingState
|
||||
telegram: TelegramConfig
|
||||
feishu: FeishuConfig
|
||||
}
|
||||
|
||||
function getConfigPath(): string {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'adapters.json')
|
||||
}
|
||||
|
||||
function loadFile(): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') {
|
||||
console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(): AdapterConfig {
|
||||
const file = loadFile()
|
||||
const tg = file.telegram ?? {}
|
||||
const fs_ = file.feishu ?? {}
|
||||
const pairing = file.pairing ?? {}
|
||||
|
||||
return {
|
||||
serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456',
|
||||
defaultProjectDir: file.defaultProjectDir || '',
|
||||
pairing: {
|
||||
code: pairing.code ?? null,
|
||||
expiresAt: pairing.expiresAt ?? null,
|
||||
createdAt: pairing.createdAt ?? null,
|
||||
},
|
||||
telegram: {
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '',
|
||||
allowedUsers: tg.allowedUsers ?? [],
|
||||
pairedUsers: tg.pairedUsers ?? [],
|
||||
defaultWorkDir: tg.defaultWorkDir || process.cwd(),
|
||||
},
|
||||
feishu: {
|
||||
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
|
||||
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
|
||||
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
|
||||
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
|
||||
allowedUsers: fs_.allowedUsers ?? [],
|
||||
pairedUsers: fs_.pairedUsers ?? [],
|
||||
defaultWorkDir: fs_.defaultWorkDir || process.cwd(),
|
||||
streamingCard: fs_.streamingCard ?? false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 消息格式化工具
|
||||
*/
|
||||
|
||||
type AdapterChatState =
|
||||
| 'idle'
|
||||
| 'thinking'
|
||||
| 'streaming'
|
||||
| 'tool_executing'
|
||||
| 'permission_pending'
|
||||
|
||||
type ImStatusSummary = {
|
||||
sessionId?: string
|
||||
projectName?: string | null
|
||||
branch?: string | null
|
||||
model?: string | null
|
||||
state?: AdapterChatState | null
|
||||
verb?: string | null
|
||||
pendingPermissionCount?: number
|
||||
taskCounts?: {
|
||||
total: number
|
||||
pending: number
|
||||
inProgress: number
|
||||
completed: number
|
||||
}
|
||||
}
|
||||
|
||||
const IM_HELP_LINES = [
|
||||
'/new [项目] — 新建会话或切换项目',
|
||||
'/projects — 查看最近项目',
|
||||
'/status — 查看当前会话状态',
|
||||
'/clear — 清空当前会话上下文',
|
||||
'/stop — 停止当前生成',
|
||||
'/help — 显示这份帮助',
|
||||
]
|
||||
|
||||
/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */
|
||||
export function splitMessage(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text]
|
||||
|
||||
const chunks: string[] = []
|
||||
let remaining = text
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
chunks.push(remaining)
|
||||
break
|
||||
}
|
||||
|
||||
let splitAt = remaining.lastIndexOf('\n\n', limit)
|
||||
if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit)
|
||||
if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit)
|
||||
if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit)
|
||||
if (splitAt <= 0) splitAt = limit
|
||||
|
||||
// Include the delimiter for paragraph/sentence breaks
|
||||
if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1
|
||||
|
||||
chunks.push(remaining.slice(0, splitAt).trimEnd())
|
||||
remaining = remaining.slice(splitAt).trimStart()
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Format tool use info for display in IM. */
|
||||
export function formatToolUse(toolName: string, input: unknown): string {
|
||||
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
|
||||
const summary = formatToolSummary(toolName, inp)
|
||||
if (summary) return `🔧 ${toolName} ${summary}`
|
||||
const preview = truncateInput(input, 200)
|
||||
return `🔧 ${toolName}\n${preview}`
|
||||
}
|
||||
|
||||
/** Generate a concise human-readable summary for common tools. */
|
||||
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
|
||||
switch (tool) {
|
||||
case 'Bash': {
|
||||
const desc = inp.description as string | undefined
|
||||
const cmd = inp.command as string | undefined
|
||||
if (desc) return desc
|
||||
if (cmd) return truncate(cmd, 120)
|
||||
return null
|
||||
}
|
||||
case 'Read': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Edit': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Write': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Grep': {
|
||||
const pat = inp.pattern as string | undefined
|
||||
const p = inp.path as string | undefined
|
||||
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
|
||||
return null
|
||||
}
|
||||
case 'Glob': {
|
||||
const pat = inp.pattern as string | undefined
|
||||
return pat ? `"${pat}"` : null
|
||||
}
|
||||
case 'Skill': {
|
||||
const skill = inp.skill as string | undefined
|
||||
return skill || null
|
||||
}
|
||||
case 'Agent': {
|
||||
const desc = inp.description as string | undefined
|
||||
return desc || null
|
||||
}
|
||||
case 'WebFetch': {
|
||||
const url = inp.url as string | undefined
|
||||
return url ? truncate(url, 120) : null
|
||||
}
|
||||
case 'WebSearch': {
|
||||
const q = inp.query as string | undefined
|
||||
return q ? `"${truncate(q, 80)}"` : null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shortPath(fp: string): string {
|
||||
const parts = fp.split('/')
|
||||
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s
|
||||
}
|
||||
|
||||
/** Format a permission request for display in IM. */
|
||||
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
|
||||
const preview = truncateInput(input, 300)
|
||||
return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}`
|
||||
}
|
||||
|
||||
/** Truncate tool input to a preview string. */
|
||||
export function truncateInput(input: unknown, maxLen: number): string {
|
||||
try {
|
||||
const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
||||
return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
|
||||
} catch {
|
||||
return '(unserializable)'
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape special characters for Telegram MarkdownV2. */
|
||||
export function escapeMarkdownV2(text: string): string {
|
||||
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1')
|
||||
}
|
||||
|
||||
export function formatImHelp(): string {
|
||||
return `可用命令:\n\n${IM_HELP_LINES.join('\n')}`
|
||||
}
|
||||
|
||||
export function formatImStatus(summary: ImStatusSummary | null): string {
|
||||
if (!summary?.sessionId) {
|
||||
return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。'
|
||||
}
|
||||
|
||||
const lines = ['当前会话状态:']
|
||||
|
||||
if (summary.projectName) {
|
||||
lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`)
|
||||
} else if (summary.branch) {
|
||||
lines.push(`分支: ${summary.branch}`)
|
||||
}
|
||||
|
||||
lines.push(`会话: ${shortSessionId(summary.sessionId)}`)
|
||||
|
||||
if (summary.model) {
|
||||
lines.push(`模型: ${summary.model}`)
|
||||
}
|
||||
|
||||
lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`)
|
||||
|
||||
const pendingPermissionCount = summary.pendingPermissionCount ?? 0
|
||||
if (pendingPermissionCount > 0) {
|
||||
lines.push(`审批: ${pendingPermissionCount} 个待确认`)
|
||||
}
|
||||
|
||||
const taskCounts = summary.taskCounts
|
||||
if (taskCounts && taskCounts.total > 0) {
|
||||
const taskParts = [`总计 ${taskCounts.total}`]
|
||||
if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`)
|
||||
if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`)
|
||||
if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`)
|
||||
lines.push(`任务: ${taskParts.join(' · ')}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatAdapterChatState(
|
||||
state: AdapterChatState | null | undefined,
|
||||
verb: string | null | undefined,
|
||||
): string {
|
||||
const label = (() => {
|
||||
switch (state) {
|
||||
case 'thinking':
|
||||
return '思考中'
|
||||
case 'streaming':
|
||||
return '生成中'
|
||||
case 'tool_executing':
|
||||
return '执行工具中'
|
||||
case 'permission_pending':
|
||||
return '等待权限确认'
|
||||
case 'idle':
|
||||
default:
|
||||
return '空闲'
|
||||
}
|
||||
})()
|
||||
|
||||
if (!verb || verb === 'Thinking') return label
|
||||
return `${label} (${verb})`
|
||||
}
|
||||
|
||||
function shortSessionId(sessionId: string): string {
|
||||
return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export type RecentProject = {
|
||||
projectPath: string
|
||||
realPath: string
|
||||
projectName: string
|
||||
isGit: boolean
|
||||
repoName: string | null
|
||||
branch: string | null
|
||||
modifiedAt: string
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
export type GitInfo = {
|
||||
branch: string | null
|
||||
repoName: string | null
|
||||
workDir: string
|
||||
changedFiles: number
|
||||
}
|
||||
|
||||
export type SessionTask = {
|
||||
id: string
|
||||
subject: string
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export class AdapterHttpClient {
|
||||
readonly httpBaseUrl: string
|
||||
/** Default timeout for HTTP requests (30 seconds) */
|
||||
private static readonly DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
constructor(wsUrl: string) {
|
||||
this.httpBaseUrl = wsUrl
|
||||
.replace(/^ws:/, 'http:')
|
||||
.replace(/^wss:/, 'https:')
|
||||
.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/** Create an AbortController with timeout */
|
||||
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
|
||||
controller: AbortController
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
return { controller, timer }
|
||||
}
|
||||
|
||||
async createSession(workDir: string): Promise<string> {
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to create session: ${(err as any).message}`)
|
||||
}
|
||||
const data = (await res.json()) as { sessionId: string }
|
||||
return data.sessionId
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async listRecentProjects(): Promise<RecentProject[]> {
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list projects: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as { projects: RecentProject[] }
|
||||
return data.projects
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a project by index (1-based) or fuzzy name from recent projects.
|
||||
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
|
||||
*/
|
||||
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
|
||||
const projects = await this.listRecentProjects()
|
||||
|
||||
// Try as 1-based index
|
||||
const num = parseInt(query, 10)
|
||||
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
|
||||
return { project: projects[num - 1] }
|
||||
}
|
||||
|
||||
const q = query.toLowerCase()
|
||||
|
||||
// Exact project name match
|
||||
const exact = projects.find(p => p.projectName.toLowerCase() === q)
|
||||
if (exact) return { project: exact }
|
||||
|
||||
// Fuzzy: name or path contains query
|
||||
const matches = projects.filter(p =>
|
||||
p.projectName.toLowerCase().includes(q) ||
|
||||
p.realPath.toLowerCase().includes(q)
|
||||
)
|
||||
if (matches.length === 1) return { project: matches[0] }
|
||||
if (matches.length > 1) return { ambiguous: matches }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
async getGitInfo(sessionId: string): Promise<GitInfo> {
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load git info: ${(err as any).message}`)
|
||||
}
|
||||
return (await res.json()) as GitInfo
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
|
||||
const { controller, timer } = this.createTimeoutController()
|
||||
try {
|
||||
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return []
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new Error(`Failed to load tasks: ${(err as any).message}`)
|
||||
}
|
||||
const data = (await res.json()) as { tasks?: SessionTask[] }
|
||||
return Array.isArray(data.tasks) ? data.tasks : []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 流式消息缓冲
|
||||
*
|
||||
* 将 content_delta 累积后按时间窗口或字符数批量 flush。
|
||||
* 用于 Telegram editMessage / 飞书流式卡片更新。
|
||||
*/
|
||||
|
||||
export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 500
|
||||
const DEFAULT_CHAR_THRESHOLD = 200
|
||||
|
||||
export class MessageBuffer {
|
||||
private buffer = ''
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private flushing = false
|
||||
private pendingComplete = false
|
||||
|
||||
constructor(
|
||||
private onFlush: FlushCallback,
|
||||
private intervalMs = DEFAULT_INTERVAL_MS,
|
||||
private charThreshold = DEFAULT_CHAR_THRESHOLD,
|
||||
) {}
|
||||
|
||||
/** Append text delta. Triggers flush if threshold reached. */
|
||||
append(text: string): void {
|
||||
this.buffer += text
|
||||
if (this.buffer.length >= this.charThreshold) {
|
||||
this.scheduleFlush()
|
||||
} else if (!this.timer) {
|
||||
this.timer = setTimeout(() => this.flush(false), this.intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Immediately flush all remaining content (called on message_complete). */
|
||||
async complete(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
if (this.flushing) {
|
||||
// A flush is in-flight; mark pending so it fires after current flush finishes
|
||||
this.pendingComplete = true
|
||||
return
|
||||
}
|
||||
await this.flush(true)
|
||||
}
|
||||
|
||||
/** Reset the buffer for a new message. */
|
||||
reset(): void {
|
||||
this.buffer = ''
|
||||
this.pendingComplete = false
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
queueMicrotask(() => this.flush(false))
|
||||
}
|
||||
|
||||
private async flush(isComplete: boolean): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
if (this.flushing) return
|
||||
if (this.buffer.length === 0) return
|
||||
|
||||
this.flushing = true
|
||||
const text = this.buffer
|
||||
this.buffer = ''
|
||||
try {
|
||||
await this.onFlush(text, isComplete)
|
||||
} catch (err) {
|
||||
console.error('[MessageBuffer] Flush error:', err)
|
||||
} finally {
|
||||
this.flushing = false
|
||||
// If complete() was called while we were flushing, do the final flush now
|
||||
if (this.pendingComplete) {
|
||||
this.pendingComplete = false
|
||||
await this.flush(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 消息去重
|
||||
*
|
||||
* 防止 WebSocket 重连等场景下消息重复处理。
|
||||
* 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
|
||||
*/
|
||||
|
||||
const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
|
||||
const DEFAULT_MAX_ENTRIES = 5000
|
||||
const SWEEP_INTERVAL_MS = 60_000 // 1 minute
|
||||
|
||||
export class MessageDedup {
|
||||
private store = new Map<string, number>()
|
||||
private sweepTimer: ReturnType<typeof setInterval>
|
||||
|
||||
constructor(
|
||||
private ttlMs = DEFAULT_TTL_MS,
|
||||
private maxEntries = DEFAULT_MAX_ENTRIES,
|
||||
) {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/** Returns true if this is a NEW message, false if duplicate. */
|
||||
tryRecord(id: string): boolean {
|
||||
const now = Date.now()
|
||||
const existing = this.store.get(id)
|
||||
|
||||
if (existing !== undefined && now - existing < this.ttlMs) {
|
||||
return false // duplicate
|
||||
}
|
||||
|
||||
// Evict oldest if at capacity
|
||||
if (this.store.size >= this.maxEntries) {
|
||||
const oldest = this.store.keys().next().value
|
||||
if (oldest !== undefined) this.store.delete(oldest)
|
||||
}
|
||||
|
||||
this.store.set(id, now)
|
||||
return true
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, ts] of this.store) {
|
||||
if (now - ts >= this.ttlMs) {
|
||||
this.store.delete(key)
|
||||
} else {
|
||||
break // Map preserves insertion order; once fresh, rest is fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.sweepTimer)
|
||||
this.store.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 配对核心逻辑
|
||||
*
|
||||
* - generatePairingCode(): 生成 6 位安全配对码
|
||||
* - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
|
||||
* - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import * as crypto from 'node:crypto'
|
||||
import type { PairedUser, PairingState } from './config.js'
|
||||
|
||||
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
|
||||
|
||||
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
|
||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
|
||||
|
||||
function isRateLimited(userId: string | number): boolean {
|
||||
const key = String(userId)
|
||||
const record = failedAttempts.get(key)
|
||||
if (!record) return false
|
||||
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
||||
failedAttempts.delete(key)
|
||||
return false
|
||||
}
|
||||
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
|
||||
}
|
||||
|
||||
function recordFailedAttempt(userId: string | number): void {
|
||||
const key = String(userId)
|
||||
const record = failedAttempts.get(key)
|
||||
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
||||
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
|
||||
} else {
|
||||
record.count++
|
||||
}
|
||||
}
|
||||
const CODE_LENGTH = 6
|
||||
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
|
||||
|
||||
function getConfigPath(): string {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'adapters.json')
|
||||
}
|
||||
|
||||
function readConfigFile(): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfigFile(data: Record<string, any>): void {
|
||||
const filePath = getConfigPath()
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
const tmp = `${filePath}.tmp.${Date.now()}`
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
||||
fs.renameSync(tmp, filePath)
|
||||
}
|
||||
|
||||
export function generatePairingCode(): string {
|
||||
let code = ''
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
|
||||
export function isPaired(
|
||||
platform: 'telegram' | 'feishu',
|
||||
userId: string | number,
|
||||
config: Record<string, any>,
|
||||
): boolean {
|
||||
const platformConfig = config[platform] ?? {}
|
||||
const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
|
||||
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
||||
|
||||
// allowedUsers 非空时检查
|
||||
if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
|
||||
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
|
||||
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
|
||||
|
||||
return pairedUsers.some((p) => String(p.userId) === String(userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试配对:验证消息文本是否匹配当前有效配对码。
|
||||
* 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
|
||||
*/
|
||||
export function tryPair(
|
||||
messageText: string,
|
||||
senderInfo: { userId: string | number; displayName: string },
|
||||
platform: 'telegram' | 'feishu',
|
||||
): boolean {
|
||||
const file = readConfigFile()
|
||||
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
|
||||
|
||||
// 速率限制检查
|
||||
if (isRateLimited(senderInfo.userId)) return false
|
||||
|
||||
// 检查配对码是否有效
|
||||
if (!pairing.code || !pairing.expiresAt) return false
|
||||
if (Date.now() > pairing.expiresAt) return false
|
||||
|
||||
// 比较(忽略大小写和空格)
|
||||
const input = messageText.trim().toUpperCase()
|
||||
if (input !== pairing.code.toUpperCase()) {
|
||||
recordFailedAttempt(senderInfo.userId)
|
||||
return false
|
||||
}
|
||||
|
||||
// 配对成功:写入 pairedUsers
|
||||
const platformConfig = file[platform] ?? {}
|
||||
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
||||
|
||||
// 避免重复
|
||||
const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
|
||||
if (!exists) {
|
||||
pairedUsers.push({
|
||||
userId: senderInfo.userId,
|
||||
displayName: senderInfo.displayName,
|
||||
pairedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 config
|
||||
file[platform] = { ...platformConfig, pairedUsers }
|
||||
file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
|
||||
writeConfigFile(file)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** 统一的用户授权检查(供各 adapter 调用) */
|
||||
export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean {
|
||||
try {
|
||||
const cfgFile = readConfigFile()
|
||||
return isPaired(platform, userId, cfgFile)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
export type SessionEntry = {
|
||||
sessionId: string
|
||||
workDir: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type StoreData = Record<string, SessionEntry>
|
||||
|
||||
function getDefaultPath(): string {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'adapter-sessions.json')
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
private data: StoreData
|
||||
private filePath: string
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath = filePath ?? getDefaultPath()
|
||||
this.data = this.load()
|
||||
}
|
||||
|
||||
get(chatId: string): SessionEntry | null {
|
||||
return this.data[chatId] ?? null
|
||||
}
|
||||
|
||||
set(chatId: string, sessionId: string, workDir: string): void {
|
||||
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
|
||||
this.save()
|
||||
}
|
||||
|
||||
delete(chatId: string): void {
|
||||
delete this.data[chatId]
|
||||
this.save()
|
||||
}
|
||||
|
||||
listAll(): Array<{ chatId: string } & SessionEntry> {
|
||||
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
|
||||
}
|
||||
|
||||
private load(): StoreData {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
const dir = path.dirname(this.filePath)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const tmp = `${this.filePath}.tmp.${Date.now()}`
|
||||
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
|
||||
fs.renameSync(tmp, this.filePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* WebSocket Bridge
|
||||
*
|
||||
* 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。
|
||||
* 管理 chatId → sessionId 映射,自动重连,心跳。
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws'
|
||||
|
||||
/** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef.
|
||||
* The server will either (a) write base64 `data` to
|
||||
* ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or
|
||||
* (b) read `path` from disk and inject `@"path"` into the prompt. */
|
||||
export type AttachmentRef = {
|
||||
type: 'file' | 'image'
|
||||
name?: string
|
||||
path?: string
|
||||
data?: string // base64 payload (images)
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
/** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */
|
||||
export type ServerMessage = {
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/** Callback for server messages */
|
||||
export type MessageHandler = (msg: ServerMessage) => void
|
||||
|
||||
type Session = {
|
||||
sessionId: string
|
||||
ws: WebSocket
|
||||
reconnectAttempts: number
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
const RECONNECT_BASE_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
|
||||
export class WsBridge {
|
||||
private sessions = new Map<string, Session>()
|
||||
/** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
|
||||
private handlers = new Map<string, MessageHandler>()
|
||||
/** Per-chat FIFO queue of in-flight handler promises.
|
||||
* Ensures an async handler for message N completes before handler for N+1
|
||||
* starts, preventing state races at `await` points. */
|
||||
private handlerChains = new Map<string, Promise<void>>()
|
||||
private serverUrl: string
|
||||
private platform: string
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private destroyed = false
|
||||
|
||||
constructor(serverUrl: string, platform: string) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, '')
|
||||
this.platform = platform
|
||||
this.startHeartbeat()
|
||||
}
|
||||
|
||||
/** Connect to a session with a known sessionId. Returns false if already connected. */
|
||||
connectSession(chatId: string, sessionId: string): boolean {
|
||||
const existing = this.sessions.get(chatId)
|
||||
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
||||
return false
|
||||
}
|
||||
this.connect(chatId, sessionId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Send a user message to the session bound to chatId. */
|
||||
sendUserMessage(
|
||||
chatId: string,
|
||||
content: string,
|
||||
attachments?: AttachmentRef[],
|
||||
): boolean {
|
||||
const payload: Record<string, unknown> = { type: 'user_message', content }
|
||||
if (attachments && attachments.length > 0) {
|
||||
payload.attachments = attachments
|
||||
}
|
||||
return this.send(chatId, payload)
|
||||
}
|
||||
|
||||
/** Respond to a permission request.
|
||||
*
|
||||
* @param rule - optional rule name to make the permission persistent.
|
||||
* Currently the server supports `'always'`, which uses the CLI's
|
||||
* permission_suggestions to produce updatedPermissions so the same
|
||||
* tool call won't prompt again in this session. Omit for one-shot allow. */
|
||||
sendPermissionResponse(
|
||||
chatId: string,
|
||||
requestId: string,
|
||||
allowed: boolean,
|
||||
rule?: string,
|
||||
): boolean {
|
||||
const message: Record<string, unknown> = {
|
||||
type: 'permission_response',
|
||||
requestId,
|
||||
allowed,
|
||||
}
|
||||
if (rule) message.rule = rule
|
||||
return this.send(chatId, message)
|
||||
}
|
||||
|
||||
/** Stop the current generation. */
|
||||
sendStopGeneration(chatId: string): boolean {
|
||||
return this.send(chatId, { type: 'stop_generation' })
|
||||
}
|
||||
|
||||
/** Register (or replace) the handler for server messages on a specific chatId. */
|
||||
onServerMessage(chatId: string, handler: MessageHandler): void {
|
||||
this.handlers.set(chatId, handler)
|
||||
}
|
||||
|
||||
/** Reset session for a chatId (e.g. /new command). */
|
||||
resetSession(chatId: string): void {
|
||||
const session = this.sessions.get(chatId)
|
||||
if (session) {
|
||||
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
||||
session.ws.close(1000, 'session reset')
|
||||
this.sessions.delete(chatId)
|
||||
}
|
||||
this.handlers.delete(chatId)
|
||||
this.handlerChains.delete(chatId)
|
||||
}
|
||||
|
||||
/** Has a session (connected or handler registered) for chatId. */
|
||||
hasSession(chatId: string): boolean {
|
||||
return this.sessions.has(chatId) || this.handlers.has(chatId)
|
||||
}
|
||||
|
||||
/** Destroy all sessions. */
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
for (const [, session] of this.sessions) {
|
||||
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
||||
session.ws.close(1000, 'bridge destroyed')
|
||||
}
|
||||
this.sessions.clear()
|
||||
this.handlers.clear()
|
||||
this.handlerChains.clear()
|
||||
}
|
||||
|
||||
// ------- internal -------
|
||||
|
||||
private connect(chatId: string, sessionId: string): void {
|
||||
const url = `${this.serverUrl}/ws/${sessionId}`
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
// Cancel any pending reconnect timer for this chatId
|
||||
const prev = this.sessions.get(chatId)
|
||||
if (prev) {
|
||||
if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
|
||||
prev.ws.removeAllListeners()
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
sessionId,
|
||||
ws,
|
||||
reconnectAttempts: prev?.reconnectAttempts ?? 0,
|
||||
reconnectTimer: null,
|
||||
}
|
||||
this.sessions.set(chatId, session)
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`[WsBridge] Connected: ${sessionId}`)
|
||||
session.reconnectAttempts = 0
|
||||
})
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg: ServerMessage
|
||||
try {
|
||||
msg = JSON.parse(raw.toString())
|
||||
} catch (err) {
|
||||
console.error('[WsBridge] Parse error:', err)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'pong') return
|
||||
const handler = this.handlers.get(chatId)
|
||||
if (!handler) return
|
||||
|
||||
// Serialize per-chat handler calls: chain each message onto the previous
|
||||
// one so a slow handler (e.g. one awaiting im.message.create) fully
|
||||
// finishes before the next message's handler runs. This prevents state
|
||||
// races where a later message reads stale map entries set up by an
|
||||
// earlier-but-still-in-flight handler.
|
||||
const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {}) // upstream errors must not poison the chain
|
||||
.then(() => Promise.resolve().then(() => handler(msg)))
|
||||
.catch((err) => {
|
||||
console.error(`[WsBridge] Handler error on ${chatId}:`, err)
|
||||
})
|
||||
this.handlerChains.set(chatId, next)
|
||||
})
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
|
||||
if (code === 1000) return
|
||||
this.scheduleReconnect(chatId, sessionId)
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`[WsBridge] Error on ${sessionId}:`, err.message)
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
|
||||
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
const session = this.sessions.get(chatId)
|
||||
if (!session) return Promise.resolve(false)
|
||||
if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
const onOpen = () => { cleanup(); resolve(true) }
|
||||
const onError = () => { cleanup(); resolve(false) }
|
||||
const onClose = () => { cleanup(); resolve(false) }
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
session.ws.removeListener('open', onOpen)
|
||||
session.ws.removeListener('error', onError)
|
||||
session.ws.removeListener('close', onClose)
|
||||
}
|
||||
session.ws.once('open', onOpen)
|
||||
session.ws.once('error', onError)
|
||||
session.ws.once('close', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
private send(chatId: string, message: Record<string, unknown>): boolean {
|
||||
const session = this.sessions.get(chatId)
|
||||
if (!session || session.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
|
||||
return false
|
||||
}
|
||||
session.ws.send(JSON.stringify(message))
|
||||
return true
|
||||
}
|
||||
|
||||
private scheduleReconnect(chatId: string, sessionId: string): void {
|
||||
if (this.destroyed) return
|
||||
const session = this.sessions.get(chatId)
|
||||
if (!session) return
|
||||
if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`)
|
||||
this.sessions.delete(chatId)
|
||||
this.handlers.delete(chatId)
|
||||
return
|
||||
}
|
||||
|
||||
session.reconnectAttempts++
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1),
|
||||
RECONNECT_MAX_MS,
|
||||
)
|
||||
console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
|
||||
session.reconnectTimer = setTimeout(() => {
|
||||
if (this.destroyed) return
|
||||
if (this.sessions.get(chatId)?.sessionId === sessionId) {
|
||||
this.connect(chatId, sessionId)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
for (const [, session] of this.sessions) {
|
||||
if (session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* card-errors 单元测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
CARD_ERROR,
|
||||
CARD_CONTENT_SUB_ERROR,
|
||||
extractLarkApiCode,
|
||||
extractSubCode,
|
||||
parseCardApiError,
|
||||
isCardRateLimitError,
|
||||
isCardTableLimitError,
|
||||
} from '../card-errors.js'
|
||||
|
||||
describe('extractLarkApiCode', () => {
|
||||
it('从 err.code 直接提取', () => {
|
||||
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
|
||||
})
|
||||
|
||||
it('从 err.data.code 提取', () => {
|
||||
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
|
||||
})
|
||||
|
||||
it('从 err.response.data.code 提取(Axios 风格)', () => {
|
||||
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
|
||||
})
|
||||
|
||||
it('数字字符串被强制转成 number', () => {
|
||||
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
|
||||
})
|
||||
|
||||
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
|
||||
const err = {
|
||||
code: 1,
|
||||
data: { code: 2 },
|
||||
response: { data: { code: 3 } },
|
||||
}
|
||||
expect(extractLarkApiCode(err)).toBe(1)
|
||||
})
|
||||
|
||||
it('none → undefined', () => {
|
||||
expect(extractLarkApiCode({})).toBeUndefined()
|
||||
expect(extractLarkApiCode(null)).toBeUndefined()
|
||||
expect(extractLarkApiCode(undefined)).toBeUndefined()
|
||||
expect(extractLarkApiCode('just a string')).toBeUndefined()
|
||||
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('非有限数字被忽略', () => {
|
||||
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
|
||||
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSubCode', () => {
|
||||
it('识别标准的 ErrCode: 11310', () => {
|
||||
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
|
||||
expect(extractSubCode(msg)).toBe(11310)
|
||||
})
|
||||
|
||||
it('无 ErrCode 时返回 null', () => {
|
||||
expect(extractSubCode('random error message')).toBeNull()
|
||||
expect(extractSubCode('')).toBeNull()
|
||||
})
|
||||
|
||||
it('ErrCode 大小写容错(冒号后多空格)', () => {
|
||||
expect(extractSubCode('ErrCode: 42')).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCardApiError', () => {
|
||||
it('从 SDK 风格错误提取完整结构', () => {
|
||||
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
|
||||
const parsed = parseCardApiError(err)
|
||||
expect(parsed).toEqual({
|
||||
code: 230099,
|
||||
subCode: 11310,
|
||||
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
|
||||
})
|
||||
})
|
||||
|
||||
it('从 Axios 风格错误(response.data.msg)提取', () => {
|
||||
const err = {
|
||||
response: {
|
||||
data: {
|
||||
code: 230020,
|
||||
msg: 'rate limited',
|
||||
},
|
||||
},
|
||||
}
|
||||
const parsed = parseCardApiError(err)
|
||||
expect(parsed?.code).toBe(230020)
|
||||
expect(parsed?.errMsg).toBe('rate limited')
|
||||
expect(parsed?.subCode).toBeNull()
|
||||
})
|
||||
|
||||
it('无 code 时返回 null', () => {
|
||||
expect(parseCardApiError({})).toBeNull()
|
||||
expect(parseCardApiError(null)).toBeNull()
|
||||
expect(parseCardApiError('string')).toBeNull()
|
||||
})
|
||||
|
||||
it('有 code 无 msg 时 errMsg 为空字符串', () => {
|
||||
const parsed = parseCardApiError({ code: 230020 })
|
||||
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
|
||||
})
|
||||
|
||||
it('fallback 到 err.message', () => {
|
||||
const err = Object.assign(new Error('fallback text'), { code: 230099 })
|
||||
const parsed = parseCardApiError(err)
|
||||
expect(parsed?.errMsg).toBe('fallback text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCardRateLimitError', () => {
|
||||
it('识别 230020', () => {
|
||||
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
|
||||
})
|
||||
|
||||
it('识别 Axios 风格 230020', () => {
|
||||
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
|
||||
})
|
||||
|
||||
it('不匹配其他 code', () => {
|
||||
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
|
||||
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
|
||||
})
|
||||
|
||||
it('非错误对象返回 false', () => {
|
||||
expect(isCardRateLimitError(null)).toBe(false)
|
||||
expect(isCardRateLimitError({})).toBe(false)
|
||||
expect(isCardRateLimitError(new Error('random'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCardTableLimitError', () => {
|
||||
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
|
||||
|
||||
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
|
||||
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('从 Axios 风格的 response.data 匹配', () => {
|
||||
const err = {
|
||||
response: {
|
||||
data: {
|
||||
code: 230099,
|
||||
msg: validMsg,
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
|
||||
const err = {
|
||||
code: CARD_ERROR.CARD_CONTENT_FAILED,
|
||||
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
|
||||
}
|
||||
expect(isCardTableLimitError(err)).toBe(false)
|
||||
})
|
||||
|
||||
it('code 不是 230099 → false', () => {
|
||||
const err = { code: 230020, msg: validMsg }
|
||||
expect(isCardTableLimitError(err)).toBe(false)
|
||||
})
|
||||
|
||||
it('没有 subCode → false', () => {
|
||||
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
|
||||
expect(isCardTableLimitError(err)).toBe(false)
|
||||
})
|
||||
|
||||
it('不区分 "table number" 的大小写', () => {
|
||||
const err = {
|
||||
code: 230099,
|
||||
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
|
||||
}
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('常量值', () => {
|
||||
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
|
||||
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
|
||||
})
|
||||
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
|
||||
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
|
||||
})
|
||||
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
|
||||
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* cardkit.ts 单元测试
|
||||
*
|
||||
* 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
|
||||
* - 每个函数构造的 payload 结构
|
||||
* - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
|
||||
* - 缺失关键字段时抛错
|
||||
* - sequence 正确传递
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
createCardEntity,
|
||||
sendCardAsMessage,
|
||||
streamCardContent,
|
||||
setCardStreamingMode,
|
||||
updateCardKitCard,
|
||||
CardKitApiError,
|
||||
STREAMING_ELEMENT_ID,
|
||||
} from '../cardkit.js'
|
||||
import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockCall = { api: string; args: any }
|
||||
|
||||
function makeMockClient(responses: Record<string, any>) {
|
||||
const calls: MockCall[] = []
|
||||
const recorder = (api: string, resp: any) => async (args: any) => {
|
||||
calls.push({ api, args })
|
||||
if (typeof resp === 'function') {
|
||||
return resp(args)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
const client: any = {
|
||||
cardkit: {
|
||||
v1: {
|
||||
card: {
|
||||
create: recorder('cardkit.v1.card.create', responses['card.create']),
|
||||
settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
|
||||
update: recorder('cardkit.v1.card.update', responses['card.update']),
|
||||
},
|
||||
cardElement: {
|
||||
content: recorder(
|
||||
'cardkit.v1.cardElement.content',
|
||||
responses['cardElement.content'],
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
im: {
|
||||
message: {
|
||||
create: recorder('im.message.create', responses['im.message.create']),
|
||||
reply: recorder('im.message.reply', responses['im.message.reply']),
|
||||
},
|
||||
},
|
||||
}
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCardEntity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createCardEntity', () => {
|
||||
it('构造 card_json payload 并返回 card_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': {
|
||||
code: 0,
|
||||
data: { card_id: 'ck_abc_123' },
|
||||
},
|
||||
})
|
||||
const card = { schema: '2.0', body: { elements: [] } }
|
||||
const id = await createCardEntity(client, card)
|
||||
|
||||
expect(id).toBe('ck_abc_123')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||||
expect(calls[0]!.args.data.type).toBe('card_json')
|
||||
// data.data 应当是 card 的 JSON 字符串
|
||||
expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
|
||||
})
|
||||
|
||||
it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, card_id: 'top_level_id' },
|
||||
})
|
||||
const id = await createCardEntity(client, {})
|
||||
expect(id).toBe('top_level_id')
|
||||
})
|
||||
|
||||
it('non-zero code 抛 CardKitApiError', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 230099, msg: 'something failed' },
|
||||
})
|
||||
await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
|
||||
})
|
||||
|
||||
it('code=0 但缺 card_id 抛错', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: {} },
|
||||
})
|
||||
await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendCardAsMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sendCardAsMessage', () => {
|
||||
it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'im.message.create': { data: { message_id: 'om_new_msg_1' } },
|
||||
})
|
||||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
|
||||
expect(mid).toBe('om_new_msg_1')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('im.message.create')
|
||||
expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
|
||||
expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
|
||||
expect(calls[0]!.args.data.msg_type).toBe('interactive')
|
||||
// content 格式: {"type":"card","data":{"card_id":"xxx"}}
|
||||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||||
expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
|
||||
})
|
||||
|
||||
it('有 replyTo: 走 im.message.reply', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'im.message.reply': { data: { message_id: 'om_reply_1' } },
|
||||
})
|
||||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
|
||||
expect(mid).toBe('om_reply_1')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('im.message.reply')
|
||||
expect(calls[0]!.args.path.message_id).toBe('om_parent')
|
||||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||||
expect(parsed.data.card_id).toBe('ck_id_xyz')
|
||||
})
|
||||
|
||||
it('缺 message_id 抛错', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'im.message.create': { data: {} },
|
||||
})
|
||||
await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
|
||||
/missing message_id/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// streamCardContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('streamCardContent', () => {
|
||||
it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'cardElement.content': { code: 0 },
|
||||
})
|
||||
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
|
||||
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
|
||||
expect(calls[0]!.args.path).toEqual({
|
||||
card_id: 'ck_abc',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
|
||||
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
|
||||
})
|
||||
|
||||
it('230020 响应可被 isCardRateLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': { code: 230020, msg: 'rate limited' },
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': {
|
||||
code: 230099,
|
||||
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
|
||||
},
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCardStreamingMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setCardStreamingMode', () => {
|
||||
it('streaming_mode=false + sequence 正确传递', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck_xxx', false, 99)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(99)
|
||||
// settings 是 JSON 字符串
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: false })
|
||||
})
|
||||
|
||||
it('streaming_mode=true 也能工作', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck', true, 1)
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: true })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCardKitCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('updateCardKitCard', () => {
|
||||
it('把 card 包装成 card_json payload + sequence', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.update': { code: 0 },
|
||||
})
|
||||
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
|
||||
await updateCardKitCard(client, 'ck_final', card, 100)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(100)
|
||||
expect(calls[0]!.args.data.card.type).toBe('card_json')
|
||||
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
|
||||
})
|
||||
|
||||
it('非零 code 抛 CardKitApiError', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.update': { code: -1, msg: 'bad card' },
|
||||
})
|
||||
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKitApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CardKitApiError', () => {
|
||||
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'card.update',
|
||||
code: 230020,
|
||||
msg: 'rate limited',
|
||||
context: 'seq=5',
|
||||
})
|
||||
expect(err.code).toBe(230020)
|
||||
expect(err.msg).toBe('rate limited')
|
||||
expect(err.name).toBe('CardKitApiError')
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('消息包含 api 名和 context', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'cardElement.content',
|
||||
code: 230099,
|
||||
msg: 'oops',
|
||||
context: 'seq=3 len=100',
|
||||
})
|
||||
expect(err.message).toContain('cardElement.content')
|
||||
expect(err.message).toContain('230099')
|
||||
expect(err.message).toContain('seq=3 len=100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { extractInboundPayload } from '../extract-payload.js'
|
||||
|
||||
describe('extractInboundPayload', () => {
|
||||
it('pulls text out of a text message', () => {
|
||||
const result = extractInboundPayload(
|
||||
JSON.stringify({ text: 'hello world' }),
|
||||
'text',
|
||||
)
|
||||
expect(result.text).toBe('hello world')
|
||||
expect(result.pendingDownloads).toEqual([])
|
||||
})
|
||||
|
||||
it('pulls text out of a post (rich text) message', () => {
|
||||
const content = JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]],
|
||||
},
|
||||
})
|
||||
const result = extractInboundPayload(content, 'post')
|
||||
expect(result.text).toBe('hi there')
|
||||
expect(result.pendingDownloads).toEqual([])
|
||||
})
|
||||
|
||||
it('identifies an image message as a pending image download', () => {
|
||||
const content = JSON.stringify({ image_key: 'img_key_abc' })
|
||||
const result = extractInboundPayload(content, 'image')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.pendingDownloads).toEqual([
|
||||
{ kind: 'image', fileKey: 'img_key_abc' },
|
||||
])
|
||||
})
|
||||
|
||||
it('identifies a file message as a pending file download with file_name', () => {
|
||||
const content = JSON.stringify({
|
||||
file_key: 'file_key_xyz',
|
||||
file_name: 'spec.pdf',
|
||||
})
|
||||
const result = extractInboundPayload(content, 'file')
|
||||
expect(result.pendingDownloads).toEqual([
|
||||
{ kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' },
|
||||
])
|
||||
})
|
||||
|
||||
it('identifies file_archive the same way as file', () => {
|
||||
const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' })
|
||||
const result = extractInboundPayload(content, 'file_archive')
|
||||
expect(result.pendingDownloads).toEqual([
|
||||
{ kind: 'file', fileKey: 'fk1', fileName: 'x.zip' },
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts img + file elements from a post message', () => {
|
||||
const content = JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [
|
||||
[{ tag: 'text', text: 'look: ' }],
|
||||
[{ tag: 'img', image_key: 'img_post_1' }],
|
||||
[{ tag: 'text', text: ' and ' }],
|
||||
[{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }],
|
||||
],
|
||||
},
|
||||
})
|
||||
const result = extractInboundPayload(content, 'post')
|
||||
expect(result.text).toBe('look: and ')
|
||||
expect(result.pendingDownloads).toEqual([
|
||||
{ kind: 'image', fileKey: 'img_post_1' },
|
||||
{ kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty on malformed JSON', () => {
|
||||
const result = extractInboundPayload('not json', 'text')
|
||||
expect(result.text).toBe('')
|
||||
expect(result.pendingDownloads).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,907 @@
|
||||
/**
|
||||
* 飞书 Adapter 翻译逻辑测试
|
||||
*
|
||||
* 不启动真实 Bot,只测试事件解析和消息翻译逻辑。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import * as path from 'node:path'
|
||||
|
||||
// ---------- helpers extracted from feishu/index.ts for testability ----------
|
||||
|
||||
function extractText(content: string, msgType: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (msgType === 'text') {
|
||||
return parsed.text ?? null
|
||||
}
|
||||
if (msgType === 'post') {
|
||||
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
|
||||
return zhContent
|
||||
.flat()
|
||||
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
|
||||
.map((n: any) => n.text ?? n.content ?? '')
|
||||
.join('')
|
||||
.trim() || null
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isBotMentioned(
|
||||
mentions: Array<{ id?: { open_id?: string } }> | undefined,
|
||||
botOpenId: string,
|
||||
): boolean {
|
||||
if (!mentions || !botOpenId) return false
|
||||
return mentions.some((m) => m.id?.open_id === botOpenId)
|
||||
}
|
||||
|
||||
function stripMentions(text: string): string {
|
||||
return text.replace(/@_user_\d+/g, '').trim()
|
||||
}
|
||||
|
||||
type RecentProject = {
|
||||
projectPath: string
|
||||
realPath: string
|
||||
projectName: string
|
||||
isGit: boolean
|
||||
repoName: string | null
|
||||
branch: string | null
|
||||
modifiedAt: string
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
function prettyPath(realPath: string, maxLen = 64): string {
|
||||
const home = process.env.HOME
|
||||
let p = realPath
|
||||
if (home) {
|
||||
if (p === home) return '~'
|
||||
if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
|
||||
}
|
||||
if (p.length <= maxLen) return p
|
||||
const tailLen = Math.floor(maxLen * 0.65)
|
||||
const headLen = maxLen - tailLen - 1
|
||||
return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
|
||||
}
|
||||
|
||||
function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
|
||||
const items = projects.slice(0, 10)
|
||||
const total = projects.length
|
||||
const subtitleText =
|
||||
total > items.length
|
||||
? `共 ${total} 个最近项目,显示前 ${items.length}`
|
||||
: `共 ${total} 个最近项目`
|
||||
|
||||
const rows = items.map((p, i) => {
|
||||
const branch = p.branch ? ` · *${p.branch}*` : ''
|
||||
return {
|
||||
tag: 'column_set',
|
||||
flex_mode: 'stretch',
|
||||
horizontal_spacing: '8px',
|
||||
margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
|
||||
columns: [
|
||||
{
|
||||
tag: 'column',
|
||||
width: 'weighted',
|
||||
weight: 1,
|
||||
vertical_align: 'center',
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: `**${p.projectName}**${branch}`,
|
||||
},
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: prettyPath(p.realPath, 56),
|
||||
text_size: 'notation',
|
||||
margin: '2px 0 0 0',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: 'column',
|
||||
width: 'auto',
|
||||
vertical_align: 'center',
|
||||
elements: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '选择' },
|
||||
type: i === 0 ? 'primary' : 'default',
|
||||
size: 'small',
|
||||
value: {
|
||||
action: 'pick_project',
|
||||
realPath: p.realPath,
|
||||
projectName: p.projectName,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
wide_screen_mode: true,
|
||||
update_multi: true,
|
||||
},
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: '📁 选择项目' },
|
||||
subtitle: { tag: 'plain_text', content: subtitleText },
|
||||
template: 'blue',
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
...rows,
|
||||
{ tag: 'hr', margin: '14px 0 0 0' },
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
|
||||
text_size: 'notation',
|
||||
margin: '6px 0 0 0',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- permission card helpers (mirrored from feishu/index.ts) ----------
|
||||
|
||||
type ToolCallSummary = {
|
||||
icon: string
|
||||
label: string
|
||||
target?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
|
||||
const rec: Record<string, unknown> =
|
||||
input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
||||
const str = (key: string): string | undefined =>
|
||||
typeof rec[key] === 'string' ? (rec[key] as string) : undefined
|
||||
|
||||
switch (toolName) {
|
||||
case 'Write': {
|
||||
const fp = str('file_path')
|
||||
return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
|
||||
}
|
||||
case 'Edit':
|
||||
case 'MultiEdit':
|
||||
case 'NotebookEdit': {
|
||||
const fp = str('file_path') ?? str('notebook_path')
|
||||
return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
|
||||
}
|
||||
case 'Read': {
|
||||
const fp = str('file_path')
|
||||
return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
|
||||
}
|
||||
case 'Bash':
|
||||
case 'BashOutput': {
|
||||
return { icon: '🖥️', label: '执行命令', target: str('command') }
|
||||
}
|
||||
case 'Grep': {
|
||||
const pattern = str('pattern')
|
||||
return {
|
||||
icon: '🔍',
|
||||
label: '搜索内容',
|
||||
target: pattern ? `pattern: ${pattern}` : undefined,
|
||||
filePath: str('path'),
|
||||
}
|
||||
}
|
||||
case 'Glob': {
|
||||
const pattern = str('pattern')
|
||||
return {
|
||||
icon: '📁',
|
||||
label: '查找文件',
|
||||
target: pattern ? `pattern: ${pattern}` : undefined,
|
||||
filePath: str('path'),
|
||||
}
|
||||
}
|
||||
case 'WebFetch':
|
||||
return { icon: '🌐', label: '访问网页', target: str('url') }
|
||||
case 'WebSearch':
|
||||
return { icon: '🌐', label: '搜索网页', target: str('query') }
|
||||
default:
|
||||
return { icon: '🔧', label: toolName }
|
||||
}
|
||||
}
|
||||
|
||||
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
|
||||
const abs = path.isAbsolute(filePath)
|
||||
? path.normalize(filePath)
|
||||
: path.resolve(workDir, filePath)
|
||||
const normWork = path.normalize(workDir).replace(/\/+$/, '')
|
||||
return abs !== normWork && !abs.startsWith(normWork + path.sep)
|
||||
}
|
||||
|
||||
function truncateTarget(s: string, maxLen = 160): string {
|
||||
if (s.length <= maxLen) return s
|
||||
return s.slice(0, maxLen - 1) + '…'
|
||||
}
|
||||
|
||||
function buildPermissionCard(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
requestId: string,
|
||||
workDir?: string,
|
||||
): Record<string, unknown> {
|
||||
const summary = summarizeToolCall(toolName, input)
|
||||
const crossDir = Boolean(
|
||||
workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
|
||||
)
|
||||
|
||||
const elements: Record<string, unknown>[] = [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: `${summary.icon} **${summary.label}** \`${toolName}\``,
|
||||
},
|
||||
]
|
||||
|
||||
if (summary.target) {
|
||||
const shown = summary.filePath
|
||||
? prettyPath(summary.target, 80)
|
||||
: truncateTarget(summary.target, 160)
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: '```\n' + shown + '\n```',
|
||||
margin: '4px 0 0 0',
|
||||
})
|
||||
}
|
||||
|
||||
if (crossDir) {
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: '⚠️ **该操作位于当前项目目录之外**',
|
||||
margin: '8px 0 0 0',
|
||||
text_size: 'notation',
|
||||
})
|
||||
}
|
||||
|
||||
elements.push({ tag: 'hr', margin: '12px 0 0 0' })
|
||||
|
||||
elements.push({
|
||||
tag: 'column_set',
|
||||
flex_mode: 'stretch',
|
||||
horizontal_spacing: '8px',
|
||||
margin: '8px 0 0 0',
|
||||
columns: [
|
||||
{
|
||||
tag: 'column',
|
||||
width: 'weighted',
|
||||
weight: 1,
|
||||
vertical_align: 'center',
|
||||
elements: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '✅ 允许' },
|
||||
type: 'primary',
|
||||
size: 'medium',
|
||||
value: { action: 'permit', requestId, allowed: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: 'column',
|
||||
width: 'weighted',
|
||||
weight: 1,
|
||||
vertical_align: 'center',
|
||||
elements: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '♾️ 永久允许' },
|
||||
type: 'default',
|
||||
size: 'medium',
|
||||
value: { action: 'permit', requestId, allowed: true, rule: 'always' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tag: 'column',
|
||||
width: 'weighted',
|
||||
weight: 1,
|
||||
vertical_align: 'center',
|
||||
elements: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: '❌ 拒绝' },
|
||||
type: 'danger',
|
||||
size: 'medium',
|
||||
value: { action: 'permit', requestId, allowed: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
wide_screen_mode: false,
|
||||
update_multi: true,
|
||||
},
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
|
||||
subtitle: {
|
||||
tag: 'plain_text',
|
||||
content: crossDir ? '⚠️ 跨目录操作' : toolName,
|
||||
},
|
||||
template: crossDir ? 'red' : 'orange',
|
||||
padding: '12px 12px 12px 12px',
|
||||
icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
|
||||
},
|
||||
body: { elements },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('Feishu: event parsing', () => {
|
||||
describe('extractText', () => {
|
||||
it('extracts text from text message', () => {
|
||||
const content = JSON.stringify({ text: 'hello world' })
|
||||
expect(extractText(content, 'text')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('extracts text from post message (zh_cn)', () => {
|
||||
const content = JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[
|
||||
{ tag: 'text', text: 'Hello ' },
|
||||
{ tag: 'text', text: 'World' },
|
||||
]],
|
||||
},
|
||||
})
|
||||
expect(extractText(content, 'post')).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('extracts text from post message with md tag', () => {
|
||||
const content = JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[{ tag: 'md', text: '**bold** text' }]],
|
||||
},
|
||||
})
|
||||
expect(extractText(content, 'post')).toBe('**bold** text')
|
||||
})
|
||||
|
||||
it('returns null for unsupported message types', () => {
|
||||
expect(extractText('{}', 'image')).toBeNull()
|
||||
expect(extractText('{}', 'audio')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed content', () => {
|
||||
expect(extractText('not-json', 'text')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty text', () => {
|
||||
const content = JSON.stringify({ text: '' })
|
||||
// empty string is falsy, so ?? null returns ''
|
||||
expect(extractText(content, 'text')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBotMentioned', () => {
|
||||
const botId = 'ou_bot_123'
|
||||
|
||||
it('returns true when bot is mentioned', () => {
|
||||
const mentions = [
|
||||
{ id: { open_id: 'ou_user_1' } },
|
||||
{ id: { open_id: 'ou_bot_123' } },
|
||||
]
|
||||
expect(isBotMentioned(mentions, botId)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when bot is not mentioned', () => {
|
||||
const mentions = [
|
||||
{ id: { open_id: 'ou_user_1' } },
|
||||
{ id: { open_id: 'ou_user_2' } },
|
||||
]
|
||||
expect(isBotMentioned(mentions, botId)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined mentions', () => {
|
||||
expect(isBotMentioned(undefined, botId)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty mentions', () => {
|
||||
expect(isBotMentioned([], botId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripMentions', () => {
|
||||
it('removes @_user_N patterns', () => {
|
||||
expect(stripMentions('@_user_1 hello world')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('removes multiple mentions', () => {
|
||||
expect(stripMentions('@_user_1 @_user_2 test')).toBe('test')
|
||||
})
|
||||
|
||||
it('leaves text without mentions unchanged', () => {
|
||||
expect(stripMentions('hello world')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(stripMentions(' @_user_1 hello ')).toBe('hello')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feishu: permission card', () => {
|
||||
// Helpers to reach into Schema 2.0 body.elements
|
||||
function getBodyElements(card: Record<string, unknown>): any[] {
|
||||
return ((card.body as any).elements ?? []) as any[]
|
||||
}
|
||||
function getActionRow(card: Record<string, unknown>): any {
|
||||
return getBodyElements(card).find((el) => el.tag === 'column_set')
|
||||
}
|
||||
function getButtons(card: Record<string, unknown>): any[] {
|
||||
return getActionRow(card).columns.map(
|
||||
(c: any) => c.elements.find((e: any) => e.tag === 'button'),
|
||||
)
|
||||
}
|
||||
|
||||
// ----- Schema 2.0 regression -----
|
||||
|
||||
it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => {
|
||||
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
||||
expect(card.schema).toBe('2.0')
|
||||
expect(card.elements).toBeUndefined() // old bug had top-level elements
|
||||
expect((card.body as any).elements).toBeDefined()
|
||||
expect((card.config as any).update_multi).toBe(true)
|
||||
expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first
|
||||
})
|
||||
|
||||
it('header has title, subtitle, template, icon', () => {
|
||||
const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc')
|
||||
const header = card.header as any
|
||||
expect(header.title.content).toContain('权限确认')
|
||||
expect(header.subtitle.content).toBe('Bash')
|
||||
expect(header.template).toBe('orange')
|
||||
expect(header.icon.tag).toBe('standard_icon')
|
||||
})
|
||||
|
||||
// ----- Three buttons -----
|
||||
|
||||
it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => {
|
||||
const card = buildPermissionCard('Read', {}, 'xyz')
|
||||
const [allow, always, deny] = getButtons(card)
|
||||
expect(allow.text.content).toContain('允许')
|
||||
expect(allow.type).toBe('primary')
|
||||
expect(always.text.content).toContain('永久允许')
|
||||
expect(always.type).toBe('default')
|
||||
expect(deny.text.content).toContain('拒绝')
|
||||
expect(deny.type).toBe('danger')
|
||||
})
|
||||
|
||||
it('允许 button carries allowed=true and no rule', () => {
|
||||
const card = buildPermissionCard('Read', {}, 'req-1')
|
||||
const [allow] = getButtons(card)
|
||||
expect(allow.value).toEqual({
|
||||
action: 'permit',
|
||||
requestId: 'req-1',
|
||||
allowed: true,
|
||||
})
|
||||
expect(allow.value.rule).toBeUndefined()
|
||||
})
|
||||
|
||||
it('永久允许 button carries allowed=true + rule=always', () => {
|
||||
const card = buildPermissionCard('Read', {}, 'req-2')
|
||||
const always = getButtons(card)[1]
|
||||
expect(always.value).toEqual({
|
||||
action: 'permit',
|
||||
requestId: 'req-2',
|
||||
allowed: true,
|
||||
rule: 'always',
|
||||
})
|
||||
})
|
||||
|
||||
it('拒绝 button carries allowed=false and no rule', () => {
|
||||
const card = buildPermissionCard('Read', {}, 'req-3')
|
||||
const deny = getButtons(card)[2]
|
||||
expect(deny.value).toEqual({
|
||||
action: 'permit',
|
||||
requestId: 'req-3',
|
||||
allowed: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ----- Tool summary rendering -----
|
||||
|
||||
it('renders Write with ✏️ 写入文件 header and file path target', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Write',
|
||||
{ file_path: '/tmp/output.txt', content: 'hi' },
|
||||
'req',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
expect(elements[0].content).toContain('✏️')
|
||||
expect(elements[0].content).toContain('写入文件')
|
||||
expect(elements[0].content).toContain('`Write`')
|
||||
// Target rendered as fenced code block
|
||||
expect(elements[1].content).toContain('/tmp/output.txt')
|
||||
expect(elements[1].content.startsWith('```')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders Edit with ✏️ 修改文件', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Edit',
|
||||
{ file_path: '/a/b.ts', old_string: 'x', new_string: 'y' },
|
||||
'req',
|
||||
)
|
||||
expect(getBodyElements(card)[0].content).toContain('修改文件')
|
||||
})
|
||||
|
||||
it('renders Bash with 🖥️ 执行命令 and command target', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Bash',
|
||||
{ command: 'rm -rf /tmp/x' },
|
||||
'req',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
expect(elements[0].content).toContain('🖥️')
|
||||
expect(elements[0].content).toContain('执行命令')
|
||||
expect(elements[1].content).toContain('rm -rf /tmp/x')
|
||||
})
|
||||
|
||||
it('truncates very long Bash commands to 160 chars', () => {
|
||||
const longCmd = 'echo ' + 'x'.repeat(500)
|
||||
const card = buildPermissionCard('Bash', { command: longCmd }, 'req')
|
||||
const targetEl = getBodyElements(card)[1]
|
||||
expect(targetEl.content).toContain('…')
|
||||
// Fenced code wraps ~10 extra chars
|
||||
expect(targetEl.content.length).toBeLessThanOrEqual(180)
|
||||
})
|
||||
|
||||
it('renders Grep with 🔍 搜索内容 and pattern target', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Grep',
|
||||
{ pattern: 'TODO', path: '/src' },
|
||||
'req',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
expect(elements[0].content).toContain('🔍')
|
||||
expect(elements[1].content).toContain('TODO')
|
||||
})
|
||||
|
||||
it('renders WebFetch with 🌐 访问网页 and url target', () => {
|
||||
const card = buildPermissionCard(
|
||||
'WebFetch',
|
||||
{ url: 'https://example.com/api' },
|
||||
'req',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
expect(elements[0].content).toContain('🌐')
|
||||
expect(elements[0].content).toContain('访问网页')
|
||||
expect(elements[1].content).toContain('https://example.com/api')
|
||||
})
|
||||
|
||||
it('falls back to 🔧 <toolName> for unknown tools', () => {
|
||||
const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req')
|
||||
expect(getBodyElements(card)[0].content).toContain('🔧')
|
||||
expect(getBodyElements(card)[0].content).toContain('CustomTool')
|
||||
})
|
||||
|
||||
it('has no target line when input is empty', () => {
|
||||
const card = buildPermissionCard('Bash', {}, 'req')
|
||||
const elements = getBodyElements(card)
|
||||
// elements: [header_md, hr, action_column_set]
|
||||
expect(elements[1].tag).toBe('hr')
|
||||
})
|
||||
|
||||
// ----- Cross-directory detection -----
|
||||
|
||||
it('does NOT show cross-dir warning when file is inside workDir', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Write',
|
||||
{ file_path: '/Users/me/proj/src/a.ts' },
|
||||
'req',
|
||||
'/Users/me/proj',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
const hasWarn = elements.some(
|
||||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||||
)
|
||||
expect(hasWarn).toBe(false)
|
||||
expect((card.header as any).template).toBe('orange')
|
||||
expect((card.header as any).subtitle.content).toBe('Write')
|
||||
})
|
||||
|
||||
it('DOES show cross-dir warning when file is outside workDir (red template)', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Write',
|
||||
{ file_path: '/tmp/evil.sh' },
|
||||
'req',
|
||||
'/Users/me/proj',
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
const warn = elements.find(
|
||||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||||
)
|
||||
expect(warn).toBeDefined()
|
||||
expect((card.header as any).template).toBe('red')
|
||||
expect((card.header as any).subtitle.content).toContain('跨目录')
|
||||
})
|
||||
|
||||
it('does NOT check cross-dir for Bash (no filePath)', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Bash',
|
||||
{ command: 'rm -rf /tmp/x' },
|
||||
'req',
|
||||
'/Users/me/proj',
|
||||
)
|
||||
expect((card.header as any).template).toBe('orange')
|
||||
})
|
||||
|
||||
it('does not warn when workDir is not provided', () => {
|
||||
const card = buildPermissionCard(
|
||||
'Write',
|
||||
{ file_path: '/tmp/x.ts' },
|
||||
'req',
|
||||
// workDir omitted
|
||||
)
|
||||
const elements = getBodyElements(card)
|
||||
const hasWarn = elements.some(
|
||||
(el) => typeof el.content === 'string' && el.content.includes('项目目录之外'),
|
||||
)
|
||||
expect(hasWarn).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feishu: isOutsideWorkDir', () => {
|
||||
it('returns false for file inside workDir', () => {
|
||||
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for file directly in workDir', () => {
|
||||
expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for file in a sibling directory', () => {
|
||||
expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for /tmp file', () => {
|
||||
expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles workDir with trailing slash', () => {
|
||||
expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves relative paths against workDir', () => {
|
||||
expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false)
|
||||
expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match prefix collisions (proj vs proj2)', () => {
|
||||
// /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string
|
||||
// but is NOT inside /Users/me/proj
|
||||
expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feishu: project picker card', () => {
|
||||
const sampleProjects: RecentProject[] = [
|
||||
{
|
||||
projectPath: '/Users/dev/claude-code-haha',
|
||||
realPath: '/Users/dev/claude-code-haha',
|
||||
projectName: 'claude-code-haha',
|
||||
isGit: true,
|
||||
repoName: 'claude-code-haha',
|
||||
branch: 'main',
|
||||
modifiedAt: '2026-04-11T00:00:00Z',
|
||||
sessionCount: 3,
|
||||
},
|
||||
{
|
||||
projectPath: '/Users/dev/desktop',
|
||||
realPath: '/Users/dev/desktop',
|
||||
projectName: 'desktop',
|
||||
isGit: false,
|
||||
repoName: null,
|
||||
branch: null,
|
||||
modifiedAt: '2026-04-10T00:00:00Z',
|
||||
sessionCount: 1,
|
||||
},
|
||||
]
|
||||
|
||||
function getBodyElements(card: Record<string, unknown>): any[] {
|
||||
return ((card.body as any).elements ?? []) as any[]
|
||||
}
|
||||
|
||||
function getRows(card: Record<string, unknown>): any[] {
|
||||
return getBodyElements(card).filter((el) => el.tag === 'column_set')
|
||||
}
|
||||
|
||||
function getRowButton(row: any): any {
|
||||
const buttonCol = row.columns.find((c: any) =>
|
||||
c.elements.some((e: any) => e.tag === 'button'),
|
||||
)
|
||||
return buttonCol.elements.find((e: any) => e.tag === 'button')
|
||||
}
|
||||
|
||||
function getRowInfoElements(row: any): any[] {
|
||||
const infoCol = row.columns.find((c: any) =>
|
||||
c.elements.every((e: any) => e.tag === 'markdown'),
|
||||
)
|
||||
return infoCol.elements
|
||||
}
|
||||
|
||||
it('uses Schema 2.0 with body.elements wrapper', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
expect(card.schema).toBe('2.0')
|
||||
expect((card.config as any).update_multi).toBe(true)
|
||||
expect((card.body as any).elements).toBeDefined()
|
||||
})
|
||||
|
||||
it('header has title and project-count subtitle', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
expect((card.header as any).title.content).toContain('选择项目')
|
||||
expect((card.header as any).subtitle.content).toContain('2')
|
||||
expect((card.header as any).subtitle.content).toContain('最近项目')
|
||||
})
|
||||
|
||||
it('subtitle notes truncation when more than 10 projects exist', () => {
|
||||
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
...sampleProjects[0]!,
|
||||
projectName: `proj-${i}`,
|
||||
realPath: `/p/${i}`,
|
||||
}))
|
||||
const card = buildProjectPickerCard(many)
|
||||
const subtitle = (card.header as any).subtitle.content
|
||||
expect(subtitle).toContain('15')
|
||||
expect(subtitle).toContain('显示前 10')
|
||||
})
|
||||
|
||||
it('body contains one column_set row per project', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
expect(getRows(card).length).toBe(2)
|
||||
})
|
||||
|
||||
it('each row has exactly 2 columns: info (weighted) + button (auto)', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
for (const row of getRows(card)) {
|
||||
expect(row.columns.length).toBe(2)
|
||||
expect(row.columns[0].width).toBe('weighted')
|
||||
expect(row.columns[0].vertical_align).toBe('center')
|
||||
expect(row.columns[1].width).toBe('auto')
|
||||
expect(row.columns[1].vertical_align).toBe('center')
|
||||
}
|
||||
})
|
||||
|
||||
it('info column has title markdown + notation path markdown', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
const row1 = getRows(card)[0]
|
||||
const info = getRowInfoElements(row1)
|
||||
|
||||
expect(info.length).toBe(2)
|
||||
// Title markdown
|
||||
expect(info[0].tag).toBe('markdown')
|
||||
expect(info[0].content).toContain('**claude-code-haha**')
|
||||
expect(info[0].content).toContain('*main*')
|
||||
// Path markdown (notation = small grey)
|
||||
expect(info[1].tag).toBe('markdown')
|
||||
expect(info[1].text_size).toBe('notation')
|
||||
expect(info[1].content).toContain('claude-code-haha')
|
||||
})
|
||||
|
||||
it('row without branch has no separator dot in title', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
const row2 = getRows(card)[1]
|
||||
const title = getRowInfoElements(row2)[0].content
|
||||
expect(title).toContain('**desktop**')
|
||||
expect(title).not.toContain('·')
|
||||
})
|
||||
|
||||
it('row button says 选择 with small size and carries per-project value', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
const rows = getRows(card)
|
||||
|
||||
const btn1 = getRowButton(rows[0])
|
||||
expect(btn1.text.content).toBe('选择')
|
||||
expect(btn1.size).toBe('small')
|
||||
expect(btn1.value.action).toBe('pick_project')
|
||||
expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha')
|
||||
expect(btn1.value.projectName).toBe('claude-code-haha')
|
||||
|
||||
const btn2 = getRowButton(rows[1])
|
||||
expect(btn2.value.realPath).toBe('/Users/dev/desktop')
|
||||
})
|
||||
|
||||
it('first row button is primary, rest are default', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
const rows = getRows(card)
|
||||
expect(getRowButton(rows[0]).type).toBe('primary')
|
||||
expect(getRowButton(rows[1]).type).toBe('default')
|
||||
})
|
||||
|
||||
it('body tail has hr and notation footer hint', () => {
|
||||
const card = buildProjectPickerCard(sampleProjects)
|
||||
const elements = getBodyElements(card)
|
||||
const hrIdx = elements.findIndex((el) => el.tag === 'hr')
|
||||
expect(hrIdx).toBeGreaterThan(0)
|
||||
expect(elements[hrIdx + 1].tag).toBe('markdown')
|
||||
expect(elements[hrIdx + 1].text_size).toBe('notation')
|
||||
})
|
||||
|
||||
it('caps to first 10 projects', () => {
|
||||
const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
...sampleProjects[0]!,
|
||||
projectName: `proj-${i}`,
|
||||
realPath: `/p/${i}`,
|
||||
}))
|
||||
const card = buildProjectPickerCard(many)
|
||||
const rows = getRows(card)
|
||||
expect(rows.length).toBe(10)
|
||||
expect(getRowButton(rows[9]).value.realPath).toBe('/p/9')
|
||||
})
|
||||
|
||||
it('uses ~ shortcut when path is under $HOME', () => {
|
||||
const home = process.env.HOME
|
||||
if (!home) return
|
||||
const project: RecentProject = {
|
||||
...sampleProjects[0]!,
|
||||
realPath: `${home}/some/sub/dir`,
|
||||
projectName: 'sub-dir',
|
||||
}
|
||||
const card = buildProjectPickerCard([project])
|
||||
const pathEl = getRowInfoElements(getRows(card)[0])[1]
|
||||
expect(pathEl.content).toBe('~/some/sub/dir')
|
||||
})
|
||||
|
||||
it('middle-truncates very long paths with ellipsis', () => {
|
||||
const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars
|
||||
const project: RecentProject = {
|
||||
...sampleProjects[0]!,
|
||||
realPath: veryLong,
|
||||
projectName: 'project',
|
||||
}
|
||||
const card = buildProjectPickerCard([project])
|
||||
const content = getRowInfoElements(getRows(card)[0])[1].content
|
||||
expect(content).toContain('…')
|
||||
expect(content.length).toBeLessThanOrEqual(56)
|
||||
expect(content.endsWith('project')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feishu: card.action.trigger parsing', () => {
|
||||
it('parses permit action from event', () => {
|
||||
const event = {
|
||||
operator: { open_id: 'ou_user_1' },
|
||||
action: { value: { action: 'permit', requestId: 'abcde', allowed: true } },
|
||||
context: { open_chat_id: 'oc_chat_123' },
|
||||
}
|
||||
|
||||
expect(event.action.value.action).toBe('permit')
|
||||
expect(event.action.value.requestId).toBe('abcde')
|
||||
expect(event.action.value.allowed).toBe(true)
|
||||
expect(event.context.open_chat_id).toBe('oc_chat_123')
|
||||
})
|
||||
|
||||
it('parses pick_project action from event', () => {
|
||||
const event = {
|
||||
operator: { open_id: 'ou_user_1' },
|
||||
action: {
|
||||
value: {
|
||||
action: 'pick_project',
|
||||
realPath: '/Users/dev/claude-code-haha',
|
||||
projectName: 'claude-code-haha',
|
||||
},
|
||||
},
|
||||
context: { open_chat_id: 'oc_chat_123' },
|
||||
}
|
||||
|
||||
expect(event.action.value.action).toBe('pick_project')
|
||||
expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha')
|
||||
expect(event.action.value.projectName).toBe('claude-code-haha')
|
||||
})
|
||||
|
||||
it('ignores non-handled actions', () => {
|
||||
const event = {
|
||||
action: { value: { action: 'other_action' } },
|
||||
}
|
||||
expect(['permit', 'pick_project']).not.toContain(event.action.value.action)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* FlushController 单元测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - 基础: cardMessageReady gate, complete() 锁死
|
||||
* - 节流窗口: 立即 flush / 延迟 flush
|
||||
* - Mutex: 进行中的 flush 重复调用标记 needsReflush
|
||||
* - Conflict reflush: API 结束后自动补一次
|
||||
* - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush
|
||||
* - waitForFlush: 等当前 flush 结束
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { FlushController, THROTTLE } from '../flush-controller.js'
|
||||
|
||||
// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve
|
||||
function makeControllableFlush() {
|
||||
const calls: string[] = []
|
||||
let resolveCurrent: (() => void) | null = null
|
||||
let latch: Promise<void> | null = null
|
||||
|
||||
const doFlush = async () => {
|
||||
calls.push('flush-start')
|
||||
if (latch) {
|
||||
await latch
|
||||
latch = null
|
||||
}
|
||||
calls.push('flush-end')
|
||||
}
|
||||
|
||||
const blockNext = () => {
|
||||
latch = new Promise<void>((resolve) => {
|
||||
resolveCurrent = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const unblock = () => {
|
||||
if (resolveCurrent) {
|
||||
const r = resolveCurrent
|
||||
resolveCurrent = null
|
||||
r()
|
||||
}
|
||||
}
|
||||
|
||||
return { doFlush, calls, blockNext, unblock }
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic gating
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: cardMessageReady gate', () => {
|
||||
it('在 cardMessageReady=false 时不 flush', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
await fc.flush()
|
||||
await fc.throttledUpdate(50)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
it('setCardMessageReady(true) 后 flush 可执行', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
await fc.flush()
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
// 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支
|
||||
await fc.throttledUpdate(500)
|
||||
// 同步阶段还没到 500ms,不应触发 flush
|
||||
expect(count).toBe(0)
|
||||
// 500+ms 后延迟 timer 触发
|
||||
await sleep(600)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FlushController: complete()', () => {
|
||||
it('complete() 后拒绝新 flush', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
fc.complete()
|
||||
await fc.flush()
|
||||
await fc.throttledUpdate(50)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throttle window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: 节流窗口', () => {
|
||||
it('超过窗口立即 flush', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
// 手动把 lastUpdateTime 挪远(等同于已过了节流窗口)
|
||||
await sleep(150)
|
||||
await fc.throttledUpdate(100)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('在窗口内首次调用安排延迟 flush', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true) // lastUpdateTime = now
|
||||
|
||||
await fc.throttledUpdate(200)
|
||||
expect(count).toBe(0) // 延迟中,还没触发
|
||||
|
||||
await sleep(300)
|
||||
expect(count).toBe(1) // 200ms 后延迟 timer 触发
|
||||
})
|
||||
|
||||
it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => {
|
||||
let count = 0
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
|
||||
await fc.throttledUpdate(200)
|
||||
await fc.throttledUpdate(200)
|
||||
await fc.throttledUpdate(200)
|
||||
await fc.throttledUpdate(200)
|
||||
|
||||
await sleep(300)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutex + conflict reflush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: mutex + 冲突重刷', () => {
|
||||
it('flush 进行中的重复调用不并发执行', async () => {
|
||||
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
|
||||
const fc = new FlushController(doFlush)
|
||||
fc.setCardMessageReady(true)
|
||||
|
||||
blockNext()
|
||||
const p1 = fc.flush() // flush-start 后被 latch 卡住
|
||||
// 让事件循环走一轮,确保第一次 flush 进入 body
|
||||
await sleep(10)
|
||||
// 第二次调用时第一次还没结束 —— 应被 mutex 挡住
|
||||
const p2 = fc.flush()
|
||||
|
||||
// 两次 Promise 都已登记,但都还没 end
|
||||
expect(calls).toEqual(['flush-start'])
|
||||
|
||||
unblock()
|
||||
await p1
|
||||
await p2
|
||||
// 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷
|
||||
// (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完)
|
||||
await sleep(20)
|
||||
|
||||
// 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end
|
||||
expect(calls).toEqual([
|
||||
'flush-start', 'flush-end',
|
||||
'flush-start', 'flush-end',
|
||||
])
|
||||
})
|
||||
|
||||
it('flush 进行中的 throttledUpdate 也会触发补刷', async () => {
|
||||
const { doFlush, calls, blockNext, unblock } = makeControllableFlush()
|
||||
const fc = new FlushController(doFlush)
|
||||
fc.setCardMessageReady(true)
|
||||
|
||||
blockNext()
|
||||
const p1 = fc.flush()
|
||||
await sleep(10)
|
||||
|
||||
// API 进行中收到新的 update 请求
|
||||
await fc.throttledUpdate(10)
|
||||
|
||||
unblock()
|
||||
await p1
|
||||
await sleep(30)
|
||||
|
||||
// 第一次 flush + 冲突补刷
|
||||
expect(calls.filter((c) => c === 'flush-end').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Long gap batching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: 长间隔批量', () => {
|
||||
it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => {
|
||||
let count = 0
|
||||
let flushAtMs = 0
|
||||
const start = Date.now()
|
||||
const fc = new FlushController(async () => {
|
||||
count += 1
|
||||
flushAtMs = Date.now() - start
|
||||
})
|
||||
fc.setCardMessageReady(true)
|
||||
|
||||
// 等到 elapsed > 2000ms
|
||||
await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50)
|
||||
const callAt = Date.now() - start
|
||||
|
||||
await fc.throttledUpdate(THROTTLE.CARDKIT_MS)
|
||||
// throttledUpdate 同步阶段不应立即 flush(因为走批量分支)
|
||||
expect(count).toBe(0)
|
||||
|
||||
// 等 BATCH_AFTER_GAP_MS + 余量
|
||||
await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50)
|
||||
expect(count).toBe(1)
|
||||
// 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms
|
||||
expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// waitForFlush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: waitForFlush', () => {
|
||||
it('没在 flush 时立即返回', async () => {
|
||||
const fc = new FlushController(async () => {})
|
||||
fc.setCardMessageReady(true)
|
||||
const start = Date.now()
|
||||
await fc.waitForFlush()
|
||||
expect(Date.now() - start).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('有 flush 在跑时等它结束', async () => {
|
||||
const { doFlush, blockNext, unblock } = makeControllableFlush()
|
||||
const fc = new FlushController(doFlush)
|
||||
fc.setCardMessageReady(true)
|
||||
|
||||
blockNext()
|
||||
const p1 = fc.flush()
|
||||
await sleep(10)
|
||||
|
||||
let resolved = false
|
||||
const waiter = fc.waitForFlush().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await sleep(20)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
unblock()
|
||||
await p1
|
||||
await waiter
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 常量合理性
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FlushController: THROTTLE 常量', () => {
|
||||
it('CARDKIT_MS=100, PATCH_MS=1500', () => {
|
||||
expect(THROTTLE.CARDKIT_MS).toBe(100)
|
||||
expect(THROTTLE.PATCH_MS).toBe(1500)
|
||||
})
|
||||
it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => {
|
||||
expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000)
|
||||
expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* markdown-style 单元测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - 标题降级 (H1~H3 workaround)
|
||||
* - 代码块保护
|
||||
* - 空行压缩
|
||||
* - Schema 2.0: 连续标题/表格/代码块 <br> 间距
|
||||
* - stripInvalidImageKeys
|
||||
* - sanitizeTextForCard (表格数限制)
|
||||
* - findMarkdownTablesOutsideCodeBlocks
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
optimizeMarkdownForFeishu,
|
||||
sanitizeTextForCard,
|
||||
findMarkdownTablesOutsideCodeBlocks,
|
||||
FEISHU_CARD_TABLE_LIMIT,
|
||||
} from '../markdown-style.js'
|
||||
|
||||
// 默认 cardVersion=2 的 shortcut
|
||||
const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 标题降级
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: 标题降级', () => {
|
||||
it('H1 → H4 (cardVersion=1 简化检查)', () => {
|
||||
expect(opt('# Title', 1)).toBe('#### Title')
|
||||
})
|
||||
|
||||
it('H2 → H5 (cardVersion=1)', () => {
|
||||
expect(opt('## Title', 1)).toBe('##### Title')
|
||||
})
|
||||
|
||||
it('H3 → H5 (cardVersion=1)', () => {
|
||||
expect(opt('### Title', 1)).toBe('##### Title')
|
||||
})
|
||||
|
||||
it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => {
|
||||
expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3')
|
||||
})
|
||||
|
||||
it('纯 H4 文档不触发降级 (cardVersion=1)', () => {
|
||||
// 触发条件: 原文必须有 H1~H3
|
||||
expect(opt('#### Already H4', 1)).toBe('#### Already H4')
|
||||
})
|
||||
|
||||
it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => {
|
||||
expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub')
|
||||
})
|
||||
|
||||
it('# 后必须有空格才算标题', () => {
|
||||
expect(opt('#notaheading', 1)).toBe('#notaheading')
|
||||
})
|
||||
|
||||
it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => {
|
||||
// openclaw-lark 源码里的关键注释:顺序不能颠倒
|
||||
expect(opt('# Top', 1)).toBe('#### Top')
|
||||
})
|
||||
|
||||
it('无标题文本原样返回', () => {
|
||||
expect(opt('just plain text', 1)).toBe('just plain text')
|
||||
})
|
||||
|
||||
it('默认 cardVersion=2 下也能正确降级标题', () => {
|
||||
const out = opt('# Title')
|
||||
expect(out).toContain('#### Title')
|
||||
expect(out).not.toMatch(/^# Title$/m)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 代码块保护
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: 代码块保护', () => {
|
||||
it('代码块内的 # 不被降级 (cardVersion=1)', () => {
|
||||
const input = '```\n# not a heading\n## also not\n```'
|
||||
expect(opt(input, 1)).toBe(input)
|
||||
})
|
||||
|
||||
it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => {
|
||||
const input = '# Real heading\n\n```\n# inside code\n```'
|
||||
expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```')
|
||||
})
|
||||
|
||||
it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => {
|
||||
const input = '## Section\n\n```python\n# python comment\n### not a heading\n```'
|
||||
expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```')
|
||||
})
|
||||
|
||||
it('多个代码块按顺序保护与还原 (cardVersion=1)', () => {
|
||||
const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```'
|
||||
expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```')
|
||||
})
|
||||
|
||||
it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => {
|
||||
const out = opt('# Heading\n\n```\n# inside\n```')
|
||||
expect(out).toContain('#### Heading') // 外部 H1 降级
|
||||
expect(out).toContain('# inside') // 代码块内保留
|
||||
expect(out).toContain('```') // fence 保留
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 空行压缩
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: 空行压缩', () => {
|
||||
it('3 个换行 → 2 个 (cardVersion=1)', () => {
|
||||
expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2')
|
||||
})
|
||||
|
||||
it('5 个换行 → 2 个 (cardVersion=1)', () => {
|
||||
expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2')
|
||||
})
|
||||
|
||||
it('2 个换行保留 (cardVersion=1)', () => {
|
||||
expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2')
|
||||
})
|
||||
|
||||
it('代码块内部连续换行被保留 (cardVersion=1)', () => {
|
||||
const input = '# Title\n\n```\nline1\n\n\nline2\n```'
|
||||
expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema 2.0: <br> 间距
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
|
||||
it('cardVersion=2 默认在代码块前后加 <br>', () => {
|
||||
const out = opt('text\n\n```\ncode\n```')
|
||||
// 代码块前后应包裹 <br>
|
||||
expect(out).toContain('<br>\n```')
|
||||
expect(out).toContain('```\n<br>')
|
||||
})
|
||||
|
||||
it('cardVersion=1 代码块前后不加 <br>', () => {
|
||||
const out = opt('text\n\n```\ncode\n```', 1)
|
||||
expect(out).not.toContain('<br>')
|
||||
})
|
||||
|
||||
it('cardVersion=2 连续标题之间加 <br>', () => {
|
||||
const out = opt('# A\n# B')
|
||||
// H1 降级为 H4,之间插入 <br>
|
||||
expect(out).toMatch(/#### A\n<br>\n#### B/)
|
||||
})
|
||||
|
||||
it('cardVersion=2 表格前后加 <br>', () => {
|
||||
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
||||
const out = opt(input)
|
||||
// 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
|
||||
expect(out).toMatch(/text before\n<br>\n\| col1/)
|
||||
// 表格后: <br> 跟两个换行到下一段文本
|
||||
expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
|
||||
})
|
||||
|
||||
it('cardVersion=1 表格前后不加 <br>', () => {
|
||||
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
|
||||
const out = opt(input, 1)
|
||||
expect(out).not.toContain('<br>')
|
||||
})
|
||||
|
||||
it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => {
|
||||
const input = '```\n| in code | not a table |\n|---|---|\n```'
|
||||
const out = opt(input)
|
||||
// 代码块本体应完整保留
|
||||
expect(out).toContain('| in code | not a table |')
|
||||
// 代码块外应该没有出现表格 br 标记(因为代码块内不算表格)
|
||||
// 代码块本身会被 <br> 包裹(Schema 2.0)但不会在 | 周围单独加 <br>
|
||||
expect(out).toMatch(/<br>\n```\n\| in code/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripInvalidImageKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => {
|
||||
it('img_* 图片 key 保留', () => {
|
||||
const out = opt('前缀  后缀')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('http:// URL 图片被删除', () => {
|
||||
const out = opt('前缀  后缀')
|
||||
expect(out).toBe('前缀 后缀')
|
||||
})
|
||||
|
||||
it('https:// URL 图片被删除', () => {
|
||||
const out = opt('')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('本地路径被删除', () => {
|
||||
const out = opt('')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('无图片文本原样', () => {
|
||||
expect(opt('no images here', 1)).toBe('no images here')
|
||||
})
|
||||
|
||||
it('混合: img_ 保留,URL 删除', () => {
|
||||
const out = opt(' 和 ')
|
||||
expect(out).toContain('')
|
||||
expect(out).not.toContain('bad.com')
|
||||
expect(out).not.toContain('![drop]')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findMarkdownTablesOutsideCodeBlocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findMarkdownTablesOutsideCodeBlocks', () => {
|
||||
it('识别单张表格', () => {
|
||||
const text = '| a | b |\n|---|---|\n| 1 | 2 |'
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
expect(matches.length).toBe(1)
|
||||
expect(matches[0]!.raw).toContain('| a | b |')
|
||||
})
|
||||
|
||||
it('识别多张表格', () => {
|
||||
const text =
|
||||
'| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |'
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
expect(matches.length).toBe(2)
|
||||
})
|
||||
|
||||
it('代码块内的 | 不被算作表格', () => {
|
||||
const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```'
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
expect(matches.length).toBe(0)
|
||||
})
|
||||
|
||||
it('代码块 + 外部表格: 只识别外部的', () => {
|
||||
const text =
|
||||
'```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |'
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
expect(matches.length).toBe(1)
|
||||
expect(matches[0]!.raw).toContain('real')
|
||||
})
|
||||
|
||||
it('无表格文本返回空数组', () => {
|
||||
expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeTextForCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sanitizeTextForCard: 表格数量限制', () => {
|
||||
function makeTable(label: string): string {
|
||||
return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |`
|
||||
}
|
||||
|
||||
it('表格数 ≤ 3 时原样返回', () => {
|
||||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||||
expect(sanitizeTextForCard(text)).toBe(text)
|
||||
})
|
||||
|
||||
it('恰好 3 张表格原样返回', () => {
|
||||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
expect(matches.length).toBe(3)
|
||||
expect(sanitizeTextForCard(text)).toBe(text)
|
||||
})
|
||||
|
||||
it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => {
|
||||
const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n')
|
||||
const out = sanitizeTextForCard(text)
|
||||
// 前 3 张表格原样
|
||||
expect(out).toContain(makeTable('A'))
|
||||
expect(out).toContain(makeTable('B'))
|
||||
expect(out).toContain(makeTable('C'))
|
||||
// 第 4 张被包裹
|
||||
expect(out).toContain('```\n' + makeTable('D') + '\n```')
|
||||
})
|
||||
|
||||
it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => {
|
||||
const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n')
|
||||
const out = sanitizeTextForCard(text, 1)
|
||||
expect(out).toContain(makeTable('A'))
|
||||
expect(out).toContain('```\n' + makeTable('B') + '\n```')
|
||||
expect(out).toContain('```\n' + makeTable('C') + '\n```')
|
||||
})
|
||||
|
||||
it('limit=0: 全部包裹', () => {
|
||||
const text = makeTable('Solo')
|
||||
const out = sanitizeTextForCard(text, 0)
|
||||
expect(out).toContain('```\n' + makeTable('Solo') + '\n```')
|
||||
})
|
||||
|
||||
it('无表格原样返回', () => {
|
||||
expect(sanitizeTextForCard('no tables here')).toBe('no tables here')
|
||||
})
|
||||
|
||||
it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => {
|
||||
expect(FEISHU_CARD_TABLE_LIMIT).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 边界与真实场景
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('optimizeMarkdownForFeishu: 边界与真实场景', () => {
|
||||
it('screenshot 里的 OpenCutSkill 项目结构报告', () => {
|
||||
const input = `## OpenCutSkill 项目架构概览
|
||||
|
||||
### 1. 项目定位
|
||||
|
||||
Screen Studio 视频自动剪辑工具。
|
||||
|
||||
### 2. 模块结构
|
||||
|
||||
\`\`\`
|
||||
opencutskill/
|
||||
├── cli/
|
||||
├── core/
|
||||
└── tests/
|
||||
\`\`\``
|
||||
const out = opt(input)
|
||||
// 所有 H2~H3 应被降级为 H5
|
||||
expect(out).toContain('##### OpenCutSkill 项目架构概览')
|
||||
expect(out).toContain('##### 1. 项目定位')
|
||||
expect(out).toContain('##### 2. 模块结构')
|
||||
// 代码块内容原封不动
|
||||
expect(out).toContain('opencutskill/')
|
||||
expect(out).toContain('├── cli/')
|
||||
// 原始 ## 字面量不残留
|
||||
expect(out).not.toMatch(/^## OpenCutSkill/m)
|
||||
expect(out).not.toMatch(/^### 1\./m)
|
||||
// 代码块前后有 <br>(Schema 2.0 默认)
|
||||
expect(out).toContain('<br>\n```')
|
||||
expect(out).toContain('```\n<br>')
|
||||
})
|
||||
|
||||
it('异常输入 fallback 到原文不抛错', () => {
|
||||
expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow()
|
||||
})
|
||||
|
||||
it('空字符串返回空字符串', () => {
|
||||
expect(opt('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { FeishuMediaService } from '../media.js'
|
||||
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
|
||||
|
||||
function makeMockClient() {
|
||||
return {
|
||||
im: {
|
||||
messageResource: {
|
||||
get: mock(async () => ({
|
||||
// node-sdk returns an object with a `.writeFile(path)` helper
|
||||
// that dumps the underlying stream. We fake that here.
|
||||
writeFile: async (target: string) => {
|
||||
await fs.writeFile(target, Buffer.from('DOWNLOADED'))
|
||||
},
|
||||
})),
|
||||
},
|
||||
image: {
|
||||
create: mock(async (_req: any) => ({
|
||||
data: { image_key: 'img_fake_123' },
|
||||
})),
|
||||
},
|
||||
file: {
|
||||
create: mock(async (_req: any) => ({
|
||||
data: { file_key: 'file_fake_456' },
|
||||
})),
|
||||
},
|
||||
message: {
|
||||
create: mock(async (_req: any) => ({
|
||||
data: { message_id: 'om_fake' },
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('FeishuMediaService', () => {
|
||||
it('downloadResource writes a local file and returns LocalAttachment', async () => {
|
||||
const client = makeMockClient()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new FeishuMediaService(client as any, store)
|
||||
const local = await svc.downloadResource({
|
||||
messageId: 'om_msg_1',
|
||||
fileKey: 'img_key_1',
|
||||
kind: 'image',
|
||||
fileName: 'cat.png',
|
||||
sessionId: 'sess-1',
|
||||
})
|
||||
expect(local.kind).toBe('image')
|
||||
expect(local.name).toBe('cat.png')
|
||||
expect(local.size).toBe('DOWNLOADED'.length)
|
||||
expect(local.path).toContain(path.join('feishu', 'sess-1'))
|
||||
const onDisk = await fs.readFile(local.path)
|
||||
expect(onDisk.toString()).toBe('DOWNLOADED')
|
||||
expect(client.im.messageResource.get).toHaveBeenCalledTimes(1)
|
||||
const call = (client.im.messageResource.get as any).mock.calls[0][0]
|
||||
expect(call.path.message_id).toBe('om_msg_1')
|
||||
expect(call.path.file_key).toBe('img_key_1')
|
||||
expect(call.params.type).toBe('image')
|
||||
})
|
||||
|
||||
it('uploadImage returns an image_key and sends the buffer through', async () => {
|
||||
const client = makeMockClient()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new FeishuMediaService(client as any, store)
|
||||
const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png')
|
||||
expect(key).toBe('img_fake_123')
|
||||
expect(client.im.image.create).toHaveBeenCalledTimes(1)
|
||||
const call = (client.im.image.create as any).mock.calls[0][0]
|
||||
expect(call.data.image_type).toBe('message')
|
||||
expect(call.data.image).toBeDefined()
|
||||
})
|
||||
|
||||
it('uploadFile returns a file_key and uses stream file_type mapping', async () => {
|
||||
const client = makeMockClient()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new FeishuMediaService(client as any, store)
|
||||
const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf')
|
||||
expect(key).toBe('file_fake_456')
|
||||
const call = (client.im.file.create as any).mock.calls[0][0]
|
||||
expect(call.data.file_name).toBe('report.pdf')
|
||||
expect(call.data.file_type).toBe('pdf')
|
||||
})
|
||||
|
||||
it('sendImageMessage posts msg_type=image', async () => {
|
||||
const client = makeMockClient()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new FeishuMediaService(client as any, store)
|
||||
await svc.sendImageMessage('oc_chat_1', 'img_fake_123')
|
||||
const call = (client.im.message.create as any).mock.calls[0][0]
|
||||
expect(call.params.receive_id_type).toBe('chat_id')
|
||||
expect(call.data.receive_id).toBe('oc_chat_1')
|
||||
expect(call.data.msg_type).toBe('image')
|
||||
const content = JSON.parse(call.data.content)
|
||||
expect(content.image_key).toBe('img_fake_123')
|
||||
})
|
||||
|
||||
it('sendFileMessage posts msg_type=file', async () => {
|
||||
const client = makeMockClient()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new FeishuMediaService(client as any, store)
|
||||
await svc.sendFileMessage('oc_chat_1', 'file_fake_456')
|
||||
const call = (client.im.message.create as any).mock.calls[0][0]
|
||||
expect(call.data.msg_type).toBe('file')
|
||||
const content = JSON.parse(call.data.content)
|
||||
expect(content.file_key).toBe('file_fake_456')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,914 @@
|
||||
/**
|
||||
* StreamingCard 生命周期测试
|
||||
*
|
||||
* 用 mock Lark client 覆盖:
|
||||
* - ensureCreated: 成功路径 / 降级路径
|
||||
* - appendText: 累积 + 触发 throttled flush
|
||||
* - finalize: settings(false) + update 顺序、sequence 单调递增
|
||||
* - abort: 渲染错误卡片
|
||||
* - 230020 → 跳帧
|
||||
* - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
|
||||
* - 纯 patch fallback 路径
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
StreamingCard,
|
||||
buildInitialStreamingCard,
|
||||
buildRenderedCard,
|
||||
buildErrorCard,
|
||||
} from '../streaming-card.js'
|
||||
import { STREAMING_ELEMENT_ID } from '../cardkit.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiCall = { api: string; args: any }
|
||||
|
||||
type MockBehavior = {
|
||||
'card.create'?: any | ((args: any) => any)
|
||||
'card.settings'?: any | ((args: any) => any)
|
||||
'card.update'?: any | ((args: any) => any)
|
||||
'cardElement.content'?: any | ((args: any, callIdx: number) => any)
|
||||
'im.message.create'?: any | ((args: any) => any)
|
||||
'im.message.reply'?: any | ((args: any) => any)
|
||||
'im.message.patch'?: any | ((args: any, callIdx: number) => any)
|
||||
}
|
||||
|
||||
function makeMockClient(behavior: MockBehavior = {}) {
|
||||
const calls: ApiCall[] = []
|
||||
let contentCallIdx = 0
|
||||
let patchCallIdx = 0
|
||||
|
||||
function handle(api: string, resp: any, args: any, idx?: number): any {
|
||||
calls.push({ api, args })
|
||||
if (typeof resp === 'function') return resp(args, idx ?? 0)
|
||||
return resp
|
||||
}
|
||||
|
||||
const client: any = {
|
||||
cardkit: {
|
||||
v1: {
|
||||
card: {
|
||||
create: async (args: any) =>
|
||||
handle('cardkit.v1.card.create', behavior['card.create'] ?? {
|
||||
code: 0, data: { card_id: 'ck_default' },
|
||||
}, args),
|
||||
settings: async (args: any) =>
|
||||
handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
|
||||
update: async (args: any) =>
|
||||
handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
|
||||
},
|
||||
cardElement: {
|
||||
content: async (args: any) => {
|
||||
const idx = contentCallIdx++
|
||||
return handle('cardkit.v1.cardElement.content',
|
||||
behavior['cardElement.content'] ?? { code: 0 }, args, idx)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
im: {
|
||||
message: {
|
||||
create: async (args: any) =>
|
||||
handle('im.message.create', behavior['im.message.create'] ?? {
|
||||
data: { message_id: 'om_default' },
|
||||
}, args),
|
||||
reply: async (args: any) =>
|
||||
handle('im.message.reply', behavior['im.message.reply'] ?? {
|
||||
data: { message_id: 'om_reply_default' },
|
||||
}, args),
|
||||
patch: async (args: any) => {
|
||||
const idx = patchCallIdx++
|
||||
return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card JSON builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildInitialStreamingCard', () => {
|
||||
it('Schema 2.0 + streaming_mode + element_id', () => {
|
||||
const card = buildInitialStreamingCard() as any
|
||||
expect(card.schema).toBe('2.0')
|
||||
expect(card.config.streaming_mode).toBe(true)
|
||||
// 唯一元素:streaming_content,初始内容为 loading 提示
|
||||
const elements = card.body.elements as any[]
|
||||
expect(elements.length).toBe(1)
|
||||
const streaming = elements[0]
|
||||
expect(streaming.tag).toBe('markdown')
|
||||
expect(streaming.content).toContain('正在思考中')
|
||||
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRenderedCard', () => {
|
||||
it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
|
||||
const card = buildRenderedCard('hello world') as any
|
||||
expect(card.schema).toBe('2.0')
|
||||
expect(card.config.streaming_mode).toBeUndefined()
|
||||
expect(card.body.elements.length).toBe(1)
|
||||
const el = card.body.elements[0]
|
||||
expect(el.tag).toBe('markdown')
|
||||
expect(el.content).toBe('hello world')
|
||||
// 最终卡无需 element_id
|
||||
expect(el.element_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('空字符串保底为单空格', () => {
|
||||
const card = buildRenderedCard('') as any
|
||||
expect(card.body.elements[0].content).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildErrorCard', () => {
|
||||
it('红色 header + markdown body', () => {
|
||||
const card = buildErrorCard('oops') as any
|
||||
expect((card.header as any).template).toBe('red')
|
||||
expect((card.header as any).title.content).toContain('出错')
|
||||
expect(card.body.elements[0].content).toBe('oops')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingCard lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
|
||||
it('依次调用 card.create + im.message.create,sequence=1', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
|
||||
'im.message.create': { data: { message_id: 'om_main_1' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBe('ck_main_1')
|
||||
expect(sc._getMessageId()).toBe('om_main_1')
|
||||
expect(sc._getSequence()).toBe(1)
|
||||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||||
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||||
expect(calls[1]!.api).toBe('im.message.create')
|
||||
|
||||
// 初始卡 JSON 包含 streaming_mode 和 element_id
|
||||
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
||||
expect(cardJson.schema).toBe('2.0')
|
||||
expect(cardJson.config.streaming_mode).toBe(true)
|
||||
// 唯一元素即 streaming_content
|
||||
expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID)
|
||||
|
||||
// IM message 引用 card_id
|
||||
const content = JSON.parse(calls[1]!.args.data.content)
|
||||
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
|
||||
})
|
||||
|
||||
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_1' } },
|
||||
'im.message.create': { data: { message_id: 'om_1' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
await sc.ensureCreated()
|
||||
await sc.ensureCreated()
|
||||
// 只一次 create + 一次 send
|
||||
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
|
||||
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
|
||||
expect(createCalls.length).toBe(1)
|
||||
expect(sendCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.reply': { data: { message_id: 'om_reply' } },
|
||||
})
|
||||
const sc = new StreamingCard({
|
||||
larkClient: client,
|
||||
chatId: 'c',
|
||||
replyToMessageId: 'om_parent',
|
||||
})
|
||||
await sc.ensureCreated()
|
||||
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
|
||||
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
|
||||
expect(sc._getMessageId()).toBe('om_reply')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
|
||||
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 99991672, msg: 'permission denied' },
|
||||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBeNull()
|
||||
expect(sc._getMessageId()).toBe('om_fb')
|
||||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||||
|
||||
// fallback 发送的是 Schema 2.0 interactive 卡
|
||||
const createCall = calls.find((c) => c.api === 'im.message.create')
|
||||
expect(createCall).toBeDefined()
|
||||
expect(createCall!.args.data.msg_type).toBe('interactive')
|
||||
const cardContent = JSON.parse(createCall!.args.data.content)
|
||||
expect(cardContent.schema).toBe('2.0')
|
||||
})
|
||||
|
||||
it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
|
||||
let sendCallCount = 0
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': () => {
|
||||
sendCallCount++
|
||||
if (sendCallCount === 1) throw new Error('send failed')
|
||||
return { data: { message_id: 'om_fb2' } }
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBeNull()
|
||||
expect(sc._getMessageId()).toBe('om_fb2')
|
||||
})
|
||||
|
||||
it('降级发送也失败 → aborted + throw', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 99991672 },
|
||||
'im.message.create': () => {
|
||||
throw new Error('really broken')
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await expect(sc.ensureCreated()).rejects.toThrow()
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// appendText + flush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: appendText + flush', () => {
|
||||
it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
// 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
|
||||
sc.appendText('Hello ')
|
||||
sc.appendText('world')
|
||||
|
||||
// 节流窗口 100ms + 余量
|
||||
await sleep(150)
|
||||
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThan(0)
|
||||
// 最后一次 flush 的内容应包含完整累积文本
|
||||
const lastCall = contentCalls[contentCalls.length - 1]!
|
||||
expect(lastCall.args.data.content).toContain('Hello world')
|
||||
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
|
||||
// sequence 严格单调递增
|
||||
const seqs = contentCalls.map((c) => c.args.data.sequence)
|
||||
for (let i = 1; i < seqs.length; i++) {
|
||||
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.appendText('same')
|
||||
await sleep(150)
|
||||
|
||||
// 强制再跑一次 flush(无新文本)
|
||||
await sc._getFlushController().flush()
|
||||
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
// 应该只有一次 content 调用
|
||||
expect(contentCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('completed 之后的 appendText 被忽略', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
await sc.finalize()
|
||||
sc.appendText('ignored')
|
||||
expect(sc._getAccumulatedText()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// finalize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: finalize', () => {
|
||||
it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_final' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('# Title\n\nBody')
|
||||
await sleep(150)
|
||||
const contentSeqs = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.map((c) => c.args.data.sequence)
|
||||
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
|
||||
|
||||
await sc.finalize()
|
||||
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
|
||||
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
|
||||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||||
expect(settingsCalls.length).toBe(1)
|
||||
expect(updateCalls.length).toBe(1)
|
||||
|
||||
const settingsSeq = settingsCalls[0]!.args.data.sequence
|
||||
const updateSeq = updateCalls[0]!.args.data.sequence
|
||||
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
|
||||
expect(updateSeq).toBeGreaterThan(settingsSeq)
|
||||
|
||||
// settings 关闭 streaming_mode
|
||||
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
|
||||
expect(settings.streaming_mode).toBe(false)
|
||||
|
||||
// update 卡内容是预处理后的 markdown
|
||||
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
|
||||
const finalContent = finalCardJson.body.elements[0].content
|
||||
// H1 被降级为 H4
|
||||
expect(finalContent).toContain('#### Title')
|
||||
expect(finalContent).toContain('Body')
|
||||
})
|
||||
|
||||
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 99991672 },
|
||||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('## Heading\n\nContent')
|
||||
await sleep(1600) // 等 PATCH_MS 窗口
|
||||
await sc.finalize()
|
||||
|
||||
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
|
||||
expect(patchCalls.length).toBeGreaterThan(0)
|
||||
// 最后一次 patch 是 finalize 的(full final card)
|
||||
const lastPatch = patchCalls[patchCalls.length - 1]!
|
||||
const finalCard = JSON.parse(lastPatch.args.data.content)
|
||||
const finalContent = finalCard.body.elements[0].content
|
||||
// ## → ##### 降级
|
||||
expect(finalContent).toContain('##### Heading')
|
||||
})
|
||||
|
||||
it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
|
||||
const { client } = makeMockClient()
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.finalize()
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
})
|
||||
|
||||
it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_term' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
// 同时塞入三种内容
|
||||
sc.appendReasoning('Let me think about this problem carefully...')
|
||||
sc.startTool('tu_1', 'Read')
|
||||
sc.completeTool('tu_1', 'Read')
|
||||
sc.appendText('## 答复\n\n这是最终答复正文。')
|
||||
await sleep(150)
|
||||
|
||||
// 流式中间帧应该包含 reasoning + tools + answer 全套
|
||||
const lastMidFrame = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
expect(lastMidFrame).toContain('思考中')
|
||||
expect(lastMidFrame).toContain('Read')
|
||||
expect(lastMidFrame).toContain('最终答复正文')
|
||||
|
||||
await sc.finalize()
|
||||
|
||||
// finalize 用的是 card.update,把整张卡换成只有 answer 的版本
|
||||
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
|
||||
const finalCardJson = JSON.parse(updateCall.args.data.card.data)
|
||||
const finalContent = finalCardJson.body.elements[0].content as string
|
||||
|
||||
expect(finalContent).toContain('最终答复正文')
|
||||
// H2 → 降级 H5
|
||||
expect(finalContent).toContain('##### 答复')
|
||||
// reasoning + tools 都不应该出现在终态
|
||||
expect(finalContent).not.toContain('思考中')
|
||||
expect(finalContent).not.toContain('think about this problem')
|
||||
expect(finalContent).not.toContain('Read')
|
||||
expect(finalContent).not.toContain('🛠️')
|
||||
expect(finalContent).not.toContain('💭')
|
||||
})
|
||||
|
||||
it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_no_answer' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
// 只有推理,没有 appendText —— 异常 case 但要可控降级
|
||||
sc.appendReasoning('I was thinking but never produced an answer.')
|
||||
await sleep(150)
|
||||
|
||||
await sc.finalize()
|
||||
const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()!
|
||||
const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string
|
||||
// 至少能看到推理内容
|
||||
expect(finalContent).toContain('thinking')
|
||||
})
|
||||
|
||||
it('finalize 失败不抛出', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'card.settings': () => {
|
||||
throw new Error('settings exploded')
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('text')
|
||||
await sleep(150)
|
||||
// finalize 内部捕获错误不 rethrow
|
||||
await sc.finalize()
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limit + table limit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: 错误处理', () => {
|
||||
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
|
||||
let callIdx = 0
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'cardElement.content': () => {
|
||||
const i = callIdx++
|
||||
if (i === 0) {
|
||||
const err: any = new Error('rate limit')
|
||||
err.code = 230020
|
||||
throw err
|
||||
}
|
||||
return { code: 0 }
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('first')
|
||||
await sleep(150)
|
||||
// 第一次被限流
|
||||
sc.appendText(' second')
|
||||
await sleep(150)
|
||||
// 第二次应能成功
|
||||
|
||||
// CardKit 仍然 active(没降级)
|
||||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'cardElement.content': () => {
|
||||
const err: any = new Error('content failed')
|
||||
err.code = 230099
|
||||
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
|
||||
throw err
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('some content')
|
||||
await sleep(150)
|
||||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||||
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
|
||||
|
||||
await sc.finalize()
|
||||
// finalize 仍然走 CardKit 的 settings + update(cardId 还在)
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
||||
// 不走 patch
|
||||
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// abort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: abort', () => {
|
||||
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_err' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('partial...')
|
||||
await sleep(150)
|
||||
|
||||
await sc.abort(new Error('something went wrong'))
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
|
||||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||||
expect(updateCalls.length).toBeGreaterThan(0)
|
||||
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
|
||||
expect(errCard.header.template).toBe('red')
|
||||
expect(errCard.body.elements[0].content).toContain('something went wrong')
|
||||
// 保留已累积的部分文本
|
||||
expect(errCard.body.elements[0].content).toContain('partial...')
|
||||
})
|
||||
|
||||
it('idle 阶段 abort 不抛错', async () => {
|
||||
const { client } = makeMockClient()
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.abort(new Error('before any card'))
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning / tool use rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: appendReasoning', () => {
|
||||
it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_think' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.appendReasoning('Analyzing the problem. ')
|
||||
sc.appendReasoning('Let me check file A.')
|
||||
await sleep(150)
|
||||
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThan(0)
|
||||
const last = contentCalls[contentCalls.length - 1]!
|
||||
expect(last.args.data.content).toContain('💭')
|
||||
expect(last.args.data.content).toContain('思考中')
|
||||
expect(last.args.data.content).toContain('Analyzing the problem.')
|
||||
expect(last.args.data.content).toContain('Let me check file A.')
|
||||
// 没有 blockquote `>` 前缀 —— 这是新格式的关键
|
||||
expect(last.args.data.content).not.toContain('> Analyzing')
|
||||
// 没有 appendText → 不应有普通正文
|
||||
expect(sc._getAccumulatedReasoning()).toContain('Analyzing')
|
||||
expect(sc._getAccumulatedText()).toBe('')
|
||||
})
|
||||
|
||||
it('completed 之后 appendReasoning 被忽略', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
await sc.finalize()
|
||||
sc.appendReasoning('too late')
|
||||
expect(sc._getAccumulatedReasoning()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingCard: startTool / completeTool', () => {
|
||||
it('startTool 压入 running 步骤,completeTool 翻到 done', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_tool' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.startTool('tu_1', 'Read')
|
||||
await sleep(150)
|
||||
let steps = sc._getToolSteps()
|
||||
expect(steps.length).toBe(1)
|
||||
expect(steps[0]!.name).toBe('Read')
|
||||
expect(steps[0]!.status).toBe('running')
|
||||
|
||||
// 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式)
|
||||
const runningContent = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.map((c) => c.args.data.content)
|
||||
.join('\n')
|
||||
expect(runningContent).toContain('⚙️')
|
||||
expect(runningContent).toContain('Read')
|
||||
expect(runningContent).toContain('🛠️')
|
||||
|
||||
sc.completeTool('tu_1', 'Read')
|
||||
await sleep(150)
|
||||
steps = sc._getToolSteps()
|
||||
expect(steps[0]!.status).toBe('done')
|
||||
|
||||
// 最新 flush 应显示 "✅ Read" 不再有 "⚙️"
|
||||
const lastContent = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
expect(lastContent).toContain('✅')
|
||||
expect(lastContent).toContain('Read')
|
||||
// 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标
|
||||
expect(lastContent).not.toContain('⚙️')
|
||||
})
|
||||
|
||||
it('按 toolUseId 去重: 同一 id 不重复压入', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.startTool('tu_1', 'Read')
|
||||
sc.startTool('tu_1', 'Read')
|
||||
sc.startTool('tu_1', 'Read')
|
||||
expect(sc._getToolSteps().length).toBe(1)
|
||||
})
|
||||
|
||||
it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.startTool(undefined, 'Read')
|
||||
sc.startTool(undefined, 'Read')
|
||||
// 合成 id 不同 → 两个独立步骤
|
||||
expect(sc._getToolSteps().length).toBe(2)
|
||||
})
|
||||
|
||||
it('completeTool 只匹配最近的 running 同名步骤', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.startTool('tu_1', 'Bash')
|
||||
sc.startTool('tu_2', 'Bash')
|
||||
sc.completeTool(undefined, 'Bash')
|
||||
const steps = sc._getToolSteps()
|
||||
// 更晚的 tu_2 被标记 done
|
||||
expect(steps[0]!.status).toBe('running')
|
||||
expect(steps[1]!.status).toBe('done')
|
||||
})
|
||||
|
||||
it('空 toolName 忽略', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.startTool('tu_1', undefined)
|
||||
sc.startTool('tu_1', '')
|
||||
expect(sc._getToolSteps().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。
|
||||
// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase
|
||||
// gate / 等任何东西吃掉)。
|
||||
describe('StreamingCard: 真实事件流(用户场景回归)', () => {
|
||||
it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_real' } },
|
||||
'im.message.create': { data: { message_id: 'om_real' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' })
|
||||
|
||||
// 1. 用户发消息 → handleMessage 预建卡(fire-and-forget)
|
||||
const creating = sc.ensureCreated()
|
||||
await creating // 等卡可写
|
||||
|
||||
// 2. 服务端: status streaming + content_start{text} (thinking block)
|
||||
// feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op)
|
||||
// (no direct call here — 等同于 no-op)
|
||||
|
||||
// 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式)
|
||||
sc.appendReasoning('Analyzing the latest commits to find ')
|
||||
await sleep(30)
|
||||
sc.appendReasoning('breaking changes. Need to look at ')
|
||||
await sleep(30)
|
||||
sc.appendReasoning('the public API surface, the schema files, ')
|
||||
await sleep(30)
|
||||
sc.appendReasoning('and any removed exports. Let me check the ')
|
||||
await sleep(30)
|
||||
sc.appendReasoning('git log first.')
|
||||
|
||||
// 等节流窗口结束
|
||||
await sleep(200)
|
||||
|
||||
const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length
|
||||
expect(flushesAfterReasoning).toBeGreaterThan(0)
|
||||
|
||||
const lastReasoningContent = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
// 应该包含 reasoning 累积内容
|
||||
expect(lastReasoningContent).toContain('breaking changes')
|
||||
expect(lastReasoningContent).toContain('git log first')
|
||||
|
||||
// 4. 服务端: content_start{tool_use, name: 'Bash'}
|
||||
sc.startTool('tu_bash_1', 'Bash')
|
||||
await sleep(150)
|
||||
|
||||
const lastWithTool = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
expect(lastWithTool).toContain('Bash')
|
||||
expect(lastWithTool).toContain('⚙️')
|
||||
expect(lastWithTool).toContain('🛠️')
|
||||
|
||||
// 5. 服务端: tool_use_complete
|
||||
sc.completeTool('tu_bash_1', 'Bash')
|
||||
await sleep(150)
|
||||
|
||||
const lastAfterToolDone = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
expect(lastAfterToolDone).toContain('Bash')
|
||||
// ⚙️ 切到 ✅ —— 当前唯一一步已完成
|
||||
expect(lastAfterToolDone).toContain('✅')
|
||||
expect(lastAfterToolDone).not.toContain('⚙️')
|
||||
|
||||
// 6. 第二个 tool 序列
|
||||
sc.startTool('tu_read_1', 'Read')
|
||||
await sleep(150)
|
||||
sc.completeTool('tu_read_1', 'Read')
|
||||
await sleep(150)
|
||||
|
||||
// 7. 最终 text 输出
|
||||
sc.appendText('## 破坏性变更分析\n\n')
|
||||
await sleep(120)
|
||||
sc.appendText('1. **API 重命名**: foo → bar\n')
|
||||
await sleep(120)
|
||||
sc.appendText('2. **删除导出**: baz')
|
||||
await sleep(200)
|
||||
|
||||
const lastWithText = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
// 应该同时包含 reasoning, tools, answer
|
||||
expect(lastWithText).toContain('git log first') // reasoning
|
||||
expect(lastWithText).toContain('Bash') // tool
|
||||
expect(lastWithText).toContain('Read') // tool
|
||||
expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5)
|
||||
expect(lastWithText).toContain('API 重命名')
|
||||
|
||||
// 8. message_complete → finalize
|
||||
await sc.finalize()
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
|
||||
// 验证有 settings + update 收尾
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
||||
})
|
||||
|
||||
it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => {
|
||||
let firstFrameRejected = false
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_recover' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'cardElement.content': () => {
|
||||
if (!firstFrameRejected) {
|
||||
firstFrameRejected = true
|
||||
// 模拟一个 *非* rate-limit、*非* table-limit 错误
|
||||
// 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题
|
||||
const err: any = new Error('mystery cardkit error')
|
||||
err.code = 999999
|
||||
throw err
|
||||
}
|
||||
return { code: 0 }
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.appendReasoning('first thought')
|
||||
await sleep(150)
|
||||
// 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续
|
||||
sc.appendReasoning(' second thought')
|
||||
await sleep(150)
|
||||
// 验证: 至少尝试了 2 次 cardElement.content 调用
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
||||
// 而且 streaming 仍是 active
|
||||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => {
|
||||
it('三个 section 按顺序 tools → reasoning → answer 组合', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_all' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.appendReasoning('Should I read file A first?')
|
||||
sc.startTool('tu_1', 'Read')
|
||||
sc.appendText('Here is the answer.')
|
||||
await sleep(150)
|
||||
|
||||
const lastContent = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.pop()!.args.data.content as string
|
||||
|
||||
const idxTools = lastContent.indexOf('🛠️')
|
||||
const idxReasoning = lastContent.indexOf('思考中')
|
||||
const idxAnswer = lastContent.indexOf('Here is the answer')
|
||||
|
||||
expect(idxTools).toBeGreaterThan(-1)
|
||||
expect(idxReasoning).toBeGreaterThan(-1)
|
||||
expect(idxAnswer).toBeGreaterThan(-1)
|
||||
// tools 在最顶部 → reasoning 居中 → answer 在底部
|
||||
expect(idxTools).toBeLessThan(idxReasoning)
|
||||
expect(idxReasoning).toBeLessThan(idxAnswer)
|
||||
})
|
||||
|
||||
it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => {
|
||||
let resolveCreate: (() => void) | null = null
|
||||
const createLatch = new Promise<void>((r) => { resolveCreate = r })
|
||||
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': async () => {
|
||||
await createLatch
|
||||
return { code: 0, data: { card_id: 'ck_slow' } }
|
||||
},
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
|
||||
// 不 await: 在 create 还没 resolve 之前,先压入一个 tool step
|
||||
const creating = sc.ensureCreated()
|
||||
// 让事件循环推进到 create 被 await
|
||||
await sleep(10)
|
||||
sc.startTool('tu_1', 'Glob')
|
||||
|
||||
// 此时 cardMessageReady 仍是 false —— 没有任何 flush
|
||||
const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentBefore.length).toBe(0)
|
||||
|
||||
// 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush
|
||||
resolveCreate!()
|
||||
await creating
|
||||
await sleep(150)
|
||||
|
||||
const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentAfter.length).toBeGreaterThan(0)
|
||||
const last = contentAfter[contentAfter.length - 1]!
|
||||
expect(last.args.data.content).toContain('Glob')
|
||||
expect(last.args.data.content).toContain('🛠️')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Feishu CardKit API 错误码解析与谓词
|
||||
*
|
||||
* 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
|
||||
*
|
||||
* Lark SDK 抛出的错误对象结构有多种:
|
||||
* - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
|
||||
* - Axios 风格: error.response.data.{code, msg}
|
||||
* - data.code 嵌套(某些包装层)
|
||||
*
|
||||
* 此模块把这些统一成 { code, subCode, errMsg } 结构,
|
||||
* 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error code constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 卡片 API 级别错误码。 */
|
||||
export const CARD_ERROR = {
|
||||
/** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
|
||||
RATE_LIMITED: 230020,
|
||||
/** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
|
||||
CARD_CONTENT_FAILED: 230099,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
|
||||
* 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
|
||||
*/
|
||||
export const CARD_CONTENT_SUB_ERROR = {
|
||||
/** 卡片元素(表格等)数量超限 */
|
||||
ELEMENT_LIMIT: 11310,
|
||||
} as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function coerceCode(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
|
||||
* - `{ code }` (SDK 直接挂载)
|
||||
* - `{ data: { code } }` (响应体嵌套)
|
||||
* - `{ response: { data: { code } } }` (Axios 风格)
|
||||
*/
|
||||
export function extractLarkApiCode(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== 'object') return undefined
|
||||
const e = err as {
|
||||
code?: unknown
|
||||
data?: { code?: unknown }
|
||||
response?: { data?: { code?: unknown } }
|
||||
}
|
||||
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-error extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
|
||||
*
|
||||
* 示例输入:
|
||||
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
|
||||
* 返回: 11310
|
||||
*/
|
||||
export function extractSubCode(msg: string): number | null {
|
||||
const match = /ErrCode:\s*(\d+)/.exec(msg)
|
||||
if (!match) return null
|
||||
const code = Number(match[1])
|
||||
return Number.isFinite(code) ? code : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured error parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CardApiErrorInfo = {
|
||||
code: number
|
||||
subCode: number | null
|
||||
errMsg: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意抛错对象中解析卡片 API 错误结构。
|
||||
*
|
||||
* 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
|
||||
*/
|
||||
export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
|
||||
const code = extractLarkApiCode(err)
|
||||
if (code === undefined) return null
|
||||
|
||||
// 按优先级提取 msg 文本
|
||||
let errMsg = ''
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as {
|
||||
msg?: unknown
|
||||
message?: unknown
|
||||
response?: { data?: { msg?: unknown } }
|
||||
}
|
||||
if (typeof e.msg === 'string') {
|
||||
errMsg = e.msg
|
||||
} else if (typeof e.response?.data?.msg === 'string') {
|
||||
errMsg = e.response.data.msg
|
||||
} else if (typeof e.message === 'string') {
|
||||
errMsg = e.message
|
||||
}
|
||||
}
|
||||
|
||||
const subCode = extractSubCode(errMsg)
|
||||
return { code, subCode, errMsg }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper predicates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 判断错误是否为卡片发送频率限制(230020)。 */
|
||||
export function isCardRateLimitError(err: unknown): boolean {
|
||||
const parsed = parseCardApiError(err)
|
||||
if (!parsed) return false
|
||||
return parsed.code === CARD_ERROR.RATE_LIMITED
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断错误是否为卡片表格数超限。
|
||||
*
|
||||
* 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
|
||||
* (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
|
||||
*
|
||||
* 实际生产错误格式(openclaw-lark 2026-03 实测):
|
||||
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
|
||||
*/
|
||||
export function isCardTableLimitError(err: unknown): boolean {
|
||||
const parsed = parseCardApiError(err)
|
||||
if (!parsed) return false
|
||||
return (
|
||||
parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
|
||||
parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
|
||||
/table number over limit/i.test(parsed.errMsg)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 飞书 CardKit API 薄封装
|
||||
*
|
||||
* 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。
|
||||
*
|
||||
* 五步流程:
|
||||
* 1. createCardEntity() —— 创建卡片实体,返回 card_id
|
||||
* 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
|
||||
* 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
|
||||
* 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
|
||||
* 5. updateCardKitCard() —— 全量替换卡片为最终态
|
||||
*
|
||||
* 关键约束:
|
||||
* - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝
|
||||
* - streamCardContent 传的是**完整累计文本**,不是 delta
|
||||
* - 必须关闭 streaming_mode 后卡片才能被用户交互
|
||||
*
|
||||
* 参考实现: openclaw-lark/src/card/cardkit.ts
|
||||
*/
|
||||
|
||||
import type * as Lark from '@larksuiteoapi/node-sdk'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
|
||||
* `cardElement.content()` 更新的那一个 markdown 元素。 */
|
||||
export const STREAMING_ELEMENT_ID = 'streaming_content'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SDK 返回的通用响应结构。
|
||||
* SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
|
||||
* 我们统一当成 CardKitResponse 处理以免到处 `as any`。
|
||||
*/
|
||||
type CardKitResponse = {
|
||||
code?: number
|
||||
msg?: string
|
||||
data?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
|
||||
* 可被 card-errors.ts 的 parseCardApiError 识别。 */
|
||||
export class CardKitApiError extends Error {
|
||||
readonly code: number
|
||||
readonly msg: string
|
||||
|
||||
constructor(params: { api: string; code: number; msg: string; context: string }) {
|
||||
const { api, code, msg, context } = params
|
||||
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
|
||||
this.name = 'CardKitApiError'
|
||||
this.code = code
|
||||
this.msg = msg
|
||||
}
|
||||
}
|
||||
|
||||
type LarkClient = Lark.Client
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
|
||||
*
|
||||
* Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
|
||||
* 判断是速率限制还是真错误。
|
||||
*/
|
||||
function assertCardKitOk(params: {
|
||||
resp: CardKitResponse
|
||||
api: string
|
||||
context: string
|
||||
}): void {
|
||||
const { resp, api, context } = params
|
||||
const code = resp.code
|
||||
if (code !== undefined && code !== 0) {
|
||||
throw new CardKitApiError({
|
||||
api,
|
||||
code,
|
||||
msg: typeof resp.msg === 'string' ? resp.msg : '',
|
||||
context,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — createCardEntity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 创建一张 CardKit 卡片实体,返回 card_id。
|
||||
*
|
||||
* 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
|
||||
*
|
||||
* @param client Lark SDK client
|
||||
* @param card Schema 2.0 格式的卡片 JSON
|
||||
* @returns 飞书分配的 card_id(失败时抛错)
|
||||
*/
|
||||
export async function createCardEntity(
|
||||
client: LarkClient,
|
||||
card: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
// SDK 返回类型不完整,cast 到运行时实际结构
|
||||
const resp = (await client.cardkit.v1.card.create({
|
||||
data: {
|
||||
type: 'card_json',
|
||||
data: JSON.stringify(card),
|
||||
},
|
||||
})) as unknown as CardKitResponse
|
||||
|
||||
assertCardKitOk({
|
||||
resp,
|
||||
api: 'card.create',
|
||||
context: `cardLen=${JSON.stringify(card).length}`,
|
||||
})
|
||||
|
||||
// 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id
|
||||
const cardId =
|
||||
(resp.data?.card_id as string | undefined) ??
|
||||
(resp.card_id as string | undefined)
|
||||
|
||||
if (!cardId) {
|
||||
throw new CardKitApiError({
|
||||
api: 'card.create',
|
||||
code: resp.code ?? -1,
|
||||
msg: 'response missing card_id',
|
||||
context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
|
||||
})
|
||||
}
|
||||
return cardId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — sendCardAsMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 把 CardKit 卡片通过 IM 消息挂到聊天窗。
|
||||
*
|
||||
* content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
|
||||
* msg_type 固定为 `interactive`。
|
||||
*
|
||||
* @param client Lark SDK client
|
||||
* @param chatId 目标 chat_id
|
||||
* @param cardId CardKit card_id(由 createCardEntity 产生)
|
||||
* @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create
|
||||
* @returns 飞书分配的 message_id
|
||||
*/
|
||||
export async function sendCardAsMessage(
|
||||
client: LarkClient,
|
||||
chatId: string,
|
||||
cardId: string,
|
||||
replyToMessageId?: string,
|
||||
): Promise<string> {
|
||||
const content = JSON.stringify({
|
||||
type: 'card',
|
||||
data: { card_id: cardId },
|
||||
})
|
||||
|
||||
if (replyToMessageId) {
|
||||
const resp = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: { content, msg_type: 'interactive' },
|
||||
})
|
||||
const messageId = resp.data?.message_id
|
||||
if (!messageId) {
|
||||
throw new CardKitApiError({
|
||||
api: 'im.message.reply',
|
||||
code: -1,
|
||||
msg: 'response missing message_id',
|
||||
context: `cardId=${cardId}`,
|
||||
})
|
||||
}
|
||||
return messageId
|
||||
}
|
||||
|
||||
const resp = await client.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'interactive',
|
||||
content,
|
||||
},
|
||||
})
|
||||
const messageId = resp.data?.message_id
|
||||
if (!messageId) {
|
||||
throw new CardKitApiError({
|
||||
api: 'im.message.create',
|
||||
code: -1,
|
||||
msg: 'response missing message_id',
|
||||
context: `chatId=${chatId} cardId=${cardId}`,
|
||||
})
|
||||
}
|
||||
return messageId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — streamCardContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端
|
||||
* 渲染打字机效果。
|
||||
*
|
||||
* **重要**: `content` 必须传**完整累计文本**,不是 delta。
|
||||
* sequence 必须**单调递增**,否则飞书拒绝。
|
||||
*
|
||||
* @param client Lark SDK client
|
||||
* @param cardId CardKit card_id
|
||||
* @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID)
|
||||
* @param content 完整累计文本
|
||||
* @param sequence 单调递增序列号
|
||||
*/
|
||||
export async function streamCardContent(
|
||||
client: LarkClient,
|
||||
cardId: string,
|
||||
elementId: string,
|
||||
content: string,
|
||||
sequence: number,
|
||||
): Promise<void> {
|
||||
const resp = (await client.cardkit.v1.cardElement.content({
|
||||
data: { content, sequence },
|
||||
path: { card_id: cardId, element_id: elementId },
|
||||
})) as unknown as CardKitResponse
|
||||
|
||||
assertCardKitOk({
|
||||
resp,
|
||||
api: 'cardElement.content',
|
||||
context: `seq=${sequence} len=${content.length}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 4 — setCardStreamingMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`,
|
||||
* 否则卡片会保持"只读"状态,用户点按钮没反应。
|
||||
*/
|
||||
export async function setCardStreamingMode(
|
||||
client: LarkClient,
|
||||
cardId: string,
|
||||
streamingMode: boolean,
|
||||
sequence: number,
|
||||
): Promise<void> {
|
||||
const resp = (await client.cardkit.v1.card.settings({
|
||||
data: {
|
||||
settings: JSON.stringify({ streaming_mode: streamingMode }),
|
||||
sequence,
|
||||
},
|
||||
path: { card_id: cardId },
|
||||
})) as unknown as CardKitResponse
|
||||
|
||||
assertCardKitOk({
|
||||
resp,
|
||||
api: 'card.settings',
|
||||
context: `seq=${sequence} streaming_mode=${streamingMode}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5 — updateCardKitCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
|
||||
* (加 header template、footer、完成样式等)。
|
||||
*/
|
||||
export async function updateCardKitCard(
|
||||
client: LarkClient,
|
||||
cardId: string,
|
||||
card: Record<string, unknown>,
|
||||
sequence: number,
|
||||
): Promise<void> {
|
||||
const resp = (await client.cardkit.v1.card.update({
|
||||
data: {
|
||||
card: { type: 'card_json', data: JSON.stringify(card) },
|
||||
sequence,
|
||||
},
|
||||
path: { card_id: cardId },
|
||||
})) as unknown as CardKitResponse
|
||||
|
||||
assertCardKitOk({
|
||||
resp,
|
||||
api: 'card.update',
|
||||
context: `seq=${sequence} cardId=${cardId}`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Feishu inbound message parser.
|
||||
*
|
||||
* Converts a raw Feishu `im.message.receive_v1` event payload (the JSON
|
||||
* string inside `message.content` plus its `message_type`) into a
|
||||
* structured `InboundPayload` containing:
|
||||
* - plain text (for direct forwarding to Claude)
|
||||
* - a list of `PendingDownload` refs describing any attachments we
|
||||
* need to fetch via FeishuMediaService.downloadResource()
|
||||
*
|
||||
* Supports the five message_type values we care about:
|
||||
* - text → text only
|
||||
* - post → rich text (text nodes + img + file elements)
|
||||
* - image → single image_key
|
||||
* - file → single file_key
|
||||
* - file_archive → single file_key (same shape as file)
|
||||
*
|
||||
* Any other shape returns an empty payload (text: '', downloads: []).
|
||||
*/
|
||||
|
||||
export type PendingDownload =
|
||||
| { kind: 'image'; fileKey: string; fileName?: string }
|
||||
| { kind: 'file'; fileKey: string; fileName?: string }
|
||||
|
||||
export interface InboundPayload {
|
||||
text: string
|
||||
pendingDownloads: PendingDownload[]
|
||||
}
|
||||
|
||||
export function extractInboundPayload(content: string, msgType: string): InboundPayload {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
return { text: '', pendingDownloads: [] }
|
||||
}
|
||||
|
||||
if (msgType === 'text') {
|
||||
return {
|
||||
text: typeof parsed.text === 'string' ? parsed.text : '',
|
||||
pendingDownloads: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === 'image') {
|
||||
if (typeof parsed.image_key === 'string' && parsed.image_key) {
|
||||
return {
|
||||
text: '',
|
||||
pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }],
|
||||
}
|
||||
}
|
||||
return { text: '', pendingDownloads: [] }
|
||||
}
|
||||
|
||||
if (msgType === 'file' || msgType === 'file_archive') {
|
||||
if (typeof parsed.file_key === 'string' && parsed.file_key) {
|
||||
return {
|
||||
text: '',
|
||||
pendingDownloads: [
|
||||
{
|
||||
kind: 'file',
|
||||
fileKey: parsed.file_key,
|
||||
fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { text: '', pendingDownloads: [] }
|
||||
}
|
||||
|
||||
if (msgType === 'post') {
|
||||
const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[]
|
||||
const flat = nodes.flat()
|
||||
const textParts: string[] = []
|
||||
const downloads: PendingDownload[] = []
|
||||
for (const node of flat) {
|
||||
if (!node || typeof node !== 'object') continue
|
||||
if (node.tag === 'text' || node.tag === 'md') {
|
||||
const t = node.text ?? node.content ?? ''
|
||||
if (typeof t === 'string') textParts.push(t)
|
||||
} else if (node.tag === 'img' && typeof node.image_key === 'string') {
|
||||
downloads.push({ kind: 'image', fileKey: node.image_key })
|
||||
} else if (node.tag === 'file' && typeof node.file_key === 'string') {
|
||||
downloads.push({
|
||||
kind: 'file',
|
||||
fileKey: node.file_key,
|
||||
fileName: typeof node.file_name === 'string' ? node.file_name : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { text: textParts.join(''), pendingDownloads: downloads }
|
||||
}
|
||||
|
||||
return { text: '', pendingDownloads: [] }
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 节流 + mutex + 冲突重刷的通用 flush 调度器
|
||||
*
|
||||
* 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。
|
||||
* 实际 flush 工作由构造函数注入的 doFlush 回调负责。
|
||||
*
|
||||
* 语义:
|
||||
* - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流
|
||||
* - flush() 被 mutex 保护,相同时刻只有一个在跑
|
||||
* - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷
|
||||
* - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动
|
||||
* - complete() 后拒绝所有新 flush
|
||||
*
|
||||
* 参考实现: openclaw-lark/src/card/flush-controller.ts
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throttle constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const THROTTLE = {
|
||||
/** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
|
||||
CARDKIT_MS: 100,
|
||||
/** im.message.patch 最小间隔 —— 严格速率限制(230020) */
|
||||
PATCH_MS: 1500,
|
||||
/** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
|
||||
LONG_GAP_THRESHOLD_MS: 2000,
|
||||
/** 长间隔后第一帧的额外延迟,让文本积累更完整 */
|
||||
BATCH_AFTER_GAP_MS: 300,
|
||||
} as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FlushController
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class FlushController {
|
||||
private flushInProgress = false
|
||||
private flushResolvers: Array<() => void> = []
|
||||
private needsReflush = false
|
||||
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastUpdateTime = 0
|
||||
private isCompleted = false
|
||||
private _cardMessageReady = false
|
||||
|
||||
constructor(private readonly doFlush: () => Promise<void>) {}
|
||||
|
||||
/** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
|
||||
complete(): void {
|
||||
this.isCompleted = true
|
||||
}
|
||||
|
||||
/** 取消任何挂起的延迟 flush 计时器。 */
|
||||
cancelPendingFlush(): void {
|
||||
if (this.pendingFlushTimer) {
|
||||
clearTimeout(this.pendingFlushTimer)
|
||||
this.pendingFlushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
|
||||
waitForFlush(): Promise<void> {
|
||||
if (!this.flushInProgress) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记卡片消息是否已发送成功,决定 flush 是否被放行。
|
||||
*
|
||||
* 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate
|
||||
* 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。
|
||||
*/
|
||||
setCardMessageReady(ready: boolean): void {
|
||||
this._cardMessageReady = ready
|
||||
if (ready) this.lastUpdateTime = Date.now()
|
||||
}
|
||||
|
||||
cardMessageReady(): boolean {
|
||||
return this._cardMessageReady
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 flush(mutex 保护 + 冲突重刷)。
|
||||
*
|
||||
* 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
|
||||
if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
|
||||
return
|
||||
}
|
||||
this.flushInProgress = true
|
||||
this.needsReflush = false
|
||||
// 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
|
||||
this.lastUpdateTime = Date.now()
|
||||
try {
|
||||
await this.doFlush()
|
||||
this.lastUpdateTime = Date.now()
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
const resolvers = this.flushResolvers
|
||||
this.flushResolvers = []
|
||||
for (const resolve of resolvers) resolve()
|
||||
|
||||
// 如果 API 调用期间有新事件进来,立即补一次 flush
|
||||
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
|
||||
this.needsReflush = false
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流更新入口。
|
||||
*
|
||||
* @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS,
|
||||
* Patch 降级路径传 THROTTLE.PATCH_MS。
|
||||
*/
|
||||
async throttledUpdate(throttleMs: number): Promise<void> {
|
||||
if (!this.cardMessageReady()) return
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.lastUpdateTime
|
||||
|
||||
if (elapsed >= throttleMs) {
|
||||
this.cancelPendingFlush()
|
||||
if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
|
||||
// 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
|
||||
// 让文本积累更完整,避免只显示一两个字
|
||||
this.lastUpdateTime = now
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, THROTTLE.BATCH_AFTER_GAP_MS)
|
||||
} else {
|
||||
await this.flush()
|
||||
}
|
||||
} else if (!this.pendingFlushTimer) {
|
||||
// 在节流窗口内 —— 延迟到窗口结束再刷
|
||||
const delay = throttleMs - elapsed
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Feishu 卡片 Markdown 样式优化
|
||||
*
|
||||
* 背景:
|
||||
* - 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常(字面量显示)。
|
||||
* 必须降级为 H4/H5 才能正常渲染。
|
||||
* - Schema 2.0 CardKit 支持完整的 markdown 但需要手动加 `<br>` 间距避免标题/表格/
|
||||
* 代码块贴得太紧。
|
||||
* - 卡片有表格数量上限(FEISHU_CARD_TABLE_LIMIT=3),超出会触发 230099/11310。
|
||||
* - 图片必须是飞书上传过的 `img_xxx` key,其它 URL 会触发 CardKit 错误 200570。
|
||||
*
|
||||
* 实现参考: openclaw-lark/src/card/markdown-style.ts + card-error.ts
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310(openclaw 2026-03 实测) */
|
||||
export const FEISHU_CARD_TABLE_LIMIT = 3
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: optimizeMarkdownForFeishu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。
|
||||
*
|
||||
* - 标题降级: 若原文包含 H1~H3,则 H2~H6 → H5,H1 → H4
|
||||
* - 代码块内容受保护,不会被降级
|
||||
* - Schema 2.0: 连续标题/表格/代码块前后加 `<br>` 间距
|
||||
* - 连续 3+ 空行压缩为 2
|
||||
* - 删除非 `img_*` 的 markdown 图片引用(防止 CardKit 200570)
|
||||
* - 任何内部错误都 fallback 到原文,不阻塞消息发送
|
||||
*
|
||||
* @param text 原始 markdown
|
||||
* @param cardVersion 卡片 schema 版本。默认 2 (Schema 2.0 CardKit),
|
||||
* 1 对应老 Schema 1.0 fallback 路径(已不推荐)。
|
||||
*/
|
||||
export function optimizeMarkdownForFeishu(text: string, cardVersion = 2): string {
|
||||
try {
|
||||
let r = _optimizeMarkdownForFeishu(text, cardVersion)
|
||||
r = stripInvalidImageKeys(r)
|
||||
return r
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string {
|
||||
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
|
||||
// 代码块内的 `#` / `|` 不能被标题降级或表格匹配误伤
|
||||
const MARK = '___CB_'
|
||||
const codeBlocks: string[] = []
|
||||
let r = text.replace(/```[\s\S]*?```/g, (m) => {
|
||||
return `${MARK}${codeBlocks.push(m) - 1}___`
|
||||
})
|
||||
|
||||
// ── 2. 标题降级 ────────────────────────────────────────────────────
|
||||
// 只有当原文档(不是保护后的 r)包含 H1~H3 时才执行降级
|
||||
// 顺序: 先 H2~H6 → H5,再 H1 → H4
|
||||
// 若先 H1→H4,`####` 会被后面的 `#{2,6}` 再次匹配成 H5
|
||||
const hasH1toH3 = /^#{1,3} /m.test(text)
|
||||
if (hasH1toH3) {
|
||||
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5
|
||||
r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4
|
||||
}
|
||||
|
||||
// ── 3. Schema 2.0 段落间距 ────────────────────────────────────────
|
||||
if (cardVersion >= 2) {
|
||||
// 3a. 连续标题之间加 <br>
|
||||
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
|
||||
|
||||
// 3b. 非表格行直接跟表格行 → 先补空行(保证后续规则生效)
|
||||
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2')
|
||||
|
||||
// 3c. 表格前: 在空行之前插入 <br>(即 `\n\n|` → `\n<br>\n\n|`)
|
||||
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1')
|
||||
|
||||
// 3d. 表格后: 在表格块末尾追加 <br>(跳过后接分隔线/标题/加粗/文末)
|
||||
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => {
|
||||
const after = r.slice(offset + m.length).replace(/^\n+/, '')
|
||||
if (!after || /^(---|#{4,5} |\*\*)/.test(after)) return m
|
||||
return m + '\n<br>\n'
|
||||
})
|
||||
|
||||
// 3e. 表格前是普通文本: 只保留 <br>,去掉多余空行
|
||||
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
|
||||
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3')
|
||||
|
||||
// 3f. 表格前是加粗行: <br> 紧贴加粗行,空行保留在后面
|
||||
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
|
||||
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3')
|
||||
|
||||
// 3g. 表格后是普通文本: 去掉多余空行
|
||||
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
|
||||
r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3')
|
||||
}
|
||||
|
||||
// ── 4. 压缩多余空行(3 个以上连续换行 → 2 个)────────────────────
|
||||
// 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤
|
||||
r = r.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// ── 5. 还原代码块 ─────────────────────────────────────────────────
|
||||
// Schema 2.0 时前后加 <br>,让代码块与周围段落拉开距离
|
||||
codeBlocks.forEach((block, i) => {
|
||||
const replacement = cardVersion >= 2 ? `\n<br>\n${block}\n<br>\n` : block
|
||||
r = r.replace(`${MARK}${i}___`, replacement)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripInvalidImageKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 匹配完整的 markdown 图片语法: `` */
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
|
||||
|
||||
/**
|
||||
* 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。
|
||||
* 防止 CardKit 错误 200570(unknown image key)。
|
||||
*
|
||||
* HTTP URL 和本地路径也会被删除 —— 上游(ImageResolver)负责把它们转成
|
||||
* `img_xxx`,此函数是 safety net。
|
||||
*/
|
||||
function stripInvalidImageKeys(text: string): string {
|
||||
if (!text.includes('![')) return text
|
||||
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
|
||||
if (value.startsWith('img_')) return fullMatch
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table limiting: sanitizeTextForCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MarkdownTableMatch = {
|
||||
index: number
|
||||
length: number
|
||||
raw: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描正文里会被飞书卡片**实际渲染**的 markdown 表格位置。
|
||||
*
|
||||
* 代码块内的示例表格不会被飞书解析成卡片表格元素,因此要先排除。
|
||||
* 这份结果供 `sanitizeTextForCard` 判断是否需要降级多余的表格。
|
||||
*/
|
||||
export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] {
|
||||
// 先扫描代码块区间
|
||||
const codeBlockRanges: Array<{ start: number; end: number }> = []
|
||||
const codeBlockRegex = /```[\s\S]*?```/g
|
||||
let cbMatch = codeBlockRegex.exec(text)
|
||||
while (cbMatch != null) {
|
||||
codeBlockRanges.push({
|
||||
start: cbMatch.index,
|
||||
end: cbMatch.index + cbMatch[0].length,
|
||||
})
|
||||
cbMatch = codeBlockRegex.exec(text)
|
||||
}
|
||||
const isInsideCodeBlock = (idx: number): boolean =>
|
||||
codeBlockRanges.some((range) => idx >= range.start && idx < range.end)
|
||||
|
||||
// 扫描表格(header | sep | body...)
|
||||
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g
|
||||
const matches: MarkdownTableMatch[] = []
|
||||
let tableMatch = tableRegex.exec(text)
|
||||
while (tableMatch != null) {
|
||||
if (!isInsideCodeBlock(tableMatch.index)) {
|
||||
matches.push({
|
||||
index: tableMatch.index,
|
||||
length: tableMatch[0].length,
|
||||
raw: tableMatch[0],
|
||||
})
|
||||
}
|
||||
tableMatch = tableRegex.exec(text)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 对正文里超出 `tableLimit` 张的 markdown 表格降级为代码块,防止触发
|
||||
* 230099/11310。前 `tableLimit` 张保持原样(卡片正常渲染),其余用
|
||||
* 反引号包裹(飞书会当成 code block 而不是表格)。
|
||||
*/
|
||||
export function sanitizeTextForCard(
|
||||
text: string,
|
||||
tableLimit: number = FEISHU_CARD_TABLE_LIMIT,
|
||||
): string {
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
if (matches.length <= tableLimit) return text
|
||||
return wrapTablesBeyondLimit(text, matches, Math.max(tableLimit, 0))
|
||||
}
|
||||
|
||||
function wrapTablesBeyondLimit(
|
||||
text: string,
|
||||
matches: readonly MarkdownTableMatch[],
|
||||
keepCount: number,
|
||||
): string {
|
||||
if (matches.length <= keepCount) return text
|
||||
// 从后往前替换,避免前面的替换导致后面的 offset 错乱
|
||||
let result = text
|
||||
for (let i = matches.length - 1; i >= keepCount; i--) {
|
||||
const { index, length, raw } = matches[i]!
|
||||
const replacement = '```\n' + raw + '\n```'
|
||||
result = result.slice(0, index) + replacement + result.slice(index + length)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Feishu media service — wraps im.messageResource / im.image / im.file
|
||||
* so adapters/feishu/index.ts stays focused on flow control.
|
||||
*
|
||||
* References:
|
||||
* - Feishu OpenAPI: POST /open-apis/im/v1/images
|
||||
* POST /open-apis/im/v1/files
|
||||
* GET /open-apis/im/v1/messages/{message_id}/resources/{file_key}
|
||||
* - OpenClaw impl: openclaw-lark/src/messaging/outbound/media.ts:226,281,323,423,454
|
||||
*/
|
||||
|
||||
import * as Lark from '@larksuiteoapi/node-sdk'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
|
||||
|
||||
type LarkClient = InstanceType<typeof Lark.Client>
|
||||
|
||||
/** Map a filename extension to Feishu's file_type enum. */
|
||||
function detectFeishuFileType(
|
||||
fileName: string,
|
||||
): 'opus' | 'mp4' | 'pdf' | 'doc' | 'xls' | 'ppt' | 'stream' {
|
||||
const ext = path.extname(fileName).toLowerCase().replace(/^\./, '')
|
||||
switch (ext) {
|
||||
case 'opus':
|
||||
return 'opus'
|
||||
case 'mp4':
|
||||
return 'mp4'
|
||||
case 'pdf':
|
||||
return 'pdf'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'doc'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'xls'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'ppt'
|
||||
default:
|
||||
return 'stream'
|
||||
}
|
||||
}
|
||||
|
||||
function guessMime(fileName: string, kind: 'image' | 'file'): string {
|
||||
const ext = path.extname(fileName).toLowerCase().replace(/^\./, '')
|
||||
if (kind === 'image') {
|
||||
return (
|
||||
({
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
heic: 'image/heic',
|
||||
} as Record<string, string>)[ext] || 'image/png'
|
||||
)
|
||||
}
|
||||
return (
|
||||
({
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
json: 'application/json',
|
||||
} as Record<string, string>)[ext] || 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
export interface DownloadParams {
|
||||
messageId: string
|
||||
fileKey: string
|
||||
kind: 'image' | 'file'
|
||||
fileName?: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export class FeishuMediaService {
|
||||
constructor(
|
||||
private readonly client: LarkClient,
|
||||
private readonly store: AttachmentStore,
|
||||
) {}
|
||||
|
||||
/** Download an image or file the user sent in Feishu into the local stage. */
|
||||
async downloadResource(params: DownloadParams): Promise<LocalAttachment> {
|
||||
const { messageId, fileKey, kind, sessionId } = params
|
||||
const fallbackName = `${fileKey}${kind === 'image' ? '.png' : ''}`
|
||||
const name = params.fileName || fallbackName
|
||||
const target = this.store.resolvePath('feishu', sessionId, name)
|
||||
|
||||
// node-sdk returns an object with a `.writeFile(target)` helper
|
||||
// that dumps the underlying stream. See OpenClaw media.ts:147 and :237.
|
||||
const resp: any = await (this.client.im as any).messageResource.get({
|
||||
path: { message_id: messageId, file_key: fileKey },
|
||||
params: { type: kind },
|
||||
})
|
||||
|
||||
if (typeof resp?.writeFile === 'function') {
|
||||
await resp.writeFile(target)
|
||||
} else if (resp?.data instanceof Buffer) {
|
||||
await this.store.write(target, resp.data)
|
||||
} else if (resp instanceof Buffer) {
|
||||
await this.store.write(target, resp)
|
||||
} else {
|
||||
throw new Error('[FeishuMedia] Unknown downloadResource response shape')
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(target)
|
||||
return {
|
||||
kind,
|
||||
name,
|
||||
path: target,
|
||||
size: buffer.length,
|
||||
mimeType: guessMime(name, kind),
|
||||
buffer,
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an image buffer, returns image_key.
|
||||
* The Lark node-sdk type for `image` accepts `Buffer | ReadStream`,
|
||||
* so passing the buffer directly is the simplest path. */
|
||||
async uploadImage(buffer: Buffer, _mime: string): Promise<string> {
|
||||
const resp: any = await this.client.im.image.create({
|
||||
data: {
|
||||
image_type: 'message',
|
||||
image: buffer,
|
||||
},
|
||||
})
|
||||
const key = resp?.data?.image_key
|
||||
if (!key) throw new Error('[FeishuMedia] uploadImage: missing image_key')
|
||||
return key
|
||||
}
|
||||
|
||||
/** Upload a non-image file, returns file_key. */
|
||||
async uploadFile(buffer: Buffer, fileName: string): Promise<string> {
|
||||
const resp: any = await this.client.im.file.create({
|
||||
data: {
|
||||
file_type: detectFeishuFileType(fileName),
|
||||
file_name: fileName,
|
||||
file: buffer,
|
||||
},
|
||||
})
|
||||
const key = resp?.data?.file_key
|
||||
if (!key) throw new Error('[FeishuMedia] uploadFile: missing file_key')
|
||||
return key
|
||||
}
|
||||
|
||||
/** Send an image message to a chat. See OpenClaw media.ts:435. */
|
||||
async sendImageMessage(chatId: string, imageKey: string): Promise<void> {
|
||||
await this.client.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'image',
|
||||
content: JSON.stringify({ image_key: imageKey }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a file message to a chat. See OpenClaw media.ts:466. */
|
||||
async sendFileMessage(chatId: string, fileKey: string): Promise<void> {
|
||||
await this.client.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: 'file',
|
||||
content: JSON.stringify({ file_key: fileKey }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 单个 chat 的流式卡片生命周期状态机
|
||||
*
|
||||
* 负责把 LLM 的流式文本增量渲染成一张随着内容生长的飞书 CardKit 卡片。
|
||||
* 封装了:
|
||||
* - CardKit API 的 5 步调用(create → send → stream × N → settings → update)
|
||||
* - 节流 + 并发保护(FlushController)
|
||||
* - Markdown 预处理(optimizeMarkdownForFeishu + sanitizeTextForCard)
|
||||
* - 错误降级:CardKit 挂了自动切到 im.message.patch + Schema 2.0 卡
|
||||
* - 速率限制:230020 跳帧,下次重试;230099 表格超限禁用 CardKit 流式
|
||||
*
|
||||
* 每个 chatId 一个实例,由 index.ts 的 handleServerMessage 协调 lifecycle。
|
||||
*/
|
||||
|
||||
import type * as Lark from '@larksuiteoapi/node-sdk'
|
||||
import { FlushController, THROTTLE } from './flush-controller.js'
|
||||
import {
|
||||
createCardEntity,
|
||||
sendCardAsMessage,
|
||||
streamCardContent,
|
||||
setCardStreamingMode,
|
||||
updateCardKitCard,
|
||||
STREAMING_ELEMENT_ID,
|
||||
} from './cardkit.js'
|
||||
import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js'
|
||||
import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card JSON builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。
|
||||
*
|
||||
* 只包含一个 markdown 元素 `streaming_content`,初始内容为 loading 提示。
|
||||
* 由 renderedText() 统一控制显示状态(思考中 / reasoning / 正文),
|
||||
* 避免静态 loading 元素和 streaming 内容同时显示造成"两个思考中"。
|
||||
*
|
||||
* finalize 时整卡 update 替换为纯答复正文。 */
|
||||
export function buildInitialStreamingCard(): Record<string, unknown> {
|
||||
return {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
streaming_mode: true,
|
||||
update_multi: true,
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: '☁️ *正在思考中...*',
|
||||
text_align: 'left',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 已渲染完成的卡片:Schema 2.0,无 streaming_mode,单 markdown 元素。
|
||||
*
|
||||
* 代码块的 "10 行代码 >" 手机端 bug 是通过 `optimizeMarkdownForFeishu` 把
|
||||
* fenced code 降级成纯文字来规避的,不依赖多元素结构。这里就是最朴素的
|
||||
* 一张纯 markdown 卡片。 */
|
||||
export function buildRenderedCard(renderedMarkdown: string): Record<string, unknown> {
|
||||
return {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
update_multi: true,
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: renderedMarkdown || ' ',
|
||||
text_align: 'left',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 错误卡片:红色 header + 错误文本。用于 abort() 兜底。 */
|
||||
export function buildErrorCard(message: string): Record<string, unknown> {
|
||||
return {
|
||||
schema: '2.0',
|
||||
config: { update_multi: true },
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: '❌ 出错了' },
|
||||
template: 'red',
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: message || '未知错误',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 从末尾截取最多 maxLen 个字符;超过时前缀 "..." 保留最新 maxLen-3 个字。
|
||||
*
|
||||
* 思考内容往往是"先分析 → 得出结论"的线性过程,截取末尾比截取开头更有用 —— 用户
|
||||
* 最关心的是"模型现在在想什么",不是"五千个 token 前在想什么"。 */
|
||||
function truncateReasoningPreview(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text
|
||||
return '...' + text.slice(text.length - maxLen + 3)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StreamingCardPhase =
|
||||
| 'idle' // constructor 后、ensureCreated 之前
|
||||
| 'creating' // ensureCreated 进行中
|
||||
| 'streaming' // 初始卡已发出,接受 appendText
|
||||
| 'finalizing' // finalize 进行中
|
||||
| 'completed' // finalize 完成
|
||||
| 'aborted' // abort 已调用
|
||||
|
||||
export type StreamingCardDeps = {
|
||||
larkClient: Lark.Client
|
||||
chatId: string
|
||||
replyToMessageId?: string
|
||||
}
|
||||
|
||||
/** One entry in the tool-use trace displayed above the answer text. */
|
||||
type ToolStep = {
|
||||
/** Prefer toolUseId for dedup; fall back to a synthetic id when missing. */
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'done'
|
||||
}
|
||||
|
||||
/** 最多保留的 reasoning 预览字符数,超过则取末尾 + 省略号前缀。 */
|
||||
const REASONING_PREVIEW_CHARS = 600
|
||||
|
||||
/** 连续 streamCardContent 失败多少次后才放弃 CardKit 流式。
|
||||
* 设成 3 而不是 1,是为了避免单次抖动(网络、临时校验失败等)把整张卡片
|
||||
* 冻结到 finalize —— 用户看到的就是 "long wait → 一次性 dump"。 */
|
||||
const STREAM_FAIL_DISABLE_THRESHOLD = 3
|
||||
|
||||
export class StreamingCard {
|
||||
// ---- lifecycle state ----
|
||||
private phase: StreamingCardPhase = 'idle'
|
||||
|
||||
// ---- CardKit state ----
|
||||
/** CardKit card_id。null = CardKit 创建失败,已退到 patch fallback 模式。 */
|
||||
private cardId: string | null = null
|
||||
/** IM message_id。始终应该有值(否则连 patch 也做不了)。 */
|
||||
private messageId: string | null = null
|
||||
/** CardKit cardElement.content() 单调递增序列号。 */
|
||||
private sequence = 0
|
||||
/** CardKit 流式还在工作。230099 或连续 N 次未知错误之后置为 false,
|
||||
* 中间帧将跳过,最终 finalize 仍会尝试 settings+update(cardId 仍然有效)。 */
|
||||
private cardKitStreamActive = false
|
||||
/** 连续 streamCardContent 未知错误计数。一次成功就清零。 */
|
||||
private consecutiveStreamFailures = 0
|
||||
|
||||
// ---- text state ----
|
||||
private accumulatedText = ''
|
||||
private lastFlushedText = ''
|
||||
/** 累积 thinking_delta,渲染为卡片顶部的推理预览 blockquote。 */
|
||||
private accumulatedReasoningText = ''
|
||||
/** 工具调用轨迹:按 startTool 调用顺序排列,completeTool 改其 status。 */
|
||||
private toolSteps: ToolStep[] = []
|
||||
|
||||
// ---- flush ----
|
||||
private flushController: FlushController
|
||||
|
||||
constructor(private readonly deps: StreamingCardDeps) {
|
||||
this.flushController = new FlushController(() => this.performFlush())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 首次创建卡片(CardKit 主路径;失败则降级到直发 Schema 2.0 卡 + patch)。
|
||||
* 幂等:已创建/正在创建时直接返回。
|
||||
*/
|
||||
async ensureCreated(): Promise<void> {
|
||||
if (this.phase !== 'idle') return
|
||||
this.phase = 'creating'
|
||||
|
||||
try {
|
||||
// CardKit 主路径
|
||||
const cardId = await createCardEntity(
|
||||
this.deps.larkClient,
|
||||
buildInitialStreamingCard(),
|
||||
)
|
||||
const messageId = await sendCardAsMessage(
|
||||
this.deps.larkClient,
|
||||
this.deps.chatId,
|
||||
cardId,
|
||||
this.deps.replyToMessageId,
|
||||
)
|
||||
this.cardId = cardId
|
||||
this.messageId = messageId
|
||||
this.cardKitStreamActive = true
|
||||
this.sequence = 1
|
||||
this.phase = 'streaming'
|
||||
this.flushController.setCardMessageReady(true)
|
||||
} catch (cardKitErr) {
|
||||
// CardKit 不可用(权限、网络、API 兼容性等)→ 降级到直发卡片 + patch
|
||||
console.warn(
|
||||
'[Feishu StreamingCard] CardKit create/send failed, falling back to im.message.patch:',
|
||||
cardKitErr instanceof Error ? cardKitErr.message : cardKitErr,
|
||||
)
|
||||
try {
|
||||
const fallbackResp = await this.deps.larkClient.im.message.create({
|
||||
params: { receive_id_type: 'chat_id' },
|
||||
data: {
|
||||
receive_id: this.deps.chatId,
|
||||
msg_type: 'interactive',
|
||||
content: JSON.stringify(buildRenderedCard(' ')),
|
||||
},
|
||||
})
|
||||
const mid = fallbackResp.data?.message_id
|
||||
if (!mid) {
|
||||
throw new Error('fallback im.message.create returned no message_id')
|
||||
}
|
||||
this.cardId = null
|
||||
this.messageId = mid
|
||||
this.cardKitStreamActive = false
|
||||
this.phase = 'streaming'
|
||||
this.flushController.setCardMessageReady(true)
|
||||
} catch (fallbackErr) {
|
||||
// 兜底都失败了 —— 无法显示任何东西
|
||||
console.error(
|
||||
'[Feishu StreamingCard] Fallback card creation also failed:',
|
||||
fallbackErr instanceof Error ? fallbackErr.message : fallbackErr,
|
||||
)
|
||||
this.phase = 'aborted'
|
||||
throw fallbackErr
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片可写之后若已有 buffered 内容(text / reasoning / tools),
|
||||
// 立刻触发一次 flush —— 否则 content_start{tool_use} 或 thinking 在
|
||||
// ensureCreated 期间到达的状态会一直卡在节流 gate 上,用户看不到。
|
||||
if (this.hasAnyContent()) {
|
||||
void this.flushController.throttledUpdate(this.currentThrottle())
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加文本增量。不等待,只安排一次节流 flush。 */
|
||||
appendText(delta: string): void {
|
||||
if (!delta) return
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
this.accumulatedText += delta
|
||||
void this.flushController.throttledUpdate(this.currentThrottle())
|
||||
}
|
||||
|
||||
/** 追加 reasoning/thinking delta —— 与 appendText 并列,渲染为顶部预览。 */
|
||||
appendReasoning(delta: string): void {
|
||||
if (!delta) return
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
this.accumulatedReasoningText += delta
|
||||
void this.flushController.throttledUpdate(this.currentThrottle())
|
||||
}
|
||||
|
||||
/** 记录一次 tool_use 开始。dedupe 按 toolUseId(缺省时按 name+index)。 */
|
||||
startTool(toolUseId: string | undefined, toolName: string | undefined): void {
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
if (!toolName) return
|
||||
const id = toolUseId || `${toolName}#${this.toolSteps.length}`
|
||||
if (this.toolSteps.some((s) => s.id === id)) return
|
||||
this.toolSteps.push({ id, name: toolName, status: 'running' })
|
||||
void this.flushController.throttledUpdate(this.currentThrottle())
|
||||
}
|
||||
|
||||
/** 把指定 tool 的状态从 running 切到 done。先按 id 匹配,再 fallback name。 */
|
||||
completeTool(toolUseId: string | undefined, toolName: string | undefined): void {
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
let step: ToolStep | undefined
|
||||
if (toolUseId) {
|
||||
step = this.toolSteps.find((s) => s.id === toolUseId)
|
||||
}
|
||||
if (!step && toolName) {
|
||||
for (let i = this.toolSteps.length - 1; i >= 0; i--) {
|
||||
const s = this.toolSteps[i]!
|
||||
if (s.name === toolName && s.status === 'running') {
|
||||
step = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!step) return
|
||||
if (step.status === 'done') return
|
||||
step.status = 'done'
|
||||
void this.flushController.throttledUpdate(this.currentThrottle())
|
||||
}
|
||||
|
||||
/** 是否已有任何可渲染内容(文本 / 推理 / 工具)。 */
|
||||
private hasAnyContent(): boolean {
|
||||
return (
|
||||
this.accumulatedText.length > 0 ||
|
||||
this.accumulatedReasoningText.length > 0 ||
|
||||
this.toolSteps.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式结束,切到最终态。
|
||||
* - 先 waitForFlush 确保中间帧写入完成
|
||||
* - 然后 close streaming_mode(仅 CardKit 路径)
|
||||
* - 最后用完整 rendered 卡片 update
|
||||
* - complete FlushController 锁死,后续 appendText 被忽略
|
||||
*/
|
||||
async finalize(): Promise<void> {
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
if (this.phase === 'idle') {
|
||||
// 完全没开始 —— 直接标记完成
|
||||
this.phase = 'completed'
|
||||
this.flushController.complete()
|
||||
return
|
||||
}
|
||||
this.phase = 'finalizing'
|
||||
this.flushController.cancelPendingFlush()
|
||||
await this.flushController.waitForFlush()
|
||||
|
||||
const finalText = this.terminalText()
|
||||
try {
|
||||
if (this.cardId) {
|
||||
// CardKit 路径: settings(false) + card.update(即使中间 stream 曾失败)
|
||||
this.sequence += 1
|
||||
await setCardStreamingMode(
|
||||
this.deps.larkClient,
|
||||
this.cardId,
|
||||
false,
|
||||
this.sequence,
|
||||
)
|
||||
this.sequence += 1
|
||||
await updateCardKitCard(
|
||||
this.deps.larkClient,
|
||||
this.cardId,
|
||||
buildRenderedCard(finalText),
|
||||
this.sequence,
|
||||
)
|
||||
} else if (this.messageId) {
|
||||
// Patch fallback 路径: 全量替换
|
||||
await this.deps.larkClient.im.message.patch({
|
||||
path: { message_id: this.messageId },
|
||||
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[Feishu StreamingCard] finalize failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
// 不抛出 —— 用户已经看到某种版本的内容,finalize 失败不是致命错误
|
||||
} finally {
|
||||
this.phase = 'completed'
|
||||
this.lastFlushedText = finalText
|
||||
this.flushController.complete()
|
||||
}
|
||||
}
|
||||
|
||||
/** 错误中止 —— 尝试把错误信息渲染到卡片上。 */
|
||||
async abort(err: Error): Promise<void> {
|
||||
if (this.phase === 'completed' || this.phase === 'aborted') return
|
||||
const wasIdle = this.phase === 'idle'
|
||||
this.phase = 'aborted'
|
||||
this.flushController.cancelPendingFlush()
|
||||
await this.flushController.waitForFlush().catch(() => {})
|
||||
|
||||
if (wasIdle || !this.messageId) {
|
||||
// 卡片还没创建成功,没法渲染错误 —— 由上层 sendText 兜底
|
||||
this.flushController.complete()
|
||||
return
|
||||
}
|
||||
|
||||
const errCard = buildErrorCard(
|
||||
`${err.message}${this.accumulatedText ? '\n\n——\n\n' + this.accumulatedText : ''}`,
|
||||
)
|
||||
try {
|
||||
if (this.cardId) {
|
||||
this.sequence += 1
|
||||
await setCardStreamingMode(
|
||||
this.deps.larkClient,
|
||||
this.cardId,
|
||||
false,
|
||||
this.sequence,
|
||||
).catch(() => {}) // 关流失败无所谓,update 才是关键
|
||||
this.sequence += 1
|
||||
await updateCardKitCard(
|
||||
this.deps.larkClient,
|
||||
this.cardId,
|
||||
errCard,
|
||||
this.sequence,
|
||||
)
|
||||
} else {
|
||||
await this.deps.larkClient.im.message.patch({
|
||||
path: { message_id: this.messageId },
|
||||
data: { content: JSON.stringify(errCard) },
|
||||
})
|
||||
}
|
||||
} catch (renderErr) {
|
||||
console.error(
|
||||
'[Feishu StreamingCard] abort render failed:',
|
||||
renderErr instanceof Error ? renderErr.message : renderErr,
|
||||
)
|
||||
} finally {
|
||||
this.flushController.complete()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internals
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 当前应使用的节流时长。 */
|
||||
private currentThrottle(): number {
|
||||
return this.cardKitStreamActive ? THROTTLE.CARDKIT_MS : THROTTLE.PATCH_MS
|
||||
}
|
||||
|
||||
/** 组合 reasoning + toolSteps + answerText,经 sanitize + optimize 管道出来。
|
||||
*
|
||||
* 顺序为 tools → reasoning → answer,以分隔符隔开:
|
||||
* - tools 永远在最顶部,方便用户先看到 "现在在跑什么"
|
||||
* - reasoning 居中(thinking 文本)
|
||||
* - answer 在底部
|
||||
*
|
||||
* 整张卡片只用最朴素的 markdown:plain text + emoji + bold + line break。
|
||||
* **不使用** blockquote / list / heading —— 这些会被
|
||||
* optimizeMarkdownForFeishu 触发额外的 <br> 注入和 H 降级,并且历史上
|
||||
* 曾导致飞书 CardKit 校验报错("long wait → 一次性 dump" 退化的根因)。
|
||||
* 任意 section 为空则忽略;全部为空时返回等待提示。 */
|
||||
private renderedText(): string {
|
||||
const sections: string[] = []
|
||||
|
||||
if (this.toolSteps.length > 0) {
|
||||
// 单行 inline 形式: ⚙️ Bash · ✅ Read · ⚙️ Glob ...
|
||||
// 用中点分隔,比 markdown list 更不容易触发 Feishu 排版异常
|
||||
const inline = this.toolSteps
|
||||
.map((s) => `${s.status === 'done' ? '✅' : '⚙️'} ${s.name}`)
|
||||
.join(' · ')
|
||||
sections.push(`🛠️ ${inline}`)
|
||||
}
|
||||
|
||||
if (this.accumulatedReasoningText) {
|
||||
const preview = truncateReasoningPreview(
|
||||
this.accumulatedReasoningText,
|
||||
REASONING_PREVIEW_CHARS,
|
||||
)
|
||||
// openclaw 风格: 一行 header + 空行 + 原文。不引用 / 不缩进,让飞书
|
||||
// markdown 元素按普通段落渲染。
|
||||
sections.push(`💭 **思考中**\n\n${preview}`)
|
||||
}
|
||||
|
||||
if (this.accumulatedText) {
|
||||
sections.push(this.accumulatedText)
|
||||
}
|
||||
|
||||
if (sections.length === 0) return '☁️ *正在思考中...*'
|
||||
|
||||
// 用一行分隔符把 sections 分开,比单纯空行更稳定
|
||||
const composed = sections.join('\n\n---\n\n')
|
||||
|
||||
// 表格数限制在 optimize 之前做 —— sanitize 对原始 markdown 最准
|
||||
const limited = sanitizeTextForCard(composed)
|
||||
return optimizeMarkdownForFeishu(limited, 2)
|
||||
}
|
||||
|
||||
/** 终态文本: 只渲染最终答复正文,丢弃 reasoning 和 toolSteps。
|
||||
*
|
||||
* 推理过程和工具调用是"过程态"信息,已经在流式中展示给用户看过;
|
||||
* message_complete 之后用户应该看到一张干净的答复卡(与 Desktop UI 对齐)。
|
||||
* 这个方法专供 finalize 调用,不要在中间帧用。
|
||||
*
|
||||
* 边界情况: 如果完全没有 accumulatedText(比如纯 thinking 没产出答案
|
||||
* 这种异常 case),退回到 renderedText() 至少把推理留下来当兜底。 */
|
||||
private terminalText(): string {
|
||||
if (this.accumulatedText) {
|
||||
const limited = sanitizeTextForCard(this.accumulatedText)
|
||||
return optimizeMarkdownForFeishu(limited, 2)
|
||||
}
|
||||
return this.renderedText()
|
||||
}
|
||||
|
||||
/** FlushController 调用的 doFlush。 */
|
||||
private async performFlush(): Promise<void> {
|
||||
if (this.phase !== 'streaming') return
|
||||
if (!this.messageId) return
|
||||
|
||||
// CardKit 中间帧被禁用但 cardId 仍有效 —— 跳过中间 flush,
|
||||
// 等 finalize 用 cardId 做最终 settings + update
|
||||
if (this.cardId && !this.cardKitStreamActive) return
|
||||
|
||||
const finalText = this.renderedText()
|
||||
if (finalText === this.lastFlushedText) return
|
||||
|
||||
if (this.cardKitStreamActive && this.cardId) {
|
||||
// CardKit 主路径
|
||||
this.sequence += 1
|
||||
try {
|
||||
await streamCardContent(
|
||||
this.deps.larkClient,
|
||||
this.cardId,
|
||||
STREAMING_ELEMENT_ID,
|
||||
finalText,
|
||||
this.sequence,
|
||||
)
|
||||
this.lastFlushedText = finalText
|
||||
this.consecutiveStreamFailures = 0
|
||||
} catch (err) {
|
||||
if (isCardRateLimitError(err)) {
|
||||
// 跳帧 —— 下次 throttledUpdate 会重试
|
||||
return
|
||||
}
|
||||
if (isCardTableLimitError(err)) {
|
||||
// 表格超限 —— 禁用流式中间帧,等 finalize 用 update 一次性发完整卡
|
||||
console.warn(
|
||||
'[Feishu StreamingCard] 230099 table limit, disabling CardKit streaming',
|
||||
)
|
||||
this.cardKitStreamActive = false
|
||||
return
|
||||
}
|
||||
// 其他错误 —— 跳帧重试,避免单次失败把整张卡冻在最初状态。
|
||||
// 只有连续失败超过阈值才认定 CardKit 不可用并降级 —— 否则
|
||||
// 用户会看到 "long wait → 完事后一次性把所有内容刷出来" 的体验
|
||||
// 退化(这是 streamCardContent 一旦报错就 disable 流式造成的)。
|
||||
this.consecutiveStreamFailures += 1
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
if (this.consecutiveStreamFailures === 1) {
|
||||
// 首帧失败先记录一次,避免日志风暴
|
||||
console.warn(
|
||||
'[Feishu StreamingCard] stream flush failed (will retry):',
|
||||
errMsg,
|
||||
)
|
||||
}
|
||||
if (this.consecutiveStreamFailures >= STREAM_FAIL_DISABLE_THRESHOLD) {
|
||||
console.error(
|
||||
`[Feishu StreamingCard] stream flush failed ${this.consecutiveStreamFailures}× consecutively, disabling CardKit streaming until finalize:`,
|
||||
errMsg,
|
||||
)
|
||||
this.cardKitStreamActive = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Patch fallback 路径(CardKit 从未成功)
|
||||
try {
|
||||
await this.deps.larkClient.im.message.patch({
|
||||
path: { message_id: this.messageId },
|
||||
data: { content: JSON.stringify(buildRenderedCard(finalText)) },
|
||||
})
|
||||
this.lastFlushedText = finalText
|
||||
} catch (err) {
|
||||
if (isCardRateLimitError(err)) return
|
||||
console.error(
|
||||
'[Feishu StreamingCard] patch flush failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Test helpers (exposed for unit tests, not part of public API)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** @internal */
|
||||
_getPhase(): StreamingCardPhase {
|
||||
return this.phase
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getCardId(): string | null {
|
||||
return this.cardId
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getMessageId(): string | null {
|
||||
return this.messageId
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getSequence(): number {
|
||||
return this.sequence
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_isCardKitStreamActive(): boolean {
|
||||
return this.cardKitStreamActive
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getAccumulatedText(): string {
|
||||
return this.accumulatedText
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getAccumulatedReasoning(): string {
|
||||
return this.accumulatedReasoningText
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getToolSteps(): ReadonlyArray<ToolStep> {
|
||||
return this.toolSteps
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_getFlushController(): FlushController {
|
||||
return this.flushController
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "claude-code-im-adapters",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"telegram": "bun run telegram/index.ts",
|
||||
"feishu": "bun run feishu/index.ts",
|
||||
"test": "bun test",
|
||||
"test:common": "bun test common/",
|
||||
"test:telegram": "bun test telegram/",
|
||||
"test:feishu": "bun test feishu/"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.42.0",
|
||||
"@larksuiteoapi/node-sdk": "^1.60.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.0",
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { TelegramMediaService } from '../media.js'
|
||||
import { AttachmentStore } from '../../common/attachment/attachment-store.js'
|
||||
|
||||
let tmpRoot: string
|
||||
let originalFetch: typeof fetch
|
||||
|
||||
function makeMockBot() {
|
||||
const fetchMock = mock(async (url: string | URL) => {
|
||||
const u = typeof url === 'string' ? url : url.toString()
|
||||
expect(u).toContain('/file/botFAKE_TOKEN/photos/abc.jpg')
|
||||
return new Response(Buffer.from('PHOTODATA'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
})
|
||||
})
|
||||
;(globalThis as any).fetch = fetchMock
|
||||
return {
|
||||
token: 'FAKE_TOKEN',
|
||||
api: {
|
||||
getFile: mock(async (fileId: string) => ({
|
||||
file_id: fileId,
|
||||
file_unique_id: 'unique',
|
||||
file_path: 'photos/abc.jpg',
|
||||
})),
|
||||
sendPhoto: mock(async () => ({ message_id: 1 })),
|
||||
sendDocument: mock(async () => ({ message_id: 2 })),
|
||||
},
|
||||
fetchMock,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
originalFetch = globalThis.fetch
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'tg-media-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('TelegramMediaService', () => {
|
||||
it('downloadFile fetches the real URL and stores a LocalAttachment', async () => {
|
||||
const bot = makeMockBot()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new TelegramMediaService(bot as any, store)
|
||||
const local = await svc.downloadFile('fid_123', 'sess-1', {
|
||||
fileName: 'abc.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
})
|
||||
expect(local.kind).toBe('image')
|
||||
expect(local.name).toBe('abc.jpg')
|
||||
expect(local.size).toBe('PHOTODATA'.length)
|
||||
expect(local.buffer.toString()).toBe('PHOTODATA')
|
||||
const onDisk = await fs.readFile(local.path)
|
||||
expect(onDisk.toString()).toBe('PHOTODATA')
|
||||
})
|
||||
|
||||
it('sendPhoto calls bot.api.sendPhoto with InputFile-like payload', async () => {
|
||||
const bot = makeMockBot()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new TelegramMediaService(bot as any, store)
|
||||
await svc.sendPhoto(42, Buffer.from('IMG'), 'caption text')
|
||||
expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1)
|
||||
const args = (bot.api.sendPhoto as any).mock.calls[0]
|
||||
expect(args[0]).toBe(42)
|
||||
// grammY InputFile wraps the buffer; just verify it's an object.
|
||||
expect(args[1]).toBeDefined()
|
||||
expect(args[2]?.caption).toBe('caption text')
|
||||
})
|
||||
|
||||
it('sendDocument calls bot.api.sendDocument', async () => {
|
||||
const bot = makeMockBot()
|
||||
const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
|
||||
const svc = new TelegramMediaService(bot as any, store)
|
||||
await svc.sendDocument(42, Buffer.from('DOC'), 'spec.pdf')
|
||||
expect(bot.api.sendDocument).toHaveBeenCalledTimes(1)
|
||||
const args = (bot.api.sendDocument as any).mock.calls[0]
|
||||
expect(args[0]).toBe(42)
|
||||
expect(args[1]).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import { splitMessage, formatPermissionRequest, truncateInput, escapeMarkdownV2 } from '../../common/format.js'
|
||||
|
||||
/**
|
||||
* Telegram Adapter 翻译逻辑测试
|
||||
*
|
||||
* 由于 grammy Bot 需要实际 Token 才能初始化,
|
||||
* 这里测试的是不依赖 Bot 实例的核心翻译逻辑。
|
||||
*/
|
||||
|
||||
describe('Telegram message formatting', () => {
|
||||
describe('long message splitting', () => {
|
||||
it('splits messages at Telegram 4096 char limit', () => {
|
||||
const longText = 'a'.repeat(8000)
|
||||
const chunks = splitMessage(longText, 4000)
|
||||
expect(chunks.length).toBe(2)
|
||||
expect(chunks[0]!.length).toBeLessThanOrEqual(4000)
|
||||
expect(chunks[1]!.length).toBeLessThanOrEqual(4000)
|
||||
})
|
||||
|
||||
it('keeps short messages as single chunk', () => {
|
||||
const chunks = splitMessage('Hello World', 4000)
|
||||
expect(chunks).toEqual(['Hello World'])
|
||||
})
|
||||
|
||||
it('splits at paragraph boundary when possible', () => {
|
||||
const text = 'A'.repeat(2000) + '\n\n' + 'B'.repeat(2000)
|
||||
const chunks = splitMessage(text, 3000)
|
||||
expect(chunks.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permission request formatting', () => {
|
||||
it('formats Bash command request', () => {
|
||||
const result = formatPermissionRequest('Bash', { command: 'npm test' }, 'abcde')
|
||||
expect(result).toContain('🔐')
|
||||
expect(result).toContain('Bash')
|
||||
expect(result).toContain('npm test')
|
||||
expect(result).toContain('abcde')
|
||||
})
|
||||
|
||||
it('formats Write file request', () => {
|
||||
const result = formatPermissionRequest(
|
||||
'Write',
|
||||
{ file_path: '/src/index.ts', content: 'console.log("hello")' },
|
||||
'fghij',
|
||||
)
|
||||
expect(result).toContain('Write')
|
||||
expect(result).toContain('index.ts')
|
||||
expect(result).toContain('fghij')
|
||||
})
|
||||
|
||||
it('truncates long input in permission request', () => {
|
||||
const longInput = { command: 'x'.repeat(500) }
|
||||
const result = formatPermissionRequest('Bash', longInput, 'xxxxx')
|
||||
expect(result.length).toBeLessThan(600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('callback_data parsing', () => {
|
||||
it('parses permit:requestId:yes format', () => {
|
||||
const data = 'permit:abcde:yes'
|
||||
const parts = data.split(':')
|
||||
expect(parts[0]).toBe('permit')
|
||||
expect(parts[1]).toBe('abcde')
|
||||
expect(parts[2]).toBe('yes')
|
||||
})
|
||||
|
||||
it('parses permit:requestId:no format', () => {
|
||||
const data = 'permit:abcde:no'
|
||||
const parts = data.split(':')
|
||||
expect(parts[2]).toBe('no')
|
||||
})
|
||||
|
||||
it('ignores non-permit callbacks', () => {
|
||||
const data = 'other:action'
|
||||
expect(data.startsWith('permit:')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownV2 escaping', () => {
|
||||
it('escapes underscores', () => {
|
||||
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
||||
})
|
||||
|
||||
it('escapes multiple special chars', () => {
|
||||
const result = escapeMarkdownV2('file.ts (line 42)')
|
||||
expect(result).toBe('file\\.ts \\(line 42\\)')
|
||||
})
|
||||
|
||||
it('handles code blocks safely', () => {
|
||||
const result = escapeMarkdownV2('`code`')
|
||||
expect(result).toBe('\\`code\\`')
|
||||
})
|
||||
})
|
||||
|
||||
describe('whitelist logic', () => {
|
||||
it('empty allowedUsers means allow all', () => {
|
||||
const allowedUsers: number[] = []
|
||||
const isAllowed = (userId: number) =>
|
||||
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
||||
expect(isAllowed(12345)).toBe(true)
|
||||
expect(isAllowed(99999)).toBe(true)
|
||||
})
|
||||
|
||||
it('non-empty allowedUsers filters correctly', () => {
|
||||
const allowedUsers = [111, 222]
|
||||
const isAllowed = (userId: number) =>
|
||||
allowedUsers.length === 0 || allowedUsers.includes(userId)
|
||||
expect(isAllowed(111)).toBe(true)
|
||||
expect(isAllowed(222)).toBe(true)
|
||||
expect(isAllowed(333)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* Telegram Adapter for Claude Code Desktop
|
||||
*
|
||||
* 基于 grammY 的轻量 Telegram Bot,直连服务端 /ws/:sessionId。
|
||||
* 启动:TELEGRAM_BOT_TOKEN=xxx bun run telegram/index.ts
|
||||
*/
|
||||
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy'
|
||||
import * as path from 'node:path'
|
||||
import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
|
||||
import { MessageBuffer } from '../common/message-buffer.js'
|
||||
import { MessageDedup } from '../common/message-dedup.js'
|
||||
import { enqueue } from '../common/chat-queue.js'
|
||||
import { loadConfig } from '../common/config.js'
|
||||
import {
|
||||
formatImHelp,
|
||||
formatImStatus,
|
||||
formatPermissionRequest,
|
||||
splitMessage,
|
||||
} from '../common/format.js'
|
||||
import { SessionStore } from '../common/session-store.js'
|
||||
import { AdapterHttpClient } from '../common/http-client.js'
|
||||
import { isAllowedUser, tryPair } from '../common/pairing.js'
|
||||
import { TelegramMediaService } from './media.js'
|
||||
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
|
||||
import type { AttachmentRef } from '../common/ws-bridge.js'
|
||||
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
|
||||
import type { PendingUpload } from '../common/attachment/attachment-types.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
|
||||
|
||||
// ---------- init ----------
|
||||
|
||||
const config = loadConfig()
|
||||
if (!config.telegram.botToken) {
|
||||
console.error('[Telegram] Missing TELEGRAM_BOT_TOKEN. Set env or ~/.claude/adapters.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bot = new Bot(config.telegram.botToken)
|
||||
const bridge = new WsBridge(config.serverUrl, 'tg')
|
||||
const dedup = new MessageDedup()
|
||||
const sessionStore = new SessionStore()
|
||||
const httpClient = new AdapterHttpClient(config.serverUrl)
|
||||
const attachmentStore = new AttachmentStore()
|
||||
const media = new TelegramMediaService(bot, attachmentStore)
|
||||
attachmentStore.gc().catch((err) => {
|
||||
console.warn('[Telegram] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
|
||||
})
|
||||
|
||||
// Track placeholder messages for streaming updates
|
||||
const placeholders = new Map<string, { chatId: string; messageId: number }>()
|
||||
// Track accumulated text per chat for streaming
|
||||
const accumulatedText = new Map<string, string>()
|
||||
// Message buffers per chat
|
||||
const buffers = new Map<string, MessageBuffer>()
|
||||
// Track chats waiting for project selection
|
||||
const pendingProjectSelection = new Map<string, boolean>()
|
||||
const runtimeStates = new Map<string, ChatRuntimeState>()
|
||||
/** Per-chat outbound image watcher for Agent-produced markdown images. */
|
||||
const tgImageWatchers = new Map<string, ImageBlockWatcher>()
|
||||
|
||||
function getTgWatcher(chatId: string): ImageBlockWatcher {
|
||||
let w = tgImageWatchers.get(chatId)
|
||||
if (!w) {
|
||||
w = new ImageBlockWatcher()
|
||||
tgImageWatchers.set(chatId, w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
type ChatRuntimeState = {
|
||||
state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
|
||||
verb?: string
|
||||
model?: string
|
||||
pendingPermissionCount: number
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function getBuffer(chatId: string): MessageBuffer {
|
||||
let buf = buffers.get(chatId)
|
||||
if (!buf) {
|
||||
buf = new MessageBuffer(async (text, isComplete) => {
|
||||
await flushToTelegram(chatId, text, isComplete)
|
||||
})
|
||||
buffers.set(chatId, buf)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
function getRuntimeState(chatId: string): ChatRuntimeState {
|
||||
let state = runtimeStates.get(chatId)
|
||||
if (!state) {
|
||||
state = { state: 'idle', pendingPermissionCount: 0 }
|
||||
runtimeStates.set(chatId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function clearTransientChatState(chatId: string): void {
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
const runtime = getRuntimeState(chatId)
|
||||
runtime.state = 'idle'
|
||||
runtime.verb = undefined
|
||||
runtime.pendingPermissionCount = 0
|
||||
tgImageWatchers.delete(chatId)
|
||||
}
|
||||
|
||||
async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
|
||||
const stored = sessionStore.get(chatId)
|
||||
if (!stored) return null
|
||||
|
||||
if (!bridge.hasSession(chatId)) {
|
||||
bridge.connectSession(chatId, stored.sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
const opened = await bridge.waitForOpen(chatId)
|
||||
if (!opened) return null
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
async function buildStatusText(chatId: string): Promise<string> {
|
||||
const stored = await ensureExistingSession(chatId)
|
||||
if (!stored) return formatImStatus(null)
|
||||
|
||||
const runtime = getRuntimeState(chatId)
|
||||
let projectName = path.basename(stored.workDir) || stored.workDir
|
||||
let branch: string | null = null
|
||||
|
||||
try {
|
||||
const gitInfo = await httpClient.getGitInfo(stored.sessionId)
|
||||
projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
|
||||
branch = gitInfo.branch
|
||||
} catch {
|
||||
// Ignore git lookup failures and fall back to stored workDir
|
||||
}
|
||||
|
||||
let taskCounts:
|
||||
| {
|
||||
total: number
|
||||
pending: number
|
||||
inProgress: number
|
||||
completed: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
try {
|
||||
const tasks = await httpClient.getTasksForSession(stored.sessionId)
|
||||
if (tasks.length > 0) {
|
||||
taskCounts = {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === 'pending').length,
|
||||
inProgress: tasks.filter((task) => task.status === 'in_progress').length,
|
||||
completed: tasks.filter((task) => task.status === 'completed').length,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore task lookup failures in IM status summary
|
||||
}
|
||||
|
||||
return formatImStatus({
|
||||
sessionId: stored.sessionId,
|
||||
projectName,
|
||||
branch,
|
||||
model: runtime.model,
|
||||
state: runtime.state,
|
||||
verb: runtime.verb,
|
||||
pendingPermissionCount: runtime.pendingPermissionCount,
|
||||
taskCounts,
|
||||
})
|
||||
}
|
||||
|
||||
async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
const prev = accumulatedText.get(chatId) ?? ''
|
||||
const fullText = prev + newText
|
||||
accumulatedText.set(chatId, fullText)
|
||||
|
||||
const placeholder = placeholders.get(chatId)
|
||||
|
||||
if (placeholder) {
|
||||
if (isComplete) {
|
||||
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
|
||||
try {
|
||||
await bot.api.editMessageText(numericChatId, placeholder.messageId, chunks[0]!)
|
||||
} catch { /* ignore */ }
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
await bot.api.sendMessage(numericChatId, chunks[i]!)
|
||||
}
|
||||
} else {
|
||||
const displayText = fullText.slice(0, TELEGRAM_TEXT_LIMIT - 2) + ' ▍'
|
||||
try {
|
||||
await bot.api.editMessageText(numericChatId, placeholder.messageId, displayText)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} else if (isComplete && fullText.trim()) {
|
||||
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
|
||||
for (const chunk of chunks) {
|
||||
await bot.api.sendMessage(numericChatId, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- session management ----------
|
||||
|
||||
async function ensureSession(chatId: string): Promise<boolean> {
|
||||
if (bridge.hasSession(chatId)) return true
|
||||
|
||||
const stored = sessionStore.get(chatId)
|
||||
if (stored) {
|
||||
bridge.connectSession(chatId, stored.sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
return await bridge.waitForOpen(chatId)
|
||||
}
|
||||
|
||||
const workDir = config.defaultProjectDir
|
||||
if (workDir) {
|
||||
return await createSessionForChat(chatId, workDir)
|
||||
}
|
||||
|
||||
await showProjectPicker(chatId)
|
||||
return false
|
||||
}
|
||||
|
||||
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
||||
const numericChatId = Number(chatId)
|
||||
try {
|
||||
// Always tear down any stale WS connection before creating a new session.
|
||||
// Without this, bridge.connectSession() below would short-circuit when an
|
||||
// old OPEN connection still exists, leaving messages routed to the old session.
|
||||
bridge.resetSession(chatId)
|
||||
|
||||
const sessionId = await httpClient.createSession(workDir)
|
||||
sessionStore.set(chatId, sessionId, workDir)
|
||||
bridge.connectSession(chatId, sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
const opened = await bridge.waitForOpen(chatId)
|
||||
if (!opened) {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 连接服务器超时,请重试。')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
`❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function showProjectPicker(chatId: string): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
try {
|
||||
const projects = await httpClient.listRecentProjects()
|
||||
if (projects.length === 0) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。')
|
||||
return
|
||||
}
|
||||
|
||||
const lines = projects.slice(0, 10).map((p, i) =>
|
||||
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
|
||||
)
|
||||
pendingProjectSelection.set(chatId, true)
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
`选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
|
||||
} catch (err) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- outbound media dispatch ----------
|
||||
|
||||
/** Upload a PendingUpload found in streaming output and send it via
|
||||
* bot.api.sendPhoto as an independent message. Runs fire-and-forget
|
||||
* from the stream handler so streaming text isn't blocked. */
|
||||
async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
try {
|
||||
let buffer: Buffer
|
||||
let mime = 'image/png'
|
||||
switch (pending.source.kind) {
|
||||
case 'base64': {
|
||||
buffer = Buffer.from(pending.source.data, 'base64')
|
||||
mime = pending.source.mime
|
||||
break
|
||||
}
|
||||
case 'path': {
|
||||
buffer = await fs.readFile(pending.source.path)
|
||||
mime = pending.source.mime ?? 'image/png'
|
||||
break
|
||||
}
|
||||
case 'url': {
|
||||
const resp = await fetch(pending.source.url)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
|
||||
}
|
||||
buffer = Buffer.from(await resp.arrayBuffer())
|
||||
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
|
||||
break
|
||||
}
|
||||
}
|
||||
const check = checkAttachmentLimit('image', buffer.length, mime)
|
||||
if (!check.ok) {
|
||||
console.warn('[Telegram] Outbound image rejected:', check.hint)
|
||||
return
|
||||
}
|
||||
await media.sendPhoto(numericChatId, buffer, pending.alt)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[Telegram] dispatchOutboundMedia failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- server message handler ----------
|
||||
|
||||
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
const buf = getBuffer(chatId)
|
||||
const runtime = getRuntimeState(chatId)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
break
|
||||
|
||||
case 'status':
|
||||
runtime.state = msg.state
|
||||
runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
|
||||
if (msg.state === 'thinking' && !placeholders.has(chatId)) {
|
||||
const sent = await bot.api.sendMessage(numericChatId, '💭 思考中...')
|
||||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||||
accumulatedText.set(chatId, '')
|
||||
}
|
||||
break
|
||||
|
||||
case 'content_start':
|
||||
if (msg.blockType === 'text') {
|
||||
if (!placeholders.has(chatId)) {
|
||||
const sent = await bot.api.sendMessage(numericChatId, '▍')
|
||||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||||
accumulatedText.set(chatId, '')
|
||||
}
|
||||
} else if (msg.blockType === 'tool_use') {
|
||||
// Finalize current text placeholder before tool calls,
|
||||
// so text after tools gets a fresh message
|
||||
await buf.complete()
|
||||
// If placeholder still exists (buffer was already empty), clean up directly
|
||||
if (placeholders.has(chatId)) {
|
||||
const text = accumulatedText.get(chatId)
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'content_delta':
|
||||
if (msg.text) {
|
||||
buf.append(msg.text)
|
||||
const newUploads = getTgWatcher(chatId).feed(msg.text)
|
||||
for (const pending of newUploads) {
|
||||
void dispatchOutboundMedia(chatId, pending)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'thinking':
|
||||
if (placeholders.has(chatId)) {
|
||||
try {
|
||||
await bot.api.editMessageText(
|
||||
numericChatId,
|
||||
placeholders.get(chatId)!.messageId,
|
||||
`💭 ${msg.text.slice(0, 200)}...`,
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_use_complete':
|
||||
// Tool details are noise for IM users; visible in Desktop if needed.
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
// Tool errors are handled internally by the AI (retries etc.)
|
||||
// No need to notify the user for every failed attempt.
|
||||
break
|
||||
|
||||
case 'permission_request': {
|
||||
runtime.pendingPermissionCount += 1
|
||||
runtime.state = 'permission_pending'
|
||||
const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
|
||||
const keyboard = new InlineKeyboard()
|
||||
.text('✅ 允许', `permit:${msg.requestId}:yes`)
|
||||
.text('❌ 拒绝', `permit:${msg.requestId}:no`)
|
||||
await bot.api.sendMessage(numericChatId, text, { reply_markup: keyboard })
|
||||
break
|
||||
}
|
||||
|
||||
case 'message_complete':
|
||||
runtime.state = 'idle'
|
||||
runtime.verb = undefined
|
||||
await buf.complete()
|
||||
// Ensure placeholder is always cleaned up even if buffer was already empty
|
||||
if (placeholders.has(chatId)) {
|
||||
const text = accumulatedText.get(chatId)
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
const chunks = splitMessage(text, TELEGRAM_TEXT_LIMIT)
|
||||
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, chunks[0]!)
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
await bot.api.sendMessage(numericChatId, chunks[i]!)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
runtime.state = 'idle'
|
||||
runtime.verb = undefined
|
||||
// Auto-recover from stale thinking block signatures by creating a fresh session.
|
||||
// This happens when the API key or provider changed since the session was created.
|
||||
if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
|
||||
const stored = sessionStore.get(chatId)
|
||||
const workDir = stored?.workDir || config.defaultProjectDir
|
||||
if (workDir) {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...')
|
||||
clearTransientChatState(chatId)
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await bot.api.sendMessage(numericChatId, '✅ 已重建会话,请重新发送消息。')
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, '❌ 重建会话失败,请发送 /new 手动新建。')
|
||||
}
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
|
||||
}
|
||||
} else {
|
||||
await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'system_notification':
|
||||
if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
|
||||
const model = (msg.data as Record<string, unknown>).model
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
runtime.model = model
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- bot handlers ----------
|
||||
|
||||
async function sendHelp(ctx: Context): Promise<void> {
|
||||
await ctx.reply(`👋 Claude Code Bot 已就绪。\n\n${formatImHelp()}`)
|
||||
}
|
||||
|
||||
bot.command('start', (ctx) => void sendHelp(ctx))
|
||||
bot.command('help', (ctx) => void sendHelp(ctx))
|
||||
|
||||
/** Reset session state and start a new session for chatId.
|
||||
* If `query` is provided, match a project by index or name;
|
||||
* otherwise use defaultProjectDir or show the picker. */
|
||||
async function startNewSession(chatId: string, query?: string): Promise<void> {
|
||||
const numericChatId = Number(chatId)
|
||||
|
||||
bridge.resetSession(chatId)
|
||||
sessionStore.delete(chatId)
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
buffers.delete(chatId)
|
||||
pendingProjectSelection.delete(chatId)
|
||||
runtimeStates.delete(chatId)
|
||||
tgImageWatchers.delete(chatId)
|
||||
|
||||
if (query) {
|
||||
try {
|
||||
const { project, ambiguous } = await httpClient.matchProject(query)
|
||||
if (project) {
|
||||
const ok = await createSessionForChat(chatId, project.realPath)
|
||||
if (ok) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
`✅ 已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ambiguous) {
|
||||
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} — ${p.realPath}`).join('\n')
|
||||
await bot.api.sendMessage(numericChatId, `匹配到多个项目,请更精确:\n\n${list}`)
|
||||
return
|
||||
}
|
||||
await bot.api.sendMessage(numericChatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
|
||||
} catch (err) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
`❌ ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
} else {
|
||||
const workDir = config.defaultProjectDir
|
||||
if (workDir) {
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await bot.api.sendMessage(numericChatId, '✅ 已新建会话,可以开始对话了。')
|
||||
}
|
||||
} else {
|
||||
await showProjectPicker(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bot.command('new', async (ctx) => {
|
||||
const chatId = String(ctx.chat.id)
|
||||
await startNewSession(chatId, ctx.match?.trim() || undefined)
|
||||
})
|
||||
|
||||
bot.command('projects', async (ctx) => {
|
||||
const chatId = String(ctx.chat.id)
|
||||
await showProjectPicker(chatId)
|
||||
})
|
||||
|
||||
bot.command('stop', (ctx) => {
|
||||
const chatId = String(ctx.chat.id)
|
||||
void (async () => {
|
||||
const stored = await ensureExistingSession(chatId)
|
||||
if (!stored) {
|
||||
await ctx.reply(formatImStatus(null))
|
||||
return
|
||||
}
|
||||
bridge.sendStopGeneration(chatId)
|
||||
await ctx.reply('⏹ 已发送停止信号。')
|
||||
})()
|
||||
})
|
||||
|
||||
bot.command('status', async (ctx) => {
|
||||
const chatId = String(ctx.chat.id)
|
||||
await ctx.reply(await buildStatusText(chatId))
|
||||
})
|
||||
|
||||
bot.command('clear', (ctx) => {
|
||||
const chatId = String(ctx.chat.id)
|
||||
void (async () => {
|
||||
const stored = await ensureExistingSession(chatId)
|
||||
if (!stored) {
|
||||
await ctx.reply(formatImStatus(null))
|
||||
return
|
||||
}
|
||||
clearTransientChatState(chatId)
|
||||
const sent = bridge.sendUserMessage(chatId, '/clear')
|
||||
if (!sent) {
|
||||
await ctx.reply('⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
|
||||
return
|
||||
}
|
||||
await ctx.reply('🧹 已清空当前会话上下文。')
|
||||
})()
|
||||
})
|
||||
|
||||
/** Shared per-user-message pipeline: dedup, pairing check, project-pick
|
||||
* routing, enqueue, ensureSession, sendUserMessage with attachments.
|
||||
* Caller has already extracted text and attachments from the context. */
|
||||
async function routeUserMessage(
|
||||
ctx: Context,
|
||||
text: string,
|
||||
attachments: AttachmentRef[],
|
||||
): Promise<void> {
|
||||
if (!ctx.from || ctx.chat?.type !== 'private') return
|
||||
if (!dedup.tryRecord(String(ctx.message?.message_id))) return
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
const userId = ctx.from.id
|
||||
|
||||
if (!isAllowedUser('telegram', userId)) {
|
||||
const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
|
||||
const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
|
||||
if (success) {
|
||||
await ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
|
||||
} else {
|
||||
await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
enqueue(chatId, async () => {
|
||||
if (pendingProjectSelection.has(chatId)) {
|
||||
if (text.trim()) await startNewSession(chatId, text.trim())
|
||||
return
|
||||
}
|
||||
const ready = await ensureSession(chatId)
|
||||
if (!ready) return
|
||||
const effective =
|
||||
text || (attachments.length > 0 ? '(用户发送了附件)' : '')
|
||||
if (!effective && attachments.length === 0) return
|
||||
const sent = bridge.sendUserMessage(chatId, effective, attachments.length ? attachments : undefined)
|
||||
if (!sent) {
|
||||
await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Scan ctx.message for photo/document/video/audio/voice, download
|
||||
* each via TelegramMediaService, apply size/mime limits, and produce
|
||||
* a ready-to-send AttachmentRef[] plus any rejection hints. */
|
||||
async function collectAttachmentsFromCtx(
|
||||
ctx: Context,
|
||||
): Promise<{ attachments: AttachmentRef[]; rejections: string[] }> {
|
||||
const msg = ctx.message
|
||||
if (!msg || !ctx.chat) return { attachments: [], rejections: [] }
|
||||
const sessionId = sessionStore.get(String(ctx.chat.id))?.sessionId ?? String(ctx.chat.id)
|
||||
const attachments: AttachmentRef[] = []
|
||||
const rejections: string[] = []
|
||||
|
||||
const runOne = async (
|
||||
fileId: string,
|
||||
fileName?: string,
|
||||
mimeType?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const local = await media.downloadFile(fileId, sessionId, { fileName, mimeType })
|
||||
const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
|
||||
if (!check.ok) {
|
||||
rejections.push(check.hint)
|
||||
return
|
||||
}
|
||||
if (local.kind === 'image') {
|
||||
attachments.push({
|
||||
type: 'image',
|
||||
name: local.name,
|
||||
data: local.buffer.toString('base64'),
|
||||
mimeType: local.mimeType,
|
||||
})
|
||||
} else {
|
||||
attachments.push({
|
||||
type: 'file',
|
||||
name: local.name,
|
||||
path: local.path,
|
||||
mimeType: local.mimeType,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Telegram] downloadFile failed:', err)
|
||||
rejections.push('📎 附件下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// Photos: grammY exposes an array of sizes, largest last.
|
||||
if (msg.photo && msg.photo.length > 0) {
|
||||
const largest = msg.photo[msg.photo.length - 1]!
|
||||
await runOne(largest.file_id, `photo-${largest.file_unique_id}.jpg`, 'image/jpeg')
|
||||
}
|
||||
if (msg.document) {
|
||||
await runOne(msg.document.file_id, msg.document.file_name, msg.document.mime_type)
|
||||
}
|
||||
if (msg.video) {
|
||||
await runOne(msg.video.file_id, msg.video.file_name, msg.video.mime_type)
|
||||
}
|
||||
if (msg.audio) {
|
||||
await runOne(msg.audio.file_id, msg.audio.file_name, msg.audio.mime_type)
|
||||
}
|
||||
if (msg.voice) {
|
||||
await runOne(
|
||||
msg.voice.file_id,
|
||||
`voice-${msg.voice.file_unique_id}.ogg`,
|
||||
msg.voice.mime_type ?? 'audio/ogg',
|
||||
)
|
||||
}
|
||||
|
||||
return { attachments, rejections }
|
||||
}
|
||||
|
||||
bot.on('message:text', async (ctx) => {
|
||||
await routeUserMessage(ctx, ctx.message.text, [])
|
||||
})
|
||||
|
||||
bot.on(
|
||||
['message:photo', 'message:document', 'message:video', 'message:audio', 'message:voice'],
|
||||
async (ctx) => {
|
||||
const caption = ctx.message.caption ?? ''
|
||||
const { attachments, rejections } = await collectAttachmentsFromCtx(ctx)
|
||||
for (const r of rejections) {
|
||||
await ctx.reply(r).catch(() => {})
|
||||
}
|
||||
if (attachments.length === 0 && !caption.trim()) return
|
||||
await routeUserMessage(ctx, caption, attachments)
|
||||
},
|
||||
)
|
||||
|
||||
bot.on('callback_query:data', async (ctx) => {
|
||||
const data = ctx.callbackQuery.data
|
||||
if (!data.startsWith('permit:')) return
|
||||
|
||||
const parts = data.split(':')
|
||||
if (parts.length !== 3) return
|
||||
|
||||
const requestId = parts[1]!
|
||||
const allowed = parts[2] === 'yes'
|
||||
const chatId = String(ctx.callbackQuery.message?.chat.id)
|
||||
|
||||
bridge.sendPermissionResponse(chatId, requestId, allowed)
|
||||
const runtime = getRuntimeState(chatId)
|
||||
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
|
||||
|
||||
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
|
||||
try {
|
||||
await ctx.editMessageText(
|
||||
ctx.callbackQuery.message?.text + `\n\n${statusText}`,
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await ctx.answerCallbackQuery(statusText)
|
||||
})
|
||||
|
||||
// ---------- start ----------
|
||||
|
||||
console.log('[Telegram] Starting bot...')
|
||||
console.log(`[Telegram] Server: ${config.serverUrl}`)
|
||||
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
|
||||
|
||||
bot.start({
|
||||
onStart: () => console.log('[Telegram] Bot is running!'),
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[Telegram] Shutting down...')
|
||||
bot.stop()
|
||||
bridge.destroy()
|
||||
dedup.destroy()
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Telegram media service — wraps grammY download/upload helpers.
|
||||
*
|
||||
* Telegram file download flow:
|
||||
* 1. bot.api.getFile(file_id) → { file_path }
|
||||
* 2. GET https://api.telegram.org/file/bot<token>/<file_path>
|
||||
*/
|
||||
|
||||
import { InputFile, type Bot } from 'grammy'
|
||||
import { AttachmentStore } from '../common/attachment/attachment-store.js'
|
||||
import type { LocalAttachment } from '../common/attachment/attachment-types.js'
|
||||
|
||||
function extOf(fileName?: string): string {
|
||||
if (!fileName) return ''
|
||||
const m = /\.([^./\\]+)$/.exec(fileName)
|
||||
return m ? m[1]!.toLowerCase() : ''
|
||||
}
|
||||
|
||||
function classifyKind(mime: string | undefined, fileName: string): 'image' | 'file' {
|
||||
if (mime?.startsWith('image/')) return 'image'
|
||||
const ext = extOf(fileName)
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'].includes(ext)) return 'image'
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export interface DownloadHint {
|
||||
fileName?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export class TelegramMediaService {
|
||||
constructor(
|
||||
private readonly bot: Bot,
|
||||
private readonly store: AttachmentStore,
|
||||
) {}
|
||||
|
||||
async downloadFile(
|
||||
fileId: string,
|
||||
sessionId: string,
|
||||
hint: DownloadHint = {},
|
||||
): Promise<LocalAttachment> {
|
||||
const file = await this.bot.api.getFile(fileId)
|
||||
if (!file.file_path) {
|
||||
throw new Error(`[TelegramMedia] getFile returned no file_path for ${fileId}`)
|
||||
}
|
||||
const token = (this.bot as unknown as { token: string }).token
|
||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`[TelegramMedia] fetch failed: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
const buffer = Buffer.from(await resp.arrayBuffer())
|
||||
const mime = hint.mimeType ?? resp.headers.get('content-type') ?? undefined
|
||||
const fallbackName = file.file_path.split('/').pop() || fileId
|
||||
const name = hint.fileName ?? fallbackName
|
||||
const kind = classifyKind(mime, name)
|
||||
const target = this.store.resolvePath('telegram', sessionId, name)
|
||||
await this.store.write(target, buffer)
|
||||
return {
|
||||
kind,
|
||||
name,
|
||||
path: target,
|
||||
size: buffer.length,
|
||||
mimeType: mime ?? (kind === 'image' ? 'image/png' : 'application/octet-stream'),
|
||||
buffer,
|
||||
}
|
||||
}
|
||||
|
||||
async sendPhoto(chatId: number, buffer: Buffer, caption?: string): Promise<void> {
|
||||
await this.bot.api.sendPhoto(
|
||||
chatId,
|
||||
new InputFile(buffer),
|
||||
caption ? { caption } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async sendDocument(
|
||||
chatId: number,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
caption?: string,
|
||||
): Promise<void> {
|
||||
await this.bot.api.sendDocument(
|
||||
chatId,
|
||||
new InputFile(buffer, fileName),
|
||||
caption ? { caption } : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@server/*": ["../src/server/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backwards-compatible shim — forwards to the new HeiCode entrypoint.
|
||||
# Will be removed in a future release.
|
||||
exec "$(dirname "${BASH_SOURCE[0]}")/heicode" "$@"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# Get your current working directory and export it as an environment variable.
|
||||
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# When spawned by the desktop/web server as a child CLI process,
|
||||
# skip .env loading — the server has already set the correct env
|
||||
# via heicode/settings.json. Loading .env would re-inject stale
|
||||
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
|
||||
# override the active provider config.
|
||||
if [[ "${HEICODE_SKIP_DOTENV:-${CC_HAHA_SKIP_DOTENV:-0}}" == "1" ]]; then
|
||||
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
|
||||
ENV_FILE_FLAG="--env-file=/dev/null"
|
||||
elif [[ -f .env ]]; then
|
||||
ENV_FILE_FLAG="--env-file=.env"
|
||||
else
|
||||
ENV_FILE_FLAG=""
|
||||
fi
|
||||
|
||||
# Force recovery CLI (simple readline REPL, no Ink TUI)
|
||||
if [[ "${HEICODE_FORCE_RECOVERY_CLI:-${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}}" == "1" ]]; then
|
||||
exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
|
||||
fi
|
||||
|
||||
# Default: full CLI with Ink TUI
|
||||
exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
|
||||
+1420
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
preload = ["./preload.ts"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Claude Code Haha Desktop
|
||||
|
||||
基于 Tauri 2 + React 的桌面客户端。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run tauri dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
./scripts/build-macos-arm64.sh
|
||||
|
||||
# Windows (x64, MSI only)
|
||||
.\scripts\build-windows-x64.ps1
|
||||
```
|
||||
|
||||
构建产物位于 `build-artifacts/` 目录,文件名会显式包含平台、架构和包类型。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### macOS 提示"已损坏,无法打开"
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/Claude\ Code\ Haha.app
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Override parent bunfig to avoid preload interference
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Claude Code Companion</title>
|
||||
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
|
||||
<style>
|
||||
/* Prevent white flash on load */
|
||||
html { background: #FAF9F5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "heicode-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
|
||||
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
|
||||
"build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
"mermaid": "^11.14.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-shiki": "^0.9.2",
|
||||
"shiki": "^4.0.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/api": "^2.3.0",
|
||||
"@tauri-apps/cli": "^2.3.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.2.1"
|
||||
}
|
||||
}
|
||||
Generated
+3832
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="accent" x1="684" y1="298" x2="834" y2="726" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6B34C"/>
|
||||
<stop offset="1" stop-color="#FF8A2A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
|
||||
<path d="M310 350 L510 512 L310 674" stroke="#20242E" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="440" y="690" width="120" height="48" rx="20" fill="#20242E"/>
|
||||
<path d="M684 298 H834 V726 H684" stroke="url(#accent)" stroke-width="64" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="#F5A623"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 773 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Douyin</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
|
||||
|
After Width: | Height: | Size: 728 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M22.405 9.879c.002.016.01.02.07.019h.725a.797.797 0 0 0 .78-.972.794.794 0 0 0-.884-.618.795.795 0 0 0-.692.794c0 .101-.002.666.001.777zm-11.509 4.808c-.203.001-1.353.004-1.685.003a2.528 2.528 0 0 1-.766-.126.025.025 0 0 0-.03.014L7.7 16.127a.025.025 0 0 0 .01.032c.111.06.336.124.495.124.66.01 1.32.002 1.981 0 .01 0 .02-.006.023-.015l.712-1.545a.025.025 0 0 0-.024-.036zM.477 9.91c-.071 0-.076.002-.076.01a.834.834 0 0 0-.01.08c-.027.397-.038.495-.234 3.06-.012.24-.034.389-.135.607-.026.057-.033.042.003.112.046.092.681 1.523.787 1.74.008.015.011.02.017.02.008 0 .033-.026.047-.044.147-.187.268-.391.371-.606.306-.635.44-1.325.486-1.706.014-.11.021-.22.03-.33l.204-2.616.022-.293c.003-.029 0-.033-.03-.034zm7.203 3.757a1.427 1.427 0 0 1-.135-.607c-.004-.084-.031-.39-.235-3.06a.443.443 0 0 0-.01-.082c-.004-.011-.052-.008-.076-.008h-1.48c-.03.001-.034.005-.03.034l.021.293c.076.982.153 1.964.233 2.946.05.4.186 1.085.487 1.706.103.215.223.419.37.606.015.018.037.051.048.049.02-.003.742-1.642.804-1.765.036-.07.03-.055.003-.112zm3.861-.913h-.872a.126.126 0 0 1-.116-.178l1.178-2.625a.025.025 0 0 0-.023-.035l-1.318-.003a.148.148 0 0 1-.135-.21l.876-1.954a.025.025 0 0 0-.023-.035h-1.56c-.01 0-.02.006-.024.015l-.926 2.068c-.085.169-.314.634-.399.938a.534.534 0 0 0-.02.191.46.46 0 0 0 .23.378.981.981 0 0 0 .46.119h.59c.041 0-.688 1.482-.834 1.972a.53.53 0 0 0-.023.172.465.465 0 0 0 .23.398c.15.092.342.12.475.12l1.66-.001c.01 0 .02-.006.023-.015l.575-1.28a.025.025 0 0 0-.024-.035zm-6.93-4.937H3.1a.032.032 0 0 0-.034.033c0 1.048-.01 2.795-.01 6.829 0 .288-.269.262-.28.262h-.74c-.04.001-.044.004-.04.047.001.037.465 1.064.555 1.263.01.02.03.033.051.033.157.003.767.009.938-.014.153-.02.3-.06.438-.132.3-.156.49-.419.595-.765.052-.172.075-.353.075-.533.002-2.33 0-4.66-.007-6.991a.032.032 0 0 0-.032-.032zm11.784 6.896c0-.014-.01-.021-.024-.022h-1.465c-.048-.001-.049-.002-.05-.049v-4.66c0-.072-.005-.07.07-.07h.863c.08 0 .075.004.075-.074V8.393c0-.082.006-.076-.08-.076h-3.5c-.064 0-.075-.006-.075.073v1.445c0 .083-.006.077.08.077h.854c.075 0 .07-.004.07.07v4.624c0 .095.008.084-.085.084-.37 0-1.11-.002-1.304 0-.048.001-.06.03-.06.03l-.697 1.519s-.014.025-.008.036c.006.01.013.008.058.008 1.748.003 3.495.002 5.243.002.03-.001.034-.006.035-.033v-1.539zm4.177-3.43c0 .013-.007.023-.02.024-.346.006-.692.004-1.037.004-.014-.002-.022-.01-.022-.024-.005-.434-.007-.869-.01-1.303 0-.072-.006-.071.07-.07l.733-.003c.041 0 .081.002.12.015.093.025.16.107.165.204.006.431.002 1.153.001 1.153zm2.67.244a1.953 1.953 0 0 0-.883-.222h-.18c-.04-.001-.04-.003-.042-.04V10.21c0-.132-.007-.263-.025-.394a1.823 1.823 0 0 0-.153-.53 1.533 1.533 0 0 0-.677-.71 2.167 2.167 0 0 0-1-.258c-.153-.003-.567 0-.72 0-.07 0-.068.004-.068-.065V7.76c0-.031-.01-.041-.046-.039H17.93s-.016 0-.023.007c-.006.006-.008.012-.008.023v.546c-.008.036-.057.015-.082.022h-.95c-.022.002-.028.008-.03.032v1.481c0 .09-.004.082.082.082h.913c.082 0 .072.128.072.128V11.19s.003.117-.06.117h-1.482c-.068 0-.06.082-.06.082v1.445s-.01.068.064.068h1.457c.082 0 .076-.006.076.079v3.225c0 .088-.007.081.082.081h1.43c.09 0 .082.007.082-.08v-3.27c0-.029.006-.035.033-.035l2.323-.003c.098 0 .191.02.28.061a.46.46 0 0 1 .274.407c.008.395.003.79.003 1.185 0 .259-.107.367-.33.367h-1.218c-.023.002-.029.008-.028.033.184.437.374.871.57 1.303a.045.045 0 0 0 .04.026c.17.005.34.002.51.003.15-.002.517.004.666-.01a2.03 2.03 0 0 0 .408-.075c.59-.18.975-.698.976-1.313v-1.981c0-.128-.01-.254-.034-.38 0 .078-.029-.641-.724-.998z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${DESKTOP_DIR}/.." && pwd)"
|
||||
|
||||
TARGET_TRIPLE="aarch64-apple-darwin"
|
||||
TAURI_TARGET_DIR="${DESKTOP_DIR}/src-tauri/target"
|
||||
CANONICAL_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/macos-arm64"
|
||||
APP_BUNDLE_NAME="Claude Code Haha.app"
|
||||
APP_BUNDLE_ID="com.claude-code-haha.desktop"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Build Claude Code Haha desktop for macOS Apple Silicon and output a DMG.
|
||||
|
||||
Usage:
|
||||
./desktop/scripts/build-macos-arm64.sh [extra tauri build args...]
|
||||
|
||||
Environment:
|
||||
SKIP_INSTALL=1 Skip `bun install` in the repo root and desktop app.
|
||||
SIGN_BUILD=1 Remove the default `--no-sign` flag and allow signed builds.
|
||||
OPEN_OUTPUT=1 Open the canonical artifact output directory in Finder after a successful build.
|
||||
|
||||
Examples:
|
||||
./desktop/scripts/build-macos-arm64.sh
|
||||
SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh
|
||||
SIGN_BUILD=1 ./desktop/scripts/build-macos-arm64.sh --skip-stapling
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "[build-macos-arm64] This script must run on macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
echo "[build-macos-arm64] This script is intended for Apple Silicon hosts (arm64)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in bun cargo rustc codesign hdiutil; do
|
||||
if ! command -v "${command}" >/dev/null 2>&1; then
|
||||
echo "[build-macos-arm64] Missing required command: ${command}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${SKIP_INSTALL:-0}" != "1" ]]; then
|
||||
echo "[build-macos-arm64] Installing root dependencies..."
|
||||
(cd "${REPO_ROOT}" && bun install)
|
||||
|
||||
echo "[build-macos-arm64] Installing desktop dependencies..."
|
||||
(cd "${DESKTOP_DIR}" && bun install)
|
||||
fi
|
||||
|
||||
# ── 清理 + 显式预热前端 / sidecar ────────────────────────────
|
||||
# 之前遇到过"改了 src/ 代码,build 的 .app 里 sidecar 还是旧的"的诡异
|
||||
# case —— 根因是 Bun.build / Tauri bundler 某一层的缓存把旧 sidecar
|
||||
# binary 复用进了新 .app(.app 被删干净了也救不回来,因为 binary 源在
|
||||
# desktop/src-tauri/binaries/)。
|
||||
#
|
||||
# 这里做三件事强制 fresh build:
|
||||
# 1) 硬删 sidecar 源 binary + Tauri bundle 目录 + 前端 dist
|
||||
# 2) 显式跑 bun run build + bun run build:sidecars
|
||||
# 3) tauri build 用 --config 覆盖 beforeBuildCommand 为 true(no-op),
|
||||
# 避免 sidecar 被重复编译浪费 ~10s
|
||||
# 任一步失败,整个脚本立即退出(set -e)。
|
||||
echo "[build-macos-arm64] Cleaning stale sidecar binaries and bundle output..."
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/bundle"
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle"
|
||||
rm -rf "${DESKTOP_DIR}/dist"
|
||||
rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo"
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/build/claude-code-desktop-"*
|
||||
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/.fingerprint/claude-code-desktop-"*
|
||||
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/claude_code_desktop-"*
|
||||
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps/libclaude_code_desktop-"*
|
||||
|
||||
echo "[build-macos-arm64] Rebuilding frontend (tsc + vite)..."
|
||||
(cd "${DESKTOP_DIR}" && bun run build)
|
||||
|
||||
echo "[build-macos-arm64] Rebuilding sidecar for ${TARGET_TRIPLE}..."
|
||||
(cd "${DESKTOP_DIR}" && TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}" bun run build:sidecars)
|
||||
|
||||
TAURI_ARGS=(
|
||||
bunx
|
||||
tauri
|
||||
build
|
||||
--target
|
||||
"${TARGET_TRIPLE}"
|
||||
--bundles
|
||||
app,dmg
|
||||
--ci
|
||||
--config
|
||||
'{"build":{"beforeBuildCommand":"true"}}'
|
||||
)
|
||||
|
||||
if [[ "${SIGN_BUILD:-0}" != "1" ]]; then
|
||||
TAURI_ARGS+=(--no-sign)
|
||||
fi
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
TAURI_ARGS+=("$@")
|
||||
fi
|
||||
|
||||
echo "[build-macos-arm64] Building DMG for ${TARGET_TRIPLE}..."
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
export TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}"
|
||||
"${TAURI_ARGS[@]}"
|
||||
)
|
||||
|
||||
TARGETED_DMG_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/dmg"
|
||||
FALLBACK_DMG_DIR="${TAURI_TARGET_DIR}/release/bundle/dmg"
|
||||
TARGETED_APP_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/macos"
|
||||
FALLBACK_APP_DIR="${TAURI_TARGET_DIR}/release/bundle/macos"
|
||||
LEGACY_BUNDLE_ROOT="${TAURI_TARGET_DIR}/release/bundle"
|
||||
|
||||
mkdir -p "${CANONICAL_OUTPUT_DIR}"
|
||||
find "${CANONICAL_OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
|
||||
find_latest_file() {
|
||||
local search_dir="$1"
|
||||
local pattern="$2"
|
||||
if [[ -d "${search_dir}" ]]; then
|
||||
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" | sort | tail -n 1
|
||||
fi
|
||||
}
|
||||
|
||||
find_latest_dir() {
|
||||
local search_dir="$1"
|
||||
local pattern="$2"
|
||||
if [[ -d "${search_dir}" ]]; then
|
||||
find "${search_dir}" -maxdepth 1 -type d -name "${pattern}" | sort | tail -n 1
|
||||
fi
|
||||
}
|
||||
|
||||
LATEST_DMG="$(find_latest_file "${TARGETED_DMG_DIR}" '*.dmg')"
|
||||
if [[ -z "${LATEST_DMG}" ]]; then
|
||||
LATEST_DMG="$(find_latest_file "${FALLBACK_DMG_DIR}" '*.dmg')"
|
||||
fi
|
||||
|
||||
LATEST_APP="$(find_latest_dir "${TARGETED_APP_DIR}" '*.app')"
|
||||
if [[ -z "${LATEST_APP}" ]]; then
|
||||
LATEST_APP="$(find_latest_dir "${FALLBACK_APP_DIR}" '*.app')"
|
||||
fi
|
||||
|
||||
build_canonical_dmg() {
|
||||
local app_bundle="$1"
|
||||
local dmg_output="$2"
|
||||
local staging_dir
|
||||
local rw_dmg
|
||||
|
||||
staging_dir="$(mktemp -d "${TMPDIR:-/tmp}/cc-haha-dmg.XXXXXX")"
|
||||
rw_dmg="$(mktemp "${TMPDIR:-/tmp}/cc-haha-rw.XXXXXX").dmg"
|
||||
|
||||
cp -R "${app_bundle}" "${staging_dir}/"
|
||||
ln -s /Applications "${staging_dir}/Applications"
|
||||
|
||||
# Create a read-write DMG first so we can customize the Finder layout
|
||||
hdiutil create \
|
||||
-volname "Claude Code Haha" \
|
||||
-srcfolder "${staging_dir}" \
|
||||
-ov \
|
||||
-format UDRW \
|
||||
"${rw_dmg}" >/dev/null
|
||||
|
||||
rm -rf "${staging_dir}"
|
||||
|
||||
# Mount the read-write DMG and apply Finder layout via AppleScript
|
||||
local dev_name mount_dir
|
||||
dev_name=$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse "${rw_dmg}" \
|
||||
| grep -E '^/dev/' | head -1 | awk '{print $1}')
|
||||
mount_dir=$(hdiutil info | grep -E "${dev_name}" | tail -1 | awk '{$1=$2=""; print}' | xargs)
|
||||
|
||||
# Finder AppleScript 在新版 macOS (Sequoia+) 上某些属性
|
||||
# (toolbar/statusbar visible、某些 container window 属性) 不再支持,
|
||||
# 会返回 -10006 错误。美化失败不是 blocker —— 即便 layout 没设上,
|
||||
# DMG 本身还是可用的,只是用户打开时看到的是 Finder 默认排布。
|
||||
# 所以这里允许 osascript 非零退出,只 warn,不让 set -e 炸掉整个脚本。
|
||||
if ! osascript <<APPLESCRIPT
|
||||
tell application "Finder"
|
||||
tell disk "Claude Code Haha"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set the bounds of container window to {100, 100, 760, 500}
|
||||
set viewOptions to the icon view options of container window
|
||||
set arrangement of viewOptions to not arranged
|
||||
set icon size of viewOptions to 128
|
||||
set position of item "${APP_BUNDLE_NAME}" of container window to {180, 170}
|
||||
set position of item "Applications" of container window to {480, 170}
|
||||
close
|
||||
open
|
||||
update without registering applications
|
||||
delay 2
|
||||
close
|
||||
end tell
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
then
|
||||
echo "[build-macos-arm64] WARN: Finder layout AppleScript failed (likely macOS version incompatible); DMG will use default Finder layout" >&2
|
||||
fi
|
||||
|
||||
sync
|
||||
# osascript 可能已经让 Finder 打开了 volume 窗口,正常 detach 可能因为
|
||||
# "Resource busy" 失败。失败时用 -force 二次尝试。
|
||||
hdiutil detach "${dev_name}" -quiet 2>/dev/null \
|
||||
|| hdiutil detach "${dev_name}" -force -quiet
|
||||
|
||||
# Convert to compressed read-only DMG
|
||||
hdiutil convert "${rw_dmg}" -format UDZO -o "${dmg_output}" -ov >/dev/null
|
||||
rm -f "${rw_dmg}"
|
||||
}
|
||||
|
||||
codesign_cdhash() {
|
||||
local executable="$1"
|
||||
codesign -d --verbose=4 "${executable}" 2>&1 \
|
||||
| awk -F= '/^CDHash=/{print $2; exit}'
|
||||
}
|
||||
|
||||
sign_canonical_app_bundle() {
|
||||
local app_bundle="$1"
|
||||
local sidecar="${app_bundle}/Contents/MacOS/claude-sidecar"
|
||||
local sidecar_cdhash_before=""
|
||||
local sidecar_cdhash_after=""
|
||||
|
||||
if [[ -x "${sidecar}" ]]; then
|
||||
sidecar_cdhash_before="$(codesign_cdhash "${sidecar}")"
|
||||
fi
|
||||
|
||||
# Tauri --no-sign leaves the outer .app with no sealed resources, which
|
||||
# fails strict bundle validation once Resources/icon.icns exists. Sign only
|
||||
# the outer bundle: do not pass --deep, because re-signing claude-sidecar
|
||||
# changes its code-signature hash and breaks existing macOS Keychain ACLs.
|
||||
codesign --force --sign - --timestamp=none "${app_bundle}"
|
||||
|
||||
if [[ -x "${sidecar}" ]]; then
|
||||
sidecar_cdhash_after="$(codesign_cdhash "${sidecar}")"
|
||||
if [[ "${sidecar_cdhash_before}" != "${sidecar_cdhash_after}" ]]; then
|
||||
echo "[build-macos-arm64] ERROR: sidecar signature hash changed while signing app bundle" >&2
|
||||
echo "[build-macos-arm64] before=${sidecar_cdhash_before}" >&2
|
||||
echo "[build-macos-arm64] after=${sidecar_cdhash_after}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "${app_bundle}"
|
||||
}
|
||||
|
||||
if [[ -n "${LATEST_DMG}" ]]; then
|
||||
cp -f "${LATEST_DMG}" "${CANONICAL_OUTPUT_DIR}/"
|
||||
fi
|
||||
|
||||
if [[ -n "${LATEST_APP}" ]]; then
|
||||
# 不要 deep re-sign。曾经脚本在这里跑过
|
||||
# `codesign --force --deep --sign - --identifier <bundle-id>` 来统一
|
||||
# sidecar 和外层的 signing identifier,但这会改变 sidecar binary 的
|
||||
# code signature hash —— macOS Keychain ACL 按 hash 识别 caller,
|
||||
# 重签完再访问时会被 ACL 当作"陌生 binary"静默拒绝,导致 CLI 读不到
|
||||
# OAuth token,最终请求打到 Anthropic 返回 403 "Request not allowed"。
|
||||
# 这里只浅签外层 bundle,让 .app 拥有有效资源封印,同时保留 sidecar hash。
|
||||
cp -R "${LATEST_APP}" "${CANONICAL_OUTPUT_DIR}/"
|
||||
sign_canonical_app_bundle "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}"
|
||||
rm -f "${CANONICAL_OUTPUT_DIR}/"*.dmg
|
||||
build_canonical_dmg \
|
||||
"${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" \
|
||||
"${CANONICAL_OUTPUT_DIR}/$(basename "${LATEST_DMG:-Claude Code Haha_0.1.0_aarch64.dmg}")"
|
||||
fi
|
||||
|
||||
cat > "${CANONICAL_OUTPUT_DIR}/BUILD_INFO.txt" <<EOF
|
||||
Target triple: ${TARGET_TRIPLE}
|
||||
Canonical output: ${CANONICAL_OUTPUT_DIR}
|
||||
Source DMG: ${LATEST_DMG:-not found}
|
||||
Source app: ${LATEST_APP:-not found}
|
||||
Built at: $(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
EOF
|
||||
|
||||
if [[ -d "${LEGACY_BUNDLE_ROOT}" ]]; then
|
||||
rm -rf "${LEGACY_BUNDLE_ROOT}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "[build-macos-arm64] Build finished."
|
||||
if [[ -n "${LATEST_DMG}" ]]; then
|
||||
echo "[build-macos-arm64] DMG source: ${LATEST_DMG}"
|
||||
else
|
||||
echo "[build-macos-arm64] No DMG found in ${TARGETED_DMG_DIR} or ${FALLBACK_DMG_DIR}" >&2
|
||||
fi
|
||||
|
||||
if [[ -n "${LATEST_APP}" ]]; then
|
||||
echo "[build-macos-arm64] App source: ${LATEST_APP}"
|
||||
else
|
||||
echo "[build-macos-arm64] No .app found in ${TARGETED_APP_DIR} or ${FALLBACK_APP_DIR}" >&2
|
||||
fi
|
||||
|
||||
echo "[build-macos-arm64] Canonical output: ${CANONICAL_OUTPUT_DIR}"
|
||||
echo "[build-macos-arm64] Removed legacy bundle dir: ${LEGACY_BUNDLE_ROOT}"
|
||||
|
||||
if [[ "${OPEN_OUTPUT:-0}" == "1" ]]; then
|
||||
open "${CANONICAL_OUTPUT_DIR}"
|
||||
fi
|
||||
@@ -0,0 +1,186 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const desktopRoot = path.resolve(import.meta.dir, '..')
|
||||
const repoRoot = path.resolve(desktopRoot, '..')
|
||||
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
|
||||
|
||||
const targetTriple =
|
||||
process.env.TAURI_ENV_TARGET_TRIPLE ||
|
||||
process.env.CARGO_BUILD_TARGET ||
|
||||
(await detectHostTriple())
|
||||
|
||||
const bunTarget = mapTargetTripleToBun(targetTriple)
|
||||
|
||||
// 编译前先扫一遍 src/ 把所有缺失的 ant-internal 模块在磁盘上 stub 出来。
|
||||
// 见 desktop/scripts/scan-missing-imports.ts。
|
||||
console.log('[build-sidecars] scanning for missing imports...')
|
||||
const scanProc = Bun.spawn(
|
||||
['bun', 'run', path.join(desktopRoot, 'scripts/scan-missing-imports.ts')],
|
||||
{ cwd: repoRoot, stdout: 'inherit', stderr: 'inherit' },
|
||||
)
|
||||
const scanExit = await scanProc.exited
|
||||
if (scanExit !== 0) {
|
||||
throw new Error(`[build-sidecars] scan-missing-imports failed (exit ${scanExit})`)
|
||||
}
|
||||
|
||||
await mkdir(binariesDir, { recursive: true })
|
||||
|
||||
// 单一合并 sidecar:server / cli 共享一份 bun runtime + 共享依赖代码。
|
||||
// 调用方(Tauri lib.rs / conversationService)通过第一个 positional 参数
|
||||
// 选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
|
||||
await compileExecutable({
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/claude-sidecar.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-sidecar-${targetTriple}`),
|
||||
productName: 'Claude Code Sidecar',
|
||||
bunTarget,
|
||||
})
|
||||
|
||||
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
|
||||
|
||||
async function detectHostTriple() {
|
||||
const proc = Bun.spawn(['rustc', '-vV'], {
|
||||
cwd: repoRoot,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`[build-sidecars] rustc -vV failed: ${stderr || stdout}`)
|
||||
}
|
||||
|
||||
const hostLine = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('host: '))
|
||||
|
||||
if (!hostLine) {
|
||||
throw new Error('[build-sidecars] Could not detect Rust host triple')
|
||||
}
|
||||
|
||||
return hostLine.replace('host: ', '')
|
||||
}
|
||||
|
||||
function mapTargetTripleToBun(triple: string) {
|
||||
switch (triple) {
|
||||
case 'aarch64-apple-darwin':
|
||||
return 'bun-darwin-arm64'
|
||||
case 'x86_64-apple-darwin':
|
||||
return 'bun-darwin-x64'
|
||||
case 'x86_64-pc-windows-msvc':
|
||||
return 'bun-windows-x64'
|
||||
case 'aarch64-pc-windows-msvc':
|
||||
return 'bun-windows-arm64'
|
||||
case 'x86_64-unknown-linux-gnu':
|
||||
return 'bun-linux-x64-baseline'
|
||||
case 'aarch64-unknown-linux-gnu':
|
||||
return 'bun-linux-arm64'
|
||||
case 'x86_64-unknown-linux-musl':
|
||||
return 'bun-linux-x64-musl'
|
||||
case 'aarch64-unknown-linux-musl':
|
||||
return 'bun-linux-arm64-musl'
|
||||
default:
|
||||
throw new Error(`[build-sidecars] Unsupported target triple: ${triple}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function compileExecutable({
|
||||
entrypoint,
|
||||
outfileBase,
|
||||
productName,
|
||||
bunTarget,
|
||||
}: {
|
||||
entrypoint: string
|
||||
outfileBase: string
|
||||
productName: string
|
||||
bunTarget: string
|
||||
}) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entrypoint],
|
||||
// minify whitespace + identifiers + dead-code 大概能省 5-15% 的二进制大小,
|
||||
// 代价是 stack trace 里的函数名变成短名 —— 终端用户场景可接受。
|
||||
minify: { whitespace: true, identifiers: true, syntax: true },
|
||||
sourcemap: 'none',
|
||||
target: 'bun',
|
||||
// 可选 npm 包:开 telemetry / 用 sharp 图像 / 用 Bedrock/Vertex 等
|
||||
// 替代 provider 时才需要,全部不在顶层 package.json 里。标 external
|
||||
// 让 bun build 跳过解析;运行时 import 在没装时自然失败,由 try/catch
|
||||
// 或 feature() gate 兜底。
|
||||
external: [
|
||||
// OpenTelemetry exporters(开 OTEL_* env 时才加载)
|
||||
'@opentelemetry/exporter-trace-otlp-grpc',
|
||||
'@opentelemetry/exporter-trace-otlp-http',
|
||||
'@opentelemetry/exporter-trace-otlp-proto',
|
||||
'@opentelemetry/exporter-logs-otlp-grpc',
|
||||
'@opentelemetry/exporter-logs-otlp-http',
|
||||
'@opentelemetry/exporter-logs-otlp-proto',
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc',
|
||||
'@opentelemetry/exporter-metrics-otlp-http',
|
||||
'@opentelemetry/exporter-metrics-otlp-proto',
|
||||
'@opentelemetry/exporter-prometheus',
|
||||
// 替代 LLM provider —— 默认不用,用户自装
|
||||
'@aws-sdk/client-bedrock',
|
||||
'@aws-sdk/client-sts',
|
||||
'@anthropic-ai/bedrock-sdk',
|
||||
'@anthropic-ai/foundry-sdk',
|
||||
'@anthropic-ai/vertex-sdk',
|
||||
'@azure/identity',
|
||||
// ant-internal / 可选工具
|
||||
'@anthropic-ai/mcpb',
|
||||
'fflate',
|
||||
'sharp',
|
||||
'react-devtools-core',
|
||||
],
|
||||
compile: {
|
||||
target: bunTarget,
|
||||
outfile: outfileBase,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
windows: {
|
||||
title: productName,
|
||||
publisher: 'Claude Code',
|
||||
description: productName,
|
||||
hideConsole: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const logs = result.logs.map((log) => log.message).join('\n')
|
||||
throw new Error(`[build-sidecars] Failed to compile ${productName}:\n${logs}`)
|
||||
}
|
||||
|
||||
const outputPath = result.outputs[0]?.path ?? outfileBase
|
||||
console.log(`[build-sidecars] ${productName} -> ${outputPath}`)
|
||||
|
||||
// macOS Apple System Policy (ASP) requires valid code signatures on all
|
||||
// executables. Bun-compiled binaries ship with an invalid/empty signature
|
||||
// that causes "load code signature error 4" and SIGKILL at launch.
|
||||
// Fix: strip the broken signature, then ad-hoc sign.
|
||||
if (process.platform === 'darwin') {
|
||||
await adHocSignMacBinary(outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function adHocSignMacBinary(outputPath: string) {
|
||||
console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`)
|
||||
const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
await strip.exited
|
||||
|
||||
const sign = Bun.spawn(
|
||||
['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath],
|
||||
{ stdout: 'inherit', stderr: 'inherit' },
|
||||
)
|
||||
const signExit = await sign.exited
|
||||
if (signExit !== 0) {
|
||||
throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`)
|
||||
}
|
||||
console.log(`[build-sidecars] ad-hoc signed ${outputPath}`)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$TauriArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$desktopDir = (Resolve-Path (Join-Path $scriptDir '..')).Path
|
||||
$repoRoot = (Resolve-Path (Join-Path $desktopDir '..')).Path
|
||||
|
||||
$targetTriple = 'x86_64-pc-windows-msvc'
|
||||
$tauriTargetDir = Join-Path $desktopDir 'src-tauri\target'
|
||||
$canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64'
|
||||
$activeOutputDir = $canonicalOutputDir
|
||||
$appVersion = (Get-Content -Path (Join-Path $desktopDir 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json).version
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "[build-windows-x64] $Message"
|
||||
}
|
||||
|
||||
function Assert-WindowsHost {
|
||||
if ($env:OS -ne 'Windows_NT') {
|
||||
throw '[build-windows-x64] This script must run on Windows.'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "[build-windows-x64] Missing required command: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
function Import-VsDevEnvironment {
|
||||
$vswhere = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
if (-not (Test-Path $vswhere)) {
|
||||
throw '[build-windows-x64] Could not find vswhere.exe. Install Visual Studio 2022 Build Tools with the C++ workload.'
|
||||
}
|
||||
|
||||
$installationPath = & $vswhere `
|
||||
-products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
|
||||
-property installationPath |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $installationPath) {
|
||||
throw '[build-windows-x64] Missing Visual C++ build tools. Install the "Desktop development with C++" / VC.Tools.x86.x64 workload first.'
|
||||
}
|
||||
|
||||
$vsDevCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
|
||||
if (-not (Test-Path $vsDevCmd)) {
|
||||
throw "[build-windows-x64] Could not find VsDevCmd.bat under $installationPath"
|
||||
}
|
||||
|
||||
Write-Step "Importing MSVC environment from $vsDevCmd"
|
||||
|
||||
$env:VSCMD_SKIP_SENDTELEMETRY = '1'
|
||||
$envDump = & cmd.exe /d /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[build-windows-x64] Failed to initialize Visual Studio build environment (exit $LASTEXITCODE)"
|
||||
}
|
||||
|
||||
foreach ($line in $envDump) {
|
||||
if ($line -match '^(.*?)=(.*)$') {
|
||||
[Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RustCargoBinDir {
|
||||
return Join-Path $env:USERPROFILE '.cargo\bin'
|
||||
}
|
||||
|
||||
function Ensure-RustInPath {
|
||||
$cargoBinDir = Get-RustCargoBinDir
|
||||
if ((Test-Path $cargoBinDir) -and -not (($env:Path -split ';') -contains $cargoBinDir)) {
|
||||
$env:Path = "$cargoBinDir;$env:Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LatestArtifact {
|
||||
param(
|
||||
[string[]]$SearchRoots,
|
||||
[string[]]$Patterns
|
||||
)
|
||||
|
||||
foreach ($root in $SearchRoots) {
|
||||
if (-not (Test-Path $root)) {
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($pattern in $Patterns) {
|
||||
$match = Get-ChildItem -Path $root -File -Filter $pattern -ErrorAction SilentlyContinue |
|
||||
Sort-Object Name |
|
||||
Select-Object -Last 1
|
||||
|
||||
if ($match) {
|
||||
return $match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-StagedArtifactName {
|
||||
param([string]$ArtifactName)
|
||||
|
||||
switch -Regex ($ArtifactName) {
|
||||
'^latest\.json$' { return 'latest.json' }
|
||||
'\.msi\.zip\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip.sig" }
|
||||
'\.msi\.zip$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip" }
|
||||
'\.msi\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.sig" }
|
||||
'\.msi$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi" }
|
||||
default { return $ArtifactName }
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-OutputDirectory {
|
||||
param([string]$PreferredPath)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $PreferredPath | Out-Null
|
||||
|
||||
$existingArtifacts = Get-ChildItem -Path $PreferredPath -Force -ErrorAction SilentlyContinue
|
||||
foreach ($artifact in $existingArtifacts) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $artifact.FullName -Force -Recurse
|
||||
} catch {
|
||||
$fallbackPath = "$PreferredPath-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Write-Step "Could not clear locked artifact '$($artifact.FullName)'. Using fallback output directory: $fallbackPath"
|
||||
New-Item -ItemType Directory -Force -Path $fallbackPath | Out-Null
|
||||
return $fallbackPath
|
||||
}
|
||||
}
|
||||
|
||||
return $PreferredPath
|
||||
}
|
||||
|
||||
Assert-WindowsHost
|
||||
Assert-Command bun
|
||||
|
||||
Ensure-RustInPath
|
||||
Import-VsDevEnvironment
|
||||
|
||||
Assert-Command cargo
|
||||
Assert-Command rustc
|
||||
Assert-Command bunx
|
||||
|
||||
if ($env:SKIP_INSTALL -ne '1') {
|
||||
Write-Step 'Installing root dependencies...'
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& bun install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[build-windows-x64] bun install failed in repo root (exit $LASTEXITCODE)"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Step 'Installing desktop dependencies...'
|
||||
Push-Location $desktopDir
|
||||
try {
|
||||
& bun install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[build-windows-x64] bun install failed in desktop (exit $LASTEXITCODE)"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$adaptersDir = Join-Path $repoRoot 'adapters'
|
||||
if (Test-Path (Join-Path $adaptersDir 'package.json')) {
|
||||
Write-Step 'Installing adapter dependencies...'
|
||||
Push-Location $adaptersDir
|
||||
try {
|
||||
& bun install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[build-windows-x64] bun install failed in adapters (exit $LASTEXITCODE)"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tauriBuildArgs = @(
|
||||
'tauri',
|
||||
'build',
|
||||
'--target',
|
||||
$targetTriple,
|
||||
'--bundles',
|
||||
'msi',
|
||||
'--ci'
|
||||
)
|
||||
|
||||
$tempConfigPath = $null
|
||||
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
|
||||
$tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json'
|
||||
$tempConfig = @{
|
||||
bundle = @{
|
||||
createUpdaterArtifacts = $false
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
Set-Content -Path $tempConfigPath -Value $tempConfig -Encoding UTF8
|
||||
Write-Step 'TAURI_SIGNING_PRIVATE_KEY not set, disabling updater artifacts for local build'
|
||||
$tauriBuildArgs += @('--config', $tempConfigPath)
|
||||
}
|
||||
|
||||
if ($null -ne $TauriArgs) {
|
||||
$remainingArgs = @($TauriArgs)
|
||||
if ($remainingArgs.Count -gt 0) {
|
||||
$tauriBuildArgs += $remainingArgs
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Building Windows desktop app for $targetTriple"
|
||||
|
||||
Push-Location $desktopDir
|
||||
try {
|
||||
$env:TAURI_ENV_TARGET_TRIPLE = $targetTriple
|
||||
& bunx @tauriBuildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[build-windows-x64] tauri build failed (exit $LASTEXITCODE)"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
if ($tempConfigPath -and (Test-Path $tempConfigPath)) {
|
||||
Remove-Item -LiteralPath $tempConfigPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
$activeOutputDir = Resolve-OutputDirectory -PreferredPath $canonicalOutputDir
|
||||
|
||||
$bundleRoots = @(
|
||||
(Join-Path $tauriTargetDir "$targetTriple\release\bundle"),
|
||||
(Join-Path $tauriTargetDir 'release\bundle')
|
||||
)
|
||||
|
||||
$artifactPatterns = @('*.msi', '*.msi.sig', '*.msi.zip', '*.msi.zip.sig', 'latest.json')
|
||||
$copiedArtifacts = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($root in $bundleRoots) {
|
||||
if (-not (Test-Path $root)) {
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($pattern in $artifactPatterns) {
|
||||
$artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
|
||||
foreach ($artifact in $artifacts) {
|
||||
$destinationName = Get-StagedArtifactName -ArtifactName $artifact.Name
|
||||
$destination = Join-Path $activeOutputDir $destinationName
|
||||
Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force
|
||||
if (-not $copiedArtifacts.Contains($destination)) {
|
||||
$copiedArtifacts.Add($destination) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$msiInstaller = Get-LatestArtifact -SearchRoots @(
|
||||
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"),
|
||||
(Join-Path $tauriTargetDir 'release\bundle\msi')
|
||||
) -Patterns @('*.msi')
|
||||
|
||||
$msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' }
|
||||
|
||||
$buildInfo = @(
|
||||
"App version: $appVersion"
|
||||
"Target triple: $targetTriple"
|
||||
"Canonical output: $canonicalOutputDir"
|
||||
"Actual output: $activeOutputDir"
|
||||
"Windows installer (MSI): $msiInstallerPath"
|
||||
"Artifacts copied: $($copiedArtifacts.Count)"
|
||||
"Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
|
||||
)
|
||||
|
||||
Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInfo -Encoding UTF8
|
||||
|
||||
Write-Host ''
|
||||
Write-Step 'Build finished.'
|
||||
Write-Step "Artifacts output: $activeOutputDir"
|
||||
if ($msiInstaller) {
|
||||
Write-Step "MSI installer source: $($msiInstaller.FullName)"
|
||||
} else {
|
||||
Write-Step 'No MSI installer found under bundle directories.'
|
||||
}
|
||||
|
||||
Write-Step "Canonical output: $canonicalOutputDir"
|
||||
|
||||
if ($env:OPEN_OUTPUT -eq '1') {
|
||||
Invoke-Item $canonicalOutputDir
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-install-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-install-e2e-${RUN_ID}-XXXX")"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
if [[ "${CC_HAHA_E2E_USE_REAL_CONFIG:-1}" == "1" ]]; then
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
||||
else
|
||||
CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${ARTIFACT_DIR}/claude-config}"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting isolated backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}"
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
browser_body() {
|
||||
agent-browser --session "${SESSION_NAME}" get text body
|
||||
}
|
||||
|
||||
wait_for_body_contains() {
|
||||
local needle="$1"
|
||||
local attempts="${2:-180}"
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
if browser_body | grep -Fq "${needle}"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for page text: ${needle}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_body_contains_any() {
|
||||
local attempts="$1"
|
||||
shift
|
||||
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
local body
|
||||
body="$(browser_body)"
|
||||
for needle in "$@"; do
|
||||
if grep -Fq "${needle}" <<<"${body}"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Timed out waiting for any expected page text: $*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_path() {
|
||||
local path="$1"
|
||||
local attempts="${2:-180}"
|
||||
for _ in $(seq 1 "${attempts}"); do
|
||||
if [[ -e "${path}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for path: ${path}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
TELEGRAM_PROMPT="请根据 https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram 安装 Telegram 官方插件。如果 URL 对应官方 external_plugins,请直接识别 plugin id 并完成用户级安装,然后告诉我结果。"
|
||||
SKILL_PROMPT="请根据 https://www.aitmpl.com/component/skill/creative-design/ui-ux-pro-max 这个 skill 页面安装 ui-ux-pro-max。如果页面里有官方安装命令,请直接识别并执行,然后告诉我结果。"
|
||||
PLUGIN_DIR="${CONFIG_DIR}/plugins/cache/claude-plugins-official/telegram"
|
||||
SKILL_DIR="${CONFIG_DIR}/skills/ui-ux-pro-max"
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} click 'button:has-text("Settings")'
|
||||
${AB} click 'button:has-text("Install")'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-install-center.png" >/dev/null
|
||||
|
||||
run_install_prompt() {
|
||||
local prompt="$1"
|
||||
${AB} click 'button:has-text("New install chat")'
|
||||
${AB} fill 'textarea' "${prompt}"
|
||||
${AB} click 'button:has-text("Send request")'
|
||||
}
|
||||
|
||||
echo "Running plugin install flow"
|
||||
run_install_prompt "${TELEGRAM_PROMPT}"
|
||||
wait_for_body_contains 'telegram@claude-plugins-official'
|
||||
wait_for_path "${PLUGIN_DIR}"
|
||||
${AB} click 'button:has-text("Open Plugins")'
|
||||
wait_for_body_contains 'Enabled1'
|
||||
wait_for_body_contains 'telegram Enabled'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-plugin-installed.png" >/dev/null
|
||||
|
||||
echo "Running skill install flow"
|
||||
${AB} click 'button:has-text("Install")'
|
||||
run_install_prompt "${SKILL_PROMPT}"
|
||||
wait_for_body_contains 'claude-code-templates@latest'
|
||||
wait_for_path "${SKILL_DIR}"
|
||||
${AB} click 'button:has-text("Open Skills")'
|
||||
wait_for_body_contains 'Installed Skills'
|
||||
wait_for_body_contains 'ui-ux-pro-max'
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-skill-installed.png" >/dev/null
|
||||
|
||||
echo "Install Center E2E passed"
|
||||
echo "Config dir: ${CONFIG_DIR}"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-rewind-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-e2e-${RUN_ID}-XXXX")"
|
||||
PROJECT_DIR="${ARTIFACT_DIR}/project"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
mkdir -p "${PROJECT_DIR}/src"
|
||||
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
|
||||
export const ORIGINAL_VALUE = 'before-rewind'
|
||||
|
||||
export function readValue() {
|
||||
return ORIGINAL_VALUE
|
||||
}
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/README.md" <<'EOF'
|
||||
# Rewind E2E Fixture
|
||||
|
||||
This project is created automatically by the agent-browser rewind E2E script.
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/package.json" <<'EOF'
|
||||
{
|
||||
"name": "rewind-e2e-fixture",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
EOF
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
|
||||
|
||||
UNIQUE_TITLE="E2E Rewind ${RUN_ID}"
|
||||
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
|
||||
|
||||
PROMPT="Edit src/app.js and replace before-rewind with after-rewind. Do not modify any other file. Reply with DONE when finished."
|
||||
TARGET_FILE="${PROJECT_DIR}/src/app.js"
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} wait 2000
|
||||
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
|
||||
${AB} wait 2000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
|
||||
${AB} find text "${UNIQUE_TITLE}" click
|
||||
${AB} wait 1000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
|
||||
for _ in $(seq 1 180); do
|
||||
${AB} find role button click --name "Allow" >/dev/null 2>&1 || true
|
||||
${AB} find role button click --name "Allow for session" >/dev/null 2>&1 || true
|
||||
if grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
echo "Timed out waiting for edited file contents" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-edit-timeout.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null
|
||||
|
||||
${AB} eval "const button = [...document.querySelectorAll('button')].find((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (!button) throw new Error('Rewind button not found'); button.click();"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! grep -q "before-rewind" "${TARGET_FILE}" || grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
echo "Timed out waiting for rewind to restore original file contents" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-rewind-timeout.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} wait 1000
|
||||
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
|
||||
if [[ "${PREFILL_VALUE}" != "${PROMPT}" ]]; then
|
||||
echo "Composer prefill mismatch after rewind" >&2
|
||||
echo "Expected: ${PROMPT}" >&2
|
||||
echo "Actual: ${PREFILL_VALUE}" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MESSAGE_COUNT="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.messages.length);')"
|
||||
if [[ "${MESSAGE_COUNT}" != "0" ]]; then
|
||||
echo "Expected rewound session to have 0 transcript messages, got ${MESSAGE_COUNT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-after-rewind.png" >/dev/null
|
||||
|
||||
echo "Rewind E2E passed"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DESKTOP_DIR="$ROOT_DIR/desktop"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')"
|
||||
API_PORT="${PORTS%% *}"
|
||||
WEB_PORT="${PORTS##* }"
|
||||
BASE_URL="http://127.0.0.1:${API_PORT}"
|
||||
WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}"
|
||||
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-rewind-complex-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-rewind-complex-e2e-${RUN_ID}-XXXX")"
|
||||
PROJECT_DIR="${ARTIFACT_DIR}/project"
|
||||
SERVER_LOG="${ARTIFACT_DIR}/server.log"
|
||||
WEB_LOG="${ARTIFACT_DIR}/web.log"
|
||||
|
||||
mkdir -p "${PROJECT_DIR}/src"
|
||||
cat > "${PROJECT_DIR}/src/app.js" <<'EOF'
|
||||
export const STEP = 'base'
|
||||
|
||||
export function readStep() {
|
||||
return STEP
|
||||
}
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/README.md" <<'EOF'
|
||||
# Complex Rewind E2E Fixture
|
||||
|
||||
Initial README content.
|
||||
EOF
|
||||
cat > "${PROJECT_DIR}/package.json" <<'EOF'
|
||||
{
|
||||
"name": "complex-rewind-e2e-fixture",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
EOF
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
wait "${SERVER_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
wait "${WEB_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
else
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Starting backend on ${BASE_URL}"
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
SERVER_PORT="${API_PORT}" bun run src/server/index.ts --port "${API_PORT}" --host 127.0.0.1
|
||||
) >"${SERVER_LOG}" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting web UI on http://127.0.0.1:${WEB_PORT}"
|
||||
(
|
||||
cd "${DESKTOP_DIR}"
|
||||
bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}"
|
||||
) >"${WEB_LOG}" 2>&1 &
|
||||
WEB_PID=$!
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for ${url}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
try_agent_browser() {
|
||||
local pid
|
||||
(agent-browser --session "${SESSION_NAME}" "$@" >/dev/null 2>&1) &
|
||||
pid=$!
|
||||
for _ in $(seq 1 5); do
|
||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
try_allow_buttons() {
|
||||
try_agent_browser find role button click --name "Allow"
|
||||
try_agent_browser find role button click --name "Allow for session"
|
||||
}
|
||||
|
||||
wait_for_file_contains() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local timeout="${3:-180}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if [[ -f "$file" ]] && grep -q "$expected" "$file"; then
|
||||
return 0
|
||||
fi
|
||||
try_allow_buttons
|
||||
sleep 2
|
||||
done
|
||||
echo "Timed out waiting for ${file} to contain ${expected}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_http "${BASE_URL}/health"
|
||||
wait_for_http "http://127.0.0.1:${WEB_PORT}"
|
||||
|
||||
SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \
|
||||
| bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')"
|
||||
|
||||
UNIQUE_TITLE="Complex Rewind ${RUN_ID}"
|
||||
curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null
|
||||
|
||||
APP_FILE="${PROJECT_DIR}/src/app.js"
|
||||
README_FILE="${PROJECT_DIR}/README.md"
|
||||
GENERATED_FILE="${PROJECT_DIR}/src/generated.js"
|
||||
FIRST_PROMPT="Edit src/app.js and replace STEP = 'base' with STEP = 'turn-one'. Do not modify any other file. Reply with DONE when finished."
|
||||
SECOND_PROMPT="Make exactly these three changes: 1. In src/app.js replace STEP = 'turn-one' with STEP = 'turn-two'. 2. Append a new line to README.md that says Second turn touched README. 3. Create src/generated.js with exactly: export const GENERATED = 'second-turn'. Reply with DONE when finished."
|
||||
|
||||
AB="agent-browser --session ${SESSION_NAME}"
|
||||
|
||||
${AB} open "${WEB_URL}"
|
||||
${AB} wait 2000
|
||||
${AB} eval "localStorage.setItem('cc-haha-locale', 'en'); location.reload();"
|
||||
${AB} wait 2000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
${AB} fill '#sidebar-search' "${UNIQUE_TITLE}"
|
||||
${AB} find text "${UNIQUE_TITLE}" click
|
||||
${AB} wait 1000
|
||||
${AB} screenshot "${ARTIFACT_DIR}/02-session-open.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${FIRST_PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
wait_for_file_contains "${APP_FILE}" "turn-one" 180
|
||||
if grep -q "turn-two" "${APP_FILE}" || grep -q "Second turn touched README" "${README_FILE}" || [[ -e "${GENERATED_FILE}" ]]; then
|
||||
echo "Unexpected second-turn state after first prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-after-first-turn.png" >/dev/null
|
||||
|
||||
${AB} fill 'textarea' "${SECOND_PROMPT}"
|
||||
${AB} find role button click --name "Run"
|
||||
wait_for_file_contains "${APP_FILE}" "turn-two" 180
|
||||
wait_for_file_contains "${README_FILE}" "Second turn touched README" 60
|
||||
wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null
|
||||
|
||||
${AB} eval "const buttons = [...document.querySelectorAll('button')].filter((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (buttons.length < 2) throw new Error('Expected at least two Rewind buttons, found ' + buttons.length); buttons[buttons.length - 1].click();"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-rewind-second-turn-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "turn-one" "${APP_FILE}" \
|
||||
&& ! grep -q "turn-two" "${APP_FILE}" \
|
||||
&& ! grep -q "Second turn touched README" "${README_FILE}" \
|
||||
&& [[ ! -e "${GENERATED_FILE}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! grep -q "turn-one" "${APP_FILE}" || grep -q "turn-two" "${APP_FILE}"; then
|
||||
echo "src/app.js was not restored to first-turn state" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-app-restore.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "Second turn touched README" "${README_FILE}"; then
|
||||
echo "README.md still contains second-turn edit" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-readme-restore.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "${GENERATED_FILE}" ]]; then
|
||||
echo "Generated file still exists after rewinding second turn" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-generated-file.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} wait 1000
|
||||
PREFILL_VALUE="$(${AB} get value 'textarea' | tr -d '\r')"
|
||||
if [[ "${PREFILL_VALUE}" != "${SECOND_PROMPT}" ]]; then
|
||||
echo "Composer prefill mismatch after second-turn rewind" >&2
|
||||
echo "Expected: ${SECOND_PROMPT}" >&2
|
||||
echo "Actual: ${PREFILL_VALUE}" >&2
|
||||
${AB} screenshot "${ARTIFACT_DIR}/failure-prefill.png" >/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRANSCRIPT_CHECK="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" \
|
||||
| FIRST_PROMPT="${FIRST_PROMPT}" SECOND_PROMPT="${SECOND_PROMPT}" bun -e '
|
||||
const input = await Bun.stdin.text()
|
||||
const data = JSON.parse(input)
|
||||
const userMessages = data.messages.filter((message) => message.type === "user")
|
||||
const texts = userMessages
|
||||
.map((message) => {
|
||||
if (typeof message.content === "string") return message.content
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter((block) => block && block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n")
|
||||
}
|
||||
return ""
|
||||
})
|
||||
console.log(JSON.stringify({
|
||||
total: data.messages.length,
|
||||
userCount: userMessages.length,
|
||||
hasFirstPrompt: texts.includes(process.env.FIRST_PROMPT),
|
||||
hasSecondPrompt: texts.includes(process.env.SECOND_PROMPT),
|
||||
}))
|
||||
')"
|
||||
TRANSCRIPT_USER_COUNT="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.userCount)')"
|
||||
TRANSCRIPT_HAS_FIRST="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasFirstPrompt ? "1" : "0")')"
|
||||
TRANSCRIPT_HAS_SECOND="$(printf '%s' "${TRANSCRIPT_CHECK}" | bun -e 'const data = JSON.parse(await Bun.stdin.text()); console.log(data.hasSecondPrompt ? "1" : "0")')"
|
||||
if [[ "${TRANSCRIPT_USER_COUNT}" != "1" || "${TRANSCRIPT_HAS_FIRST}" != "1" || "${TRANSCRIPT_HAS_SECOND}" != "0" ]]; then
|
||||
echo "Expected rewound session to keep only the first user prompt, got ${TRANSCRIPT_CHECK}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/06-after-second-turn-rewind.png" >/dev/null
|
||||
|
||||
echo "Complex rewind E2E passed"
|
||||
echo "API port: ${API_PORT}"
|
||||
echo "Web port: ${WEB_PORT}"
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
if ! command -v agent-browser >/dev/null 2>&1; then
|
||||
echo "agent-browser is required but was not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API_URL="${API_URL:-http://127.0.0.1:3457}"
|
||||
WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}"
|
||||
RUN_ID="$(date +%s)-$RANDOM"
|
||||
SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}"
|
||||
ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")"
|
||||
AB=(agent-browser --session "${SESSION_NAME}")
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
"${AB[@]}" close >/dev/null 2>&1 || true
|
||||
echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_text() {
|
||||
local needle="$1"
|
||||
if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then
|
||||
echo "Timed out waiting for page text: ${needle}" >&2
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
press_escape() {
|
||||
"${AB[@]}" press Escape >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
focus_composer() {
|
||||
"${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
submit_slash_command() {
|
||||
local command="$1"
|
||||
focus_composer
|
||||
"${AB[@]}" fill textarea "${command}"
|
||||
"${AB[@]}" press Enter
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
curl -fsS "${API_URL}/health" >/dev/null
|
||||
}
|
||||
|
||||
select_plugin_targets() {
|
||||
DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0))
|
||||
| sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills))
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select(.enabled == true and .componentCounts.skills > 0))
|
||||
| sort_by(-.componentCounts.skills)
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r '
|
||||
.plugins
|
||||
| map(select(.enabled == true and .componentCounts.mcpServers > 0))
|
||||
| sort_by(-.componentCounts.mcpServers)
|
||||
| .[0].id // empty
|
||||
')"
|
||||
|
||||
if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then
|
||||
echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then
|
||||
echo "No enabled plugin with skills was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${MCP_PLUGIN_ID}" ]]; then
|
||||
echo "No plugin with MCP servers was found in current API data." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')"
|
||||
DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')"
|
||||
DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')"
|
||||
DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')"
|
||||
DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')"
|
||||
DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')"
|
||||
DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
|
||||
DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')"
|
||||
DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')"
|
||||
DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
|
||||
MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')"
|
||||
MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')"
|
||||
MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')"
|
||||
MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')"
|
||||
}
|
||||
|
||||
open_plugin_detail() {
|
||||
local plugin_name="$1"
|
||||
local escaped_name="${plugin_name//\"/\\\"}"
|
||||
if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then
|
||||
wait_for_text "Bundled capabilities"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to open plugin detail for: ${plugin_name}" >&2
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
go_back_to_plugin_list() {
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null
|
||||
wait_for_text "Browse installed plugins"
|
||||
}
|
||||
|
||||
open_settings_sidebar_tab() {
|
||||
local tab_name="$1"
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null
|
||||
}
|
||||
|
||||
click_visible_card_text() {
|
||||
local text="$1"
|
||||
local escaped_text="${text//\"/\\\"}"
|
||||
"${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null
|
||||
}
|
||||
|
||||
healthcheck
|
||||
select_plugin_targets
|
||||
|
||||
echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})"
|
||||
echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})"
|
||||
echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})"
|
||||
|
||||
"${AB[@]}" open "${WEB_URL}"
|
||||
"${AB[@]}" wait --load networkidle
|
||||
wait_for_text "Claude Code Haha"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null
|
||||
|
||||
# Always work from a fresh chat surface so slash-command behavior is deterministic.
|
||||
"${AB[@]}" find role button click --name "New session"
|
||||
"${AB[@]}" wait textarea >/dev/null
|
||||
|
||||
submit_slash_command "/mcp"
|
||||
wait_for_text "Available MCP tools"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null
|
||||
press_escape
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
|
||||
submit_slash_command "/skills"
|
||||
wait_for_text "Available skills"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null
|
||||
press_escape
|
||||
"${AB[@]}" wait 300 >/dev/null
|
||||
|
||||
submit_slash_command "/plugin"
|
||||
wait_for_text "Browse installed plugins"
|
||||
wait_for_text "Plugin Manager"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null
|
||||
|
||||
open_plugin_detail "${DETAIL_PLUGIN_NAME}"
|
||||
wait_for_text "Commands"
|
||||
wait_for_text "Agents"
|
||||
wait_for_text "Hooks"
|
||||
wait_for_text "Skills"
|
||||
if [[ -n "${DETAIL_COMMAND}" ]]; then
|
||||
wait_for_text "/${DETAIL_COMMAND}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_AGENT}" ]]; then
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then
|
||||
wait_for_text "${DETAIL_HOOK_EVENT}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then
|
||||
wait_for_text "${DETAIL_HOOK_ACTION}"
|
||||
fi
|
||||
if [[ -n "${DETAIL_SKILL_NAME}" ]]; then
|
||||
wait_for_text "/${DETAIL_SKILL_NAME}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Plugins"
|
||||
wait_for_text "Plugin Detail"
|
||||
go_back_to_plugin_list
|
||||
open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}"
|
||||
|
||||
if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then
|
||||
click_visible_card_text "${DETAIL_SKILL_LABEL}"
|
||||
wait_for_text "Skill metadata"
|
||||
wait_for_text "${DETAIL_SKILL_LABEL}"
|
||||
if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then
|
||||
wait_for_text "${DETAIL_SKILL_DESCRIPTION}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Skills"
|
||||
wait_for_text "Skill Browser"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${DETAIL_SKILL_LABEL}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
open_settings_sidebar_tab "Plugins"
|
||||
wait_for_text "Plugin Detail"
|
||||
|
||||
if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then
|
||||
click_visible_card_text "${DETAIL_AGENT_LABEL}"
|
||||
wait_for_text "Agent Profile"
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "Agents"
|
||||
wait_for_text "Agent Browser"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${DETAIL_AGENT}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
go_back_to_plugin_list
|
||||
open_plugin_detail "${MCP_PLUGIN_NAME}"
|
||||
wait_for_text "MCP servers"
|
||||
if [[ -n "${MCP_SERVER_NAME}" ]]; then
|
||||
wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}"
|
||||
fi
|
||||
if [[ -n "${MCP_SKILL_NAME}" ]]; then
|
||||
wait_for_text "/${MCP_SKILL_NAME}"
|
||||
fi
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null
|
||||
|
||||
if [[ -n "${MCP_SERVER_LABEL}" ]]; then
|
||||
click_visible_card_text "${MCP_SERVER_LABEL}"
|
||||
wait_for_text "${MCP_SERVER_NAME}"
|
||||
wait_for_text "Plugin"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null
|
||||
|
||||
open_settings_sidebar_tab "MCP"
|
||||
wait_for_text "MCP servers"
|
||||
wait_for_text "Plugin"
|
||||
wait_for_text "${MCP_SERVER_NAME}"
|
||||
"${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null
|
||||
fi
|
||||
|
||||
echo "agent-browser web UI regression passed"
|
||||
echo "Artifacts: ${ARTIFACT_DIR}"
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* scan-missing-imports.ts
|
||||
*
|
||||
* 在编译 sidecar 之前,扫描 src/ 里所有相对路径的 import / require / 类型 import
|
||||
* specifier,找出磁盘上不存在的目标,给它们生成最小 stub 文件。
|
||||
*
|
||||
* 为什么需要:本 fork 的 src/ 大量使用 ant-internal 的 feature() macro 配
|
||||
* dynamic require/import,gating 一堆只在 Anthropic 内部 build 才存在的源文件。
|
||||
* bun build --compile 在 DCE 之前必须先把所有 import specifier 都 resolve 到
|
||||
* 实际文件,找不到就直接 fail。
|
||||
*
|
||||
* Stub 文件内容是一个 Proxy,任何属性读、函数调用、构造调用都返回安全 noop。
|
||||
* 由于这些代码路径都被 feature(...) === false 的 DCE 干掉了,stub 在运行时
|
||||
* 永远不会真的被求值 —— 它只是给 resolver 的"占位符"。
|
||||
*
|
||||
* 生成的 stub 标记 `// @generated stub from scan-missing-imports` 让脚本可以
|
||||
* 安全地覆写它们而不会动到真实代码。
|
||||
*/
|
||||
|
||||
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, '../..')
|
||||
const srcRoot = path.join(repoRoot, 'src')
|
||||
const adaptersRoot = path.join(repoRoot, 'adapters')
|
||||
|
||||
// 扫描 + 创建 stub 时允许的根目录。stub 写到这些目录之外会被拒绝,
|
||||
// 防止意外往 node_modules / 系统路径写文件。
|
||||
const ALLOWED_STUB_ROOTS = [srcRoot, adaptersRoot]
|
||||
|
||||
const STUB_MARKER_TS = '// @generated stub from scan-missing-imports'
|
||||
const STUB_MARKER_TEXT = '<!-- @generated stub from scan-missing-imports -->'
|
||||
|
||||
const TS_STUB_CONTENT = `${STUB_MARKER_TS}
|
||||
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
|
||||
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
|
||||
// bun build resolver 的占位符。
|
||||
const __target = function noop() {}
|
||||
const __handler: ProxyHandler<any> = {
|
||||
get(_t, prop) {
|
||||
if (prop === '__esModule') return true
|
||||
if (prop === 'default') return new Proxy(__target, __handler)
|
||||
if (prop === Symbol.toPrimitive) return () => undefined
|
||||
if (prop === Symbol.iterator) return function* () {}
|
||||
if (prop === Symbol.asyncIterator) return async function* () {}
|
||||
if (prop === 'then') return undefined
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
apply() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
construct() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
}
|
||||
const stub: any = new Proxy(__target, __handler)
|
||||
export default stub
|
||||
export const __stubMissing = true
|
||||
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
|
||||
export const createCachedMCState = stub
|
||||
export const isCachedMicrocompactEnabled = stub
|
||||
export const isModelSupportedForCacheEditing = stub
|
||||
export const getCachedMCConfig = stub
|
||||
export const markToolsSentToAPI = stub
|
||||
export const resetCachedMCState = stub
|
||||
export const checkProtectedNamespace = stub
|
||||
export const getCoordinatorUserContext = stub
|
||||
`
|
||||
|
||||
// 文本类资源(.md / .txt / .json 等)通过 Bun 的 text/json loader 内联,
|
||||
// stub 内容只要是合法的对应格式且非空即可。
|
||||
const TEXT_STUB_CONTENT = `${STUB_MARKER_TEXT}\nstub\n`
|
||||
const JSON_STUB_CONTENT = `{"__stubMissing": true}\n`
|
||||
|
||||
const TEXT_EXTS = new Set(['.md', '.markdown', '.txt'])
|
||||
const JSON_EXTS = new Set(['.json', '.json5'])
|
||||
|
||||
const IMPORT_PATTERNS = [
|
||||
// import X from './foo'
|
||||
/from\s+['"](\.[^'"]+)['"]/g,
|
||||
// import('./foo')
|
||||
/import\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||||
// require('./foo')
|
||||
/require\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||||
// import './foo' (side-effect only)
|
||||
/import\s+['"](\.[^'"]+)['"]/g,
|
||||
// typeof import('./foo')
|
||||
/typeof\s+import\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||||
]
|
||||
|
||||
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'])
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
if (entry.name === 'node_modules') continue
|
||||
if (entry.name === '__tests__') continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(full)
|
||||
} else if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name))) {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCandidates(importer: string, spec: string): string[] {
|
||||
const importerDir = path.dirname(importer)
|
||||
const base = path.resolve(importerDir, spec)
|
||||
return [
|
||||
base,
|
||||
base + '.ts',
|
||||
base + '.tsx',
|
||||
base + '.mts',
|
||||
base + '.cts',
|
||||
base + '.js',
|
||||
base + '.jsx',
|
||||
base + '.mjs',
|
||||
base + '.cjs',
|
||||
base.replace(/\.(m|c)?js$/, '.ts'),
|
||||
base.replace(/\.(m|c)?js$/, '.tsx'),
|
||||
path.join(base, 'index.ts'),
|
||||
path.join(base, 'index.tsx'),
|
||||
path.join(base, 'index.js'),
|
||||
]
|
||||
}
|
||||
|
||||
function pickStubPath(importer: string, spec: string): string {
|
||||
const importerDir = path.dirname(importer)
|
||||
const base = path.resolve(importerDir, spec)
|
||||
// 把 .js 还原成 .ts —— TS 源里写 .js 是 ESM-on-Node 的惯例
|
||||
if (base.endsWith('.js')) return base.slice(0, -3) + '.ts'
|
||||
if (base.endsWith('.jsx')) return base.slice(0, -4) + '.tsx'
|
||||
if (path.extname(base) === '') return base + '.ts'
|
||||
return base
|
||||
}
|
||||
|
||||
function pickStubContent(stubPath: string): { content: string; marker: string } {
|
||||
const ext = path.extname(stubPath).toLowerCase()
|
||||
if (TEXT_EXTS.has(ext)) {
|
||||
return { content: TEXT_STUB_CONTENT, marker: STUB_MARKER_TEXT }
|
||||
}
|
||||
if (JSON_EXTS.has(ext)) {
|
||||
return { content: JSON_STUB_CONTENT, marker: '"__stubMissing"' }
|
||||
}
|
||||
return { content: TS_STUB_CONTENT, marker: STUB_MARKER_TS }
|
||||
}
|
||||
|
||||
async function* walkRoots(roots: string[]): AsyncGenerator<string> {
|
||||
for (const root of roots) {
|
||||
if (!existsSync(root)) continue
|
||||
yield* walk(root)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const missing = new Map<string, Set<string>>() // stubPath → set of importers
|
||||
let scannedFiles = 0
|
||||
|
||||
for await (const file of walkRoots([srcRoot, adaptersRoot])) {
|
||||
scannedFiles++
|
||||
let contents: string
|
||||
try {
|
||||
contents = await readFile(file, 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const pattern of IMPORT_PATTERNS) {
|
||||
pattern.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(contents)) !== null) {
|
||||
const spec = match[1]!
|
||||
if (!spec.startsWith('.')) continue
|
||||
const candidates = resolveCandidates(file, spec)
|
||||
let exists = false
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (exists) continue
|
||||
const stubPath = pickStubPath(file, spec)
|
||||
if (!missing.has(stubPath)) missing.set(stubPath, new Set())
|
||||
missing.get(stubPath)!.add(path.relative(repoRoot, file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scan] scanned ${scannedFiles} source files`)
|
||||
console.log(`[scan] missing ${missing.size} stub targets`)
|
||||
|
||||
let createdCount = 0
|
||||
let skippedCount = 0
|
||||
for (const [stubPath, importers] of missing) {
|
||||
// 安全检查:只在 ALLOWED_STUB_ROOTS(src/、adapters/)下创建,
|
||||
// 且如果文件已存在但不是 stub 就跳过
|
||||
const isAllowed = ALLOWED_STUB_ROOTS.some(
|
||||
(root) => stubPath.startsWith(root + path.sep),
|
||||
)
|
||||
if (!isAllowed) {
|
||||
console.warn(`[scan] skip out-of-tree stub target: ${stubPath}`)
|
||||
continue
|
||||
}
|
||||
const { content, marker } = pickStubContent(stubPath)
|
||||
if (existsSync(stubPath)) {
|
||||
try {
|
||||
const existing = await readFile(stubPath, 'utf8')
|
||||
if (!existing.includes(marker) && !existing.includes(STUB_MARKER_TS)) {
|
||||
console.warn(
|
||||
`[scan] skip non-stub existing file: ${path.relative(repoRoot, stubPath)}`,
|
||||
)
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await mkdir(path.dirname(stubPath), { recursive: true })
|
||||
await writeFile(stubPath, content, 'utf8')
|
||||
createdCount++
|
||||
const rel = path.relative(repoRoot, stubPath)
|
||||
const sample = [...importers].slice(0, 2).join(', ')
|
||||
console.log(
|
||||
`[scan] stub: ${rel} (referenced from ${sample}${importers.size > 2 ? `, +${importers.size - 2}` : ''})`,
|
||||
)
|
||||
}
|
||||
console.log(`[scan] created ${createdCount} stubs, skipped ${skippedCount}`)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Claude Code 桌面端合并 sidecar 入口。
|
||||
*
|
||||
* 历史上 server / cli / IM adapters 是各自独立的进程。每个 bun-compile
|
||||
* 二进制都要带一份 ~55MB 的 bun runtime,光这一项就重复占了 100MB+。
|
||||
* 把所有运行模式合并到同一个二进制里,runtime 只保留一份;调用方通过
|
||||
* 第一个 positional 参数选择模式:
|
||||
*
|
||||
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
|
||||
* claude-sidecar cli --app-root <path> [其它 CLI 参数...]
|
||||
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram]
|
||||
*
|
||||
* 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的
|
||||
* 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及
|
||||
* adapters/feishu/index.ts 等顶层都会立即读 process.argv / process.env,
|
||||
* 必须在它们求值前 splice 掉 --app-root、mode、--feishu/--telegram 这些
|
||||
* launcher-only 参数。
|
||||
*/
|
||||
|
||||
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
|
||||
|
||||
const rawArgs = process.argv.slice(2)
|
||||
const invocation = resolveSidecarInvocation(rawArgs)
|
||||
if (!invocation.mode) {
|
||||
console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")')
|
||||
process.exit(2)
|
||||
}
|
||||
const mode = invocation.mode
|
||||
const restArgs = invocation.restArgs
|
||||
|
||||
if (mode === 'adapters') {
|
||||
await runAdapters(restArgs)
|
||||
} else {
|
||||
const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot)
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
await import('../../preload.ts')
|
||||
|
||||
if (mode === 'server') {
|
||||
const { startServer } = await import('../../src/server/index.ts')
|
||||
startServer()
|
||||
} else if (mode === 'cli') {
|
||||
await import('../../src/entrypoints/cli.tsx')
|
||||
} else {
|
||||
console.error(`claude-sidecar: unknown mode "${mode}" (expected "server", "cli" or "adapters")`)
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
async function runAdapters(rawArgs: string[]): Promise<void> {
|
||||
// adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu /
|
||||
// --telegram 选择启用哪个适配器,再加可选的 --app-root(透传给
|
||||
// adapters/common/config.ts 内的 process.env 读取)。
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
let enableFeishu = false
|
||||
let enableTelegram = false
|
||||
|
||||
for (let i = 0; i < rawArgs.length; i++) {
|
||||
const arg = rawArgs[i]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[i + 1] ?? null
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--feishu') {
|
||||
enableFeishu = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--telegram') {
|
||||
enableTelegram = true
|
||||
continue
|
||||
}
|
||||
console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`)
|
||||
}
|
||||
|
||||
if (!enableFeishu && !enableTelegram) {
|
||||
console.error(
|
||||
'claude-sidecar adapters: must enable at least one of --feishu / --telegram',
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
if (appRoot) {
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
}
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
|
||||
await import('../../preload.ts')
|
||||
|
||||
// 在 import adapter 之前先用同一份 loadConfig() 检查凭据。adapter 的
|
||||
// top-level 代码里已经有 if (!cred) process.exit(1),但那会把整个
|
||||
// 进程拖死 —— 包括另一个本来正常的 adapter。这里提前 gate 一下,
|
||||
// 缺凭据的 adapter 直接跳过、不 import。
|
||||
const { loadConfig } = await import('../../adapters/common/config.ts')
|
||||
const config = loadConfig()
|
||||
|
||||
let started = 0
|
||||
|
||||
if (enableFeishu) {
|
||||
if (!config.feishu.appId || !config.feishu.appSecret) {
|
||||
console.warn(
|
||||
'[claude-sidecar] --feishu requested but FEISHU_APP_ID / FEISHU_APP_SECRET missing in env or ~/.claude/adapters.json — skipping',
|
||||
)
|
||||
} else {
|
||||
console.log('[claude-sidecar] starting Feishu adapter')
|
||||
// 副作用 import:feishu/index.ts 顶层会自动 new WSClient + start()
|
||||
await import('../../adapters/feishu/index.ts')
|
||||
started += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (enableTelegram) {
|
||||
if (!config.telegram.botToken) {
|
||||
console.warn(
|
||||
'[claude-sidecar] --telegram requested but TELEGRAM_BOT_TOKEN missing in env or ~/.claude/adapters.json — skipping',
|
||||
)
|
||||
} else {
|
||||
console.log('[claude-sidecar] starting Telegram adapter')
|
||||
// 副作用 import:telegram/index.ts 顶层会自动 bot.start()
|
||||
await import('../../adapters/telegram/index.ts')
|
||||
started += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (started === 0) {
|
||||
console.error(
|
||||
'[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 让进程保持存活:两个 adapter 都通过 long-lived WebSocket(Lark WSClient
|
||||
// / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外
|
||||
// setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting'
|
||||
|
||||
describe('resolveSidecarInvocation', () => {
|
||||
it('keeps explicit sidecar modes unchanged', () => {
|
||||
expect(
|
||||
resolveSidecarInvocation(
|
||||
['server', '--host', '127.0.0.1'],
|
||||
'/tmp/claude-sidecar',
|
||||
),
|
||||
).toEqual({
|
||||
mode: 'server',
|
||||
restArgs: ['--host', '127.0.0.1'],
|
||||
defaultAppRoot: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults claude-haha invocations to cli mode', () => {
|
||||
expect(
|
||||
resolveSidecarInvocation(
|
||||
['plugin', 'install', 'demo'],
|
||||
'/Users/demo/.local/bin/claude-haha',
|
||||
),
|
||||
).toEqual({
|
||||
mode: 'cli',
|
||||
restArgs: ['plugin', 'install', 'demo'],
|
||||
defaultAppRoot: '/Users/demo/.local/bin',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLauncherArgs', () => {
|
||||
it('falls back to the provided default app root', () => {
|
||||
expect(
|
||||
parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'),
|
||||
).toEqual({
|
||||
appRoot: '/Users/demo/.local/bin',
|
||||
args: ['plugin', 'install', 'demo'],
|
||||
})
|
||||
})
|
||||
|
||||
it('lets explicit app root override the default', () => {
|
||||
expect(
|
||||
parseLauncherArgs(
|
||||
['--app-root', '/tmp/app', 'plugin', 'install', 'demo'],
|
||||
'/Users/demo/.local/bin',
|
||||
),
|
||||
).toEqual({
|
||||
appRoot: '/tmp/app',
|
||||
args: ['plugin', 'install', 'demo'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export type SidecarMode = 'server' | 'cli' | 'adapters'
|
||||
|
||||
const EXPLICIT_MODES = new Set<SidecarMode>(['server', 'cli', 'adapters'])
|
||||
const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe'])
|
||||
|
||||
export function resolveSidecarInvocation(
|
||||
rawArgs: string[],
|
||||
execPath: string = process.execPath,
|
||||
envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
|
||||
): {
|
||||
mode: SidecarMode | null
|
||||
restArgs: string[]
|
||||
defaultAppRoot: string | null
|
||||
} {
|
||||
const explicitMode = rawArgs[0]
|
||||
if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) {
|
||||
return {
|
||||
mode: explicitMode as SidecarMode,
|
||||
restArgs: rawArgs.slice(1),
|
||||
defaultAppRoot: envAppRoot,
|
||||
}
|
||||
}
|
||||
|
||||
const execName = path.basename(execPath).toLowerCase()
|
||||
if (DESKTOP_CLI_NAMES.has(execName)) {
|
||||
return {
|
||||
mode: 'cli',
|
||||
restArgs: rawArgs,
|
||||
defaultAppRoot: envAppRoot ?? path.dirname(execPath),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: null,
|
||||
restArgs: rawArgs,
|
||||
defaultAppRoot: envAppRoot,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLauncherArgs(
|
||||
rawArgs: string[],
|
||||
defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null,
|
||||
): { appRoot: string; args: string[] } {
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = defaultAppRoot
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg!)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
Generated
+5710
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "heicode-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "heicode_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1.0.102"
|
||||
portable-pty = "0.9.0"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user