Merge pull request #14 from Salastil/development

Telegram Adapter
This commit is contained in:
Salastil
2026-07-23 18:41:54 -04:00
committed by GitHub
29 changed files with 1914 additions and 50 deletions
+525 -1
View File
@@ -14,7 +14,8 @@
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
"jsdom": "^29.1.1",
"rss-parser": "^3.13.0"
"rss-parser": "^3.13.0",
"telegram": "^2.26.22"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
@@ -82,6 +83,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@cryptography/aes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@cryptography/aes/-/aes-0.1.1.tgz",
"integrity": "sha512-PcYz4FDGblO6tM2kSC+VzhhK62vml6k6/YAkiWtyPvrgJVfnDRoHGDtKn5UiaRRUrvUTTocBpvc2rRgTCqxjsg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
@@ -1257,6 +1264,15 @@
}
}
},
"node_modules/async-mutex": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
"integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.1"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1286,6 +1302,26 @@
"fastq": "^1.17.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1295,6 +1331,52 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
@@ -1321,6 +1403,19 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -1334,6 +1429,15 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -1349,6 +1453,61 @@
"node": ">=6"
}
},
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.0",
"entities": "^2.0.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.2.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
@@ -1358,6 +1517,46 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -1400,6 +1599,40 @@
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -1564,6 +1797,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1576,6 +1815,63 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
"integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0",
"domutils": "^2.5.2",
"entities": "^2.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -1591,6 +1887,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@@ -1721,6 +2023,53 @@
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-localstorage": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz",
"integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==",
"license": "MIT",
"dependencies": {
"write-file-atomic": "^1.1.4"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -1730,6 +2079,22 @@
"node": ">=14.0.0"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -1754,6 +2119,12 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1822,6 +2193,12 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-cancellable-promise": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/real-cancellable-promise/-/real-cancellable-promise-1.2.3.tgz",
"integrity": "sha512-hBI5Gy/55VEeeMtImMgEirD7eq5UmqJf1J8dFZtbJZA/3rB0pYFZ7PayMGueb6v4UtUtpKpP+05L0VwyE1hI9Q==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -1961,6 +2338,39 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/slide": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz",
"integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==",
"license": "ISC",
"engines": {
"node": "*"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -1988,12 +2398,45 @@
"node": ">= 10.x"
}
},
"node_modules/store2": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz",
"integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==",
"license": "MIT"
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/telegram": {
"version": "2.26.22",
"resolved": "https://registry.npmjs.org/telegram/-/telegram-2.26.22.tgz",
"integrity": "sha512-EIj7Yrjiu0Yosa3FZ/7EyPg9s6UiTi/zDQrFmR/2Mg7pIUU+XjAit1n1u9OU9h2oRnRM5M+67/fxzQluZpaJJg==",
"deprecated": "This package is archived and no longer maintained. Development continues in teleproto (https://npmjs.com/package/teleproto), a largely compatible, actively maintained fork. See the migration guide at https://docs.teleproto.dev/migrating-from-gramjs",
"license": "MIT",
"dependencies": {
"@cryptography/aes": "^0.1.1",
"async-mutex": "^0.3.0",
"big-integer": "^1.6.48",
"buffer": "^6.0.3",
"htmlparser2": "^6.1.0",
"mime": "^3.0.0",
"node-localstorage": "^2.2.1",
"pako": "^2.0.3",
"path-browserify": "^1.0.1",
"real-cancellable-promise": "^1.1.1",
"socks": "^2.6.2",
"store2": "^2.13.0",
"ts-custom-error": "^3.2.0",
"websocket": "^1.0.34"
},
"optionalDependencies": {
"bufferutil": "^4.0.3",
"utf-8-validate": "^5.0.5"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@@ -2063,6 +2506,21 @@
"node": ">=20"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
@@ -2082,6 +2540,21 @@
"fsevents": "~2.3.3"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
"license": "MIT",
"dependencies": {
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -2133,6 +2606,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -2154,6 +2640,23 @@
"node": ">=20"
}
},
"node_modules/websocket": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
"integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
"license": "Apache-2.0",
"dependencies": {
"bufferutil": "^4.0.1",
"debug": "^2.2.0",
"es5-ext": "^0.10.63",
"typedarray-to-buffer": "^3.1.5",
"utf-8-validate": "^5.0.2",
"yaeti": "^0.0.6"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
@@ -2177,6 +2680,17 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/write-file-atomic": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz",
"integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"slide": "^1.1.5"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -2213,6 +2727,16 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"engines": {
"node": ">=0.10.32"
}
}
}
}
+2 -1
View File
@@ -17,7 +17,8 @@
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
"jsdom": "^29.1.1",
"rss-parser": "^3.13.0"
"rss-parser": "^3.13.0",
"telegram": "^2.26.22"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
+46
View File
@@ -7,6 +7,7 @@ import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMed
import { OllamaProvider } from '../inference/ollama-provider.js';
import { pollSourceNow } from '../ingestion/poller.js';
import { logger, listLogs } from '../storage/db/logs.js';
import * as telegramClient from '../telegram/client.js';
export async function registerAdminRoutes(app: FastifyInstance) {
// --- Settings ---
@@ -148,6 +149,51 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null };
});
// --- Telegram account (Connections tab — see telegram/client.ts and credentials.ts.
// API ID/hash and the resulting login session are stored encrypted at rest; none of
// these routes ever echo them back to the client.) ---
app.get('/api/admin/telegram/status', async () => telegramClient.getStatus());
app.post('/api/admin/telegram/credentials', async (req, reply) => {
const { apiId, apiHash } = req.body as { apiId?: number; apiHash?: string };
if (!apiId || !apiHash) return reply.code(400).send({ error: 'apiId and apiHash are required' });
telegramClient.saveApiCredentials(apiId, apiHash);
return { credentialsConfigured: true };
});
app.post('/api/admin/telegram/login/start', async (req, reply) => {
const { phoneNumber } = req.body as { phoneNumber?: string };
if (!phoneNumber) return reply.code(400).send({ error: 'phoneNumber is required' });
try {
await telegramClient.startLogin(phoneNumber);
return { phase: 'code-sent' };
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
});
app.post('/api/admin/telegram/login/verify', async (req, reply) => {
const { code, password } = req.body as { code?: string; password?: string };
try {
if (password !== undefined) {
await telegramClient.verifyPassword(password);
return { phase: 'connected', ...telegramClient.getStatus() };
}
if (code !== undefined) {
const phase = await telegramClient.verifyCode(code);
return phase === 'connected' ? { phase, ...telegramClient.getStatus() } : { phase };
}
return reply.code(400).send({ error: 'code or password is required' });
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
});
app.post('/api/admin/telegram/logout', async (_req, reply) => {
await telegramClient.logout();
return reply.code(200).send(telegramClient.getStatus());
});
// --- Logs ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
+81
View File
@@ -0,0 +1,81 @@
// Backs the "proxy" Telegram media mode (see Retention tab / GlobalSettings.telegramMediaMode).
// Unlike media/proxy.ts (which forwards a plain HTTP request to an already-public CDN
// URL), Telegram media has no public URL at all — every request here re-authenticates
// to Telegram via the logged-in account (telegram/client.ts) and streams the result, so
// nothing is written to disk and only this server ever touches Telegram's servers. A
// small time-boxed in-memory cache absorbs repeat views of the same message/avatar
// without hitting Telegram (and its rate limits) on every single page load.
import type { FastifyInstance } from 'fastify';
import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js';
import { logger } from '../storage/db/logs.js';
const CACHE_TTL_MS = 10 * 60_000;
const CACHE_MAX_ENTRIES = 200;
interface CacheEntry {
buffer: Buffer;
contentType: string;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
function cacheGet(key: string): CacheEntry | null {
const entry = cache.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
cache.delete(key);
return null;
}
return entry;
}
function cacheSet(key: string, entry: CacheEntry) {
if (cache.size >= CACHE_MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
}
function contentTypeFor(type: string | undefined): string {
if (type === 'video' || type === 'gif') return 'video/mp4';
return 'image/jpeg';
}
export async function registerTelegramMediaProxy(app: FastifyInstance) {
app.get('/media/telegram-proxy', async (req, reply) => {
const { channel, message, avatar, type } = req.query as {
channel?: string;
message?: string;
avatar?: string;
type?: string;
};
if (!channel) return reply.code(400).send({ error: 'channel required' });
if (!avatar && !message) return reply.code(400).send({ error: 'message or avatar required' });
const cacheKey = avatar ? `avatar:${channel}` : `message:${channel}:${message}`;
const cached = cacheGet(cacheKey);
if (cached) {
reply.header('content-type', cached.contentType);
reply.header('cache-control', 'private, max-age=300');
return reply.send(cached.buffer);
}
try {
const buffer = avatar ? await downloadChannelAvatar(channel) : await downloadMessageMedia(channel, message!);
if (!buffer) return reply.code(404).send();
const contentType = contentTypeFor(type);
cacheSet(cacheKey, { buffer, contentType, expiresAt: Date.now() + CACHE_TTL_MS });
reply.header('content-type', contentType);
reply.header('cache-control', 'private, max-age=300');
return reply.send(buffer);
} catch (err) {
logger.error('telegram', `Proxy fetch failed for channel=${channel} message=${message ?? 'avatar'}: ${(err as Error).message}`);
return reply.code(502).send();
}
});
}
+8 -3
View File
@@ -9,8 +9,10 @@ import { registerAuth } from './api/auth.js';
import { registerPublicRoutes } from './api/public.js';
import { registerAdminRoutes } from './api/admin.js';
import { registerMediaProxy } from './api/mediaProxy.js';
import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js';
import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js';
import { startScheduler } from './queue/scheduler.js';
import { initFromSavedSession } from './telegram/client.js';
import { logger } from './storage/db/logs.js';
const PORT = Number(process.env.PORT) || 4000;
@@ -32,6 +34,7 @@ function printApiKeyBanner() {
async function main() {
migrate();
printApiKeyBanner();
await initFromSavedSession();
const app = Fastify({ logger: false });
@@ -89,10 +92,12 @@ async function main() {
return reply.send(fs.createReadStream(filePath));
});
// Static "/media/proxy" takes priority over the "/media/:filename" param route
// above regardless of registration order (find-my-way, Fastify's router, always
// prefers a static segment over a parametric one at the same depth).
// Static "/media/proxy" and "/media/telegram-proxy" take priority over the
// "/media/:filename" param route above regardless of registration order
// (find-my-way, Fastify's router, always prefers a static segment over a parametric
// one at the same depth).
await registerMediaProxy(app);
await registerTelegramMediaProxy(app);
app.get('/health', async () => ({ ok: true }));
+17 -1
View File
@@ -1,4 +1,10 @@
import type { Source, ContentItem, TweetMediaItem, QuotedTweet } from '../../storage/db/types.js';
import type {
Source,
ContentItem,
TweetMediaItem,
QuotedTweet,
TelegramMediaRef
} from '../../storage/db/types.js';
import { cleanHtml, toSummary } from '../clean.js';
export interface FetchedItem {
@@ -19,6 +25,15 @@ export interface FetchedItem {
repostedByHandle: string | null;
quotedTweet: QuotedTweet | null;
};
/** Set by the Telegram adapter only — carries the channel/message info through to ContentItem.telegramMessage. Media is unresolved refs; publish.ts resolves them per the admin's configured telegramMediaMode. */
telegramMessage?: {
channelName: string;
channelUsername: string | null;
sourceChannelUsername: string;
messageId: string;
media: TelegramMediaRef[];
repostedByHandle: string | null;
};
raw: unknown;
}
@@ -51,6 +66,7 @@ export function toContentItem(source: Source, item: FetchedItem): Omit<ContentIt
eventId: null,
clusterId: null,
tweet: item.tweet ? { ...item.tweet } : null,
telegramMessage: item.telegramMessage ? { ...item.telegramMessage } : null,
raw: item.raw
};
}
+189 -8
View File
@@ -1,17 +1,198 @@
import type { Source } from '../../storage/db/types.js';
// Telegram is its own ingestion module: unlike every other adapter (rss/nitter/
// youtube — all stateless per-call HTTP fetches), this one rides a long-lived
// authenticated MTProto connection (see telegram/client.ts) owned outside this file.
// Each message (or grouped album) becomes its own article via publishDirect — see
// pipeline/publish.ts and queue/priorityQueue.ts, which route telegram-sourced items
// straight there rather than through the LLM clustering/synthesis pipeline, same
// reasoning as YouTube/Nitter: merging two unrelated channel posts into one
// AI-rewritten story wouldn't make sense the way merging two outlets' coverage of the
// same event does.
//
// Media is deliberately NOT downloaded here — Telegram has no public hotlinkable media
// URL, so unlike every field pulled from the message itself, media can only be resolved
// by re-authenticating to Telegram, which the admin's configured telegramMediaMode
// (self-host vs. proxy) governs. This adapter only records *references* (message id,
// kind, mime type, dimensions) — see pipeline/publish.ts's resolveTelegramMedia for
// where those refs turn into an actual servable url.
import type { Api } from 'telegram';
import type { Source, TelegramMediaRef } from '../../storage/db/types.js';
import type { SourceAdapter, FetchedItem } from './base.js';
import { logger } from '../../storage/db/logs.js';
import { getClient, fetchChannelMessages } from '../../telegram/client.js';
/** Mirrors MAX_TWEET_MEDIA in nitter.ts — the frontend media grid only defines 1/2/3/4-item layouts. */
const MAX_TELEGRAM_MEDIA = 4;
const FETCH_LIMIT = 50;
type TgMessage = Api.Message;
/** Strips whatever shape the admin typed (@handle, t.me link, bare username) down to a bare username. */
function normalizeChannelIdentifier(raw: string): string {
return raw
.trim()
.replace(/^https?:\/\/(t\.me|telegram\.me)\//i, '')
.replace(/^@/, '')
.replace(/\/s\/?$/, '')
.replace(/\/+$/, '');
}
function videoDimensions(document: { attributes?: { className?: string; w?: number; h?: number }[] } | undefined) {
const attr = document?.attributes?.find((a) => a.className === 'DocumentAttributeVideo');
return { width: attr?.w ?? null, height: attr?.h ?? null };
}
function photoDimensions(photo: { sizes?: { w?: number; h?: number }[] } | undefined) {
const largest = photo?.sizes?.reduce<{ w?: number; h?: number } | undefined>(
(best, size) => (!best || (size.w ?? 0) > (best.w ?? 0) ? size : best),
undefined
);
return { width: largest?.w ?? null, height: largest?.h ?? null };
}
/** A reference to a single message's attached media, if any — no download, just what's needed to resolve it later. */
function refForMessage(message: TgMessage): TelegramMediaRef | null {
if (message.video) {
const doc = message.video as unknown as { mimeType?: string; attributes?: { className?: string; w?: number; h?: number }[] };
const { width, height } = videoDimensions(doc);
return { type: 'video', messageId: String(message.id), mimeType: doc.mimeType ?? null, width, height };
}
if (message.gif) {
const doc = message.gif as unknown as { mimeType?: string; attributes?: { className?: string; w?: number; h?: number }[] };
const { width, height } = videoDimensions(doc);
return { type: 'gif', messageId: String(message.id), mimeType: doc.mimeType ?? null, width, height };
}
if (message.photo) {
const { width, height } = photoDimensions(message.photo as unknown as { sizes?: { w?: number; h?: number }[] });
return { type: 'photo', messageId: String(message.id), mimeType: null, width, height };
}
return null;
}
interface ForwardOrigin {
name: string;
/** Null when the origin has no public handle (e.g. a private channel/user, or a sender who hid their identity) — the card then falls back to showing just the name, with no avatar. */
username: string | null;
}
/**
* TODO: real implementation. Telegram channels are read via the Bot API (grammY,
* per project-structure.md) using `getUpdates` or a channel-history fetch, keyed by
* `source.config.telegramChannelId`. Left as a stub — satisfies the SourceAdapter
* interface so the poller and admin panel can already list/configure Telegram sources
* (e.g. for the "Iran war" tracked event example) before this is filled in.
* Detects a forwarded message and resolves where it came from. GramJS's `message.forward`
* wraps the raw fwdFrom header using entities Telegram already sent alongside the same
* getMessages response (the whole point of that header is letting clients show forward
* attribution without a separate resolve call) — `.chat` is the origin channel/group,
* `.sender` the origin user. Falls back to fwdFrom.fromName for the rarer case where the
* origin has no resolvable identity (e.g. a user who hid their account from forwards).
*/
function detectForward(message: TgMessage): ForwardOrigin | null {
const fwd = message.fwdFrom;
if (!fwd) return null;
const chat = message.forward?.chat as { title?: string; username?: string } | undefined;
if (chat) return { name: chat.title ?? chat.username ?? 'Unknown', username: chat.username ?? null };
const sender = message.forward?.sender as { firstName?: string; lastName?: string; username?: string } | undefined;
if (sender) {
const name = [sender.firstName, sender.lastName].filter(Boolean).join(' ') || sender.username || 'Unknown';
return { name, username: sender.username ?? null };
}
return { name: fwd.fromName ?? 'Unknown', username: null };
}
/** Groups consecutive messages sharing a non-null groupedId (Telegram's multi-photo/video "album" concept) into one entry each. */
function groupMessages(messages: TgMessage[]): TgMessage[][] {
const order: string[] = [];
const groups = new Map<string, TgMessage[]>();
for (const message of messages) {
const key = message.groupedId ? message.groupedId.toString() : `single-${message.id}`;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(message);
}
return order.map((key) => groups.get(key)!);
}
export const telegramAdapter: SourceAdapter = {
async fetch(source: Source): Promise<FetchedItem[]> {
logger.warn('telegram', `Adapter not yet implemented — skipping source "${source.name}"`);
return [];
const client = getClient();
if (!client) {
logger.warn('telegram', `No connected Telegram account — skipping source "${source.name}"`);
return [];
}
if (!source.url) return [];
const identifier = normalizeChannelIdentifier(source.url);
let entity: any;
let messages: TgMessage[];
try {
const result = await fetchChannelMessages(identifier, FETCH_LIMIT);
entity = result.entity;
messages = result.messages as unknown as TgMessage[];
} catch (err) {
logger.error('telegram', `Failed to resolve/fetch channel "${identifier}" for source "${source.name}": ${(err as Error).message}`);
return [];
}
// Only channels with a public username get a permalink (https://t.me/<username>/<id>)
// that means anything to a non-member — a private channel's t.me/c/<internal_id>/...
// link would make "click to open on Telegram" mostly useless, so v1 restricts to
// public channels and fails soft otherwise (same style as youtube.ts's resolveFeedUrl).
// This is always the channel we polled — used for the permalink and, on a forward,
// as the "Forwarded by @X" attribution — never the identity actually displayed.
const pollingChannelUsername: string | undefined = entity?.username;
if (!pollingChannelUsername) {
logger.warn('telegram', `"${source.name}" has no public username — private channels aren't supported yet, skipping`);
return [];
}
const pollingChannelName: string = entity?.title ?? pollingChannelUsername;
const items: FetchedItem[] = [];
for (const group of groupMessages(messages)) {
const primary = group.find((m) => m.message) ?? group[0];
const text = primary.message ?? '';
const firstLine = text.split('\n')[0].trim();
const media: TelegramMediaRef[] = [];
for (const message of group) {
if (media.length >= MAX_TELEGRAM_MEDIA) break;
const ref = refForMessage(message);
if (ref) media.push(ref);
}
if (!text && media.length === 0) continue; // nothing worth publishing (e.g. a service message)
// A forward displays as if it were authored by the ORIGIN channel/user — same
// treatment as a retweet, where tweet.authorName is always the original tweet's
// author, never the retweeter — with a "Forwarded by @<polled channel>" line
// (repostedByHandle) taking the place of the retweeter's own attribution.
const origin = detectForward(primary);
const channelName = origin?.name ?? pollingChannelName;
const channelUsername = origin ? origin.username : pollingChannelUsername;
const repostedByHandle = origin ? pollingChannelUsername : null;
items.push({
title: firstLine || `Message from ${pollingChannelName}`,
summary: text.slice(0, 500),
body: text || null,
images: [],
videos: [],
link: `https://t.me/${pollingChannelUsername}/${group[0].id}`,
publishedAt: new Date(primary.date * 1000).toISOString(),
telegramMessage: {
channelName,
channelUsername,
sourceChannelUsername: pollingChannelUsername,
messageId: String(group[0].id),
media,
repostedByHandle
},
raw: { messageIds: group.map((m) => m.id) }
});
}
return items;
}
};
+118 -4
View File
@@ -3,12 +3,21 @@ import type { InferenceProvider } from '../inference/provider.js';
import type { Cluster } from './clustering.js';
import { synthesizeArticle } from './synthesis.js';
import { selectBestImage, faviconUrlFor } from './image-selection.js';
import { downloadAndStore, promoteToPublished } from '../storage/media/index.js';
import { downloadAndStore, promoteToPublished, storeMediaBuffer } from '../storage/media/index.js';
import { downloadMessageMedia, downloadChannelAvatar } from '../telegram/client.js';
import { logger } from '../storage/db/logs.js';
import * as articles from '../storage/db/articles.js';
import * as tags from '../storage/db/tags.js';
import * as sources from '../storage/db/sources.js';
import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem, QuotedTweet } from '../storage/db/types.js';
import type {
GlobalSettings,
MergedArticle,
ContentItem,
TweetMediaItem,
QuotedTweet,
TelegramMediaItem,
TelegramMediaRef
} from '../storage/db/types.js';
const FOLLOW_UP_LOOKBACK_DAYS = 3;
@@ -129,6 +138,81 @@ async function resolveTweetMedia(
return { media: resolved, storedMediaIds };
}
function guessTelegramExtension(mimeType: string | null, kind: 'photo' | 'video' | 'gif'): string {
if (mimeType?.includes('png')) return '.png';
if (mimeType?.includes('webp')) return '.webp';
if (mimeType?.includes('jpeg') || mimeType?.includes('jpg')) return '.jpg';
if (mimeType?.includes('mp4')) return '.mp4';
return kind === 'photo' ? '.jpg' : '.mp4';
}
/**
* Resolves a single Telegram media reference into a servable url per the admin's
* chosen telegramMediaMode (Retention tab). Unlike Nitter — which starts from an
* already-public CDN URL — Telegram media only exists behind the authenticated MTProto
* session, so there's no 'direct' hotlink option: 'self-host' downloads it now (via the
* live session) and stores it locally exactly like every other self-hosted image;
* 'proxy' doesn't touch Telegram at all here, it just builds a url the live
* telegram-proxy route resolves (via that same session) on each view.
*/
async function resolveTelegramMediaUrl(
channelUsername: string,
ref: TelegramMediaRef,
mode: GlobalSettings['telegramMediaMode']
): Promise<{ item: TelegramMediaItem; storedMediaId: string | null } | null> {
if (mode === 'proxy') {
const url = `/media/telegram-proxy?channel=${encodeURIComponent(channelUsername)}&message=${encodeURIComponent(ref.messageId)}&type=${ref.type}`;
return { item: { type: ref.type, url, thumbnailUrl: null, width: ref.width, height: ref.height }, storedMediaId: null };
}
try {
const buffer = await downloadMessageMedia(channelUsername, ref.messageId);
if (!buffer) return null;
const ext = guessTelegramExtension(ref.mimeType, ref.type);
const stored = storeMediaBuffer(buffer, ext, `telegram-message:${channelUsername}:${ref.messageId}`, 'published', {});
return { item: { type: ref.type, url: stored.servedPath, thumbnailUrl: null, width: ref.width, height: ref.height }, storedMediaId: stored.id };
} catch (err) {
logger.error('telegram', `Failed to self-host media for message ${ref.messageId}: ${(err as Error).message}`);
return null;
}
}
/** Resolves every attached photo/video/gif ref for one message — order preserved, failed items dropped rather than leaving a broken entry. */
async function resolveTelegramMedia(
channelUsername: string,
refs: TelegramMediaRef[],
mode: GlobalSettings['telegramMediaMode']
): Promise<{ media: TelegramMediaItem[]; storedMediaIds: string[] }> {
const media: TelegramMediaItem[] = [];
const storedMediaIds: string[] = [];
for (const ref of refs) {
const resolved = await resolveTelegramMediaUrl(channelUsername, ref, mode);
if (resolved) {
media.push(resolved.item);
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
}
}
return { media, storedMediaIds };
}
async function resolveTelegramAvatarUrl(
channelUsername: string,
mode: GlobalSettings['telegramMediaMode']
): Promise<{ url: string | null; storedMediaId: string | null }> {
if (mode === 'proxy') {
return { url: `/media/telegram-proxy?channel=${encodeURIComponent(channelUsername)}&avatar=1`, storedMediaId: null };
}
try {
const buffer = await downloadChannelAvatar(channelUsername);
if (!buffer) return { url: null, storedMediaId: null };
const stored = storeMediaBuffer(buffer, '.jpg', `telegram-avatar:${channelUsername}`, 'published', {});
return { url: stored.servedPath, storedMediaId: stored.id };
} catch (err) {
logger.error('telegram', `Failed to self-host avatar for "${channelUsername}": ${(err as Error).message}`);
return { url: null, storedMediaId: null };
}
}
/** Resolves a quote-tweet's embedded image (if any) through the Nitter media mode, same as any other tweet media. */
async function resolveQuotedTweet(
quoted: QuotedTweet,
@@ -153,9 +237,10 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
const category = uniqueCategories([item]);
const storedMediaIds: string[] = [];
// Tweets never get a "hero image" — TweetCard.svelte renders tweet.media directly.
// Tweets and Telegram messages never get a "hero image" — their own card components
// render tweet.media / telegramMessage.media directly.
let heroImage: MergedArticle['heroImage'] = null;
if (!item.tweet) {
if (!item.tweet && !item.telegramMessage) {
const resolved = await resolveHeroImage([item], item.link);
heroImage = resolved.heroImage;
if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId);
@@ -194,12 +279,40 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
};
}
let telegramMessage: MergedArticle['telegramMessage'] = null;
if (item.telegramMessage) {
const { channelUsername, sourceChannelUsername } = item.telegramMessage;
// The avatar shown is whoever is DISPLAYED (the origin channel on a forward) — if
// that has no public handle, there's no avatar to fetch at all, regardless of mode.
const avatar = channelUsername
? await resolveTelegramAvatarUrl(channelUsername, settings.telegramMediaMode)
: { url: null, storedMediaId: null };
if (avatar.storedMediaId) storedMediaIds.push(avatar.storedMediaId);
// Attached media always lives on the polled channel's own copy of the message
// (forward or not), so media resolution uses sourceChannelUsername, never the
// (possibly different, possibly null) displayed channelUsername.
const resolvedMedia = await resolveTelegramMedia(sourceChannelUsername, item.telegramMessage.media, settings.telegramMediaMode);
storedMediaIds.push(...resolvedMedia.storedMediaIds);
telegramMessage = {
channelName: item.telegramMessage.channelName,
channelUsername,
channelAvatarUrl: avatar.url,
sourceItemId: item.id,
media: resolvedMedia.media,
repostedByHandle: item.telegramMessage.repostedByHandle
};
}
const article = await articles.insertArticle({
title: item.title,
body: item.body || item.summary,
heroImage,
video,
tweet,
telegramMessage,
category,
geo: item.geo,
eventId: item.eventId,
@@ -302,6 +415,7 @@ export async function publishCluster(
heroImage,
video,
tweet: null, // tweets never reach clustering — see priorityQueue.ts's direct-publish bypass
telegramMessage: null, // telegram messages never reach clustering either — same bypass
category,
geo,
eventId: opts.eventId ?? items[0]?.eventId ?? null,
+7 -4
View File
@@ -77,11 +77,14 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds);
if (items.length === 0) return 0;
// YouTube videos and Nitter tweets never get LLM-merged with anything else — each
// is always its own article, same shape whether the AI service is up or not. Route
// them straight to publishDirect, same as the no-AI passthrough path.
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
// anything else — each is always its own article, same shape whether the AI service
// is up or not. Route them straight to publishDirect, same as the no-AI passthrough path.
const directPublishSourceIds = new Set(
sourcesDb.listSources().filter((s) => s.type === 'youtube' || s.type === 'nitter').map((s) => s.id)
sourcesDb
.listSources()
.filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram')
.map((s) => s.id)
);
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
+54
View File
@@ -0,0 +1,54 @@
// Generic AES-256-GCM encrypt/decrypt for at-rest secrets (currently just Telegram
// credentials — see telegram/credentials.ts — but deliberately not Telegram-specific
// here, so any future sensitive field can reuse it). The key is generated once into a
// file next to the DB but not inside it, so a naive copy of just the .db file reveals
// nothing — this is "not easily retrieved," not "safe against a fully compromised
// host": anyone with full filesystem access has both files anyway, same trust model as
// every other secret in this app. The key file must be included in backups, or
// previously-saved Telegram credentials become undecryptable (fixed by logging in
// again — no other data loss).
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DB_PATH = process.env.DB_PATH || './data/homefeed.db';
const KEY_PATH = path.join(path.dirname(DB_PATH), '.encryption-key');
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
let cachedKey: Buffer | null = null;
function getKey(): Buffer {
if (cachedKey) return cachedKey;
if (fs.existsSync(KEY_PATH)) {
cachedKey = fs.readFileSync(KEY_PATH);
return cachedKey;
}
const key = randomBytes(32);
fs.mkdirSync(path.dirname(KEY_PATH), { recursive: true });
fs.writeFileSync(KEY_PATH, key, { mode: 0o600 });
cachedKey = key;
return cachedKey;
}
export function encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
export function decrypt(ciphertext: string): string {
const raw = Buffer.from(ciphertext, 'base64');
const iv = raw.subarray(0, IV_LENGTH);
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
const encrypted = raw.subarray(IV_LENGTH + 16);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
+6 -4
View File
@@ -23,7 +23,8 @@ function rowToArticle(row: any): MergedArticle {
previousArticleId: row.previous_article_id,
nextArticleId: row.next_article_id,
topStories: !!row.top_stories,
tweet: row.tweet ? JSON.parse(row.tweet) : null
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null
};
}
@@ -31,8 +32,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
const id = `art-${randomUUID()}`;
db.prepare(
`INSERT INTO merged_articles
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
article.title,
@@ -52,7 +53,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
article.previousArticleId,
article.nextArticleId,
article.topStories ? 1 : 0,
article.tweet ? JSON.stringify(article.tweet) : null
article.tweet ? JSON.stringify(article.tweet) : null,
article.telegramMessage ? JSON.stringify(article.telegramMessage) : null
);
if (article.previousArticleId) {
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId);
+4 -2
View File
@@ -21,6 +21,7 @@ function rowToItem(row: any): ContentItem {
eventId: row.event_id,
clusterId: row.cluster_id,
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null,
raw: row.raw ? JSON.parse(row.raw) : null
};
}
@@ -29,8 +30,8 @@ export function insertContentItem(item: Omit<ContentItem, 'id'>): ContentItem {
const id = `ci-${randomUUID()}`;
db.prepare(
`INSERT INTO content_items
(id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, tweet, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, tweet, telegram_message, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
item.sourceId,
@@ -49,6 +50,7 @@ export function insertContentItem(item: Omit<ContentItem, 'id'>): ContentItem {
item.eventId,
item.clusterId,
item.tweet ? JSON.stringify(item.tweet) : null,
item.telegramMessage ? JSON.stringify(item.telegramMessage) : null,
item.raw ? JSON.stringify(item.raw) : null
);
return { ...item, id };
+31 -2
View File
@@ -59,6 +59,7 @@ export function migrate() {
event_id TEXT,
cluster_id TEXT, -- set once assigned to a cluster awaiting synthesis
tweet TEXT, -- JSON {id, authorName, authorHandle, avatarUrl}, nitter-sourced items only
telegram_message TEXT, -- JSON {channelName, channelUsername, channelAvatarUrl, messageId, media}, telegram-sourced items only
raw TEXT -- JSON, original payload
);
CREATE INDEX IF NOT EXISTS idx_content_items_source ON content_items(source_id);
@@ -84,7 +85,8 @@ export function migrate() {
previous_article_id TEXT,
next_article_id TEXT,
top_stories INTEGER NOT NULL DEFAULT 0, -- true if any contributing source opted into "Push to Top Stories?"
tweet TEXT -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only
tweet TEXT, -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only
telegram_message TEXT -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only
);
CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
@@ -170,10 +172,28 @@ export function migrate() {
storage_cap_value INTEGER NOT NULL DEFAULT 500,
storage_cap_unit TEXT NOT NULL DEFAULT 'GB',
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host' -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
-- the resulting login session. Deliberately its own table, not part of
-- global_settings, so these encrypted blobs never ride along in the generic
-- GET /api/admin/settings payload.
CREATE TABLE IF NOT EXISTS telegram_credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
api_id_enc TEXT,
api_hash_enc TEXT,
session_enc TEXT,
phone_enc TEXT
);
`);
const existingTelegramCreds = db.prepare('SELECT id FROM telegram_credentials WHERE id = 1').get();
if (!existingTelegramCreds) {
db.prepare('INSERT INTO telegram_credentials (id) VALUES (1)').run();
}
// Seed the singleton settings row if it doesn't exist yet.
const existing = db.prepare('SELECT id FROM global_settings WHERE id = 1').get();
if (!existing) {
@@ -207,6 +227,15 @@ export function migrate() {
if (!hasColumn('categories', 'is_private')) {
db.exec('ALTER TABLE categories ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('content_items', 'telegram_message')) {
db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT');
}
if (!hasColumn('merged_articles', 'telegram_message')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN telegram_message TEXT');
}
if (!hasColumn('global_settings', 'telegram_media_mode')) {
db.exec("ALTER TABLE global_settings ADD COLUMN telegram_media_mode TEXT NOT NULL DEFAULT 'self-host'");
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+3 -1
View File
@@ -15,6 +15,7 @@ function rowToSettings(row: any): GlobalSettings {
selectedModels: JSON.parse(row.selected_models),
nitterMediaMode: row.nitter_media_mode,
fxtwitterBaseUrl: row.fxtwitter_base_url,
telegramMediaMode: row.telegram_media_mode,
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -43,7 +44,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merge_strictness=?, default_poll_interval_minutes=?, hold_before_publish_minutes=?,
tag_dedup_threshold=?, tag_expiry_days=?, follow_up_min_hours_since_last=?, follow_up_min_new_sources=?,
ai_service_host=?, ai_service_port=?, selected_models=?,
nitter_media_mode=?, fxtwitter_base_url=?,
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?
WHERE id = 1`
@@ -60,6 +61,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
JSON.stringify(merged.selectedModels),
merged.nitterMediaMode,
merged.fxtwitterBaseUrl,
merged.telegramMediaMode,
merged.retention.publishedArticleMaxAgeDays,
merged.retention.rawItemMaxAgeDays,
merged.retention.storageCapEnabled ? 1 : 0,
+51
View File
@@ -37,6 +37,27 @@ export interface QuotedTweet {
link: string;
}
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
export type TelegramMediaItem = TweetMediaItem;
/**
* A raw reference to a single message's attached media, captured at ingestion time —
* deliberately NOT a URL, since Telegram has no public hotlinkable media URL the way
* Twitter does; media bytes only ever come from the authenticated MTProto session.
* publish.ts resolves this into a real TelegramMediaItem (a servable url) according to
* the admin's configured telegramMediaMode, at publish time — mirroring how Nitter's
* tweet media URLs are resolved at publish time too, just starting from a message
* reference here instead of an already-public CDN URL.
*/
export interface TelegramMediaRef {
type: 'photo' | 'video' | 'gif';
/** This media's own message id (a grouped album's items are separate messages, each individually re-fetchable). */
messageId: string;
mimeType: string | null;
width: number | null;
height: number | null;
}
export interface ContentItem {
id: string;
sourceId: string;
@@ -66,6 +87,25 @@ export interface ContentItem {
/** Set when this item is a quote-tweet — the tweet embedded in its <blockquote>. */
quotedTweet: QuotedTweet | null;
} | null;
/**
* Telegram-sourced items only — null for everything else. channelName/channelUsername
* describe whoever should be *displayed* as the author — the original channel when
* this message is a forward (same as tweet.authorName always being the original
* tweet's author, not the retweeter), or the polled channel itself otherwise.
* channelUsername is null when a forward's origin has no public handle. Media is
* unresolved refs (see TelegramMediaRef); publish.ts resolves them (and the display
* avatar) per the admin's configured media mode.
*/
telegramMessage: {
channelName: string;
channelUsername: string | null;
/** The channel actually polled — always non-null, used to re-fetch this message's media (attached media lives on the polled channel's own copy of the message, forward or not). */
sourceChannelUsername: string;
messageId: string;
media: TelegramMediaRef[];
/** Set when this message is a forward — the polled channel's own handle, e.g. "Forwarded by @X". */
repostedByHandle: string | null;
} | null;
raw: unknown;
}
@@ -92,6 +132,15 @@ export interface MergedArticle {
repostedByHandle: string | null;
quotedTweet: QuotedTweet | null;
} | null;
/** Telegram-sourced articles only — the embed card's channel info and attached media (see TelegramCard.svelte). Never set alongside video. */
telegramMessage: {
channelName: string;
channelUsername: string | null;
channelAvatarUrl: string | null;
sourceItemId: string;
media: TelegramMediaItem[];
repostedByHandle: string | null;
} | null;
category: string[];
geo: string | null;
eventId: string | null;
@@ -166,6 +215,8 @@ export interface GlobalSettings {
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
/** Base URL of the fxtwitter-compatible enrichment API — defaults to the public instance, overridable for a self-hosted FixTweet mirror. */
fxtwitterBaseUrl: string;
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
telegramMediaMode: 'self-host' | 'proxy';
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+28 -12
View File
@@ -29,25 +29,41 @@ export async function downloadAndStore(
const res = await fetch(sourceUrl, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const buffer = Buffer.from(await res.arrayBuffer());
const ext = guessExtension(res.headers.get('content-type') ?? '', sourceUrl);
const id = randomUUID();
const filename = `${id}${ext}`;
const localPath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(localPath, buffer);
db.prepare(
`INSERT INTO media_assets (id, source_url, local_path, size_bytes, downloaded_at, tier, content_item_id, article_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, sourceUrl, localPath, buffer.length, new Date().toISOString(), tier, refs.contentItemId ?? null, refs.articleId ?? null);
return { id, localPath, servedPath: `/media/${filename}`, sizeBytes: buffer.length };
return storeMediaBuffer(buffer, ext, sourceUrl, tier, refs);
} catch (err) {
logger.error('media', `Failed to download ${sourceUrl}: ${(err as Error).message}`);
return null;
}
}
/**
* Writes an already-downloaded buffer to disk and records it in media_assets — the
* common tail of downloadAndStore, extracted so the Telegram adapter (which downloads
* media itself via the authenticated GramJS session rather than a plain fetch()) can
* reuse the same storage/bookkeeping logic.
*/
export function storeMediaBuffer(
buffer: Buffer,
ext: string,
sourceUrl: string,
tier: 'candidate' | 'published',
refs: { contentItemId?: string; articleId?: string }
): StoredMedia {
const id = randomUUID();
const filename = `${id}${ext}`;
const localPath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(localPath, buffer);
db.prepare(
`INSERT INTO media_assets (id, source_url, local_path, size_bytes, downloaded_at, tier, content_item_id, article_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(id, sourceUrl, localPath, buffer.length, new Date().toISOString(), tier, refs.contentItemId ?? null, refs.articleId ?? null);
return { id, localPath, servedPath: `/media/${filename}`, sizeBytes: buffer.length };
}
/** Promotes a candidate-tier asset to published-tier so raw-item retention can no longer prune it. */
export function promoteToPublished(mediaId: string, articleId: string) {
db.prepare("UPDATE media_assets SET tier = 'published', article_id = ? WHERE id = ?").run(articleId, mediaId);
+178
View File
@@ -0,0 +1,178 @@
// Singleton GramJS client + login state machine. Unlike every other adapter (rss/
// nitter/youtube — all stateless per-call HTTP fetches), Telegram needs one long-lived
// authenticated MTProto connection reused across every poll — this module owns that
// connection and the interactive phone/code/2FA login flow that produces it, consumed
// by three call sites: the adapter (getClient/fetchChannelMessages), the admin login
// routes (startLogin/verifyCode/verifyPassword/logout/getStatus), and startup
// (initFromSavedSession).
import { TelegramClient, Api } from 'telegram';
import { StringSession } from 'telegram/sessions/index.js';
import { computeCheck } from 'telegram/Password.js';
import * as credentials from './credentials.js';
import { logger } from '../storage/db/logs.js';
interface PendingLogin {
client: TelegramClient;
phoneNumber: string;
phoneCodeHash: string;
phase: 'code-sent' | 'password-needed';
}
let client: TelegramClient | null = null;
let connectedPhone: string | null = null;
let pendingLogin: PendingLogin | null = null;
export function telegramConfigured(): boolean {
return credentials.getApiCredentials() !== null;
}
function newClient(sessionString = ''): TelegramClient {
const creds = credentials.getApiCredentials();
if (!creds) throw new Error('Telegram API credentials have not been saved yet');
return new TelegramClient(new StringSession(sessionString), creds.apiId, creds.apiHash, {
connectionRetries: 5
});
}
/** Called from index.ts's main() right after migrate() — reconnects a saved session, if any. */
export async function initFromSavedSession(): Promise<void> {
const session = credentials.getSession();
if (!session) return;
try {
const c = newClient(session.sessionString);
await c.connect();
client = c;
connectedPhone = session.phone;
logger.info('telegram', `Reconnected as ${session.phone}`);
} catch (err) {
// A network blip or transient failure shouldn't force a re-login — only an
// explicit logout() clears the saved session. Leaving client null here means the
// adapter just fails soft (same as before this feature existed) until the next
// restart or a manual reconnect.
client = null;
connectedPhone = null;
logger.error('telegram', `Failed to reconnect saved session: ${(err as Error).message}`);
}
}
export function getClient(): TelegramClient | null {
return client;
}
export function getStatus(): { credentialsConfigured: boolean; connected: boolean; phone: string | null } {
return {
credentialsConfigured: telegramConfigured(),
connected: client !== null,
phone: connectedPhone
};
}
export function saveApiCredentials(apiId: number, apiHash: string): void {
credentials.saveApiCredentials(apiId, apiHash);
}
export async function startLogin(phoneNumber: string): Promise<void> {
if (!telegramConfigured()) throw new Error('Save Telegram API ID/hash before logging in');
const creds = credentials.getApiCredentials()!;
const loginClient = newClient();
await loginClient.connect();
const sent = await loginClient.sendCode({ apiId: creds.apiId, apiHash: creds.apiHash }, phoneNumber);
pendingLogin = { client: loginClient, phoneNumber, phoneCodeHash: sent.phoneCodeHash, phase: 'code-sent' };
}
/** Returns 'connected' or 'password-needed' depending on whether this account has 2FA enabled. */
export async function verifyCode(code: string): Promise<'connected' | 'password-needed'> {
if (!pendingLogin || pendingLogin.phase !== 'code-sent') throw new Error('No login in progress — start again');
try {
await pendingLogin.client.invoke(
new Api.auth.SignIn({
phoneNumber: pendingLogin.phoneNumber,
phoneCodeHash: pendingLogin.phoneCodeHash,
phoneCode: code
})
);
await completeLogin(pendingLogin.client, pendingLogin.phoneNumber);
return 'connected';
} catch (err) {
if ((err as { errorMessage?: string }).errorMessage === 'SESSION_PASSWORD_NEEDED') {
pendingLogin.phase = 'password-needed';
return 'password-needed';
}
pendingLogin = null;
throw err;
}
}
export async function verifyPassword(password: string): Promise<void> {
if (!pendingLogin || pendingLogin.phase !== 'password-needed') throw new Error('No password step in progress — start again');
try {
const passwordInfo = await pendingLogin.client.invoke(new Api.account.GetPassword());
const passwordSrpCheck = await computeCheck(passwordInfo, password);
await pendingLogin.client.invoke(new Api.auth.CheckPassword({ password: passwordSrpCheck }));
await completeLogin(pendingLogin.client, pendingLogin.phoneNumber);
} catch (err) {
pendingLogin = null;
throw err;
}
}
async function completeLogin(loginClient: TelegramClient, phoneNumber: string): Promise<void> {
const sessionString = loginClient.session.save() as unknown as string;
credentials.saveSession(sessionString, phoneNumber);
client = loginClient;
connectedPhone = phoneNumber;
pendingLogin = null;
logger.info('telegram', `Logged in as ${phoneNumber}`);
}
export async function logout(): Promise<void> {
if (client) {
try {
await client.invoke(new Api.auth.LogOut());
} catch (err) {
logger.error('telegram', `Logout call failed (clearing local session anyway): ${(err as Error).message}`);
}
await client.disconnect();
}
client = null;
connectedPhone = null;
pendingLogin = null;
credentials.clearSession();
}
/** Used by the adapter — resolves a channel and fetches its most recent messages. */
export async function fetchChannelMessages(channelIdentifier: string, limit: number) {
if (!client) throw new Error('Telegram client not connected');
const entity = await client.getEntity(channelIdentifier);
const messages = await client.getMessages(entity, { limit });
return { entity, messages };
}
/**
* Re-fetches a single message by id and downloads its attached media — used by
* pipeline/publish.ts (self-host mode, at publish time) and the live telegram-proxy
* route (proxy mode, on every view). Telegram media has no public URL; this
* authenticated call is the only way to get the bytes.
*/
export async function downloadMessageMedia(channelUsername: string, messageId: string): Promise<Buffer | null> {
if (!client) throw new Error('Telegram client not connected');
const entity = await client.getEntity(channelUsername);
const [message] = await client.getMessages(entity, { ids: [Number(messageId)] });
if (!message) return null;
const buffer = await message.downloadMedia();
return buffer && typeof buffer !== 'string' ? buffer : null;
}
/** Downloads a channel's current avatar — same on-demand, no-public-URL reasoning as downloadMessageMedia. */
export async function downloadChannelAvatar(channelUsername: string): Promise<Buffer | null> {
if (!client) throw new Error('Telegram client not connected');
const entity = await client.getEntity(channelUsername);
const photo = await client.downloadProfilePhoto(entity);
return photo && typeof photo !== 'string' ? photo : null;
}
+48
View File
@@ -0,0 +1,48 @@
// The only module that touches storage/crypto.ts for Telegram data — everything else
// (telegram/client.ts, the admin routes) goes through these functions and never sees
// ciphertext or handles encryption itself.
import { db } from '../storage/db/index.js';
import { encrypt, decrypt } from '../storage/crypto.js';
interface TelegramCredentialsRow {
api_id_enc: string | null;
api_hash_enc: string | null;
session_enc: string | null;
phone_enc: string | null;
}
function getRow(): TelegramCredentialsRow {
return db.prepare('SELECT * FROM telegram_credentials WHERE id = 1').get() as unknown as TelegramCredentialsRow;
}
export function saveApiCredentials(apiId: number, apiHash: string): void {
db.prepare('UPDATE telegram_credentials SET api_id_enc = ?, api_hash_enc = ? WHERE id = 1').run(
encrypt(String(apiId)),
encrypt(apiHash)
);
}
export function getApiCredentials(): { apiId: number; apiHash: string } | null {
const row = getRow();
if (!row.api_id_enc || !row.api_hash_enc) return null;
return { apiId: Number(decrypt(row.api_id_enc)), apiHash: decrypt(row.api_hash_enc) };
}
export function saveSession(sessionString: string, phone: string): void {
db.prepare('UPDATE telegram_credentials SET session_enc = ?, phone_enc = ? WHERE id = 1').run(
encrypt(sessionString),
encrypt(phone)
);
}
export function getSession(): { sessionString: string; phone: string } | null {
const row = getRow();
if (!row.session_enc || !row.phone_enc) return null;
return { sessionString: decrypt(row.session_enc), phone: decrypt(row.phone_enc) };
}
/** Logout — clears the session/phone only, keeps API ID/hash so re-login doesn't need them re-entered. */
export function clearSession(): void {
db.prepare('UPDATE telegram_credentials SET session_enc = NULL, phone_enc = NULL WHERE id = 1').run();
}
+38
View File
@@ -6,6 +6,7 @@ import type {
AdminTrackedEvent,
ModelCatalog,
AiStatus,
TelegramStatus,
LogEntry
} from './adminTypes';
@@ -120,6 +121,43 @@ export const getModels = (fetchFn?: typeof fetch) =>
export const getAiStatus = (fetchFn?: typeof fetch) =>
request<AiStatus>('/api/admin/ai-status', {}, fetchFn);
// Telegram account (Connections tab) — API ID/hash and the resulting login session are
// stored encrypted at rest server-side (see backend telegram/credentials.ts); none of
// these ever come back from the server, only status flags.
export const getTelegramStatus = (fetchFn?: typeof fetch) =>
request<TelegramStatus>('/api/admin/telegram/status', {}, fetchFn);
export const saveTelegramCredentials = (apiId: number, apiHash: string, fetchFn?: typeof fetch) =>
request<{ credentialsConfigured: boolean }>(
'/api/admin/telegram/credentials',
{ method: 'POST', body: JSON.stringify({ apiId, apiHash }) },
fetchFn
);
export const startTelegramLogin = (phoneNumber: string, fetchFn?: typeof fetch) =>
request<{ phase: 'code-sent' }>(
'/api/admin/telegram/login/start',
{ method: 'POST', body: JSON.stringify({ phoneNumber }) },
fetchFn
);
export const verifyTelegramCode = (code: string, fetchFn?: typeof fetch) =>
request<{ phase: 'connected' | 'password-needed'; connected?: boolean; phone?: string | null }>(
'/api/admin/telegram/login/verify',
{ method: 'POST', body: JSON.stringify({ code }) },
fetchFn
);
export const verifyTelegramPassword = (password: string, fetchFn?: typeof fetch) =>
request<{ phase: 'connected'; connected?: boolean; phone?: string | null }>(
'/api/admin/telegram/login/verify',
{ method: 'POST', body: JSON.stringify({ password }) },
fetchFn
);
export const telegramLogout = (fetchFn?: typeof fetch) =>
request<TelegramStatus>('/api/admin/telegram/logout', { method: 'POST' }, fetchFn);
// Logs
export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: number } = {}, fetchFn?: typeof fetch) => {
const qs = new URLSearchParams(filters as Record<string, string>).toString();
+7
View File
@@ -28,6 +28,7 @@ export interface AdminSettings {
selectedModels: { embedding: string; image: string; synthesis: string };
nitterMediaMode: 'self-host' | 'proxy' | 'direct';
fxtwitterBaseUrl: string;
telegramMediaMode: 'self-host' | 'proxy';
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
}
@@ -71,6 +72,12 @@ export interface AiStatus {
gpu: string;
}
export interface TelegramStatus {
credentialsConfigured: boolean;
connected: boolean;
phone: string | null;
}
export interface LogEntry {
id: number;
timestamp: string;
@@ -3,6 +3,7 @@
import { timeAgo, exactTime, excerpt } from '$lib/format';
import { resolveMediaUrl } from '$lib/config';
import TweetCard from './TweetCard.svelte';
import TelegramCard from './TelegramCard.svelte';
let { article }: { article: MergedArticle } = $props();
@@ -15,6 +16,8 @@
{#if article.tweet}
<TweetCard {article} />
{:else if article.telegramMessage}
<TelegramCard {article} />
{:else}
<a class="row" href={`/article/${article.id}`}>
{#if article.heroImage}
@@ -0,0 +1,197 @@
<script lang="ts">
import type { MergedArticle } from '$lib/types';
import { timeAgo, exactTime } from '$lib/format';
import { resolveMediaUrl } from '$lib/config';
let { article }: { article: MergedArticle } = $props();
const sourceLabel = $derived(article.sources[0]?.sourceName ?? 'Telegram');
// article.telegramMessage.media is undefined for messages published before this field
// existed — older rows in the DB weren't backfilled, so this can't assume it's always an array.
const media = $derived(article.telegramMessage?.media?.slice(0, 4) ?? []);
// The card's own href — clicking the frame goes to the original message on Telegram,
// not our internal article page (there's no separate "full article" for a message anyway).
const messageUrl = $derived(article.sources[0]?.link ?? `/article/${article.id}`);
// A <video controls> click must not fall through to the card's own <a> navigation
// (play/pause/scrub would otherwise just open the message in a new tab instead).
function stopVideoNav(e: MouseEvent) {
e.preventDefault();
}
// A photo click opens the image itself in a new tab, rather than the message link the
// rest of the card points to.
function openImage(e: MouseEvent, url: string) {
e.preventDefault();
window.open(resolveMediaUrl(url), '_blank', 'noopener,noreferrer');
}
</script>
<a class="telegram-card" href={messageUrl} target="_blank" rel="noreferrer">
{#if article.telegramMessage?.repostedByHandle}
<div class="forward-line">↪️ Forwarded by @{article.telegramMessage.repostedByHandle}</div>
{/if}
<div class="meta">
<span>{article.category[0] ?? ''}</span>
<span>&middot;</span>
<span>{sourceLabel}</span>
<span>&middot;</span>
<span>✈ Telegram</span>
</div>
<div class="channel-row">
{#if article.telegramMessage?.channelAvatarUrl}
<img class="avatar" src={resolveMediaUrl(article.telegramMessage.channelAvatarUrl)} alt="" loading="lazy" />
{:else}
<div class="avatar placeholder"></div>
{/if}
<span class="name">{article.telegramMessage?.channelName}</span>
{#if article.telegramMessage?.channelUsername}
<span class="handle">@{article.telegramMessage.channelUsername}</span>
{/if}
</div>
<div class="text">{article.body}</div>
{#if media.length > 0}
<div class="media-grid" data-count={media.length}>
{#each media as item, i (item.url)}
<div class="media-cell" class:span-2={media.length === 3 && i === 0}>
{#if item.type === 'video' || item.type === 'gif'}
<video
class="media-el"
controls
preload="metadata"
playsinline
loop={item.type === 'gif'}
muted={item.type === 'gif'}
poster={item.thumbnailUrl ? resolveMediaUrl(item.thumbnailUrl) : undefined}
onclick={stopVideoNav}
>
<source src={resolveMediaUrl(item.url)} type="video/mp4" />
</video>
{:else}
<button type="button" class="media-btn" onclick={(e) => openImage(e, item.url)}>
<img class="media-el" src={resolveMediaUrl(item.url)} alt="" loading="lazy" />
</button>
{/if}
</div>
{/each}
</div>
{/if}
<div class="time">{timeAgo(article.publishedAt)} &middot; {exactTime(article.publishedAt)}</div>
</a>
<style>
.telegram-card {
display: block;
border: 0.5px solid var(--border);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
background: var(--surface-2);
color: inherit;
}
.telegram-card:hover {
text-decoration: none;
border-color: var(--border-accent);
}
.forward-line {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
}
.meta {
font-size: 11px;
color: var(--text-accent);
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.channel-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--surface-1);
}
.avatar.placeholder {
display: block;
}
.name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.handle {
font-size: 13px;
color: var(--text-muted);
}
.text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-line;
margin-bottom: 8px;
}
/* Fixed cell heights, same 1/2/3/4-item grid shapes as TweetCard.svelte — see that
component for the reasoning (a tall portrait photo/video shouldn't dictate the
whole card's height). */
.media-grid {
display: grid;
gap: 3px;
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 8px;
background: var(--surface-1);
}
.media-grid[data-count='1'] {
grid-template-columns: 1fr;
height: 380px;
}
.media-grid[data-count='2'] {
grid-template-columns: 1fr 1fr;
height: 240px;
}
.media-grid[data-count='3'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
}
.media-grid[data-count='4'] {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: 300px;
}
.media-cell {
overflow: hidden;
}
.media-cell.span-2 {
grid-row: 1 / 3;
}
.media-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.media-el {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: var(--surface-1);
}
.time {
font-size: 11px;
color: var(--text-muted);
}
</style>
@@ -1,10 +1,22 @@
<script lang="ts">
import type { AdminSettings, AiStatus } from '$lib/adminTypes';
import type { AdminSettings, AiStatus, TelegramStatus } from '$lib/adminTypes';
import { getBackendUrl, setBackendUrl } from '$lib/config';
import { updateSettings, getAiStatus } from '$lib/adminApi';
import {
updateSettings,
getAiStatus,
saveTelegramCredentials,
startTelegramLogin,
verifyTelegramCode,
verifyTelegramPassword,
telegramLogout
} from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte';
let { settings, aiStatus: initialStatus }: { settings: AdminSettings; aiStatus: AiStatus } = $props();
let {
settings,
aiStatus: initialStatus,
telegramStatus: initialTelegramStatus
}: { settings: AdminSettings; aiStatus: AiStatus; telegramStatus: TelegramStatus } = $props();
let backendUrl = $state(getBackendUrl());
let backendSaved = $state(false);
@@ -41,6 +53,107 @@
testing = false;
}
}
// --- Telegram account ---
let credentialsConfigured = $state(initialTelegramStatus.credentialsConfigured);
let apiId = $state('');
let apiHash = $state('');
let apiCredsStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
type LoginPhase = 'disconnected' | 'phone-entry' | 'code-sent' | 'password-needed' | 'connected' | 'error';
let loginPhase = $state<LoginPhase>(initialTelegramStatus.connected ? 'connected' : 'disconnected');
let connectedPhone = $state(initialTelegramStatus.phone);
let phoneNumber = $state('');
let code = $state('');
let password = $state('');
let loginError = $state('');
let loginBusy = $state(false);
async function saveApiCredentials() {
const idNum = Number(apiId);
if (!idNum || !apiHash.trim()) return;
apiCredsStatus = 'saving';
try {
await saveTelegramCredentials(idNum, apiHash.trim());
credentialsConfigured = true;
apiId = '';
apiHash = '';
apiCredsStatus = 'saved';
setTimeout(() => (apiCredsStatus = 'idle'), 1500);
} catch {
apiCredsStatus = 'error';
}
}
async function submitPhone() {
loginBusy = true;
loginError = '';
try {
await startTelegramLogin(phoneNumber.trim());
loginPhase = 'code-sent';
} catch (err) {
loginError = (err as Error).message;
loginPhase = 'error';
} finally {
loginBusy = false;
}
}
async function submitCode() {
loginBusy = true;
loginError = '';
try {
const result = await verifyTelegramCode(code.trim());
if (result.phase === 'connected') {
connectedPhone = result.phone ?? phoneNumber.trim();
loginPhase = 'connected';
} else {
loginPhase = 'password-needed';
}
} catch (err) {
loginError = (err as Error).message;
loginPhase = 'error';
} finally {
loginBusy = false;
}
}
async function submitPassword() {
loginBusy = true;
loginError = '';
try {
const result = await verifyTelegramPassword(password);
connectedPhone = result.phone ?? phoneNumber.trim();
loginPhase = 'connected';
} catch (err) {
loginError = (err as Error).message;
loginPhase = 'error';
} finally {
loginBusy = false;
}
}
async function disconnectTelegram() {
if (!confirm('Disconnect this Telegram account? Telegram sources will stop ingesting until you log in again.')) return;
loginBusy = true;
try {
await telegramLogout();
} finally {
loginBusy = false;
loginPhase = 'disconnected';
connectedPhone = null;
phoneNumber = '';
code = '';
password = '';
}
}
function tryLoginAgain() {
loginError = '';
loginPhase = 'disconnected';
code = '';
password = '';
}
</script>
<div class="panel">
@@ -82,6 +195,63 @@
</div>
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Telegram account</span>
<SaveStatus status={apiCredsStatus} />
</div>
<p class="hint">
Reading channels requires logging into a real Telegram account (not a bot), so it can see
any public channel that account can see. Get an API ID/hash from
<a href="https://my.telegram.org" target="_blank" rel="noreferrer">my.telegram.org</a> — a
dedicated/secondary account is recommended over your primary one. Both are stored encrypted,
never sent back to this browser.
</p>
<div class="row">
<input type="text" inputmode="numeric" placeholder="API ID" bind:value={apiId} style="width: 120px" />
<input type="text" placeholder="API Hash" bind:value={apiHash} style="flex: 1" />
<button class="primary" onclick={saveApiCredentials} disabled={!apiId || !apiHash.trim()}>Save</button>
</div>
{#if credentialsConfigured}
<div class="status-row"><span class="connected">✓ API credentials saved</span></div>
{/if}
<div class="login-section">
{#if !credentialsConfigured}
<p class="hint">Save your API ID and hash above before logging in.</p>
{:else if loginPhase === 'connected'}
<div class="status-row"><span class="connected">✓ Connected as {connectedPhone}</span></div>
<button onclick={disconnectTelegram} disabled={loginBusy}>{loginBusy ? 'Disconnecting…' : 'Disconnect'}</button>
{:else if loginPhase === 'error'}
<div class="status-row"><span class="disconnected">{loginError}</span></div>
<button onclick={tryLoginAgain}>Try again</button>
{:else if loginPhase === 'code-sent'}
<p class="hint">Enter the login code Telegram just sent to {phoneNumber}.</p>
<div class="row">
<input type="text" placeholder="Login code" bind:value={code} />
<button class="primary" onclick={submitCode} disabled={loginBusy || !code.trim()}>
{loginBusy ? 'Verifying…' : 'Verify'}
</button>
</div>
{:else if loginPhase === 'password-needed'}
<p class="hint">This account has two-factor authentication enabled — enter its password.</p>
<div class="row">
<input type="password" placeholder="2FA password" bind:value={password} />
<button class="primary" onclick={submitPassword} disabled={loginBusy || !password}>
{loginBusy ? 'Verifying…' : 'Verify'}
</button>
</div>
{:else}
<div class="row">
<input type="text" placeholder="Phone number, e.g. +15551234567" bind:value={phoneNumber} />
<button class="primary" onclick={submitPhone} disabled={loginBusy || !phoneNumber.trim()}>
{loginBusy ? 'Sending…' : 'Log in'}
</button>
</div>
{/if}
</div>
</div>
<style>
.panel {
background: var(--surface-1);
@@ -130,4 +300,12 @@
.disconnected {
color: var(--text-danger);
}
.login-section {
margin-top: 14px;
padding-top: 14px;
border-top: 0.5px solid var(--border);
}
.login-section .hint {
margin: 0 0 10px;
}
</style>
@@ -36,6 +36,31 @@
{ label: 'Direct', value: 'direct' }
];
let telegramMediaMode = $state(settings.telegramMediaMode);
let telegramMediaStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let telegramMediaSaveTimer: ReturnType<typeof setTimeout>;
function scheduleTelegramMediaSave() {
telegramMediaStatus = 'saving';
clearTimeout(telegramMediaSaveTimer);
telegramMediaSaveTimer = setTimeout(async () => {
try {
await updateSettings({ telegramMediaMode });
telegramMediaStatus = 'saved';
setTimeout(() => (telegramMediaStatus = 'idle'), 1500);
} catch {
telegramMediaStatus = 'error';
}
}, 500);
}
// No "Direct" option here — unlike Twitter's CDN, Telegram has no public
// hotlinkable media URL, so there's nothing to hotlink straight from.
const telegramMediaModes: { label: string; value: 'self-host' | 'proxy' }[] = [
{ label: 'Self-host', value: 'self-host' },
{ label: 'Proxy', value: 'proxy' }
];
async function handleClearArticles() {
if (!confirm('Delete every published article and its media? Raw ingested items are kept, so sources can be re-synthesized fresh.')) return;
clearing = 'articles';
@@ -200,6 +225,36 @@
<input type="text" bind:value={fxtwitterBaseUrl} oninput={scheduleNitterSave} placeholder="https://api.fxtwitter.com" style="width: 100%" />
</div>
<div class="panel">
<div class="head">
<span class="panel-title">Telegram (message media)</span>
<SaveStatus status={telegramMediaStatus} />
</div>
<p class="hint">
How photos/videos attached to ingested Telegram messages (and channel avatars) are served
to visitors. Telegram has no public URL for this media the way Twitter's CDN does — bytes
only ever come from the logged-in account (Connections tab), so there's no "Direct" option.
Self-hosting downloads and stores everything locally, same as regular article images.
Proxying re-fetches each request live through the logged-in account and streams it straight
through without persisting anything, so only this server ever touches Telegram's servers —
at the cost of a live round-trip to Telegram on every view (a short cache absorbs repeats).
</p>
<div class="pill-row">
{#each telegramMediaModes as mode}
<button
class="pill"
class:active={telegramMediaMode === mode.value}
onclick={() => {
telegramMediaMode = mode.value;
scheduleTelegramMediaSave();
}}
>
{mode.label}
</button>
{/each}
</div>
</div>
<div class="panel">
<span class="panel-title">Clear content</span>
<p class="hint">
@@ -202,6 +202,8 @@
<input placeholder="Channel URL (@handle or /channel/UC…), or channel ID" bind:value={form.channelId} />
{:else if form.type === 'nitter'}
<input placeholder="Nitter list/user RSS feed URL" bind:value={form.url} />
{:else if form.type === 'telegram'}
<input placeholder="Channel username (e.g. @channelname) or t.me/channelname link" bind:value={form.url} />
{:else}
<input placeholder="URL or channel" bind:value={form.url} />
{/if}
@@ -211,6 +213,9 @@
<option value={60}>Every hour</option>
</select>
</div>
{#if form.type === 'telegram'}
<span class="hint">Public channels only for now — private channels without a public username aren't supported yet.</span>
{/if}
<div class="categories-label">Categories</div>
<div class="category-checks">
{#each assignableCategories as cat (cat.id)}
+11
View File
@@ -26,6 +26,9 @@ export interface QuotedTweet {
link: string;
}
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
export type TelegramMediaItem = TweetMediaItem;
export interface MergedArticle {
id: string;
title: string;
@@ -41,6 +44,14 @@ export interface MergedArticle {
repostedByHandle: string | null;
quotedTweet: QuotedTweet | null;
} | null;
telegramMessage: {
channelName: string;
channelUsername: string | null;
channelAvatarUrl: string | null;
sourceItemId: string;
media: TelegramMediaItem[];
repostedByHandle: string | null;
} | null;
category: string[];
geo: string | null;
eventId: string | null;
@@ -46,7 +46,7 @@
{:else if active === 'events'}
<EventsTab events={data.events} sources={data.sources} />
{:else if active === 'connections'}
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} />
<ConnectionsTab settings={data.settings} aiStatus={data.aiStatus} telegramStatus={data.telegramStatus} />
{:else if active === 'logs'}
<LogsTab logs={data.logs} />
{/if}
+7 -3
View File
@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { getSettings, getSources, getEvents, getModels, getAiStatus, getLogs } from '$lib/adminApi';
import type { ModelCatalog, AiStatus } from '$lib/adminTypes';
import { getSettings, getSources, getEvents, getModels, getAiStatus, getTelegramStatus, getLogs } from '$lib/adminApi';
import type { ModelCatalog, AiStatus, TelegramStatus } from '$lib/adminTypes';
const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
@@ -24,7 +24,11 @@ export const load: PageLoad = async ({ fetch }) => {
// swallow — surfaced instead via aiStatus.connected in the UI
}
return { settings, sources, events, models, aiStatus, logs };
const telegramStatus: TelegramStatus = await getTelegramStatus(fetch).catch(
() => ({ credentialsConfigured: false, connected: false, phone: null })
);
return { settings, sources, events, models, aiStatus, telegramStatus, logs };
} catch (err) {
if ((err as { status?: number }).status === 401) {
throw redirect(302, '/admin/login?redirectTo=/admin/settings');
@@ -3,6 +3,7 @@
import { timeAgo, exactTime } from '$lib/format';
import { resolveMediaUrl } from '$lib/config';
import TweetCard from '$lib/components/TweetCard.svelte';
import TelegramCard from '$lib/components/TelegramCard.svelte';
let { data }: { data: PageData } = $props();
const a = $derived(data.article);
@@ -36,6 +37,18 @@
{#if a.sources[0]}
<a class="view-original" href={a.sources[0].link} target="_blank" rel="noreferrer">View original tweet →</a>
{/if}
{:else if a.telegramMessage}
<!-- Telegram messages have no separate "full article" concept either — same
reasoning as tweets above. -->
<div class="dates">
<span>Published {timeAgo(a.publishedAt)} &middot; {exactTime(a.publishedAt)}</span>
</div>
<TelegramCard article={a} />
{#if a.sources[0]}
<a class="view-original" href={a.sources[0].link} target="_blank" rel="noreferrer">View original message on Telegram →</a>
{/if}
{:else if a.video?.provider === 'youtube'}
<h1>{a.title}</h1>