tangram-ai 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/release.yml +49 -0
- package/README.md +302 -0
- package/TODO.md +5 -0
- package/config.example.json +66 -0
- package/dist/channels/telegram.js +214 -0
- package/dist/config/load.js +62 -0
- package/dist/config/schema.js +147 -0
- package/dist/deploy/githubRelease.js +34 -0
- package/dist/deploy/paths.js +16 -0
- package/dist/deploy/systemdUser.js +134 -0
- package/dist/deploy/upgrade.js +144 -0
- package/dist/graph/agentGraph.js +351 -0
- package/dist/index.js +267 -0
- package/dist/memory/store.js +168 -0
- package/dist/onboard/files.js +100 -0
- package/dist/onboard/prompts.js +77 -0
- package/dist/onboard/run.js +47 -0
- package/dist/onboard/templates.js +94 -0
- package/dist/providers/anthropicMessages.js +148 -0
- package/dist/providers/openaiResponses.js +172 -0
- package/dist/providers/registry.js +20 -0
- package/dist/providers/types.js +1 -0
- package/dist/scheduler/cronRunner.js +100 -0
- package/dist/scheduler/cronStore.js +167 -0
- package/dist/scheduler/heartbeat.js +77 -0
- package/dist/scheduler/timezone.js +134 -0
- package/dist/session/locks.js +14 -0
- package/dist/skills/catalog.js +137 -0
- package/dist/skills/runtime.js +251 -0
- package/dist/tools/bashTool.js +152 -0
- package/dist/tools/cronTools.js +345 -0
- package/dist/tools/fileTools.js +257 -0
- package/dist/tools/memoryTools.js +88 -0
- package/dist/utils/logger.js +48 -0
- package/dist/utils/path.js +11 -0
- package/dist/utils/telegram.js +25 -0
- package/package.json +44 -0
- package/scripts/bump-version.mjs +44 -0
- package/scripts/prepare-release.mjs +35 -0
- package/src/channels/telegram.ts +258 -0
- package/src/config/load.ts +77 -0
- package/src/config/schema.ts +154 -0
- package/src/deploy/paths.ts +27 -0
- package/src/deploy/systemdUser.ts +169 -0
- package/src/deploy/upgrade.ts +189 -0
- package/src/graph/agentGraph.ts +471 -0
- package/src/index.ts +335 -0
- package/src/memory/store.ts +190 -0
- package/src/onboard/files.ts +127 -0
- package/src/onboard/prompts.ts +123 -0
- package/src/onboard/run.ts +57 -0
- package/src/onboard/templates.ts +105 -0
- package/src/providers/anthropicMessages.ts +189 -0
- package/src/providers/openaiResponses.ts +194 -0
- package/src/providers/registry.ts +24 -0
- package/src/providers/types.ts +46 -0
- package/src/scheduler/cronRunner.ts +117 -0
- package/src/scheduler/cronStore.ts +222 -0
- package/src/scheduler/heartbeat.ts +92 -0
- package/src/scheduler/timezone.ts +186 -0
- package/src/session/locks.ts +16 -0
- package/src/skills/catalog.ts +148 -0
- package/src/skills/runtime.ts +294 -0
- package/src/tools/bashTool.ts +180 -0
- package/src/tools/cronTools.ts +415 -0
- package/src/tools/fileTools.ts +280 -0
- package/src/tools/memoryTools.ts +99 -0
- package/src/utils/logger.ts +56 -0
- package/src/utils/path.ts +9 -0
- package/src/utils/telegram.ts +26 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Setup Node.js
|
|
18
|
+
uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: 22
|
|
21
|
+
cache: npm
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm ci
|
|
25
|
+
|
|
26
|
+
- name: Lint
|
|
27
|
+
run: npm run lint
|
|
28
|
+
|
|
29
|
+
- name: Test
|
|
30
|
+
run: npm test
|
|
31
|
+
|
|
32
|
+
- name: Build
|
|
33
|
+
run: npm run build
|
|
34
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
tag:
|
|
10
|
+
description: "Existing git tag to release (example: v1.2.3)"
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
release:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
env:
|
|
21
|
+
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- name: Setup Node.js
|
|
28
|
+
uses: actions/setup-node@v4
|
|
29
|
+
with:
|
|
30
|
+
node-version: 22
|
|
31
|
+
cache: npm
|
|
32
|
+
|
|
33
|
+
- name: Install dependencies
|
|
34
|
+
run: npm ci
|
|
35
|
+
|
|
36
|
+
- name: Build
|
|
37
|
+
run: npm run build
|
|
38
|
+
|
|
39
|
+
- name: Pack artifact
|
|
40
|
+
run: |
|
|
41
|
+
tar -czf tangram-ai-${RELEASE_TAG}.tar.gz dist package.json package-lock.json README.md
|
|
42
|
+
|
|
43
|
+
- name: Create GitHub Release
|
|
44
|
+
uses: softprops/action-gh-release@v2
|
|
45
|
+
with:
|
|
46
|
+
tag_name: ${{ env.RELEASE_TAG }}
|
|
47
|
+
files: |
|
|
48
|
+
tangram-ai-${{ env.RELEASE_TAG }}.tar.gz
|
|
49
|
+
generate_release_notes: true
|
package/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# tangram2 (MVP)
|
|
2
|
+
|
|
3
|
+
Minimal Telegram chatbot built with **TypeScript + LangGraph**, with **multi-provider config** and **OpenAI Responses API** as the default provider.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
1) Install deps
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2) Create config
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mkdir -p ~/.tangram2 && cp config.example.json ~/.tangram2/config.json
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Edit `~/.tangram2/config.json` and set:
|
|
20
|
+
- `channels.telegram.token`
|
|
21
|
+
- `providers.<yourProviderKey>.apiKey`
|
|
22
|
+
- optionally `providers.<yourProviderKey>.baseUrl`
|
|
23
|
+
|
|
24
|
+
Supported providers:
|
|
25
|
+
- `openai` (Responses API)
|
|
26
|
+
- `anthropic` (Messages API, supports custom `baseUrl`)
|
|
27
|
+
|
|
28
|
+
3) Run
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm run gateway -- --verbose
|
|
32
|
+
npm run onboard
|
|
33
|
+
npm run gateway -- status
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Deploy & Upgrade
|
|
37
|
+
|
|
38
|
+
Deployment bootstrap is part of `onboard`.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm run onboard
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
During onboarding, the wizard can optionally install/start a user-level `systemd` service.
|
|
45
|
+
|
|
46
|
+
Gateway service operations:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run gateway -- status
|
|
50
|
+
npm run gateway -- stop
|
|
51
|
+
npm run gateway -- restart
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Upgrade and rollback:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm run upgrade -- --dry-run
|
|
58
|
+
npm run upgrade -- --version v0.0.1
|
|
59
|
+
npm run rollback -- --to v0.0.1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Notes:
|
|
63
|
+
- `upgrade` uses global npm install (`npm install -g tangram-ai@...`) and auto-restarts service
|
|
64
|
+
- use `--no-restart` to skip restart
|
|
65
|
+
- if `systemd --user` is unavailable, run foreground mode: `npm run gateway -- --verbose`
|
|
66
|
+
|
|
67
|
+
## Release Workflow
|
|
68
|
+
|
|
69
|
+
This repo includes a baseline release pipeline:
|
|
70
|
+
|
|
71
|
+
- CI workflow: `.github/workflows/ci.yml`
|
|
72
|
+
- runs on push/PR
|
|
73
|
+
- executes `npm ci`, `npm run lint`, `npm test`, `npm run build`
|
|
74
|
+
- Release workflow: `.github/workflows/release.yml`
|
|
75
|
+
- triggers on tag push `v*`
|
|
76
|
+
- builds project and uploads tarball asset to GitHub Release
|
|
77
|
+
|
|
78
|
+
### Local release commands
|
|
79
|
+
|
|
80
|
+
- Bump version only:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm run release:patch
|
|
84
|
+
npm run release:minor
|
|
85
|
+
npm run release:major
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- Prepare a full release (bump version + build + commit + tag):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run release:prepare:patch
|
|
92
|
+
npm run release:prepare:minor
|
|
93
|
+
npm run release:prepare:major
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
After `release:prepare:*` completes, push branch and tag:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git push origin master
|
|
100
|
+
git push origin vX.Y.Z
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pushing the tag triggers GitHub Actions release creation automatically.
|
|
104
|
+
|
|
105
|
+
## Onboard Wizard
|
|
106
|
+
|
|
107
|
+
Run `npm run onboard` for an interactive setup that:
|
|
108
|
+
- asks for provider/API/Telegram settings
|
|
109
|
+
- applies developer-default permissions (shell enabled but restricted)
|
|
110
|
+
- initializes `~/.tangram2` directories and baseline files
|
|
111
|
+
- initializes runtime directories under `~/.tangram2/app`
|
|
112
|
+
- can install/start user-level `systemd` service
|
|
113
|
+
- handles existing files one by one (`overwrite` / `skip` / `backup then overwrite`)
|
|
114
|
+
|
|
115
|
+
## Memory (Shared)
|
|
116
|
+
|
|
117
|
+
Shared memory lives under the configured workspace directory (default: `~/.tangram2/workspace`):
|
|
118
|
+
- Long-term memory: `memory/memory.md`
|
|
119
|
+
- Daily notes: `memory/YYYY-MM-DD.md`
|
|
120
|
+
|
|
121
|
+
Telegram commands:
|
|
122
|
+
- `/memory` show memory context
|
|
123
|
+
- `/remember <text>` append to today's daily memory
|
|
124
|
+
- `/remember_long <text>` append to long-term memory
|
|
125
|
+
|
|
126
|
+
Telegram UX behaviors:
|
|
127
|
+
- bot sends `typing` action while processing
|
|
128
|
+
- during tool-calling loops, progress hints may be sent as temporary `⏳ ...` updates (controlled by `channels.telegram.progressUpdates`, default `true`)
|
|
129
|
+
|
|
130
|
+
## Memory Tools (LLM)
|
|
131
|
+
|
|
132
|
+
The agent exposes function tools to the model (via OpenAI Responses API):
|
|
133
|
+
- `memory_search` search shared memory files
|
|
134
|
+
- `memory_write` append to shared memory files
|
|
135
|
+
- `file_read` read local skill/content files from allowed roots
|
|
136
|
+
- `file_write` write local files under allowed roots
|
|
137
|
+
- `file_edit` edit files by targeted text replacement
|
|
138
|
+
- `bash` execute CLI commands when `agents.defaults.shell.enabled=true`
|
|
139
|
+
- `cron_schedule` schedule one-time/repeating callbacks
|
|
140
|
+
- `cron_list` list scheduled callbacks
|
|
141
|
+
- `cron_cancel` cancel scheduled callbacks
|
|
142
|
+
|
|
143
|
+
The LangGraph workflow also runs a post-reply "memory reflection" node that can automatically summarize the latest turn into memory using a strict JSON format prompt.
|
|
144
|
+
|
|
145
|
+
## Skills Metadata
|
|
146
|
+
|
|
147
|
+
The runtime discovers local skills and injects a compact skills list into the model instructions, so the model can decide which skill to open/use.
|
|
148
|
+
|
|
149
|
+
By default it scans:
|
|
150
|
+
- `~/.tangram2/skills`
|
|
151
|
+
|
|
152
|
+
You can customize via `agents.defaults.skills`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"agents": {
|
|
157
|
+
"defaults": {
|
|
158
|
+
"skills": {
|
|
159
|
+
"enabled": true,
|
|
160
|
+
"roots": [
|
|
161
|
+
"~/.tangram2/skills"
|
|
162
|
+
],
|
|
163
|
+
"maxSkills": 40,
|
|
164
|
+
"hotReload": {
|
|
165
|
+
"enabled": true,
|
|
166
|
+
"debounceMs": 800,
|
|
167
|
+
"logDiff": true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Hot reload behavior:
|
|
176
|
+
- skill directory/file changes are detected with filesystem watchers
|
|
177
|
+
- reload is debounced (`hotReload.debounceMs`) to avoid noisy rapid rescans
|
|
178
|
+
- updates apply globally to the next LLM execution without restarting gateway
|
|
179
|
+
- when `hotReload.logDiff=true`, gateway logs added/removed/changed skills
|
|
180
|
+
|
|
181
|
+
`file_read` / `file_write` / `file_edit` are path-restricted to these resolved skill roots.
|
|
182
|
+
|
|
183
|
+
## Shell Tool (Optional)
|
|
184
|
+
|
|
185
|
+
Enable shell execution only when needed:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"agents": {
|
|
190
|
+
"defaults": {
|
|
191
|
+
"shell": {
|
|
192
|
+
"enabled": true,
|
|
193
|
+
"fullAccess": false,
|
|
194
|
+
"roots": ["~/.tangram2"],
|
|
195
|
+
"defaultCwd": "~/.tangram2/workspace",
|
|
196
|
+
"timeoutMs": 120000,
|
|
197
|
+
"maxOutputChars": 12000
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
When enabled, the model can call a `bash` tool with argv form commands (e.g. `['bash','-lc','ls -la']`), constrained to allowed roots.
|
|
205
|
+
|
|
206
|
+
Set `shell.fullAccess=true` to disable cwd root restrictions and allow any local path.
|
|
207
|
+
|
|
208
|
+
## Heartbeat (Optional)
|
|
209
|
+
|
|
210
|
+
Heartbeat periodically reads `HEARTBEAT.md` and triggers a model run with that content.
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"agents": {
|
|
215
|
+
"defaults": {
|
|
216
|
+
"heartbeat": {
|
|
217
|
+
"enabled": true,
|
|
218
|
+
"intervalSeconds": 300,
|
|
219
|
+
"filePath": "~/.tangram2/workspace/HEARTBEAT.md",
|
|
220
|
+
"threadId": "heartbeat"
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Cron Scheduler
|
|
228
|
+
|
|
229
|
+
Cron scheduler runs due tasks and sends their payload to the model at the scheduled time.
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"agents": {
|
|
234
|
+
"defaults": {
|
|
235
|
+
"cron": {
|
|
236
|
+
"enabled": true,
|
|
237
|
+
"tickSeconds": 15,
|
|
238
|
+
"storePath": "~/.tangram2/workspace/cron-tasks.json",
|
|
239
|
+
"defaultThreadId": "cron"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Model-facing cron tools:
|
|
247
|
+
- `cron_schedule` set run time, repeat mode, and `callbackPrompt` (sent to model when due, not directly to user)
|
|
248
|
+
- `cron_schedule_local` set local timezone schedules (e.g. daily 09:00 Asia/Shanghai) and `callbackPrompt`
|
|
249
|
+
- `cron_list` inspect pending tasks
|
|
250
|
+
- `cron_cancel` remove a task by id
|
|
251
|
+
|
|
252
|
+
Compatibility note:
|
|
253
|
+
- old `message` field is still accepted for backward compatibility, but `callbackPrompt` is recommended
|
|
254
|
+
|
|
255
|
+
## Config
|
|
256
|
+
|
|
257
|
+
This project supports **multiple provider instances**. Example:
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"providers": {
|
|
262
|
+
"openai": {
|
|
263
|
+
"type": "openai",
|
|
264
|
+
"apiKey": "sk-...",
|
|
265
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
266
|
+
"defaultModel": "gpt-4.1-mini"
|
|
267
|
+
},
|
|
268
|
+
"anthropic": {
|
|
269
|
+
"type": "anthropic",
|
|
270
|
+
"apiKey": "sk-ant-...",
|
|
271
|
+
"baseUrl": "https://api.anthropic.com",
|
|
272
|
+
"defaultModel": "claude-3-5-sonnet-latest"
|
|
273
|
+
},
|
|
274
|
+
"local": {
|
|
275
|
+
"type": "openai",
|
|
276
|
+
"apiKey": "dummy",
|
|
277
|
+
"baseUrl": "http://localhost:8000/v1",
|
|
278
|
+
"defaultModel": "meta-llama/Llama-3.1-8B-Instruct"
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
"agents": {
|
|
282
|
+
"defaults": {
|
|
283
|
+
"provider": "openai",
|
|
284
|
+
"temperature": 0.7,
|
|
285
|
+
"systemPrompt": "You are a helpful assistant."
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
"channels": {
|
|
289
|
+
"telegram": {
|
|
290
|
+
"enabled": true,
|
|
291
|
+
"token": "123456:ABCDEF...",
|
|
292
|
+
"allowFrom": []
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Config lookup order:
|
|
299
|
+
- `--config <path>`
|
|
300
|
+
- `TANGRAM2_CONFIG`
|
|
301
|
+
- `~/.tangram2/config.json`
|
|
302
|
+
- `./config.json` (legacy fallback)
|
package/TODO.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"providers": {
|
|
3
|
+
"openai": {
|
|
4
|
+
"type": "openai",
|
|
5
|
+
"apiKey": "sk-...",
|
|
6
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
7
|
+
"defaultModel": "gpt-4.1-mini"
|
|
8
|
+
},
|
|
9
|
+
"anthropic": {
|
|
10
|
+
"type": "anthropic",
|
|
11
|
+
"apiKey": "sk-ant-...",
|
|
12
|
+
"baseUrl": "https://api.anthropic.com",
|
|
13
|
+
"defaultModel": "claude-3-5-sonnet-latest"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"agents": {
|
|
17
|
+
"defaults": {
|
|
18
|
+
"provider": "openai",
|
|
19
|
+
"workspace": "~/.tangram2/workspace",
|
|
20
|
+
"skills": {
|
|
21
|
+
"enabled": true,
|
|
22
|
+
"roots": [
|
|
23
|
+
"~/.tangram2/skills"
|
|
24
|
+
],
|
|
25
|
+
"maxSkills": 40,
|
|
26
|
+
"hotReload": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"debounceMs": 800,
|
|
29
|
+
"logDiff": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"shell": {
|
|
33
|
+
"enabled": false,
|
|
34
|
+
"fullAccess": false,
|
|
35
|
+
"roots": [
|
|
36
|
+
"~/.tangram2"
|
|
37
|
+
],
|
|
38
|
+
"defaultCwd": "~/.tangram2/workspace",
|
|
39
|
+
"timeoutMs": 120000,
|
|
40
|
+
"maxOutputChars": 12000
|
|
41
|
+
},
|
|
42
|
+
"heartbeat": {
|
|
43
|
+
"enabled": false,
|
|
44
|
+
"intervalSeconds": 300,
|
|
45
|
+
"filePath": "~/.tangram2/workspace/HEARTBEAT.md",
|
|
46
|
+
"threadId": "heartbeat"
|
|
47
|
+
},
|
|
48
|
+
"cron": {
|
|
49
|
+
"enabled": true,
|
|
50
|
+
"tickSeconds": 15,
|
|
51
|
+
"storePath": "~/.tangram2/workspace/cron-tasks.json",
|
|
52
|
+
"defaultThreadId": "cron"
|
|
53
|
+
},
|
|
54
|
+
"temperature": 0.7,
|
|
55
|
+
"systemPrompt": "You are a helpful assistant. Keep replies concise."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"channels": {
|
|
59
|
+
"telegram": {
|
|
60
|
+
"enabled": true,
|
|
61
|
+
"token": "123456:ABCDEF...",
|
|
62
|
+
"progressUpdates": true,
|
|
63
|
+
"allowFrom": []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Telegraf } from "telegraf";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { splitTelegramMessage } from "../utils/telegram.js";
|
|
5
|
+
import { withKeyLock } from "../session/locks.js";
|
|
6
|
+
function createTypingLoop(ctx, chatId) {
|
|
7
|
+
let stopped = false;
|
|
8
|
+
const run = async () => {
|
|
9
|
+
while (!stopped) {
|
|
10
|
+
try {
|
|
11
|
+
await ctx.telegram.sendChatAction(chatId, "typing");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
}
|
|
15
|
+
await sleep(3500);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
void run();
|
|
19
|
+
return () => {
|
|
20
|
+
stopped = true;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function resolveChatId(rawThreadId, fallbackThreadId) {
|
|
24
|
+
const aliases = new Set(["current", "current_thread", "this_thread", "this"]);
|
|
25
|
+
let effective = rawThreadId.trim();
|
|
26
|
+
if (aliases.has(effective) && fallbackThreadId) {
|
|
27
|
+
effective = fallbackThreadId;
|
|
28
|
+
}
|
|
29
|
+
if (/^-?\d+$/.test(effective)) {
|
|
30
|
+
const asNum = Number(effective);
|
|
31
|
+
if (Number.isSafeInteger(asNum)) {
|
|
32
|
+
return asNum;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return effective;
|
|
36
|
+
}
|
|
37
|
+
export async function startTelegramGateway(config, invoke, memory, logger) {
|
|
38
|
+
const tg = config.channels.telegram;
|
|
39
|
+
if (!tg?.enabled) {
|
|
40
|
+
throw new Error("Telegram channel is not enabled in config.channels.telegram.enabled");
|
|
41
|
+
}
|
|
42
|
+
if (!tg.token) {
|
|
43
|
+
throw new Error("Telegram token is required when channels.telegram.enabled=true");
|
|
44
|
+
}
|
|
45
|
+
const bot = new Telegraf(tg.token);
|
|
46
|
+
let lastSeenChatId;
|
|
47
|
+
logger?.info("Telegram gateway starting", {
|
|
48
|
+
allowFromCount: Array.isArray(tg.allowFrom) ? tg.allowFrom.length : 0,
|
|
49
|
+
progressUpdates: tg.progressUpdates !== false,
|
|
50
|
+
});
|
|
51
|
+
const replyText = async (ctx, text) => {
|
|
52
|
+
const safeText = text && text.length > 0 ? text : "(empty reply)";
|
|
53
|
+
// Use a safety margin below Telegram's hard 4096-char limit.
|
|
54
|
+
const parts = splitTelegramMessage(safeText, 3800);
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
await ctx.reply(part, { link_preview_options: { is_disabled: true } });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const sendToThread = async ({ threadId, text }) => {
|
|
60
|
+
const safeText = text && text.length > 0 ? text : "(empty reply)";
|
|
61
|
+
const chatId = resolveChatId(threadId, lastSeenChatId);
|
|
62
|
+
const parts = splitTelegramMessage(safeText, 3800);
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
await bot.telegram.sendMessage(chatId, part, { link_preview_options: { is_disabled: true } });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
bot.start(async (ctx) => {
|
|
68
|
+
await replyText(ctx, "Connected. Send me a message.");
|
|
69
|
+
});
|
|
70
|
+
bot.command("memory", async (ctx) => {
|
|
71
|
+
logger?.debug("Command /memory", {
|
|
72
|
+
chatId: String(ctx.chat?.id ?? ""),
|
|
73
|
+
userId: String(ctx.from?.id ?? ""),
|
|
74
|
+
});
|
|
75
|
+
const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
|
|
76
|
+
if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
|
|
77
|
+
if (!userId || !tg.allowFrom.includes(userId)) {
|
|
78
|
+
await replyText(ctx, "Not allowed.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const text = await withKeyLock("memory", async () => memory.getMemoryContext());
|
|
83
|
+
if (!text) {
|
|
84
|
+
await replyText(ctx, "(memory is empty)");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Avoid spamming too many messages if memory grows large.
|
|
88
|
+
const maxChars = 20000;
|
|
89
|
+
const trimmed = text.length > maxChars ? text.slice(-maxChars) : text;
|
|
90
|
+
const note = text.length > maxChars ? "(showing last 20000 chars)\n\n" : "";
|
|
91
|
+
await replyText(ctx, note + trimmed);
|
|
92
|
+
});
|
|
93
|
+
bot.command("remember", async (ctx) => {
|
|
94
|
+
logger?.debug("Command /remember", {
|
|
95
|
+
chatId: String(ctx.chat?.id ?? ""),
|
|
96
|
+
userId: String(ctx.from?.id ?? ""),
|
|
97
|
+
});
|
|
98
|
+
const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
|
|
99
|
+
if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
|
|
100
|
+
if (!userId || !tg.allowFrom.includes(userId)) {
|
|
101
|
+
await replyText(ctx, "Not allowed.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const raw = ctx.message?.text;
|
|
106
|
+
const payload = raw?.replace(/^\/remember\s*/i, "").trim() ?? "";
|
|
107
|
+
if (!payload) {
|
|
108
|
+
await replyText(ctx, "Usage: /remember <text>");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await withKeyLock("memory", async () => memory.appendToday(payload));
|
|
112
|
+
await replyText(ctx, "Saved to today's memory.");
|
|
113
|
+
});
|
|
114
|
+
bot.command("remember_long", async (ctx) => {
|
|
115
|
+
logger?.debug("Command /remember_long", {
|
|
116
|
+
chatId: String(ctx.chat?.id ?? ""),
|
|
117
|
+
userId: String(ctx.from?.id ?? ""),
|
|
118
|
+
});
|
|
119
|
+
const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
|
|
120
|
+
if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
|
|
121
|
+
if (!userId || !tg.allowFrom.includes(userId)) {
|
|
122
|
+
await replyText(ctx, "Not allowed.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const raw = ctx.message?.text;
|
|
127
|
+
const payload = raw?.replace(/^\/remember_long\s*/i, "").trim() ?? "";
|
|
128
|
+
if (!payload) {
|
|
129
|
+
await replyText(ctx, "Usage: /remember_long <text>");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await withKeyLock("memory", async () => memory.appendLongTerm(payload));
|
|
133
|
+
await replyText(ctx, "Saved to long-term memory.");
|
|
134
|
+
});
|
|
135
|
+
bot.on("text", async (ctx) => {
|
|
136
|
+
const chatId = String(ctx.chat.id);
|
|
137
|
+
lastSeenChatId = chatId;
|
|
138
|
+
const userId = ctx.from?.id != null ? String(ctx.from.id) : "";
|
|
139
|
+
const text = ctx.message?.text;
|
|
140
|
+
if (!text)
|
|
141
|
+
return;
|
|
142
|
+
logger?.debug("Incoming text", {
|
|
143
|
+
chatId,
|
|
144
|
+
userId,
|
|
145
|
+
length: text.length,
|
|
146
|
+
});
|
|
147
|
+
if (Array.isArray(tg.allowFrom) && tg.allowFrom.length > 0) {
|
|
148
|
+
if (!userId || !tg.allowFrom.includes(userId)) {
|
|
149
|
+
await replyText(ctx, "Not allowed.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const stopTyping = createTypingLoop(ctx, chatId);
|
|
155
|
+
const progressThrottleMs = 1200;
|
|
156
|
+
let lastProgressAt = 0;
|
|
157
|
+
const progressEnabled = tg.progressUpdates !== false;
|
|
158
|
+
const onProgress = async (event) => {
|
|
159
|
+
if (event.kind === "tool_progress" && !progressEnabled)
|
|
160
|
+
return;
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (now - lastProgressAt < progressThrottleMs)
|
|
163
|
+
return;
|
|
164
|
+
lastProgressAt = now;
|
|
165
|
+
if (event.kind === "assistant_explanation") {
|
|
166
|
+
await replyText(ctx, `💬 ${event.message}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await replyText(ctx, `⏳ ${event.message}`);
|
|
170
|
+
};
|
|
171
|
+
// Prevent concurrent invokes within a chat to keep ordering and memory sane.
|
|
172
|
+
try {
|
|
173
|
+
const reply = await withKeyLock(chatId, async () => invoke({ threadId: chatId, text, onProgress }));
|
|
174
|
+
logger?.debug("Outgoing reply", { chatId, length: reply.length });
|
|
175
|
+
await replyText(ctx, reply);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
stopTyping();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
// Avoid echoing huge payloads back to Telegram (which can recurse into the same error).
|
|
183
|
+
// Log full error locally.
|
|
184
|
+
const errorId = randomUUID().slice(0, 8);
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.error(`[telegram][${errorId}]`, err);
|
|
187
|
+
logger?.error("Invoke failed", { errorId, chatId, userId, message: err?.message });
|
|
188
|
+
// User-facing error should be short and never include provider payloads.
|
|
189
|
+
const safe = `Provider error (${errorId}). Check server logs.`;
|
|
190
|
+
try {
|
|
191
|
+
await replyText(ctx, safe);
|
|
192
|
+
}
|
|
193
|
+
catch (inner) {
|
|
194
|
+
// eslint-disable-next-line no-console
|
|
195
|
+
console.error("Failed to send error message", inner);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
void bot
|
|
200
|
+
.launch()
|
|
201
|
+
.then(() => {
|
|
202
|
+
logger?.info("Telegram bot launched");
|
|
203
|
+
})
|
|
204
|
+
.catch((err) => {
|
|
205
|
+
logger?.error("Telegram bot launch failed", {
|
|
206
|
+
message: err?.message,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
// Graceful shutdown.
|
|
210
|
+
const stop = () => bot.stop("SIGTERM");
|
|
211
|
+
process.once("SIGINT", stop);
|
|
212
|
+
process.once("SIGTERM", stop);
|
|
213
|
+
return { sendToThread };
|
|
214
|
+
}
|