feat: add document analysis + PostgreSQL/Redis persistence

- Add document analysis for PDFs, text, code files (up to 20MB)
- Add PostgreSQL storage for task persistence (survives restarts)
- Add Redis for conversation caching (24hr TTL)
- Create storage.ts abstraction layer with fallback to memory
- Update scheduler to persist tasks to database
- Update config with DATABASE_URL and REDIS_URL support
- Add railway.toml for Railway deployment
- Update README with new architecture and features

https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs
This commit is contained in:
Claude 2026-01-30 07:13:06 +00:00
parent a44d683dd7
commit b5d78db832
No known key found for this signature in database
11 changed files with 863 additions and 30 deletions

246
pnpm-lock.yaml generated
View File

@ -314,7 +314,7 @@ importers:
specifier: ^10.5.0
version: 10.5.0
devDependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
@ -322,7 +322,7 @@ importers:
extensions/line:
devDependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
@ -348,7 +348,7 @@ importers:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
@ -356,7 +356,7 @@ importers:
extensions/memory-core:
devDependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
@ -386,7 +386,7 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
proper-lockfile:
@ -397,12 +397,12 @@ importers:
extensions/nostr:
dependencies:
moltbot:
specifier: workspace:*
version: link:../..
nostr-tools:
specifier: ^2.20.0
version: 2.20.0(typescript@5.9.3)
openclaw:
specifier: workspace:*
version: link:../..
zod:
specifier: ^4.3.6
version: 4.3.6
@ -439,7 +439,7 @@ importers:
specifier: ^4.3.5
version: 4.3.6
devDependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
@ -459,7 +459,7 @@ importers:
extensions/zalo:
dependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
undici:
@ -471,13 +471,19 @@ importers:
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
packages/clawdbot:
dependencies:
moltbot:
openclaw:
specifier: workspace:*
version: link:../..
packages/moltbot:
dependencies:
openclaw:
specifier: workspace:*
version: link:../..
@ -495,10 +501,22 @@ importers:
openai:
specifier: ^4.77.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
pdf-parse:
specifier: ^1.1.1
version: 1.1.4
pg:
specifier: ^8.11.3
version: 8.17.2
redis:
specifier: ^4.6.12
version: 4.7.1
devDependencies:
'@types/node':
specifier: ^22.10.2
version: 22.19.7
'@types/pg':
specifier: ^8.10.9
version: 8.16.0
tsx:
specifier: ^4.7.0
version: 4.21.0
@ -2129,6 +2147,35 @@ packages:
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@redis/bloom@1.2.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/client@1.6.1':
resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
engines: {node: '>=14'}
'@redis/graph@1.1.1':
resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/json@1.0.7':
resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/search@1.2.0':
resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/time-series@1.1.0':
resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
peerDependencies:
'@redis/client': ^1.0.0
'@reflink/reflink-darwin-arm64@0.1.19':
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
engines: {node: '>= 10'}
@ -2786,6 +2833,9 @@ packages:
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
'@types/proper-lockfile@4.1.4':
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
@ -3282,6 +3332,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
cmake-js@7.4.0:
resolution: {integrity: sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q==}
engines: {node: '>= 14.15.0'}
@ -3739,6 +3793,10 @@ packages:
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
engines: {node: '>=18'}
generic-pool@3.9.0:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@ -4474,6 +4532,9 @@ packages:
resolution: {integrity: sha512-fvfW1dUgJdZAdTniC6MzLTMwnNUFKGKaUdRJ1OsveOYlfnPUETBU973CG89565txvbBowCQ4Czdeu3qSX8bNOg==}
hasBin: true
node-ensure@0.0.0:
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@ -4725,6 +4786,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-parse@1.1.4:
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
engines: {node: '>=6.8.1'}
pdfjs-dist@5.4.530:
resolution: {integrity: sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
@ -4735,6 +4800,40 @@ packages:
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.10.1:
resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.11.0:
resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.11.0:
resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.17.2:
resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -4786,6 +4885,22 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres@3.4.8:
resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
engines: {node: '>=12'}
@ -4929,6 +5044,9 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
redis@4.7.1:
resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -5601,6 +5719,10 @@ packages:
utf-8-validate:
optional: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -7828,6 +7950,32 @@ snapshots:
'@protobufjs/utf8@1.1.0': {}
'@redis/bloom@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/client@1.6.1':
dependencies:
cluster-key-slot: 1.1.2
generic-pool: 3.9.0
yallist: 4.0.0
'@redis/graph@1.1.1(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/json@1.0.7(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/search@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/time-series@1.1.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@reflink/reflink-darwin-arm64@0.1.19':
optional: true
@ -8605,7 +8753,7 @@ snapshots:
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 22.19.7
'@types/node': 25.0.10
form-data: 4.0.5
'@types/node@10.17.60': {}
@ -8630,6 +8778,12 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/pg@8.16.0':
dependencies:
'@types/node': 25.0.10
pg-protocol: 1.11.0
pg-types: 2.2.0
'@types/proper-lockfile@4.1.4':
dependencies:
'@types/retry': 0.12.5
@ -9235,6 +9389,8 @@ snapshots:
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
cmake-js@7.4.0:
dependencies:
axios: 1.13.2(debug@4.4.3)
@ -9767,6 +9923,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
generic-pool@3.9.0: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.4.0: {}
@ -10527,6 +10685,8 @@ snapshots:
- supports-color
- utf-8-validate
node-ensure@0.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@ -10827,6 +10987,10 @@ snapshots:
pathe@2.0.3: {}
pdf-parse@1.1.4:
dependencies:
node-ensure: 0.0.0
pdfjs-dist@5.4.530:
optionalDependencies:
'@napi-rs/canvas': 0.1.88
@ -10835,6 +10999,41 @@ snapshots:
performance-now@2.1.0: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.10.1: {}
pg-int8@1.0.1: {}
pg-pool@3.11.0(pg@8.17.2):
dependencies:
pg: 8.17.2
pg-protocol@1.11.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.17.2:
dependencies:
pg-connection-string: 2.10.1
pg-pool: 3.11.0(pg@8.17.2)
pg-protocol: 1.11.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -10885,6 +11084,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres@3.4.8: {}
pretty-bytes@6.1.1:
@ -11073,6 +11282,15 @@ snapshots:
real-require@0.2.0: {}
redis@4.7.1:
dependencies:
'@redis/bloom': 1.2.0(@redis/client@1.6.1)
'@redis/client': 1.6.1
'@redis/graph': 1.1.1(@redis/client@1.6.1)
'@redis/json': 1.0.7(@redis/client@1.6.1)
'@redis/search': 1.2.0(@redis/client@1.6.1)
'@redis/time-series': 1.1.0(@redis/client@1.6.1)
reflect-metadata@0.2.2: {}
request-promise-core@1.1.4(request@2.88.2):
@ -11814,6 +12032,8 @@ snapshots:
ws@8.19.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@4.0.0: {}

View File

@ -21,19 +21,24 @@ Your AI agent that runs on your infrastructure, answers only to you, and you can
```
┌─────────────────────────────────────────────────────┐
│ TELEGRAM (your secure UI) │
│ ├── Chat with AI (text, voice, images)
│ ├── Chat with AI (text, images, documents)
│ ├── Forward anything → get analysis │
│ └── /commands for actions │
├─────────────────────────────────────────────────────┤
│ DOCUMENT ANALYSIS │
│ ├── PDF extraction and summarization │
│ ├── Code files, markdown, JSON, CSV │
│ └── Up to 20MB per document │
├─────────────────────────────────────────────────────┤
│ WEBHOOKS IN (authenticated) │
│ ├── GitHub → "PR merged, here's the summary" │
│ ├── Uptime → "Site down, checking why..." │
│ └── Anything → AI-summarized to Telegram │
├─────────────────────────────────────────────────────┤
│ SCHEDULED TASKS (cron)
│ SCHEDULED TASKS (persistent cron) │
│ ├── Morning briefing │
│ ├── Monitor RSS/sites
│ └── Recurring research
│ ├── Stored in PostgreSQL (survives restarts)
│ └── Conversations cached in Redis
├─────────────────────────────────────────────────────┤
│ SANDBOX (isolated execution) │
│ ├── Docker container │
@ -71,6 +76,10 @@ ANTHROPIC_API_KEY=sk-ant-... # Or OPENAI_API_KEY
### Optional
```bash
# Storage (Railway provides these automatically)
DATABASE_URL=postgresql://... # PostgreSQL for task persistence
REDIS_URL=redis://... # Redis for conversation caching
# Webhooks
WEBHOOK_SECRET=random-32-chars # Auto-generated if missing
WEBHOOK_BASE_PATH=/hooks # Default: /hooks
@ -178,9 +187,12 @@ All webhooks are:
│ • Allowlist auth │ │ • Ephemeral │
└────────────────────┘ └────────────────────┘
[Anthropic/OpenAI]
(Direct API calls)
┌────┴────┬─────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────────────┐
│ Pg │ │ Redis │ │ Anthropic/ │
│ Tasks │ │ Cache │ │ OpenAI │
└────────┘ └────────┘ └────────────────┘
```
## License

View File

@ -53,6 +53,12 @@ export type SecureConfig = {
host: string;
gatewayToken: string;
};
// Storage (optional)
storage: {
postgresUrl?: string;
redisUrl?: string;
};
};
function required(name: string): string {
@ -179,6 +185,10 @@ export function loadSecureConfig(): SecureConfig {
host: optional("HOST", "0.0.0.0"),
gatewayToken: optional("ASSUREBOT_GATEWAY_TOKEN", generateSecureToken()),
},
storage: {
postgresUrl: process.env.DATABASE_URL || process.env.POSTGRES_URL,
redisUrl: process.env.REDIS_URL,
},
};
}
@ -231,5 +241,9 @@ export function redactConfig(config: SecureConfig): Record<string, unknown> {
host: config.server.host,
gatewayToken: "[REDACTED]",
},
storage: {
postgresUrl: config.storage.postgresUrl ? "[CONFIGURED]" : undefined,
redisUrl: config.storage.redisUrl ? "[CONFIGURED]" : undefined,
},
};
}

120
secure/documents.ts Normal file
View File

@ -0,0 +1,120 @@
/**
* AssureBot - Document Analysis
*
* Extract text from various document formats for AI analysis.
*/
export type DocumentResult = {
text: string;
pageCount?: number;
format: string;
truncated: boolean;
};
const MAX_TEXT_LENGTH = 50000; // ~12k tokens
/**
* Extract text from a buffer based on mime type
*/
export async function extractText(
buffer: Buffer,
mimeType: string,
filename?: string
): Promise<DocumentResult> {
const ext = filename?.split(".").pop()?.toLowerCase();
// Plain text files
if (
mimeType.startsWith("text/") ||
ext === "txt" ||
ext === "md" ||
ext === "json" ||
ext === "xml" ||
ext === "csv" ||
ext === "log"
) {
return extractPlainText(buffer);
}
// PDF
if (mimeType === "application/pdf" || ext === "pdf") {
return extractPdf(buffer);
}
// Code files (treat as text)
const codeExtensions = [
"js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "java",
"c", "cpp", "h", "hpp", "cs", "php", "swift", "kt", "scala",
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "env",
"sql", "graphql", "html", "css", "scss", "less"
];
if (ext && codeExtensions.includes(ext)) {
return extractPlainText(buffer, ext);
}
// Unsupported format
return {
text: `[Unsupported document format: ${mimeType}${ext ? ` (.${ext})` : ""}]`,
format: "unsupported",
truncated: false,
};
}
/**
* Extract plain text
*/
function extractPlainText(buffer: Buffer, format = "text"): DocumentResult {
let text = buffer.toString("utf-8");
let truncated = false;
if (text.length > MAX_TEXT_LENGTH) {
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
truncated = true;
}
return { text, format, truncated };
}
/**
* Extract text from PDF using pdf-parse
*/
async function extractPdf(buffer: Buffer): Promise<DocumentResult> {
try {
// Dynamic import to avoid bundling issues
const pdfParse = await import("pdf-parse").then(m => m.default);
const data = await pdfParse(buffer);
let text = data.text;
let truncated = false;
if (text.length > MAX_TEXT_LENGTH) {
text = text.slice(0, MAX_TEXT_LENGTH) + "\n\n[... truncated ...]";
truncated = true;
}
return {
text,
pageCount: data.numpages,
format: "pdf",
truncated,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
text: `[Failed to parse PDF: ${msg}]`,
format: "pdf-error",
truncated: false,
};
}
}
/**
* Summarize document metadata for logging
*/
export function summarizeDocument(result: DocumentResult): string {
const parts = [result.format.toUpperCase()];
if (result.pageCount) parts.push(`${result.pageCount} pages`);
parts.push(`${result.text.length} chars`);
if (result.truncated) parts.push("truncated");
return parts.join(", ");
}

View File

@ -15,6 +15,7 @@ import { createTelegramBot } from "./telegram.js";
import { createWebhookHandler } from "./webhooks.js";
import { createSandboxRunner } from "./sandbox.js";
import { createScheduler } from "./scheduler.js";
import { createStorage, type Storage } from "./storage.js";
async function main() {
console.log("=".repeat(50));
@ -49,6 +50,15 @@ async function main() {
});
audit.startup();
// Create storage (PostgreSQL + Redis)
console.log("[init] Creating storage layer...");
const storage = await createStorage({
postgres: config.storage.postgresUrl ? { url: config.storage.postgresUrl } : undefined,
redis: config.storage.redisUrl ? { url: config.storage.redisUrl } : undefined,
});
const storageHealthy = await storage.isHealthy();
console.log(`[init] Storage healthy: ${storageHealthy}`);
// Create AI agent
console.log(`[init] Creating AI agent (${config.ai.provider})...`);
const agent = createAgent(config, audit);
@ -67,13 +77,14 @@ async function main() {
const { Bot } = await import("grammy");
const bot = new Bot(config.telegram.botToken);
// Create scheduler (needs bot for notifications)
// Create scheduler (needs bot for notifications, storage for persistence)
console.log("[init] Creating scheduler...");
const scheduler = createScheduler({
config,
audit,
agent,
telegramBot: bot,
storage,
});
// Create Telegram bot handler (with sandbox and scheduler)
@ -103,6 +114,7 @@ async function main() {
// Health check
if (url.pathname === "/health" || url.pathname === "/healthz") {
const isStorageHealthy = await storage.isHealthy();
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
@ -111,6 +123,9 @@ async function main() {
uptime: process.uptime(),
telegram: "connected",
sandbox: sandboxAvailable ? "available" : "unavailable",
storage: isStorageHealthy ? "healthy" : "degraded",
postgres: config.storage.postgresUrl ? "configured" : "none",
redis: config.storage.redisUrl ? "configured" : "none",
}));
return;
}
@ -148,6 +163,7 @@ async function main() {
try {
scheduler.stop();
await telegram.stop();
await storage.close();
await new Promise<void>((resolve, reject) => {
server.close((err) => {
@ -175,8 +191,8 @@ async function main() {
console.log(`[start] HTTP server listening on ${config.server.host}:${config.server.port}`);
});
// Start scheduler
scheduler.start();
// Start scheduler (loads tasks from storage)
await scheduler.start();
// Start Telegram bot (polling mode for simplicity)
await telegram.start();
@ -188,6 +204,7 @@ async function main() {
console.log(` Telegram: Polling mode`);
console.log(` Webhooks: http://localhost:${config.server.port}${config.webhooks.basePath}/*`);
console.log(` Health: http://localhost:${config.server.port}/health`);
console.log(` Storage: ${config.storage.postgresUrl ? "PostgreSQL" : "memory"}${config.storage.redisUrl ? " + Redis" : ""}`);
console.log(` Allowed: ${config.telegram.allowedUsers.length} users`);
console.log();
console.log(" Press Ctrl+C to stop");

View File

@ -13,10 +13,14 @@
"@anthropic-ai/sdk": "^0.39.0",
"cron": "^3.1.7",
"grammy": "^1.21.1",
"openai": "^4.77.0"
"openai": "^4.77.0",
"pdf-parse": "^1.1.1",
"pg": "^8.11.3",
"redis": "^4.6.12"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/pg": "^8.10.9",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},

13
secure/pdf-parse.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare module "pdf-parse" {
interface PDFData {
numpages: number;
numrender: number;
info: Record<string, unknown>;
metadata: Record<string, unknown> | null;
text: string;
version: string;
}
function pdfParse(dataBuffer: Buffer, options?: Record<string, unknown>): Promise<PDFData>;
export default pdfParse;
}

10
secure/railway.toml Normal file
View File

@ -0,0 +1,10 @@
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"
[deploy]
startCommand = "node dist/index.js"
healthcheckPath = "/health"
healthcheckTimeout = 30
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3

View File

@ -11,6 +11,7 @@ import type { AuditLogger } from "./audit.js";
import type { AgentCore } from "./agent.js";
import type { Bot } from "grammy";
import { sendToUser } from "./telegram.js";
import type { Storage } from "./storage.js";
export type ScheduledTask = {
id: string;
@ -29,7 +30,7 @@ export type Scheduler = {
enableTask: (id: string, enabled: boolean) => boolean;
listTasks: () => ScheduledTask[];
runTask: (id: string) => Promise<void>;
start: () => void;
start: () => Promise<void>;
stop: () => void;
};
@ -38,6 +39,7 @@ export type SchedulerDeps = {
audit: AuditLogger;
agent: AgentCore;
telegramBot: Bot;
storage?: Storage;
};
function generateId(): string {
@ -45,9 +47,44 @@ function generateId(): string {
}
export function createScheduler(deps: SchedulerDeps): Scheduler {
const { config, audit, agent, telegramBot } = deps;
const { config, audit, agent, telegramBot, storage } = deps;
const tasks = new Map<string, ScheduledTask>();
const cronJobs = new Map<string, CronJob<null, unknown>>();
let initialized = false;
// Save task to storage (if available)
async function persistTask(task: ScheduledTask): Promise<void> {
if (storage) {
await storage.saveTask(task).catch((err) => {
console.error("[scheduler] Failed to persist task:", err);
});
}
}
// Delete task from storage (if available)
async function unpersistTask(id: string): Promise<void> {
if (storage) {
await storage.deleteTask(id).catch((err) => {
console.error("[scheduler] Failed to delete persisted task:", err);
});
}
}
// Load tasks from storage
async function loadFromStorage(): Promise<void> {
if (!storage || initialized) return;
initialized = true;
try {
const storedTasks = await storage.getAllTasks();
for (const task of storedTasks) {
tasks.set(task.id, task);
}
console.log(`[scheduler] Loaded ${storedTasks.length} tasks from storage`);
} catch (err) {
console.error("[scheduler] Failed to load tasks from storage:", err);
}
}
async function executeTask(task: ScheduledTask): Promise<void> {
const startTime = Date.now();
@ -67,6 +104,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
task.lastRun = new Date();
task.lastStatus = "ok";
task.lastError = undefined;
await persistTask(task);
audit.cron({
jobId: task.id,
@ -80,6 +118,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
task.lastRun = new Date();
task.lastStatus = "error";
task.lastError = errorMsg;
await persistTask(task);
audit.cron({
jobId: task.id,
@ -133,6 +172,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
const task: ScheduledTask = { ...taskInput, id };
tasks.set(id, task);
scheduleTask(task);
void persistTask(task);
return id;
},
@ -147,6 +187,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
}
tasks.delete(id);
void unpersistTask(id);
return true;
},
@ -156,6 +197,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
task.enabled = enabled;
scheduleTask(task);
void persistTask(task);
return true;
},
@ -171,16 +213,21 @@ export function createScheduler(deps: SchedulerDeps): Scheduler {
await executeTask(task);
},
start(): void {
async start(): Promise<void> {
if (!config.scheduler.enabled) {
console.log("[scheduler] Scheduler is disabled");
return;
}
console.log("[scheduler] Starting scheduler...");
// Load tasks from persistent storage
await loadFromStorage();
for (const task of tasks.values()) {
scheduleTask(task);
}
console.log(`[scheduler] ${tasks.size} tasks scheduled`);
},
stop(): void {

293
secure/storage.ts Normal file
View File

@ -0,0 +1,293 @@
/**
* AssureBot - Storage Layer
*
* PostgreSQL for persistent data (tasks, audit)
* Redis for caching and sessions
*/
import type { ScheduledTask } from "./scheduler.js";
export type StorageConfig = {
postgres?: {
url: string;
};
redis?: {
url: string;
};
};
export type Storage = {
// Tasks
saveTask: (task: ScheduledTask) => Promise<void>;
getTask: (id: string) => Promise<ScheduledTask | null>;
getAllTasks: () => Promise<ScheduledTask[]>;
deleteTask: (id: string) => Promise<boolean>;
// Conversations (Redis cache)
getConversation: (userId: number) => Promise<ConversationMessage[]>;
saveConversation: (userId: number, messages: ConversationMessage[]) => Promise<void>;
clearConversation: (userId: number) => Promise<void>;
// Health
isHealthy: () => Promise<boolean>;
close: () => Promise<void>;
};
export type ConversationMessage = {
role: "user" | "assistant";
content: string;
timestamp?: string;
};
/**
* In-memory storage (fallback when no DB configured)
*/
function createMemoryStorage(): Storage {
const tasks = new Map<string, ScheduledTask>();
const conversations = new Map<number, ConversationMessage[]>();
return {
async saveTask(task) {
tasks.set(task.id, task);
},
async getTask(id) {
return tasks.get(id) || null;
},
async getAllTasks() {
return Array.from(tasks.values());
},
async deleteTask(id) {
return tasks.delete(id);
},
async getConversation(userId) {
return conversations.get(userId) || [];
},
async saveConversation(userId, messages) {
conversations.set(userId, messages);
},
async clearConversation(userId) {
conversations.delete(userId);
},
async isHealthy() {
return true;
},
async close() {
// Nothing to close
},
};
}
/**
* PostgreSQL storage for tasks
*/
async function createPostgresStorage(url: string): Promise<{
saveTask: Storage["saveTask"];
getTask: Storage["getTask"];
getAllTasks: Storage["getAllTasks"];
deleteTask: Storage["deleteTask"];
isHealthy: () => Promise<boolean>;
close: () => Promise<void>;
}> {
const { default: pg } = await import("pg");
const pool = new pg.Pool({ connectionString: url });
// Create tables if not exist
await pool.query(`
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
schedule TEXT NOT NULL,
prompt TEXT NOT NULL,
enabled BOOLEAN DEFAULT true,
last_run TIMESTAMPTZ,
last_status TEXT,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
console.log("[storage] PostgreSQL connected, tables ready");
return {
async saveTask(task) {
await pool.query(
`INSERT INTO scheduled_tasks (id, name, schedule, prompt, enabled, last_run, last_status, last_error, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (id) DO UPDATE SET
name = $2, schedule = $3, prompt = $4, enabled = $5,
last_run = $6, last_status = $7, last_error = $8, updated_at = NOW()`,
[
task.id,
task.name,
task.schedule,
task.prompt,
task.enabled,
task.lastRun || null,
task.lastStatus || null,
task.lastError || null,
]
);
},
async getTask(id) {
const result = await pool.query(
"SELECT * FROM scheduled_tasks WHERE id = $1",
[id]
);
if (result.rows.length === 0) return null;
return rowToTask(result.rows[0]);
},
async getAllTasks() {
const result = await pool.query("SELECT * FROM scheduled_tasks ORDER BY created_at");
return result.rows.map(rowToTask);
},
async deleteTask(id) {
const result = await pool.query(
"DELETE FROM scheduled_tasks WHERE id = $1",
[id]
);
return (result.rowCount ?? 0) > 0;
},
async isHealthy() {
try {
await pool.query("SELECT 1");
return true;
} catch {
return false;
}
},
async close() {
await pool.end();
},
};
}
function rowToTask(row: Record<string, unknown>): ScheduledTask {
return {
id: row.id as string,
name: row.name as string,
schedule: row.schedule as string,
prompt: row.prompt as string,
enabled: row.enabled as boolean,
lastRun: row.last_run ? new Date(row.last_run as string) : undefined,
lastStatus: row.last_status as "ok" | "error" | undefined,
lastError: row.last_error as string | undefined,
};
}
/**
* Redis storage for conversations/cache
*/
async function createRedisStorage(url: string): Promise<{
getConversation: Storage["getConversation"];
saveConversation: Storage["saveConversation"];
clearConversation: Storage["clearConversation"];
isHealthy: () => Promise<boolean>;
close: () => Promise<void>;
}> {
const { createClient } = await import("redis");
const client = createClient({ url });
client.on("error", (err) => console.error("[redis] Error:", err));
await client.connect();
console.log("[storage] Redis connected");
const CONVERSATION_TTL = 60 * 60 * 24; // 24 hours
const MAX_MESSAGES = 50;
return {
async getConversation(userId) {
const key = `conv:${userId}`;
const data = await client.get(key);
if (!data) return [];
try {
return JSON.parse(data) as ConversationMessage[];
} catch {
return [];
}
},
async saveConversation(userId, messages) {
const key = `conv:${userId}`;
// Keep only last N messages
const trimmed = messages.slice(-MAX_MESSAGES);
await client.setEx(key, CONVERSATION_TTL, JSON.stringify(trimmed));
},
async clearConversation(userId) {
const key = `conv:${userId}`;
await client.del(key);
},
async isHealthy() {
try {
await client.ping();
return true;
} catch {
return false;
}
},
async close() {
await client.quit();
},
};
}
/**
* Create storage based on config
*/
export async function createStorage(config: StorageConfig): Promise<Storage> {
const memory = createMemoryStorage();
let pgStorage: Awaited<ReturnType<typeof createPostgresStorage>> | null = null;
let redisStorage: Awaited<ReturnType<typeof createRedisStorage>> | null = null;
// Try PostgreSQL
if (config.postgres?.url) {
try {
pgStorage = await createPostgresStorage(config.postgres.url);
} catch (err) {
console.error("[storage] PostgreSQL connection failed, using memory:", err);
}
}
// Try Redis
if (config.redis?.url) {
try {
redisStorage = await createRedisStorage(config.redis.url);
} catch (err) {
console.error("[storage] Redis connection failed, using memory:", err);
}
}
return {
// Tasks: prefer PostgreSQL, fallback to memory
saveTask: pgStorage?.saveTask ?? memory.saveTask,
getTask: pgStorage?.getTask ?? memory.getTask,
getAllTasks: pgStorage?.getAllTasks ?? memory.getAllTasks,
deleteTask: pgStorage?.deleteTask ?? memory.deleteTask,
// Conversations: prefer Redis, fallback to memory
getConversation: redisStorage?.getConversation ?? memory.getConversation,
saveConversation: redisStorage?.saveConversation ?? memory.saveConversation,
clearConversation: redisStorage?.clearConversation ?? memory.clearConversation,
async isHealthy() {
const pgOk = pgStorage ? await pgStorage.isHealthy() : true;
const redisOk = redisStorage ? await redisStorage.isHealthy() : true;
return pgOk && redisOk;
},
async close() {
await pgStorage?.close();
await redisStorage?.close();
},
};
}

View File

@ -11,6 +11,7 @@ import type { AuditLogger } from "./audit.js";
import type { AgentCore, ConversationStore, ImageContent } from "./agent.js";
import type { SandboxRunner } from "./sandbox.js";
import type { Scheduler } from "./scheduler.js";
import { extractText, summarizeDocument } from "./documents.js";
export type TelegramBot = {
bot: Bot;
@ -501,13 +502,95 @@ Cron format: minute hour day month weekday
// Handle documents
bot.on("message:document", async (ctx) => {
const userId = ctx.from?.id;
const username = formatUsername(ctx);
if (!userId || !isUserAllowed(userId, config.telegram.allowedUsers)) {
audit.messageBlocked({
userId: userId || 0,
username,
reason: "User not in allowlist",
});
return;
}
await ctx.reply(
"I received your document. Document analysis coming soon - for now, please copy/paste the text content."
);
const doc = ctx.message?.document;
if (!doc) {
await ctx.reply("Could not process document.");
return;
}
const startTime = Date.now();
const caption = ctx.message?.caption || "Please analyze this document and summarize the key points.";
try {
await ctx.replyWithChatAction("typing");
// Check file size (max 20MB)
if (doc.file_size && doc.file_size > 20 * 1024 * 1024) {
await ctx.reply("Document too large (max 20MB).");
return;
}
// Get file info
const file = await ctx.api.getFile(doc.file_id);
if (!file.file_path) {
await ctx.reply("Could not download document.");
return;
}
// Download the file
const fileUrl = `https://api.telegram.org/file/bot${config.telegram.botToken}/${file.file_path}`;
const response = await fetch(fileUrl);
if (!response.ok) {
await ctx.reply("Failed to download document.");
return;
}
const buffer = Buffer.from(await response.arrayBuffer());
const mimeType = doc.mime_type || "application/octet-stream";
// Extract text
const extracted = await extractText(buffer, mimeType, doc.file_name);
if (extracted.format === "unsupported") {
await ctx.reply(
`Unsupported document format: ${mimeType}\n\nSupported: PDF, TXT, MD, JSON, CSV, code files`
);
return;
}
if (extracted.format === "pdf-error") {
await ctx.reply(`Could not parse PDF: ${extracted.text}`);
return;
}
// Analyze with AI
const result = await agent.chat([
{
role: "user",
content: `${caption}\n\n--- Document Content (${summarizeDocument(extracted)}) ---\n\n${extracted.text}`,
},
]);
await ctx.reply(result.text, { parse_mode: "Markdown" }).catch(async () => {
await ctx.reply(result.text);
});
audit.message({
userId,
username,
text: `[DOCUMENT: ${doc.file_name || "unnamed"}] ${caption}`,
response: result.text,
durationMs: Date.now() - startTime,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
audit.error({
error: `Failed to analyze document: ${errorMsg}`,
metadata: { userId, username, filename: doc.file_name },
});
await ctx.reply("Sorry, I couldn't analyze that document. Please try again.");
}
});
return {