Build out a functional Telegram adapter, rendered like the tweet card

Logs into a real Telegram account via MTProto (GramJS) rather than a bot,
so it can read any public channel's history. API ID/hash and the login
session are entered through the admin Connections panel and stored
encrypted at rest (new storage/crypto.ts AES-256-GCM helper) rather than
via .env. Messages render as their own TelegramCard (same treatment as
tweets) and open the original message on Telegram instead of an internal
article page; attached media/albums are downloaded and self-hosted at
ingestion time since Telegram has no public hotlinkable media URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-23 20:23:28 +00:00
parent 9ec09e2b1b
commit f8072ab339
27 changed files with 1598 additions and 47 deletions
+525 -1
View File
@@ -14,7 +14,8 @@
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
"jsdom": "^29.1.1",
"rss-parser": "^3.13.0"
"rss-parser": "^3.13.0",
"telegram": "^2.26.22"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
@@ -82,6 +83,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@cryptography/aes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@cryptography/aes/-/aes-0.1.1.tgz",
"integrity": "sha512-PcYz4FDGblO6tM2kSC+VzhhK62vml6k6/YAkiWtyPvrgJVfnDRoHGDtKn5UiaRRUrvUTTocBpvc2rRgTCqxjsg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
@@ -1257,6 +1264,15 @@
}
}
},
"node_modules/async-mutex": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
"integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.1"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1286,6 +1302,26 @@
"fastq": "^1.17.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -1295,6 +1331,52 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
"license": "Unlicense",
"engines": {
"node": ">=0.6"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
@@ -1321,6 +1403,19 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -1334,6 +1429,15 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -1349,6 +1453,61 @@
"node": ">=6"
}
},
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.2.0",
"entities": "^2.0.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.2.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
@@ -1358,6 +1517,46 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -1400,6 +1599,40 @@
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -1564,6 +1797,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1576,6 +1815,63 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
"integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"domhandler": "^4.0.0",
"domutils": "^2.5.2",
"entities": "^2.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -1591,6 +1887,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@@ -1721,6 +2023,53 @@
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-localstorage": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz",
"integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==",
"license": "MIT",
"dependencies": {
"write-file-atomic": "^1.1.4"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -1730,6 +2079,22 @@
"node": ">=14.0.0"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -1754,6 +2119,12 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1822,6 +2193,12 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-cancellable-promise": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/real-cancellable-promise/-/real-cancellable-promise-1.2.3.tgz",
"integrity": "sha512-hBI5Gy/55VEeeMtImMgEirD7eq5UmqJf1J8dFZtbJZA/3rB0pYFZ7PayMGueb6v4UtUtpKpP+05L0VwyE1hI9Q==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -1961,6 +2338,39 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/slide": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz",
"integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==",
"license": "ISC",
"engines": {
"node": "*"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -1988,12 +2398,45 @@
"node": ">= 10.x"
}
},
"node_modules/store2": {
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz",
"integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==",
"license": "MIT"
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/telegram": {
"version": "2.26.22",
"resolved": "https://registry.npmjs.org/telegram/-/telegram-2.26.22.tgz",
"integrity": "sha512-EIj7Yrjiu0Yosa3FZ/7EyPg9s6UiTi/zDQrFmR/2Mg7pIUU+XjAit1n1u9OU9h2oRnRM5M+67/fxzQluZpaJJg==",
"deprecated": "This package is archived and no longer maintained. Development continues in teleproto (https://npmjs.com/package/teleproto), a largely compatible, actively maintained fork. See the migration guide at https://docs.teleproto.dev/migrating-from-gramjs",
"license": "MIT",
"dependencies": {
"@cryptography/aes": "^0.1.1",
"async-mutex": "^0.3.0",
"big-integer": "^1.6.48",
"buffer": "^6.0.3",
"htmlparser2": "^6.1.0",
"mime": "^3.0.0",
"node-localstorage": "^2.2.1",
"pako": "^2.0.3",
"path-browserify": "^1.0.1",
"real-cancellable-promise": "^1.1.1",
"socks": "^2.6.2",
"store2": "^2.13.0",
"ts-custom-error": "^3.2.0",
"websocket": "^1.0.34"
},
"optionalDependencies": {
"bufferutil": "^4.0.3",
"utf-8-validate": "^5.0.5"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@@ -2063,6 +2506,21 @@
"node": ">=20"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
@@ -2082,6 +2540,21 @@
"fsevents": "~2.3.3"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
"license": "MIT",
"dependencies": {
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -2133,6 +2606,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -2154,6 +2640,23 @@
"node": ">=20"
}
},
"node_modules/websocket": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
"integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
"license": "Apache-2.0",
"dependencies": {
"bufferutil": "^4.0.1",
"debug": "^2.2.0",
"es5-ext": "^0.10.63",
"typedarray-to-buffer": "^3.1.5",
"utf-8-validate": "^5.0.2",
"yaeti": "^0.0.6"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
@@ -2177,6 +2680,17 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/write-file-atomic": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz",
"integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"slide": "^1.1.5"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -2213,6 +2727,16 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"engines": {
"node": ">=0.10.32"
}
}
}
}
+2 -1
View File
@@ -17,7 +17,8 @@
"@mozilla/readability": "^0.6.0",
"fastify": "^5.10.0",
"jsdom": "^29.1.1",
"rss-parser": "^3.13.0"
"rss-parser": "^3.13.0",
"telegram": "^2.26.22"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
+46
View File
@@ -7,6 +7,7 @@ import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMed
import { OllamaProvider } from '../inference/ollama-provider.js';
import { pollSourceNow } from '../ingestion/poller.js';
import { logger, listLogs } from '../storage/db/logs.js';
import * as telegramClient from '../telegram/client.js';
export async function registerAdminRoutes(app: FastifyInstance) {
// --- Settings ---
@@ -148,6 +149,51 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null };
});
// --- Telegram account (Connections tab — see telegram/client.ts and credentials.ts.
// API ID/hash and the resulting login session are stored encrypted at rest; none of
// these routes ever echo them back to the client.) ---
app.get('/api/admin/telegram/status', async () => telegramClient.getStatus());
app.post('/api/admin/telegram/credentials', async (req, reply) => {
const { apiId, apiHash } = req.body as { apiId?: number; apiHash?: string };
if (!apiId || !apiHash) return reply.code(400).send({ error: 'apiId and apiHash are required' });
telegramClient.saveApiCredentials(apiId, apiHash);
return { credentialsConfigured: true };
});
app.post('/api/admin/telegram/login/start', async (req, reply) => {
const { phoneNumber } = req.body as { phoneNumber?: string };
if (!phoneNumber) return reply.code(400).send({ error: 'phoneNumber is required' });
try {
await telegramClient.startLogin(phoneNumber);
return { phase: 'code-sent' };
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
});
app.post('/api/admin/telegram/login/verify', async (req, reply) => {
const { code, password } = req.body as { code?: string; password?: string };
try {
if (password !== undefined) {
await telegramClient.verifyPassword(password);
return { phase: 'connected', ...telegramClient.getStatus() };
}
if (code !== undefined) {
const phase = await telegramClient.verifyCode(code);
return phase === 'connected' ? { phase, ...telegramClient.getStatus() } : { phase };
}
return reply.code(400).send({ error: 'code or password is required' });
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
});
app.post('/api/admin/telegram/logout', async (_req, reply) => {
await telegramClient.logout();
return reply.code(200).send(telegramClient.getStatus());
});
// --- Logs ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
+2
View File
@@ -11,6 +11,7 @@ import { registerAdminRoutes } from './api/admin.js';
import { registerMediaProxy } from './api/mediaProxy.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 +33,7 @@ function printApiKeyBanner() {
async function main() {
migrate();
printApiKeyBanner();
await initFromSavedSession();
const app = Fastify({ logger: false });
+20 -1
View File
@@ -1,4 +1,4 @@
import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js';
import type { Source, ContentItem, TweetMediaItem, TelegramMediaItem } from '../../storage/db/types.js';
import { cleanHtml, toSummary } from '../clean.js';
export interface FetchedItem {
@@ -11,6 +11,24 @@ export interface FetchedItem {
publishedAt: string;
/** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */
tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] };
/** Set by the Telegram adapter only — carries the channel/message info through to ContentItem.telegramMessage. */
telegramMessage?: {
channelName: string;
channelUsername: string;
channelAvatarUrl: string | null;
messageId: string;
media: TelegramMediaItem[];
};
/**
* Set by the Telegram adapter only — media_assets row IDs already downloaded and
* self-hosted at fetch time (before this item's ContentItem even exists, since the
* authenticated Telegram access producing them could disappear later). Not part of
* ContentItem itself — poller.ts uses this right after insertContentItem to attach
* these rows to the real content item id (see storage/media/index.ts's
* attachToContentItem), so publishDirect can later find and promote them via
* mediaIdsForContentItem without threading ids through the JSON blob.
*/
telegramMediaAssetIds?: string[];
raw: unknown;
}
@@ -43,6 +61,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
};
}
+164 -10
View File
@@ -1,17 +1,171 @@
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.
import type { Api } from 'telegram';
import type { Source, TelegramMediaItem } 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';
import { storeMediaBuffer } from '../../storage/media/index.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 guessExtension(mimeType: string, 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';
if (kind === 'photo') return '.jpg';
return '.mp4';
}
/** Downloads and self-hosts a single message's attached media, if any — Telegram has no public hotlinkable media URL, so this happens immediately rather than being deferred to publish time (that authenticated access could disappear later: message deleted, channel left, session revoked). */
async function extractMedia(message: TgMessage): Promise<{ item: TelegramMediaItem; mediaId: string } | null> {
let kind: 'photo' | 'video' | 'gif';
let mimeType = '';
if (message.video) {
kind = 'video';
mimeType = (message.video as unknown as { mimeType?: string }).mimeType ?? '';
} else if (message.gif) {
kind = 'gif';
mimeType = (message.gif as unknown as { mimeType?: string }).mimeType ?? '';
} else if (message.photo) {
kind = 'photo';
} else {
return null;
}
const buffer = await message.downloadMedia();
if (!buffer || typeof buffer === 'string') return null;
const ext = guessExtension(mimeType, kind);
const stored = storeMediaBuffer(buffer, ext, `telegram-message:${message.id}`, 'candidate', {});
return { item: { type: kind, url: stored.servedPath, thumbnailUrl: null, width: null, height: null }, mediaId: stored.id };
}
/** 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)!);
}
/**
* 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.
*/
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).
const channelUsername: string | undefined = entity?.username;
if (!channelUsername) {
logger.warn('telegram', `"${source.name}" has no public username — private channels aren't supported yet, skipping`);
return [];
}
const channelName: string = entity?.title ?? channelUsername;
let avatarBuffer: Buffer | null = null;
try {
const photo = await client.downloadProfilePhoto(entity);
if (photo && typeof photo !== 'string') avatarBuffer = photo;
} catch (err) {
logger.warn('telegram', `Failed to download avatar for "${channelName}": ${(err as Error).message}`);
}
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: TelegramMediaItem[] = [];
const mediaAssetIds: string[] = [];
for (const message of group) {
if (media.length >= MAX_TELEGRAM_MEDIA) break;
const extracted = await extractMedia(message);
if (extracted) {
media.push(extracted.item);
mediaAssetIds.push(extracted.mediaId);
}
}
let channelAvatarUrl: string | null = null;
if (avatarBuffer) {
const stored = storeMediaBuffer(avatarBuffer, '.jpg', `telegram-avatar:${channelUsername}`, 'candidate', {});
channelAvatarUrl = stored.servedPath;
mediaAssetIds.push(stored.id);
}
if (!text && media.length === 0) continue; // nothing worth publishing (e.g. a service message)
items.push({
title: firstLine || `Message from ${channelName}`,
summary: text.slice(0, 500),
body: text || null,
images: [],
videos: [],
link: `https://t.me/${channelUsername}/${group[0].id}`,
publishedAt: new Date(primary.date * 1000).toISOString(),
telegramMessage: {
channelName,
channelUsername,
channelAvatarUrl,
messageId: String(group[0].id),
media
},
telegramMediaAssetIds: mediaAssetIds,
raw: { messageIds: group.map((m) => m.id) }
});
}
return items;
}
};
+5 -1
View File
@@ -1,5 +1,6 @@
import * as sourcesDb from '../storage/db/sources.js';
import * as contentItemsDb from '../storage/db/contentItems.js';
import { attachToContentItem } from '../storage/media/index.js';
import { logger } from '../storage/db/logs.js';
import { rssAdapter } from './adapters/rss.js';
import { telegramAdapter } from './adapters/telegram.js';
@@ -47,7 +48,10 @@ async function pollOne(source: Source): Promise<number> {
const finalItem = FOLLOWS_LINK_FOR_FULL_ARTICLE.includes(source.type) ? await withFullArticle(item) : item;
contentItemsDb.insertContentItem(toContentItem(source, finalItem));
const created = contentItemsDb.insertContentItem(toContentItem(source, finalItem));
if (finalItem.telegramMediaAssetIds?.length) {
attachToContentItem(finalItem.telegramMediaAssetIds, created.id);
}
ingested++;
}
sourcesDb.markPolled(source.id, null);
+22 -3
View File
@@ -3,7 +3,7 @@ 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, mediaIdsForContentItem } from '../storage/media/index.js';
import { logger } from '../storage/db/logs.js';
import * as articles from '../storage/db/articles.js';
import * as tags from '../storage/db/tags.js';
@@ -143,9 +143,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);
@@ -174,12 +175,29 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
};
}
// Telegram media (attached photos/videos and the channel avatar) is already
// downloaded and self-hosted by the adapter at ingestion time — there's no separate
// media-mode resolution step the way Nitter has; just find those already-stored
// assets by content item id and promote them alongside everything else below.
let telegramMessage: MergedArticle['telegramMessage'] = null;
if (item.telegramMessage) {
storedMediaIds.push(...mediaIdsForContentItem(item.id));
telegramMessage = {
channelName: item.telegramMessage.channelName,
channelUsername: item.telegramMessage.channelUsername,
channelAvatarUrl: item.telegramMessage.channelAvatarUrl,
sourceItemId: item.id,
media: item.telegramMessage.media
};
}
const article = await articles.insertArticle({
title: item.title,
body: item.body || item.summary,
heroImage,
video,
tweet,
telegramMessage,
category,
geo: item.geo,
eventId: item.eventId,
@@ -282,6 +300,7 @@ export async function publishCluster(
heroImage,
video,
tweet: null, // tweets never reach clustering — see priorityQueue.ts's direct-publish bypass
telegramMessage: null, // telegram messages never reach clustering either — same bypass
category,
geo,
eventId: opts.eventId ?? items[0]?.eventId ?? null,
+7 -4
View File
@@ -77,11 +77,14 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
const items = contentItemsDb.unclusteredItemsExcludingSources(eventSourceIds);
if (items.length === 0) return 0;
// YouTube videos and Nitter tweets never get LLM-merged with anything else — each
// is always its own article, same shape whether the AI service is up or not. Route
// them straight to publishDirect, same as the no-AI passthrough path.
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
// anything else — each is always its own article, same shape whether the AI service
// is up or not. Route them straight to publishDirect, same as the no-AI passthrough path.
const directPublishSourceIds = new Set(
sourcesDb.listSources().filter((s) => s.type === 'youtube' || s.type === 'nitter').map((s) => s.id)
sourcesDb
.listSources()
.filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram')
.map((s) => s.id)
);
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
+54
View File
@@ -0,0 +1,54 @@
// Generic AES-256-GCM encrypt/decrypt for at-rest secrets (currently just Telegram
// credentials — see telegram/credentials.ts — but deliberately not Telegram-specific
// here, so any future sensitive field can reuse it). The key is generated once into a
// file next to the DB but not inside it, so a naive copy of just the .db file reveals
// nothing — this is "not easily retrieved," not "safe against a fully compromised
// host": anyone with full filesystem access has both files anyway, same trust model as
// every other secret in this app. The key file must be included in backups, or
// previously-saved Telegram credentials become undecryptable (fixed by logging in
// again — no other data loss).
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DB_PATH = process.env.DB_PATH || './data/homefeed.db';
const KEY_PATH = path.join(path.dirname(DB_PATH), '.encryption-key');
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
let cachedKey: Buffer | null = null;
function getKey(): Buffer {
if (cachedKey) return cachedKey;
if (fs.existsSync(KEY_PATH)) {
cachedKey = fs.readFileSync(KEY_PATH);
return cachedKey;
}
const key = randomBytes(32);
fs.mkdirSync(path.dirname(KEY_PATH), { recursive: true });
fs.writeFileSync(KEY_PATH, key, { mode: 0o600 });
cachedKey = key;
return cachedKey;
}
export function encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
export function decrypt(ciphertext: string): string {
const raw = Buffer.from(ciphertext, 'base64');
const iv = raw.subarray(0, IV_LENGTH);
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
const encrypted = raw.subarray(IV_LENGTH + 16);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
+6 -4
View File
@@ -23,7 +23,8 @@ function rowToArticle(row: any): MergedArticle {
previousArticleId: row.previous_article_id,
nextArticleId: row.next_article_id,
topStories: !!row.top_stories,
tweet: row.tweet ? JSON.parse(row.tweet) : null
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null
};
}
@@ -31,8 +32,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
const id = `art-${randomUUID()}`;
db.prepare(
`INSERT INTO merged_articles
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, title, body, hero_image, video, category, geo, event_id, source_count, sources, published_at, updated_at, merge_confidence, tags, thread_id, previous_article_id, next_article_id, top_stories, tweet, telegram_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
article.title,
@@ -52,7 +53,8 @@ export function insertArticle(article: Omit<MergedArticle, 'id'>): MergedArticle
article.previousArticleId,
article.nextArticleId,
article.topStories ? 1 : 0,
article.tweet ? JSON.stringify(article.tweet) : null
article.tweet ? JSON.stringify(article.tweet) : null,
article.telegramMessage ? JSON.stringify(article.telegramMessage) : null
);
if (article.previousArticleId) {
db.prepare('UPDATE merged_articles SET next_article_id = ? WHERE id = ?').run(id, article.previousArticleId);
+4 -2
View File
@@ -21,6 +21,7 @@ function rowToItem(row: any): ContentItem {
eventId: row.event_id,
clusterId: row.cluster_id,
tweet: row.tweet ? JSON.parse(row.tweet) : null,
telegramMessage: row.telegram_message ? JSON.parse(row.telegram_message) : null,
raw: row.raw ? JSON.parse(row.raw) : null
};
}
@@ -29,8 +30,8 @@ export function insertContentItem(item: Omit<ContentItem, 'id'>): ContentItem {
const id = `ci-${randomUUID()}`;
db.prepare(
`INSERT INTO content_items
(id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, tweet, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(id, source_id, type, title, summary, body, images, videos, link, published_at, fetched_at, tags, geo, embedding, event_id, cluster_id, tweet, telegram_message, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
item.sourceId,
@@ -49,6 +50,7 @@ export function insertContentItem(item: Omit<ContentItem, 'id'>): ContentItem {
item.eventId,
item.clusterId,
item.tweet ? JSON.stringify(item.tweet) : null,
item.telegramMessage ? JSON.stringify(item.telegramMessage) : null,
item.raw ? JSON.stringify(item.raw) : null
);
return { ...item, id };
+26 -1
View File
@@ -59,6 +59,7 @@ export function migrate() {
event_id TEXT,
cluster_id TEXT, -- set once assigned to a cluster awaiting synthesis
tweet TEXT, -- JSON {id, authorName, authorHandle, avatarUrl}, nitter-sourced items only
telegram_message TEXT, -- JSON {channelName, channelUsername, channelAvatarUrl, messageId, media}, telegram-sourced items only
raw TEXT -- JSON, original payload
);
CREATE INDEX IF NOT EXISTS idx_content_items_source ON content_items(source_id);
@@ -84,7 +85,8 @@ export function migrate() {
previous_article_id TEXT,
next_article_id TEXT,
top_stories INTEGER NOT NULL DEFAULT 0, -- true if any contributing source opted into "Push to Top Stories?"
tweet TEXT -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only
tweet TEXT, -- JSON {authorName, authorHandle, avatarUrl, sourceItemId}, nitter-sourced articles only
telegram_message TEXT -- JSON {channelName, channelUsername, channelAvatarUrl, sourceItemId, media}, telegram-sourced articles only
);
CREATE INDEX IF NOT EXISTS idx_articles_published ON merged_articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_thread ON merged_articles(thread_id);
@@ -172,8 +174,25 @@ export function migrate() {
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com'
);
-- 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 +226,12 @@ 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');
}
// 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
+19
View File
@@ -24,6 +24,9 @@ export interface TweetMediaItem {
height: number | null;
}
/** Same shape as TweetMediaItem — distinct name for readability at Telegram call sites. */
export type TelegramMediaItem = TweetMediaItem;
export interface ContentItem {
id: string;
sourceId: string;
@@ -43,6 +46,14 @@ export interface ContentItem {
clusterId: string | null;
/** Nitter-sourced items only — null for everything else. */
tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] } | null;
/** Telegram-sourced items only — null for everything else. */
telegramMessage: {
channelName: string;
channelUsername: string;
channelAvatarUrl: string | null;
messageId: string;
media: TelegramMediaItem[];
} | null;
raw: unknown;
}
@@ -61,6 +72,14 @@ export interface MergedArticle {
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
/** Nitter-sourced articles only — the embed card's author info and attached media (see TweetCard.svelte). Never set alongside video. */
tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | 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;
channelAvatarUrl: string | null;
sourceItemId: string;
media: TelegramMediaItem[];
} | null;
category: string[];
geo: string | null;
eventId: string | null;
+43 -12
View File
@@ -29,25 +29,56 @@ 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 };
}
/** Finds already-downloaded media for a content item (Telegram's adapter self-hosts at ingestion time) so publishDirect can promote them without threading media-asset IDs through the JSON blob. */
export function mediaIdsForContentItem(contentItemId: string): string[] {
const rows = db.prepare('SELECT id FROM media_assets WHERE content_item_id = ?').all(contentItemId) as { id: string }[];
return rows.map((r) => r.id);
}
/**
* Links media rows downloaded before their content item existed (Telegram's adapter —
* see FetchedItem.telegramMediaAssetIds) to the content item id assigned once
* insertContentItem runs. Called by poller.ts right after insertion.
*/
export function attachToContentItem(mediaIds: string[], contentItemId: string): void {
const stmt = db.prepare('UPDATE media_assets SET content_item_id = ? WHERE id = ?');
for (const id of mediaIds) stmt.run(contentItemId, id);
}
/** 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);
+155
View File
@@ -0,0 +1,155 @@
// 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 };
}
+48
View File
@@ -0,0 +1,48 @@
// The only module that touches storage/crypto.ts for Telegram data — everything else
// (telegram/client.ts, the admin routes) goes through these functions and never sees
// ciphertext or handles encryption itself.
import { db } from '../storage/db/index.js';
import { encrypt, decrypt } from '../storage/crypto.js';
interface TelegramCredentialsRow {
api_id_enc: string | null;
api_hash_enc: string | null;
session_enc: string | null;
phone_enc: string | null;
}
function getRow(): TelegramCredentialsRow {
return db.prepare('SELECT * FROM telegram_credentials WHERE id = 1').get() as unknown as TelegramCredentialsRow;
}
export function saveApiCredentials(apiId: number, apiHash: string): void {
db.prepare('UPDATE telegram_credentials SET api_id_enc = ?, api_hash_enc = ? WHERE id = 1').run(
encrypt(String(apiId)),
encrypt(apiHash)
);
}
export function getApiCredentials(): { apiId: number; apiHash: string } | null {
const row = getRow();
if (!row.api_id_enc || !row.api_hash_enc) return null;
return { apiId: Number(decrypt(row.api_id_enc)), apiHash: decrypt(row.api_hash_enc) };
}
export function saveSession(sessionString: string, phone: string): void {
db.prepare('UPDATE telegram_credentials SET session_enc = ?, phone_enc = ? WHERE id = 1').run(
encrypt(sessionString),
encrypt(phone)
);
}
export function getSession(): { sessionString: string; phone: string } | null {
const row = getRow();
if (!row.session_enc || !row.phone_enc) return null;
return { sessionString: decrypt(row.session_enc), phone: decrypt(row.phone_enc) };
}
/** Logout — clears the session/phone only, keeps API ID/hash so re-login doesn't need them re-entered. */
export function clearSession(): void {
db.prepare('UPDATE telegram_credentials SET session_enc = NULL, phone_enc = NULL WHERE id = 1').run();
}