Generated
+525
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>·</span>
|
||||
<span>{sourceLabel}</span>
|
||||
<span>·</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)} · {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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} · {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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user