@ironcode-ai/telegram 1.16.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.
Files changed (3) hide show
  1. package/README.md +97 -0
  2. package/package.json +24 -0
  3. package/src/index.ts +344 -0
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @ironcode-ai/telegram
2
+
3
+ Telegram bot integration for ironcode. Send messages from Telegram to run an AI coding agent on your machine — tool calls, file edits, and responses stream back in real-time.
4
+
5
+ ## Requirements
6
+
7
+ - [Bun](https://bun.sh) >= 1.0
8
+ - [`ironcode-ai`](https://www.npmjs.com/package/ironcode-ai) CLI installed and authenticated
9
+
10
+ ## Installation
11
+
12
+ ### 1. Install ironcode-ai CLI
13
+
14
+ ```bash
15
+ bun install -g ironcode-ai
16
+ ```
17
+
18
+ ### 2. Authenticate a provider
19
+
20
+ ```bash
21
+ ironcode auth login # GitHub Copilot (recommended)
22
+ ironcode auth login anthropic # Anthropic
23
+ ironcode auth login openai # OpenAI
24
+ ```
25
+
26
+ List available models:
27
+ ```bash
28
+ ironcode models
29
+ ```
30
+
31
+ ### 3. Create a Telegram Bot
32
+
33
+ 1. Open Telegram → search for [@BotFather](https://t.me/BotFather)
34
+ 2. Send `/newbot` → follow prompts → copy the **Bot Token**
35
+
36
+ ### 4. Install ironcode-telegram
37
+
38
+ ```bash
39
+ bun install -g @ironcode-ai/telegram
40
+ ```
41
+
42
+ ### 5. Configure
43
+
44
+ ```bash
45
+ ironcode-telegram setup
46
+ # Bot Token (from @BotFather): ****
47
+ # Model [github-copilot/claude-sonnet-4.6]:
48
+ # ✅ Config saved to ~/.config/ironcode/telegram.json
49
+ ```
50
+
51
+ Config is stored at `~/.config/ironcode/telegram.json`:
52
+ ```json
53
+ {
54
+ "token": "your-bot-token",
55
+ "model": "github-copilot/claude-sonnet-4.6"
56
+ }
57
+ ```
58
+
59
+ ### 6. Run
60
+
61
+ ```bash
62
+ # cd into the repo you want the agent to work on
63
+ cd /path/to/your/project
64
+
65
+ ironcode-telegram
66
+ ```
67
+
68
+ The agent runs with the current directory as its working directory, so it can read and edit files in your project.
69
+
70
+ ## Bot Commands
71
+
72
+ | Command | Description |
73
+ |---|---|
74
+ | `/sessions` | List recent sessions — tap to switch |
75
+ | `/new` | Start a new session |
76
+ | `/info` | Show current session details (title, ID, file changes) |
77
+ | `/start` | Show help |
78
+
79
+ ## How It Works
80
+
81
+ ```
82
+ You send a message
83
+ → Bot creates/resumes an ironcode session on your machine
84
+ → Agent reads/writes files, runs bash, calls LLM
85
+ → Each completed tool call is sent as a separate message
86
+ → Text response is streamed live by editing a ⏳ placeholder
87
+ → Final response replaces the placeholder when done
88
+ ```
89
+
90
+ Each chat (DM, group, or group thread) gets its own independent session. Use `/sessions` to switch between them.
91
+
92
+ ## Session Management
93
+
94
+ - **Automatic**: Each chat automatically gets its own session on first message.
95
+ - **Switch**: `/sessions` shows the 10 most recent sessions with an inline keyboard. Tap any to resume it.
96
+ - **New**: `/new` clears the current slot — your next message starts a fresh session.
97
+ - **Persistent**: Sessions and their file change history are preserved across bot restarts.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@ironcode-ai/telegram",
3
+ "version": "1.16.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "ironcode-telegram": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "dev": "bun run src/index.ts",
14
+ "typecheck": "tsgo --noEmit"
15
+ },
16
+ "dependencies": {
17
+ "@ironcode-ai/sdk": "^1.15.4",
18
+ "grammy": "^1.36.3"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.13.9",
22
+ "typescript": "^5.9.3"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env bun
2
+ import { Bot, InlineKeyboard } from "grammy"
3
+ import { createIroncode } from "@ironcode-ai/sdk"
4
+ import type { Session } from "@ironcode-ai/sdk"
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs"
6
+ import { join } from "path"
7
+ import { homedir } from "os"
8
+ import * as readline from "readline"
9
+
10
+ // ── Config ────────────────────────────────────────────────────────────────────
11
+
12
+ type Config = {
13
+ token: string
14
+ model?: string
15
+ }
16
+
17
+ function configPath() {
18
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
19
+ return join(xdg, "ironcode", "telegram.json")
20
+ }
21
+
22
+ function loadConfig(): Config | null {
23
+ const p = configPath()
24
+ if (!existsSync(p)) return null
25
+ try {
26
+ return JSON.parse(readFileSync(p, "utf8")) as Config
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ function saveConfig(cfg: Config) {
33
+ const p = configPath()
34
+ mkdirSync(join(p, ".."), { recursive: true })
35
+ writeFileSync(p, JSON.stringify(cfg, null, 2))
36
+ }
37
+
38
+ async function ask(prompt: string): Promise<string> {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
40
+ return new Promise((resolve) => {
41
+ rl.question(prompt, (ans) => {
42
+ rl.close()
43
+ resolve(ans.trim())
44
+ })
45
+ })
46
+ }
47
+
48
+ // ── Setup command ─────────────────────────────────────────────────────────────
49
+
50
+ if (process.argv[2] === "setup") {
51
+ console.log("⚙️ ironcode-telegram setup\n")
52
+
53
+ const existing = loadConfig()
54
+ const token = await ask(`Bot Token (from @BotFather)${existing?.token ? " [keep current: Enter]" : ""}: `)
55
+ const model = await ask(`Model [${existing?.model ?? "github-copilot/claude-sonnet-4.6"}]: `)
56
+
57
+ const cfg: Config = {
58
+ token: token || existing?.token || "",
59
+ model: model || existing?.model || "github-copilot/claude-sonnet-4.6",
60
+ }
61
+
62
+ if (!cfg.token) {
63
+ console.error("❌ Bot Token is required.")
64
+ process.exit(1)
65
+ }
66
+
67
+ saveConfig(cfg)
68
+ console.log(`\n✅ Config saved to ${configPath()}`)
69
+ console.log(" Run: ironcode-telegram\n")
70
+ process.exit(0)
71
+ }
72
+
73
+ // ── Load config ───────────────────────────────────────────────────────────────
74
+
75
+ const cfg = loadConfig()
76
+ if (!cfg) {
77
+ console.error(`❌ No config found. Run:\n\n ironcode-telegram setup\n`)
78
+ process.exit(1)
79
+ }
80
+
81
+ // ── Helpers ───────────────────────────────────────────────────────────────────
82
+
83
+ function parseModel(model: string) {
84
+ const [providerID, ...rest] = model.split("/")
85
+ return { providerID, modelID: rest.join("/") }
86
+ }
87
+
88
+ function relativeTime(ms: number) {
89
+ const diff = Date.now() - ms
90
+ const m = Math.floor(diff / 60000)
91
+ const h = Math.floor(diff / 3600000)
92
+ const d = Math.floor(diff / 86400000)
93
+ if (m < 1) return "just now"
94
+ if (m < 60) return `${m}m ago`
95
+ if (h < 24) return `${h}h ago`
96
+ return `${d}d ago`
97
+ }
98
+
99
+ function sessionLabel(s: Session, isCurrent: boolean) {
100
+ const changes = s.summary ? ` (+${s.summary.additions}/-${s.summary.deletions})` : ""
101
+ const cur = isCurrent ? " ✓" : ""
102
+ return `${s.title}${changes} · ${relativeTime(s.time.updated)}${cur}`
103
+ }
104
+
105
+ // ── Bot ───────────────────────────────────────────────────────────────────────
106
+
107
+ const bot = new Bot(cfg.token)
108
+
109
+ console.log("🚀 Starting ironcode server...")
110
+ const ironcode = await createIroncode({ port: 0 })
111
+ console.log("✅ Ironcode server ready at", ironcode.server.url)
112
+
113
+ type SessionState = {
114
+ sessionId: string
115
+ chatId: number
116
+ threadId?: number
117
+ liveMessageId?: number
118
+ liveText: string
119
+ lastEditMs: number
120
+ }
121
+
122
+ const EDIT_INTERVAL_MS = 1200
123
+
124
+ const sessions = new Map<string, SessionState>()
125
+
126
+ function getChatKey(chatId: number, threadId?: number) {
127
+ return threadId ? `${chatId}-${threadId}` : `${chatId}`
128
+ }
129
+
130
+ async function editLive(state: SessionState, text: string) {
131
+ if (!state.liveMessageId) return
132
+ await bot.api
133
+ .editMessageText(state.chatId, state.liveMessageId, text || "…")
134
+ .catch(() => {})
135
+ }
136
+
137
+ // Event loop
138
+ ;(async () => {
139
+ const events = await ironcode.client.event.subscribe()
140
+ for await (const event of events.stream) {
141
+ const getState = (sessionID: string) =>
142
+ [...sessions.values()].find((s) => s.sessionId === sessionID)
143
+
144
+ if (event.type === "message.part.updated") {
145
+ const part = event.properties.part as any
146
+ const state = getState(part.sessionID)
147
+ if (!state) continue
148
+
149
+ if (part.type === "text") {
150
+ state.liveText = part.text
151
+ const now = Date.now()
152
+ if (state.liveMessageId && now - state.lastEditMs > EDIT_INTERVAL_MS) {
153
+ await editLive(state, state.liveText)
154
+ state.lastEditMs = now
155
+ }
156
+ } else if (part.type === "tool" && part.state?.status === "completed") {
157
+ await bot.api
158
+ .sendMessage(state.chatId, `*${part.tool}* — ${part.state.title}`, {
159
+ parse_mode: "Markdown",
160
+ ...(state.threadId ? { message_thread_id: state.threadId } : {}),
161
+ })
162
+ .catch(() => {})
163
+ }
164
+ } else if (event.type === "message.updated") {
165
+ const info = event.properties.info as any
166
+ if (info.role !== "assistant") continue
167
+ const state = getState(info.sessionID)
168
+ if (!state) continue
169
+
170
+ if (info.error) {
171
+ const msg = info.error.data?.message ?? info.error.name ?? "Unknown error"
172
+ await editLive(state, `❌ ${msg}`)
173
+ state.liveMessageId = undefined
174
+ state.liveText = ""
175
+ continue
176
+ }
177
+
178
+ if (info.finish && info.finish !== "tool-calls" && info.finish !== "unknown") {
179
+ const finalText = state.liveText.trim()
180
+ if (finalText) {
181
+ await editLive(state, finalText)
182
+ } else if (state.liveMessageId) {
183
+ await bot.api.deleteMessage(state.chatId, state.liveMessageId).catch(() => {})
184
+ }
185
+ state.liveMessageId = undefined
186
+ state.liveText = ""
187
+ }
188
+ }
189
+ }
190
+ })()
191
+
192
+ bot.catch((err) => {
193
+ console.error("❌ Unhandled bot error:", err.message)
194
+ err.ctx.reply(`❌ ${err.message}`).catch(() => {})
195
+ })
196
+
197
+ // ── Commands ──────────────────────────────────────────────────────────────────
198
+
199
+ bot.command("start", async (ctx) => {
200
+ await ctx.reply(
201
+ "👋 *IronCode Bot*\n\n" +
202
+ "Send a message to start coding with the AI agent.\n\n" +
203
+ "Commands:\n" +
204
+ "/sessions — list sessions\n" +
205
+ "/new — start a new session\n" +
206
+ "/info — current session details",
207
+ { parse_mode: "Markdown" },
208
+ )
209
+ })
210
+
211
+ bot.command("new", async (ctx) => {
212
+ const key = getChatKey(ctx.chat.id, ctx.message?.message_thread_id)
213
+ sessions.delete(key)
214
+ await ctx.reply("✨ New session will be created on your next message.")
215
+ })
216
+
217
+ bot.command("info", async (ctx) => {
218
+ const key = getChatKey(ctx.chat.id, ctx.message?.message_thread_id)
219
+ const state = sessions.get(key)
220
+
221
+ if (!state) {
222
+ await ctx.reply("No active session. Send a message to create one.")
223
+ return
224
+ }
225
+
226
+ const res = await ironcode.client.session.get({ path: { id: state.sessionId } })
227
+ if (res.error) {
228
+ await ctx.reply(`❌ ${JSON.stringify(res.error)}`)
229
+ return
230
+ }
231
+
232
+ const s = res.data
233
+ const changes = s.summary
234
+ ? `📊 ${s.summary.files} files · +${s.summary.additions}/-${s.summary.deletions}`
235
+ : "📊 No changes yet"
236
+
237
+ await ctx.reply(
238
+ `*${s.title}*\n` +
239
+ `ID: \`${s.id}\`\n` +
240
+ `Created: ${relativeTime(s.time.created)}\n` +
241
+ `Updated: ${relativeTime(s.time.updated)}\n` +
242
+ changes,
243
+ { parse_mode: "Markdown" },
244
+ )
245
+ })
246
+
247
+ bot.command("sessions", async (ctx) => {
248
+ const key = getChatKey(ctx.chat.id, ctx.message?.message_thread_id)
249
+ const currentState = sessions.get(key)
250
+
251
+ const res = await ironcode.client.session.list()
252
+ if (res.error) {
253
+ await ctx.reply(`❌ ${JSON.stringify(res.error)}`)
254
+ return
255
+ }
256
+
257
+ const list = res.data!
258
+ .filter((s) => !(s as any).time?.archived)
259
+ .sort((a, b) => b.time.updated - a.time.updated)
260
+ .slice(0, 10)
261
+
262
+ if (list.length === 0) {
263
+ await ctx.reply("No sessions yet.")
264
+ return
265
+ }
266
+
267
+ const kb = new InlineKeyboard()
268
+ for (const s of list) {
269
+ kb.text(sessionLabel(s, s.id === currentState?.sessionId), `switch:${s.id}`).row()
270
+ }
271
+
272
+ await ctx.reply("Select a session to continue:", { reply_markup: kb })
273
+ })
274
+
275
+ bot.callbackQuery(/^switch:(.+)$/, async (ctx) => {
276
+ const sessionId = ctx.match[1]
277
+ const chatId = ctx.chat!.id
278
+ const threadId = (ctx.callbackQuery.message as any)?.message_thread_id
279
+ const key = getChatKey(chatId, threadId)
280
+
281
+ const res = await ironcode.client.session.get({ path: { id: sessionId } })
282
+ if (res.error) {
283
+ await ctx.answerCallbackQuery({ text: "❌ Session not found" })
284
+ return
285
+ }
286
+
287
+ const existing = sessions.get(key)
288
+ sessions.set(key, { sessionId, chatId, threadId, liveText: "", lastEditMs: 0, liveMessageId: existing?.liveMessageId })
289
+
290
+ await ctx.answerCallbackQuery({ text: "✓ Session switched" })
291
+ await ctx.editMessageText(`✅ Now using: *${res.data!.title}*`, { parse_mode: "Markdown" })
292
+ })
293
+
294
+ // ── Main message handler ──────────────────────────────────────────────────────
295
+
296
+ bot.on("message:text", async (ctx) => {
297
+ const chatId = ctx.chat.id
298
+ const threadId = ctx.message.message_thread_id
299
+ const key = getChatKey(chatId, threadId)
300
+ const text = ctx.message.text
301
+
302
+ if (text.startsWith("/")) return
303
+
304
+ let state = sessions.get(key)
305
+
306
+ if (!state) {
307
+ const res = await ironcode.client.session.create({
308
+ body: { title: `Telegram ${ctx.chat.type} ${key}` },
309
+ })
310
+ if (res.error) {
311
+ await ctx.reply(`❌ Failed to create session: ${JSON.stringify(res.error)}`)
312
+ return
313
+ }
314
+ state = { sessionId: res.data.id, chatId, threadId, liveText: "", lastEditMs: 0 }
315
+ sessions.set(key, state)
316
+
317
+ const share = await ironcode.client.session.share({ path: { id: res.data.id } })
318
+ if (!share.error && share.data?.share?.url) {
319
+ await ctx.reply(`🔗 Session: ${share.data.share.url}`)
320
+ }
321
+ }
322
+
323
+ const placeholder = await ctx.reply("⏳", {
324
+ ...(threadId ? { message_thread_id: threadId } : {}),
325
+ })
326
+ state.liveMessageId = placeholder.message_id
327
+ state.liveText = ""
328
+ state.lastEditMs = 0
329
+
330
+ const model = cfg.model ? parseModel(cfg.model) : undefined
331
+
332
+ const result = await ironcode.client.session.promptAsync({
333
+ path: { id: state.sessionId },
334
+ body: { parts: [{ type: "text", text }], model },
335
+ })
336
+
337
+ if (result.error) {
338
+ await ctx.api.editMessageText(chatId, placeholder.message_id, `❌ ${JSON.stringify(result.error)}`)
339
+ state.liveMessageId = undefined
340
+ }
341
+ })
342
+
343
+ await bot.start()
344
+ console.log("⚡️ Telegram bot is running!")