diff --git a/bun.lock b/bun.lock index 5d9b51d..b86fd02 100644 --- a/bun.lock +++ b/bun.lock @@ -17,63 +17,63 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.3", "@biomejs/cli-darwin-x64": "2.4.3", "@biomejs/cli-linux-arm64": "2.4.3", "@biomejs/cli-linux-arm64-musl": "2.4.3", "@biomejs/cli-linux-x64": "2.4.3", "@biomejs/cli-linux-x64-musl": "2.4.3", "@biomejs/cli-win32-arm64": "2.4.3", "@biomejs/cli-win32-x64": "2.4.3" }, "bin": { "biome": "bin/biome" } }, "sha512-cBrjf6PNF6yfL8+kcNl85AjiK2YHNsbU0EvDOwiZjBPbMbQ5QcgVGFpjD0O52p8nec5O8NYw7PKw3xUR7fPAkQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eOafSFlI/CF4id2tlwq9CVHgeEqvTL5SrhWff6ZORp6S3NL65zdsR3ugybItkgF8Pf4D9GSgtbB6sE3UNgOM9w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-V2+av4ilbWcBMNufTtMMXVW00nPwyIjI5qf7n9wSvUaZ+tt0EvMGk46g9sAFDJBEDOzSyoRXiSP6pCvKTOEbPA=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0m+O0x9FgK99FAwDK+fiDtjs2wnqq7bvfj17KJVeCkTwT/liI+Q9njJG7lwXK0iSJVXeFNRIxukpVI3SifMYAA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QuFzvsGo8BA4Xm7jGX5idkw6BqFblcCPySMTvq0AhGYnhUej5VJIDJbmTKfHqwjHepZiC4fA+T5i6wmiZolZNw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-NVqh0saIU0u5OfOp/0jFdlKRE59+XyMvWmtx0f6Nm/2OpdxBl04coRIftBbY9d1gfu+23JVv4CItAqPYrjYh5w=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-qEc0OCpj/uytruQ4wLM0yWNJLZy0Up8H1Er5MW3SrstqM6J2d4XqdNA86xzCy8MQCHpoVZ3lFye3GBlIL4/ljw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gRO96vrIARilv/Cp2ZnmNNL5LSZg3RO75GPp13hsLO3N4YVpE7saaMDp2bcyV48y2N2Pbit1brkGVGta0yd6VQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vSm/vOJe06pf14aGHfHl3Ar91Nlx4YYmohElDJ+17UbRwe99n987S/MhAlQOkONqf1utJor04ChkCPmKb8SWdw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-2WPoh/2oK9r/i2R4o4J18AOrm3HVlWiHZ8TnuCaS4dX8m5ZzRmHW0I3eLxEurQLHWVruhQN7fHgZnah+ag5iQg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YqJAGvNB11EzoKm1euVhZntb79alhMvWW/j12bYqdvVxn6xzEQWrEDCJg9BPo3A3tBCSUBKH7bVkAiCBqK/L1w=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.50.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.49.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WFocCRlvVkMhChCJ2qpJfp1Gj/IjvyjuifH9Pex8m8yHonxxQa3d8DZYreuDQU3T4jvSY8rqhoRqnpc61Nlbxw=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.50.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.49.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BN0KniwvehbUfYztOMwEDkYoojGm/narf5oJf+/ap+6PnzMeWLezMaVARNIS0j3OdMkjHTEP8s3+GdPJ7WDywQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.50.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.49.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SnkAc/DPIY6joMCiP/+53Q+N2UOGMU6ULvbztpmvPJNF/jYPGhNbKtN982uj2Gs6fpbxYkmyj08QnpkD4fbHJA=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.50.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Z3EzRvpQVIpO7uFhdiGhdE8Mh3S2VWKLL9xuxVqD6fzPhyI3ugthpYXlCChXzO8FzcYIZ3t1+Kau+h2NY1hqA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.49.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wdjXaQYAL/L25732mLlngfst4Jdmi/HLPVHb3yfCoP5mE3lO/pFFrmOJpqWodgv29suWY74Ij+RmJ/YIG5VuzQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSHpm8zmSvAG1BWUumbDRSg7moJbnwoEXKAkwDf/xTQJOzvbUknq95NVQdw/AduZr5dePftalB8rzJNGBogUMg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.49.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xeqkMOARgGBlEg9BQuPDf6ZW711X6BT5qjDyeM5XNowCJeTSdmMhpePJjTEiVbbr3t21sIlK8RE6X5bc04nWyQ=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.49.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uvcqRO6PnlJGbL7TeePhTK5+7/JXbxGbN+C6FVmfICDeeRomgQqrfVjf0lUrVpUU8ii8TSkIbNdft3M+oNlOsQ=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.50.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-Dw1HkdXAwHNH+ZDserHP2RzXQmhHtpsYYI0hf8fuGAVCIVwvS6w1+InLxpPMY25P8ASRNiFN3hADtoh6lI+4lg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.49.0", "", { "os": "linux", "cpu": "none" }, "sha512-EPlMYaA05tJ9km/0dI9K57iuMq3Tw+nHst7TNIegAJZrBPtsOtYaMFZEaWj02HA8FI5QvSnRHMt+CI+RIhXJBQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.49.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-yZiQL9qEwse34aMbnMb5VqiAWfDY+fLFuoJbHOuzB1OaJZbN1MRF9Nk+W89PIpGr5DNPDipwjZb8+Q7wOywoUQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.50.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-CcCDwMMXSchNkhdgvhVn3DLZ4EnBXAD8o8+gRzahg+IdSt/72y19xBgShJgadIRF0TsRcV/MhDUMwL5N/W54aQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.49.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u3HfKV8BV6t6UCCbN0RRiyqcymhrnpunVmLFI8sEa5S/EBu+p/0bJ3D7LZ2KT6PsBbrB71SWq4DeFrskOVgIZg=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.49.0", "", { "os": "none", "cpu": "arm64" }, "sha512-dRDpH9fw+oeUMpM4br0taYCFpW6jQtOuEIec89rOgDA1YhqwmeRcx0XYeCv7U48p57qJ1XZHeMGM9LdItIjfzA=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.50.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.49.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-6rrKe/wL9tn0qnOy76i1/0f4Dc3dtQnibGlU4HqR/brVHlVjzLSoaH0gAFnLnznh9yQ6gcFTBFOPrcN/eKPDGA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.50.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.49.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-CXHLWAtLs2xG/aVy1OZiYJzrULlq0QkYpI6cd7VKMrab+qur4fXVE/B1Bp1m0h1qKTj5/FTGg6oU4qaXMjS/ug=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.50.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="], - "@shetty4l/core": ["@shetty4l/core@0.1.35", "", { "bin": { "version-bump": "bin/version-bump.js" } }, "sha512-gYNsZ98oWQXi0jiKgWecA9dVoK74aQat+r5WQETEsyrUhTk+DLFOTyqmq3xK4lCmGJQamAAUQq7Ew1yx5YcigA=="], + "@shetty4l/core": ["@shetty4l/core@0.1.40", "", { "bin": { "version-bump": "bin/version-bump.js" } }, "sha512-W1PMBmT/mZ5UTWLHrS2VeAqnzzyBbRzbfCgn69dE4AiZGcJS8AhfyfLtSx5VGRePGrKmVwyjFnJpCBf2cLc/iw=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -83,7 +83,7 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "oxlint": ["oxlint@1.49.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.49.0", "@oxlint/binding-android-arm64": "1.49.0", "@oxlint/binding-darwin-arm64": "1.49.0", "@oxlint/binding-darwin-x64": "1.49.0", "@oxlint/binding-freebsd-x64": "1.49.0", "@oxlint/binding-linux-arm-gnueabihf": "1.49.0", "@oxlint/binding-linux-arm-musleabihf": "1.49.0", "@oxlint/binding-linux-arm64-gnu": "1.49.0", "@oxlint/binding-linux-arm64-musl": "1.49.0", "@oxlint/binding-linux-ppc64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-gnu": "1.49.0", "@oxlint/binding-linux-riscv64-musl": "1.49.0", "@oxlint/binding-linux-s390x-gnu": "1.49.0", "@oxlint/binding-linux-x64-gnu": "1.49.0", "@oxlint/binding-linux-x64-musl": "1.49.0", "@oxlint/binding-openharmony-arm64": "1.49.0", "@oxlint/binding-win32-arm64-msvc": "1.49.0", "@oxlint/binding-win32-ia32-msvc": "1.49.0", "@oxlint/binding-win32-x64-msvc": "1.49.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-YZffp0gM+63CJoRhHjtjRnwKtAgUnXM6j63YQ++aigji2NVvLGsUlrXo9gJUXZOdcbfShLYtA6RuTu8GZ4lzOQ=="], + "oxlint": ["oxlint@1.50.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.50.0", "@oxlint/binding-android-arm64": "1.50.0", "@oxlint/binding-darwin-arm64": "1.50.0", "@oxlint/binding-darwin-x64": "1.50.0", "@oxlint/binding-freebsd-x64": "1.50.0", "@oxlint/binding-linux-arm-gnueabihf": "1.50.0", "@oxlint/binding-linux-arm-musleabihf": "1.50.0", "@oxlint/binding-linux-arm64-gnu": "1.50.0", "@oxlint/binding-linux-arm64-musl": "1.50.0", "@oxlint/binding-linux-ppc64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-musl": "1.50.0", "@oxlint/binding-linux-s390x-gnu": "1.50.0", "@oxlint/binding-linux-x64-gnu": "1.50.0", "@oxlint/binding-linux-x64-musl": "1.50.0", "@oxlint/binding-openharmony-arm64": "1.50.0", "@oxlint/binding-win32-arm64-msvc": "1.50.0", "@oxlint/binding-win32-ia32-msvc": "1.50.0", "@oxlint/binding-win32-x64-msvc": "1.50.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package.json b/package.json index 98075c6..0744a6f 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,13 @@ "prepare": "[ -d .git ] && husky || true" }, "dependencies": { - "@shetty4l/core": "^0.1.35" + "@shetty4l/core": "^0.1.40" }, "devDependencies": { - "@biomejs/biome": "^2.4.2", + "@biomejs/biome": "^2.4.4", "@types/bun": "latest", "husky": "^9.1.7", - "oxlint": "^1.48.0", + "oxlint": "^1.50.0", "typescript": "^5.9.3" } } diff --git a/src/channels/telegram/api.ts b/src/channels/telegram/api.ts index 41b997f..f21e2ba 100644 --- a/src/channels/telegram/api.ts +++ b/src/channels/telegram/api.ts @@ -311,3 +311,38 @@ export async function editMessageReplyMarkup( 15000, ); } + +export interface ForumTopic { + message_thread_id: number; +} + +export async function createForumTopic( + botToken: string, + chatId: number, + name: string, +): Promise { + const payload: Record = { + chat_id: chatId, + name, + }; + + return callTelegramApi( + botToken, + "createForumTopic", + payload, + 15000, + ); +} + +export async function deleteForumTopic( + botToken: string, + chatId: number, + messageThreadId: number, +): Promise { + const payload: Record = { + chat_id: chatId, + message_thread_id: messageThreadId, + }; + + return callTelegramApi(botToken, "deleteForumTopic", payload, 15000); +} diff --git a/src/channels/telegram/index.ts b/src/channels/telegram/index.ts index 36591e2..6bfce2c 100644 --- a/src/channels/telegram/index.ts +++ b/src/channels/telegram/index.ts @@ -13,16 +13,20 @@ import type { StateLoader } from "@shetty4l/core/state"; import type { TelegramChannelConfig } from "../../config"; import { TelegramChannelState } from "../../state/telegram"; import { TelegramCallback } from "../../state/telegram-callback"; +import { TopicChannelMapping } from "../../state/topic-channel"; import type { CortexClient, OutboxMessage } from "../cortex-client"; import type { Channel, ChannelStats } from "../index"; import { answerCallbackQuery, type CallbackQuery, + createForumTopic, + deleteForumTopic, editMessageReplyMarkup, getUpdates, type InlineKeyboardMarkup, parseTelegramTopicKey, sendMessage, + type TelegramTopic, } from "./api"; import { chunkMarkdownV2 } from "./chunker"; import { formatForTelegram } from "./format"; @@ -427,10 +431,15 @@ export class TelegramChannel implements Channel { } private async deliverMessage(msg: OutboxMessage): Promise { - // Parse topic key to get chat ID and optional thread ID - const topic = parseTelegramTopicKey(msg.topicKey); - if (!topic) { - log(`invalid topic key: ${msg.topicKey}, acking to skip`); + // Resolve topic key to chat coordinates + let topic: TelegramTopic; + try { + topic = await this.resolveTopicKey(msg.topicKey); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + log( + `failed to resolve topic "${msg.topicKey}": ${errorMsg}, acking to skip`, + ); await this.cortex.ackOutbox(msg.messageId, msg.leaseToken); return; } @@ -451,6 +460,14 @@ export class TelegramChannel implements Channel { } : undefined; + // Log resolved destination + const destination = topic.threadId + ? `chat:${topic.chatId}:thread:${topic.threadId}` + : `chat:${topic.chatId}`; + log( + `delivering message ${msg.messageId} to ${destination} (topic: ${msg.topicKey})`, + ); + // Send each chunk (only last chunk gets buttons) for (let i = 0; i < chunks.length; i++) { const isLastChunk = i === chunks.length - 1; @@ -474,6 +491,130 @@ export class TelegramChannel implements Channel { } } + // --- Topic Resolution --- + + /** + * Resolve a topic key to Telegram chat coordinates. + * + * - Numeric keys (e.g., "123456", "-100123:42"): Parse directly (backward compat) + * - Semantic keys with existing mapping: Return stored {chatId, threadId} + * - Semantic keys without mapping + supergroupId: Create thread, store mapping + * - Semantic keys without mapping + no supergroupId: Fallback to allowedUserIds[0] DM + * + * Race condition handling: On UNIQUE constraint error during storeMapping, + * delete orphan thread via deleteForumTopic, then return getMapping result. + */ + private async resolveTopicKey(topicKey: string): Promise { + // Try parsing as numeric topic key first (backward compatibility) + const parsed = parseTelegramTopicKey(topicKey); + if (parsed) { + return parsed; + } + + // Semantic topic key - check for existing mapping + const existing = this.getMapping(topicKey); + if (existing) { + return { + chatId: existing.chatId, + threadId: existing.threadId ?? undefined, + }; + } + + // No existing mapping - check if supergroup is configured + const supergroupId = this.config.supergroupId; + if (supergroupId) { + // Create new forum thread + const threadName = topicKey.slice(0, 128); + const result = await createForumTopic( + this.config.botToken, + supergroupId, + threadName, + ); + const threadId = result.message_thread_id; + + // Try to store the mapping + try { + this.storeMapping(topicKey, supergroupId, threadId); + log(`created thread ${threadId} for topic "${topicKey}"`); + return { chatId: supergroupId, threadId }; + } catch (e) { + // Check for UNIQUE constraint violation (race condition) + const errorMsg = e instanceof Error ? e.message : String(e); + if (errorMsg.includes("UNIQUE constraint failed")) { + // Another process won the race - delete our orphan thread + log( + `race condition on topic "${topicKey}", deleting orphan thread ${threadId}`, + ); + try { + await deleteForumTopic( + this.config.botToken, + supergroupId, + threadId, + ); + } catch (deleteErr) { + // Log but don't throw - orphan deletion is best effort + log(`failed to delete orphan thread ${threadId}: ${deleteErr}`); + } + + // Return the winner's mapping + const winnerMapping = this.getMapping(topicKey); + if (winnerMapping) { + return { + chatId: winnerMapping.chatId, + threadId: winnerMapping.threadId ?? undefined, + }; + } + // Should not happen, but fall through to DM fallback + log(`race condition resolved but no mapping found for "${topicKey}"`); + } else { + // Some other error - rethrow + throw e; + } + } + } + + // Fallback: Send to first allowed user as DM + const fallbackChatId = this.config.allowedUserIds[0]; + if (!fallbackChatId) { + throw new Error( + `Cannot resolve topic "${topicKey}": no supergroupId configured and no allowedUserIds`, + ); + } + log(`no supergroup configured, falling back to DM for topic "${topicKey}"`); + return { chatId: fallbackChatId }; + } + + /** + * Get an existing topic-to-channel mapping from the database. + */ + private getMapping(topicKey: string): TopicChannelMapping | null { + if (!this.stateLoader) { + return null; + } + return this.stateLoader.get(TopicChannelMapping, topicKey); + } + + /** + * Store a new topic-to-channel mapping in the database. + * Throws on UNIQUE constraint violation (race condition). + */ + private storeMapping( + topicKey: string, + chatId: number, + threadId: number | null, + ): void { + if (!this.stateLoader) { + log(`no stateLoader, skipping mapping storage for "${topicKey}"`); + return; + } + // Note: created_at is auto-managed by @PersistedCollection + this.stateLoader.create(TopicChannelMapping, { + topicKey, + chatId, + threadId, + }); + } + // --- Helpers --- private sleep(ms: number): Promise { diff --git a/src/config.ts b/src/config.ts index b115265..6853a37 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export interface TelegramChannelConfig { pollIntervalMs?: number; deliveryMaxBatch?: number; deliveryLeaseSeconds?: number; + supergroupId?: number; } export interface CortexConfig { diff --git a/src/state/topic-channel.ts b/src/state/topic-channel.ts new file mode 100644 index 0000000..d6fb90c --- /dev/null +++ b/src/state/topic-channel.ts @@ -0,0 +1,38 @@ +/** + * Persisted collection for topic-to-channel mappings. + * + * Maps semantic topic keys (e.g., "cortex:alerts", "daily:standup") + * to Telegram chat coordinates (chatId + optional threadId for forum topics). + * Enables Wilson to create and reuse Telegram forum threads automatically. + * + * Note: created_at and updated_at are auto-managed by @PersistedCollection. + */ + +import { + CollectionEntity, + CollectionField, + Id, + PersistedCollection, +} from "@shetty4l/core/state"; + +@PersistedCollection("topic_channel_mappings") +export class TopicChannelMapping extends CollectionEntity { + /** Semantic topic key (e.g., "cortex:alerts", "daily:standup"). Primary key. */ + @Id() topicKey: string = ""; + + /** Telegram chat ID (group/supergroup). */ + @CollectionField("number") chatId: number = 0; + + /** Telegram message thread ID for forum topics (null for non-forum chats). */ + @CollectionField("number") threadId: number | null = null; + + // Note: created_at is auto-managed by @PersistedCollection, accessible via this.created_at + + async save(): Promise { + throw new Error("Not bound to StateLoader"); + } + + async delete(): Promise { + throw new Error("Not bound to StateLoader"); + } +} diff --git a/test/topic-resolution.test.ts b/test/topic-resolution.test.ts new file mode 100644 index 0000000..8f895be --- /dev/null +++ b/test/topic-resolution.test.ts @@ -0,0 +1,1739 @@ +/** + * Exhaustive test suite for topic resolution in TelegramChannel. + * + * Tests the resolveTopicKey() method which maps topic keys to Telegram chat coordinates. + * - Numeric keys: Direct parsing (backward compatibility) + * - Semantic keys: Mapping lookup, thread creation, fallback to DM + * - Race condition handling: UNIQUE constraint → delete orphan → return winner + */ + +import { Database } from "bun:sqlite"; +import { describe, expect, spyOn, test } from "bun:test"; +import { ok, type Result } from "@shetty4l/core/result"; +import { StateLoader } from "@shetty4l/core/state"; +import type { + CortexClient, + OutboxMessage, + ReceivePayload, + ReceiveResponse, +} from "../src/channels/cortex-client"; +import * as telegramApi from "../src/channels/telegram/api"; +import { + parseTelegramTopicKey, + TelegramChannel, +} from "../src/channels/telegram/index"; +import type { TelegramChannelConfig } from "../src/config"; +import { TopicChannelMapping } from "../src/state/topic-channel"; + +// --- Mock CortexClient --- + +interface MockCortexClient extends CortexClient { + receiveCalls: ReceivePayload[]; + pollCalls: { channel: string; opts?: Record }[]; + ackCalls: { messageId: string; leaseToken: string }[]; + pendingMessages: OutboxMessage[]; +} + +function makeMockCortex(): MockCortexClient { + const receiveCalls: ReceivePayload[] = []; + const pollCalls: { channel: string; opts?: Record }[] = []; + const ackCalls: { messageId: string; leaseToken: string }[] = []; + const pendingMessages: OutboxMessage[] = []; + + return { + receiveCalls, + pollCalls, + ackCalls, + pendingMessages, + receive: async ( + payload: ReceivePayload, + ): Promise> => { + receiveCalls.push(payload); + return ok({ eventId: "evt-1", status: "queued" as const }); + }, + pollOutbox: async (channel: string, opts?: Record) => { + pollCalls.push({ channel, opts }); + const msgs = [...pendingMessages]; + pendingMessages.length = 0; + return ok(msgs); + }, + ackOutbox: async (messageId: string, leaseToken: string) => { + ackCalls.push({ messageId, leaseToken }); + return ok(undefined); + }, + } as MockCortexClient; +} + +// --- Default config --- + +const DEFAULT_CONFIG: TelegramChannelConfig = { + enabled: true, + botToken: "test-bot-token", + allowedUserIds: [123456, 789012], + pollIntervalMs: 50, + deliveryMaxBatch: 5, + deliveryLeaseSeconds: 30, +}; + +// Helper to run a channel test with proper cleanup +async function runChannelTest( + config: TelegramChannelConfig, + cortex: MockCortexClient, + loader: StateLoader, + db: Database, + testFn: (channel: TelegramChannel) => Promise, +): Promise { + const channel = new TelegramChannel(cortex, config, loader); + try { + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); // Allow poll cycles + await channel.stop(); + // Wait for loops to fully exit and pending ops to complete + await new Promise((r) => setTimeout(r, 200)); + await loader.flush(); // Ensure all pending state is flushed + await testFn(channel); + } finally { + await loader.flush(); // Final flush + await new Promise((r) => setTimeout(r, 150)); // Extra settle time before db.close + db.close(); + } +} + +// --- parseTelegramTopicKey Tests (Numeric keys) - 4 tests --- + +describe("parseTelegramTopicKey - Numeric keys", () => { + test("simple chatId", () => { + const result = parseTelegramTopicKey("123456"); + expect(result).toEqual({ chatId: 123456 }); + }); + + test("chatId:threadId", () => { + const result = parseTelegramTopicKey("123456:789"); + expect(result).toEqual({ chatId: 123456, threadId: 789 }); + }); + + test("negative group chatId", () => { + const result = parseTelegramTopicKey("-100123456789"); + expect(result).toEqual({ chatId: -100123456789 }); + }); + + test("negative group chatId with threadId", () => { + const result = parseTelegramTopicKey("-100123456789:42"); + expect(result).toEqual({ chatId: -100123456789, threadId: 42 }); + }); +}); + +// --- Mapping exists tests - 4 tests --- + +describe("Topic resolution - Mapping exists", () => { + test("returns stored mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "cortex:alerts", + chatId: -100555666777, + threadId: 42, + }); + + cortex.pendingMessages.push({ + messageId: "msg-1", + topicKey: "cortex:alerts", + text: "Test alert", + leaseToken: "lease-1", + payload: null, + }); + + let sentToChatId = 0; + let sentToThreadId: number | undefined; + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + chatId, + _text, + opts, + ): Promise => { + sentToChatId = chatId; + sentToThreadId = opts?.threadId; + return { message_id: 1, date: Date.now() / 1000, chat: { id: chatId } }; + }, + ); + + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sentToChatId).toBe(-100555666777); + expect(sentToThreadId).toBe(42); + }); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); + + test("returns correct chatId and threadId from mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "test:topic", + chatId: -100111222333, + threadId: 99, + }); + cortex.pendingMessages.push({ + messageId: "msg-2", + topicKey: "test:topic", + text: "Test", + leaseToken: "lease-2", + payload: null, + }); + + let capturedChatId = 0; + let capturedThreadId: number | undefined; + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + chatId, + _text, + opts, + ): Promise => { + capturedChatId = chatId; + capturedThreadId = opts?.threadId; + return { message_id: 2, date: Date.now() / 1000, chat: { id: chatId } }; + }, + ); + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(capturedChatId).toBe(-100111222333); + expect(capturedThreadId).toBe(99); + }); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); + + test("handles null threadId in mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "general:channel", + chatId: -100999888777, + threadId: null, + }); + cortex.pendingMessages.push({ + messageId: "msg-3", + topicKey: "general:channel", + text: "Test", + leaseToken: "lease-3", + payload: null, + }); + + let capturedThreadId: number | undefined = 999; + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + chatId, + _text, + opts, + ): Promise => { + capturedThreadId = opts?.threadId; + return { message_id: 3, date: Date.now() / 1000, chat: { id: chatId } }; + }, + ); + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(capturedThreadId).toBeUndefined(); + }); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); + + test("handles set threadId in mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "team:eng", + chatId: -100444555666, + threadId: 123, + }); + cortex.pendingMessages.push({ + messageId: "msg-4", + topicKey: "team:eng", + text: "Test", + leaseToken: "lease-4", + payload: null, + }); + + let capturedThreadId: number | undefined; + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + chatId, + _text, + opts, + ): Promise => { + capturedThreadId = opts?.threadId; + return { message_id: 4, date: Date.now() / 1000, chat: { id: chatId } }; + }, + ); + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(capturedThreadId).toBe(123); + }); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); +}); + +// --- No mapping + supergroup tests - 6 tests --- + +describe("Topic resolution - No mapping + supergroup", () => { + const configWithSupergroup: TelegramChannelConfig = { + ...DEFAULT_CONFIG, + supergroupId: -100111222333, + }; + + test("creates thread via createForumTopic", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-create", + topicKey: "new:topic", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let createForumTopicCalled = false; + let createForumTopicChatId = 0; + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async (_token, chatId) => { + createForumTopicCalled = true; + createForumTopicChatId = chatId; + return { message_thread_id: 777 }; + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 10, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(createForumTopicCalled).toBe(true); + expect(createForumTopicChatId).toBe(-100111222333); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("stores mapping after creating thread", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-store", + topicKey: "store:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => ({ message_thread_id: 888 }), + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 11, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, configWithSupergroup, loader); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader.flush(); + await new Promise((r) => setTimeout(r, 100)); + + const mapping = loader.get(TopicChannelMapping, "store:test"); + expect(mapping).not.toBeNull(); + expect(mapping?.chatId).toBe(-100111222333); + expect(mapping?.threadId).toBe(888); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + await loader.flush(); + await new Promise((r) => setTimeout(r, 150)); + db.close(); + } + }); + + test("uses topicKey as thread name", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-name", + topicKey: "alerts:production", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let capturedThreadName = ""; + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async (_token, _chatId, name) => { + capturedThreadName = name; + return { message_thread_id: 999 }; + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 12, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(capturedThreadName).toBe("alerts:production"); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("truncates long names (>128 chars)", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + const longTopicKey = "a".repeat(200); + cortex.pendingMessages.push({ + messageId: "msg-long", + topicKey: longTopicKey, + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let capturedThreadName = ""; + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async (_token, _chatId, name) => { + capturedThreadName = name; + return { message_thread_id: 1000 }; + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 13, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(capturedThreadName.length).toBe(128); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("handles special chars in topic key", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + const specialTopicKey = "alerts:prod/test@email.com"; + cortex.pendingMessages.push({ + messageId: "msg-special", + topicKey: specialTopicKey, + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let capturedThreadName = ""; + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async (_token, _chatId, name) => { + capturedThreadName = name; + return { message_thread_id: 1001 }; + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 14, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(capturedThreadName).toBe(specialTopicKey); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("returns new threadId from created topic", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-return", + topicKey: "return:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => ({ message_thread_id: 5555 }), + ); + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 15, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(sentToThreadId).toBe(5555); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); +}); + +// --- No mapping + no supergroup tests - 4 tests --- + +describe("Topic resolution - No mapping + no supergroup", () => { + test("falls back to allowedUserIds[0] DM", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-dm", + topicKey: "no:supergroup", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToChatId = 0; + + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => { + sentToChatId = cid; + return { message_id: 20, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sentToChatId).toBe(123456); // First allowedUserId + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("returns chatId only (no threadId)", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-nothread", + topicKey: "dm:only", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToThreadId: number | undefined = 999; + + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 21, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sentToThreadId).toBeUndefined(); + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("doesn't create mapping for fallback DM", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-nomap", + topicKey: "no:mapping:stored", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 22, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG, loader); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader.flush(); + await new Promise((r) => setTimeout(r, 100)); + + const mapping = loader.get(TopicChannelMapping, "no:mapping:stored"); + expect(mapping).toBeNull(); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + await loader.flush(); + await new Promise((r) => setTimeout(r, 150)); + db.close(); + } + }); + + test("logs warning when falling back to DM", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-log", + topicKey: "log:warning", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sendMessageCalled = false; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => { + sendMessageCalled = true; + return { message_id: 23, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sendMessageCalled).toBe(true); + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); +}); + +// --- Invalid inputs tests - 4 tests --- + +describe("parseTelegramTopicKey - Invalid inputs", () => { + test("empty string returns null", () => { + expect(parseTelegramTopicKey("")).toBeNull(); + }); + + test("whitespace returns null", () => { + expect(parseTelegramTopicKey(" ")).toBeNull(); + }); + + test("non-numeric string returns null", () => { + expect(parseTelegramTopicKey("abc")).toBeNull(); + }); + + test("mixed numeric/alpha returns null", () => { + expect(parseTelegramTopicKey("123abc")).toBeNull(); + expect(parseTelegramTopicKey("abc123")).toBeNull(); + }); +}); + +// --- API failures tests - 6 tests --- + +describe("Topic resolution - API failures", () => { + const configWithSupergroup: TelegramChannelConfig = { + ...DEFAULT_CONFIG, + supergroupId: -100111222333, + }; + + test("createForumTopic failure throws", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-fail", + topicKey: "api:failure", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 400, + "Bad Request", + ); + }, + ); + let sendMessageCalled = false; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => { + sendMessageCalled = true; + return { message_id: 30, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(sendMessageCalled).toBe(false); + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("propagates error message from API", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-prop", + topicKey: "error:propagate", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 400, + "chat not found", + ); + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 31, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("no partial mapping stored on API failure", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-partial", + topicKey: "no:partial", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 403, + "Forbidden", + ); + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 32, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, configWithSupergroup, loader); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader.flush(); + await new Promise((r) => setTimeout(r, 100)); + + const mapping = loader.get(TopicChannelMapping, "no:partial"); + expect(mapping).toBeNull(); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + await loader.flush(); + await new Promise((r) => setTimeout(r, 150)); + db.close(); + } + }); + + test("handles 400 error", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-400", + topicKey: "error:400", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 400, + "Bad Request", + ); + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 33, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("handles 403 error", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-403", + topicKey: "error:403", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 403, + "Forbidden", + ); + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 34, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("handles timeout error", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-timeout", + topicKey: "error:timeout", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 0, + "Request timed out", + ); + }, + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 35, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); +}); + +// --- Race condition tests - 3 tests --- + +describe("Topic resolution - Race condition", () => { + const configWithSupergroup: TelegramChannelConfig = { + ...DEFAULT_CONFIG, + supergroupId: -100111222333, + }; + + test("concurrent calls use existing mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + // Pre-create the winning mapping + loader.create(TopicChannelMapping, { + topicKey: "race:condition", + chatId: -100111222333, + threadId: 1111, + }); + cortex.pendingMessages.push({ + messageId: "msg-race", + topicKey: "race:condition", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let createForumTopicCalled = false; + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + createForumTopicCalled = true; + return { message_thread_id: 2222 }; + }, + ); + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 40, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(createForumTopicCalled).toBe(false); + expect(sentToThreadId).toBe(1111); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("orphan thread deleted via deleteForumTopic on UNIQUE constraint error", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-orphan", + topicKey: "orphan:delete", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let createCalls = 0; + let deleteCalls = 0; + let deletedThreadId = 0; + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + createCalls++; + return { message_thread_id: 3333 }; + }, + ); + const deleteSpy = spyOn(telegramApi, "deleteForumTopic").mockImplementation( + async (_token, _chatId, threadId) => { + deleteCalls++; + deletedThreadId = threadId; + return true; + }, + ); + + // Mock create to simulate UNIQUE constraint failure + let mappingCreated = false; + const originalCreate = loader.create.bind(loader); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (loader as any).create = function ( + EntityClass: new () => unknown, + data: Record, + ): unknown { + if (!mappingCreated && data.topicKey === "orphan:delete") { + mappingCreated = true; + originalCreate(TopicChannelMapping, { + topicKey: "orphan:delete", + chatId: -100111222333, + threadId: 4444, + }); + throw new Error( + "UNIQUE constraint failed: topic_channel_mappings.topicKey", + ); + } + return originalCreate(EntityClass as new () => TopicChannelMapping, data); + }; + + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 41, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(createCalls).toBe(1); + expect(deleteCalls).toBe(1); + expect(deletedThreadId).toBe(3333); + expect(sentToThreadId).toBe(4444); + }, + ); + } finally { + createSpy.mockRestore(); + deleteSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("winner's mapping returned after race resolution", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "winner:mapping", + chatId: -100111222333, + threadId: 7777, + }); + cortex.pendingMessages.push({ + messageId: "msg-winner", + topicKey: "winner:mapping", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 42, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(sentToThreadId).toBe(7777); + }, + ); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); +}); + +// --- Persistence tests - 3 tests --- + +describe("Topic resolution - Persistence", () => { + const configWithSupergroup: TelegramChannelConfig = { + ...DEFAULT_CONFIG, + supergroupId: -100111222333, + }; + + test("mapping survives channel restart", async () => { + const db = new Database(":memory:"); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => ({ message_thread_id: 8888 }), + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 50, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + let loader1: StateLoader | null = null; + let loader2: StateLoader | null = null; + + try { + // First channel creates mapping + loader1 = new StateLoader(db); + const cortex1 = makeMockCortex(); + cortex1.pendingMessages.push({ + messageId: "msg-p1", + topicKey: "persist:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const channel1 = new TelegramChannel( + cortex1, + configWithSupergroup, + loader1, + ); + await channel1.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel1.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader1.flush(); + await new Promise((r) => setTimeout(r, 100)); + + const mapping1 = loader1.get(TopicChannelMapping, "persist:test"); + expect(mapping1?.threadId).toBe(8888); + + // Second channel uses existing mapping + createSpy.mockClear(); + loader2 = new StateLoader(db); + const cortex2 = makeMockCortex(); + cortex2.pendingMessages.push({ + messageId: "msg-p2", + topicKey: "persist:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToThreadId: number | undefined; + sendSpy.mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToThreadId = opts?.threadId; + return { message_id: 51, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + + const channel2 = new TelegramChannel( + cortex2, + configWithSupergroup, + loader2, + ); + await channel2.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel2.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader2.flush(); + await new Promise((r) => setTimeout(r, 100)); + + expect(createSpy).not.toHaveBeenCalled(); + expect(sentToThreadId).toBe(8888); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + if (loader1) await loader1.flush(); + if (loader2) await loader2.flush(); + await new Promise((r) => setTimeout(r, 150)); + db.close(); + } + }); + + test("stateLoader retrieval works", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + + loader.create(TopicChannelMapping, { + topicKey: "direct:create", + chatId: -100999888777, + threadId: 555, + }); + await loader.flush(); + + const mapping = loader.get(TopicChannelMapping, "direct:create"); + + expect(mapping).not.toBeNull(); + expect(mapping?.topicKey).toBe("direct:create"); + expect(mapping?.chatId).toBe(-100999888777); + expect(mapping?.threadId).toBe(555); + + await loader.flush(); + db.close(); + }); + + test("created_at timestamp set on new mapping", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-ts", + topicKey: "timestamp:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => ({ message_thread_id: 6666 }), + ); + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 52, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, configWithSupergroup, loader); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + await new Promise((r) => setTimeout(r, 100)); + await loader.flush(); + await new Promise((r) => setTimeout(r, 100)); + + const mapping = loader.get(TopicChannelMapping, "timestamp:test"); + expect(mapping).not.toBeNull(); + expect(mapping?.created_at).toBeDefined(); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + await loader.flush(); + await new Promise((r) => setTimeout(r, 150)); + db.close(); + } + }); +}); + +// --- deliverMessage integration tests - 4 tests --- + +describe("deliverMessage integration", () => { + test("uses resolveTopicKey for delivery", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "delivery:test", + chatId: -100444555666, + threadId: 101, + }); + cortex.pendingMessages.push({ + messageId: "msg-del", + topicKey: "delivery:test", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToChatId = 0; + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToChatId = cid; + sentToThreadId = opts?.threadId; + return { message_id: 60, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sentToChatId).toBe(-100444555666); + expect(sentToThreadId).toBe(101); + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("acks on successful delivery", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + cortex.pendingMessages.push({ + messageId: "msg-ack", + topicKey: "123456", + text: "Test", + leaseToken: "lease-ack", + payload: null, + }); + + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => ({ + message_id: 61, + date: Date.now() / 1000, + chat: { id: cid }, + }), + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(cortex.ackCalls.length).toBe(1); + expect(cortex.ackCalls[0].messageId).toBe("msg-ack"); + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("acks on resolution failure (to skip bad message)", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + const configWithSupergroup: TelegramChannelConfig = { + ...DEFAULT_CONFIG, + supergroupId: -100111222333, + }; + + cortex.pendingMessages.push({ + messageId: "msg-fail-ack", + topicKey: "resolution:failure", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + const createSpy = spyOn(telegramApi, "createForumTopic").mockImplementation( + async () => { + throw new telegramApi.TelegramApiError( + "createForumTopic", + 400, + "Bad Request", + ); + }, + ); + let sendMessageCalled = false; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid): Promise => { + sendMessageCalled = true; + return { message_id: 62, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest( + configWithSupergroup, + cortex, + loader, + db, + async () => { + expect(sendMessageCalled).toBe(false); + expect(cortex.ackCalls.length).toBe(1); + }, + ); + } finally { + createSpy.mockRestore(); + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); + + test("logs resolved target", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + loader.create(TopicChannelMapping, { + topicKey: "log:target", + chatId: -100777888999, + threadId: 202, + }); + cortex.pendingMessages.push({ + messageId: "msg-log-target", + topicKey: "log:target", + text: "Test", + leaseToken: "lease", + payload: null, + }); + + let sentToChatId = 0; + let sentToThreadId: number | undefined; + const sendSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async (_t, cid, _txt, opts): Promise => { + sentToChatId = cid; + sentToThreadId = opts?.threadId; + return { message_id: 63, date: Date.now() / 1000, chat: { id: cid } }; + }, + ); + const getSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + try { + await runChannelTest(DEFAULT_CONFIG, cortex, loader, db, async () => { + expect(sentToChatId).toBe(-100777888999); + expect(sentToThreadId).toBe(202); + expect(cortex.ackCalls.length).toBe(1); + }); + } finally { + sendSpy.mockRestore(); + getSpy.mockRestore(); + } + }); +});