@ironcode-ai/discord 1.19.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 +267 -0
  2. package/package.json +25 -0
  3. package/src/index.ts +619 -0
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # @ironcode-ai/discord
2
+
3
+ Discord bot integration for IronCode. Send messages from Discord 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
+ - Discord account (any account, no special permissions needed)
10
+
11
+ ## Installation
12
+
13
+ ### 1. Install ironcode-ai CLI
14
+
15
+ ```bash
16
+ bun install -g ironcode-ai
17
+ ```
18
+
19
+ ### 2. Authenticate a provider
20
+
21
+ ```bash
22
+ ironcode auth login # GitHub Copilot (recommended)
23
+ ironcode auth login anthropic # Anthropic
24
+ ironcode auth login openai # OpenAI
25
+ ```
26
+
27
+ List available models:
28
+
29
+ ```bash
30
+ ironcode models
31
+ ```
32
+
33
+ ### 3. Create a Discord Bot
34
+
35
+ 1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
36
+ 2. Click "New Application" → give it a name
37
+ 3. Go to "Bot" section → click "Add Bot"
38
+ 4. Under "Privileged Gateway Intents", enable:
39
+ - ✅ Message Content Intent
40
+ 5. Click "Reset Token" → copy the bot token
41
+ 6. Go to "OAuth2" → "URL Generator"
42
+ 7. Select scopes: `bot`, `applications.commands`
43
+ 8. Select permissions: `Send Messages`, `Read Messages/View Channels`, `Use Slash Commands`, `Add Reactions`
44
+ 9. Copy the generated URL and open it to invite bot to your server
45
+
46
+ ### 4. Install ironcode-discord
47
+
48
+ ```bash
49
+ bun install -g @ironcode-ai/discord
50
+ ```
51
+
52
+ ### 5. Configure
53
+
54
+ ```bash
55
+ ironcode-discord setup
56
+ # Bot Token (from Discord Developer Portal): paste_your_token_here
57
+ # Model [github-copilot/claude-sonnet-4.6]:
58
+ # Groq API Key (for voice transcription, optional) [skip: Enter]:
59
+ # ✅ Config saved to ~/.config/ironcode/discord.json
60
+ ```
61
+
62
+ Config is stored at `~/.config/ironcode/discord.json`:
63
+
64
+ ```json
65
+ {
66
+ "token": "your-bot-token",
67
+ "model": "github-copilot/claude-sonnet-4.6",
68
+ "groqApiKey": "optional-for-voice"
69
+ }
70
+ ```
71
+
72
+ ### 6. Run
73
+
74
+ ```bash
75
+ # cd into the repo you want the agent to work on
76
+ cd /path/to/your/project
77
+
78
+ ironcode-discord
79
+ ```
80
+
81
+ The agent runs with the current directory as its working directory, so it can read and edit files in your project.
82
+
83
+ ## Bot Commands
84
+
85
+ Discord uses **slash commands** (type `/` to see all commands):
86
+
87
+ | Command | Description |
88
+ | ----------- | --------------------------------------------------------- |
89
+ | `/start` | Show bot help and features |
90
+ | `/new` | Start a new session |
91
+ | `/info` | Show current session details (title, ID, file changes) |
92
+ | `/sessions` | List recent sessions |
93
+ | `/diff` | Show all file changes made in the current session |
94
+ | `/init` | Analyze the project and create an `AGENTS.md` config file |
95
+
96
+ ## How It Works
97
+
98
+ ```
99
+ You send a message or use /command
100
+ → Bot replies "🤔 Thinking..."
101
+ → Bot creates/resumes an ironcode session on your machine
102
+ → Agent reads/writes files, runs bash, calls LLM
103
+ → Each completed tool call is sent as a separate message
104
+ → Text response is streamed live by editing the placeholder (every 2s)
105
+ → Final response + 👍 reaction when done
106
+ ```
107
+
108
+ Each channel (text channel or DM) gets its own independent session. Sessions are persistent across bot restarts.
109
+
110
+ ## Upload Support
111
+
112
+ The bot supports multiple file types for code analysis and generation:
113
+
114
+ ### 📸 Images
115
+
116
+ - **Screenshots** — UI bugs, design mockups, error messages
117
+ - **Diagrams** — Architecture diagrams, flowcharts, wireframes
118
+ - **Code screenshots** — OCR extraction and conversion
119
+ - Supported: JPG, PNG, GIF, WebP
120
+ - Just drag & drop or attach images to your message!
121
+
122
+ ### 📄 Documents
123
+
124
+ - **Code files** — `.js`, `.ts`, `.py`, `.java`, etc.
125
+ - **Text files** — `.txt`, `.md`, `.json`, `.xml`
126
+ - **PDFs** — Technical docs, API specs
127
+ - **Any file type** — up to 25MB per file
128
+ - Discord automatically previews many file types
129
+
130
+ ### 🎤 Voice/Audio Messages
131
+
132
+ - Transcribed using Groq Whisper API
133
+ - Requires Groq API key in config (`ironcode-discord setup`)
134
+ - Perfect for hands-free coding instructions
135
+ - Supports Discord voice messages
136
+
137
+ **Usage Examples:**
138
+
139
+ ```
140
+ You: [Upload screenshot.png] "Fix this bug"
141
+ Bot: 📥 Processing 1 file(s)...
142
+ Bot: 🤔 Thinking...
143
+ Bot: [AI analyzes screenshot and provides fix]
144
+ ```
145
+
146
+ ```
147
+ You: [Upload 3 files: app.py, utils.py, test.py] "Add docstrings"
148
+ Bot: 📥 Processing 3 file(s)...
149
+ Bot: 🤔 Thinking...
150
+ Bot: [AI adds docstrings to all files]
151
+ ```
152
+
153
+ ```
154
+ You: [Record voice message] "Refactor the database module"
155
+ Bot: 🎤 Refactor the database module
156
+ Bot: 🤔 Thinking...
157
+ Bot: [AI refactors the code]
158
+ ```
159
+
160
+ ## Features
161
+
162
+ ✅ Slash commands (modern Discord UX)
163
+ ✅ Text channels & DM support
164
+ ✅ Real-time response streaming (edits every 2s)
165
+ ✅ Image upload and analysis
166
+ ✅ Document upload (any file type, up to 25MB)
167
+ ✅ Voice/audio transcription
168
+ ✅ Tool call notifications (🔧 tool completed)
169
+ ✅ Session sharing (generates URL on first message)
170
+ ✅ Multi-session management (one per channel)
171
+ ✅ File editing and tracking
172
+ ✅ Git diff support
173
+ ✅ Embeds for better formatting
174
+
175
+ ## Comparison with Other Integrations
176
+
177
+ | Feature | Discord | Telegram | Slack |
178
+ | ---------------- | ---------------- | ------------------ | -------------- |
179
+ | Authentication | Bot token | Bot token | OAuth + Socket |
180
+ | File size limit | 25MB | 20MB | 1GB |
181
+ | Commands | Slash commands | Text commands | Slash commands |
182
+ | Setup complexity | Easy | Easy | Medium |
183
+ | Voice support | ✅ | ✅ | ❌ |
184
+ | Streaming edits | ✅ (2s interval) | ✅ (1.2s interval) | ❌ |
185
+ | Embeds | ✅ | ❌ | ✅ |
186
+
187
+ ## Troubleshooting
188
+
189
+ ### "Invalid token"
190
+
191
+ - Make sure you copied the bot token correctly
192
+ - Run `ironcode-discord setup` again
193
+ - Generate a new token in Discord Developer Portal
194
+
195
+ ### "Missing Permissions"
196
+
197
+ - Make sure "Message Content Intent" is enabled in Discord Developer Portal
198
+ - Re-invite the bot with correct permissions (use OAuth2 URL generator)
199
+
200
+ ### "Failed to start ironcode server"
201
+
202
+ - Make sure ironcode CLI is installed: `ironcode --version`
203
+ - Authenticate with a provider: `ironcode auth login`
204
+ - Test manually: `ironcode serve`
205
+
206
+ ### Voice messages not working
207
+
208
+ - Add Groq API key: `ironcode-discord setup`
209
+ - Get free key at: https://console.groq.com
210
+
211
+ ### Bot not responding to messages
212
+
213
+ - Check bot has "Message Content Intent" enabled
214
+ - Make sure bot has permissions in the channel
215
+ - Check terminal for error logs
216
+
217
+ ## Security
218
+
219
+ - Bot token is stored locally at `~/.config/ironcode/discord.json`
220
+ - Bot runs on your machine with your file permissions
221
+ - No data is sent to external servers except:
222
+ - LLM provider (for AI responses)
223
+ - Groq (for voice transcription, optional)
224
+ - Discord API (for sending/receiving messages)
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ # Clone the repo
230
+ git clone https://github.com/sst/ironcode
231
+ cd ironcode/packages/discord
232
+
233
+ # Install dependencies
234
+ bun install
235
+
236
+ # Run in dev mode
237
+ bun run dev
238
+
239
+ # Type check
240
+ bun run typecheck
241
+ ```
242
+
243
+ ## Tips
244
+
245
+ - **Use threads** - Discord threads keep conversations organized
246
+ - **Pin important messages** - Pin session URLs or important code snippets
247
+ - **Use embeds** - Commands like `/info` and `/sessions` use rich embeds
248
+ - **Multiple channels** - Each channel gets its own session automatically
249
+ - **Voice channels** - Record voice messages for hands-free coding
250
+
251
+ ## Example Workflow
252
+
253
+ ```
254
+ 1. Invite bot to your server
255
+ 2. Create a channel: #coding-agent
256
+ 3. Run: ironcode-discord (in your project directory)
257
+ 4. In Discord #coding-agent:
258
+ - Type: /init (creates AGENTS.md)
259
+ - Upload screenshot of bug
260
+ - Message: "Fix this bug"
261
+ - Bot analyzes and fixes
262
+ - Type: /diff (see all changes)
263
+ ```
264
+
265
+ ## License
266
+
267
+ MIT
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@ironcode-ai/discord",
3
+ "version": "1.19.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "ironcode-discord": "./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
+ "discord.js": "^14.17.3"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "22.13.9",
22
+ "@typescript/native-preview": "7.0.0-dev.20251207.1",
23
+ "typescript": "5.9.3"
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,619 @@
1
+ #!/usr/bin/env bun
2
+ import { Client, GatewayIntentBits, REST, Routes, AttachmentBuilder, EmbedBuilder } from "discord.js"
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 path from "path"
7
+ import { homedir, tmpdir } from "os"
8
+ import { pathToFileURL } from "url"
9
+ import * as readline from "readline"
10
+
11
+ // ── Config ────────────────────────────────────────────────────────────────────
12
+
13
+ type Config = {
14
+ token: string
15
+ model?: string
16
+ groqApiKey?: string
17
+ }
18
+
19
+ function configPath() {
20
+ const xdg = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config")
21
+ return path.join(xdg, "ironcode", "discord.json")
22
+ }
23
+
24
+ function loadConfig(): Config | null {
25
+ const p = configPath()
26
+ if (!existsSync(p)) return null
27
+ try {
28
+ return JSON.parse(readFileSync(p, "utf8")) as Config
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ function saveConfig(cfg: Config) {
35
+ const p = configPath()
36
+ mkdirSync(path.join(p, ".."), { recursive: true })
37
+ writeFileSync(p, JSON.stringify(cfg, null, 2))
38
+ }
39
+
40
+ async function ask(prompt: string): Promise<string> {
41
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
42
+ return new Promise((resolve) => {
43
+ rl.question(prompt, (ans) => {
44
+ rl.close()
45
+ resolve(ans.trim())
46
+ })
47
+ })
48
+ }
49
+
50
+ // ── Setup command ─────────────────────────────────────────────────────────────
51
+
52
+ if (process.argv[2] === "setup") {
53
+ console.log("⚙️ ironcode-discord setup\n")
54
+
55
+ const existing = loadConfig()
56
+ const token = await ask(
57
+ `Bot Token (from Discord Developer Portal)${existing?.token ? " [keep current: Enter]" : ""}: `,
58
+ )
59
+ const model = await ask(`Model [${existing?.model ?? "github-copilot/claude-sonnet-4.6"}]: `)
60
+ const groqApiKey = await ask(
61
+ `Groq API Key (for voice transcription, optional) [${existing?.groqApiKey ? "keep current: Enter" : "skip: Enter"}]: `,
62
+ )
63
+
64
+ const cfg: Config = {
65
+ token: token || existing?.token || "",
66
+ model: model || existing?.model || "github-copilot/claude-sonnet-4.6",
67
+ groqApiKey: groqApiKey || existing?.groqApiKey,
68
+ }
69
+
70
+ if (!cfg.token) {
71
+ console.error("❌ Bot Token is required.")
72
+ process.exit(1)
73
+ }
74
+
75
+ saveConfig(cfg)
76
+ console.log(`\n✅ Config saved to ${configPath()}`)
77
+ console.log(" Run: ironcode-discord\n")
78
+ process.exit(0)
79
+ }
80
+
81
+ // ── Load config ───────────────────────────────────────────────────────────────
82
+
83
+ const cfg = loadConfig()
84
+ if (!cfg) {
85
+ console.error(`❌ No config found. Run:\n\n ironcode-discord setup\n`)
86
+ process.exit(1)
87
+ }
88
+
89
+ // ── Helpers ───────────────────────────────────────────────────────────────────
90
+
91
+ function parseModel(model: string) {
92
+ const [providerID, ...rest] = model.split("/")
93
+ return { providerID: providerID!, modelID: rest.join("/") }
94
+ }
95
+
96
+ function relativeTime(ms: number) {
97
+ const diff = Date.now() - ms
98
+ const m = Math.floor(diff / 60000)
99
+ const h = Math.floor(diff / 3600000)
100
+ const d = Math.floor(diff / 86400000)
101
+ if (m < 1) return "just now"
102
+ if (m < 60) return `${m}m ago`
103
+ if (h < 24) return `${h}h ago`
104
+ return `${d}d ago`
105
+ }
106
+
107
+ function sessionLabel(s: Session, isCurrent: boolean) {
108
+ const changes = s.summary ? ` (+${s.summary.additions}/-${s.summary.deletions})` : ""
109
+ const cur = isCurrent ? " ✓" : ""
110
+ return `${s.title}${changes} · ${relativeTime(s.time.updated)}${cur}`
111
+ }
112
+
113
+ // ── Discord Client ────────────────────────────────────────────────────────────
114
+
115
+ console.log("🚀 Starting Discord client...")
116
+
117
+ const client = new Client({
118
+ intents: [
119
+ GatewayIntentBits.Guilds,
120
+ GatewayIntentBits.GuildMessages,
121
+ GatewayIntentBits.MessageContent,
122
+ GatewayIntentBits.DirectMessages,
123
+ ],
124
+ })
125
+
126
+ client.once("clientReady", () => {
127
+ console.log(`✅ Discord bot logged in as ${client.user?.tag}`)
128
+ })
129
+
130
+ await client.login(cfg.token)
131
+
132
+ // ── Register Slash Commands ───────────────────────────────────────────────────
133
+
134
+ const commands = [
135
+ {
136
+ name: "start",
137
+ description: "Show bot help and features",
138
+ },
139
+ {
140
+ name: "new",
141
+ description: "Start a new session",
142
+ },
143
+ {
144
+ name: "info",
145
+ description: "Show current session details",
146
+ },
147
+ {
148
+ name: "sessions",
149
+ description: "List recent sessions",
150
+ },
151
+ {
152
+ name: "diff",
153
+ description: "Show code changes in current session",
154
+ },
155
+ {
156
+ name: "init",
157
+ description: "Analyze project and create AGENTS.md",
158
+ },
159
+ ]
160
+
161
+ const rest = new REST({ version: "10" }).setToken(cfg.token)
162
+
163
+ try {
164
+ console.log("📝 Registering slash commands...")
165
+ await rest.put(Routes.applicationCommands(client.user!.id), { body: commands })
166
+ console.log("✅ Slash commands registered!")
167
+ } catch (error) {
168
+ console.error("❌ Failed to register commands:", error)
169
+ }
170
+
171
+ // ── Start Ironcode server ─────────────────────────────────────────────────────
172
+
173
+ console.log("🚀 Starting ironcode server...")
174
+ let localServer: Awaited<ReturnType<typeof createIroncode>>
175
+ try {
176
+ localServer = await createIroncode({ port: 0 })
177
+ } catch (err: any) {
178
+ const msg = err?.message ?? String(err)
179
+ if (msg.includes("exited with code 0") || msg.includes("ENOENT") || msg.includes("Illegal instruction")) {
180
+ console.error("❌ Failed to start ironcode server.\n")
181
+ console.error(" Make sure the ironcode CLI is installed and authenticated:")
182
+ console.error(" 1. npm install -g ironcode-ai")
183
+ console.error(" 2. ironcode auth login")
184
+ console.error(" 3. ironcode serve ← test manually first\n")
185
+ } else {
186
+ console.error("❌ Failed to start ironcode server:", msg)
187
+ }
188
+ process.exit(1)
189
+ }
190
+ console.log("✅ Ironcode server ready at", localServer.server.url)
191
+ const ironcodeClient = localServer.client
192
+
193
+ // ── Session state management ──────────────────────────────────────────────────
194
+
195
+ type SessionState = {
196
+ sessionId: string
197
+ channelId: string
198
+ liveMessageId?: string
199
+ liveText: string
200
+ lastEditMs: number
201
+ currentTool?: string
202
+ }
203
+
204
+ type FilePart = {
205
+ type: "file"
206
+ url: string
207
+ filename: string
208
+ mime: string
209
+ }
210
+
211
+ const EDIT_INTERVAL_MS = 2000 // Discord rate limits are stricter
212
+ const MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB for Discord
213
+
214
+ const sessions = new Map<string, SessionState>()
215
+
216
+ async function editLive(state: SessionState, text: string) {
217
+ if (!state.liveMessageId) return
218
+ try {
219
+ const channel = await client.channels.fetch(state.channelId)
220
+ if (channel?.isTextBased()) {
221
+ const msg = await channel.messages.fetch(state.liveMessageId)
222
+ if (msg) {
223
+ await msg.edit(text.slice(0, 2000) || "…") // Discord 2000 char limit
224
+ }
225
+ }
226
+ } catch {}
227
+ }
228
+
229
+ // ── Event loop ────────────────────────────────────────────────────────────────
230
+
231
+ ;(async () => {
232
+ const events = await ironcodeClient.event.subscribe()
233
+ for await (const event of events.stream) {
234
+ const getState = (sessionID: string) => [...sessions.values()].find((s) => s.sessionId === sessionID)
235
+
236
+ if (event.type === "message.part.updated") {
237
+ const part = event.properties.part as any
238
+ const state = getState(part.sessionID)
239
+ if (!state) continue
240
+
241
+ if (part.type === "text") {
242
+ state.liveText = part.text
243
+ const now = Date.now()
244
+ if (state.liveMessageId && now - state.lastEditMs > EDIT_INTERVAL_MS) {
245
+ await editLive(state, state.liveText)
246
+ state.lastEditMs = now
247
+ }
248
+ } else if (part.type === "tool") {
249
+ if (part.state?.status === "completed") {
250
+ state.currentTool = undefined
251
+ try {
252
+ const channel = await client.channels.fetch(state.channelId)
253
+ if (channel?.isTextBased() && "send" in channel) {
254
+ await (channel as any).send(`🔧 **${part.tool}** — ${part.state.title}`)
255
+ }
256
+ } catch {}
257
+ } else if (state.currentTool !== part.tool) {
258
+ state.currentTool = part.tool
259
+ if (!state.liveText.trim() && state.liveMessageId) {
260
+ const now = Date.now()
261
+ if (now - state.lastEditMs > 500) {
262
+ await editLive(state, `⏳ ${part.tool}...`)
263
+ state.lastEditMs = now
264
+ }
265
+ }
266
+ }
267
+ }
268
+ } else if (event.type === "message.updated") {
269
+ const info = event.properties.info as any
270
+ if (info.role !== "assistant") continue
271
+ const state = getState(info.sessionID)
272
+ if (!state) continue
273
+
274
+ if (info.error) {
275
+ const msg = info.error.data?.message ?? info.error.name ?? "Unknown error"
276
+ await editLive(state, `❌ ${msg}`)
277
+ state.liveMessageId = undefined
278
+ state.liveText = ""
279
+ state.currentTool = undefined
280
+ continue
281
+ }
282
+
283
+ if (info.finish && info.finish !== "tool-calls" && info.finish !== "unknown") {
284
+ const finalText = state.liveText.trim()
285
+ const savedMessageId = state.liveMessageId
286
+
287
+ if (finalText) {
288
+ await editLive(state, finalText)
289
+ } else if (savedMessageId) {
290
+ await editLive(state, "✅ Done")
291
+ }
292
+
293
+ if (savedMessageId) {
294
+ try {
295
+ const channel = await client.channels.fetch(state.channelId)
296
+ if (channel?.isTextBased()) {
297
+ const msg = await channel.messages.fetch(savedMessageId)
298
+ if (msg) await msg.react("👍")
299
+ }
300
+ } catch {}
301
+ }
302
+
303
+ state.liveMessageId = undefined
304
+ state.liveText = ""
305
+ state.currentTool = undefined
306
+ }
307
+ }
308
+ }
309
+ })().catch((err) => console.error("[events] event loop crashed:", err))
310
+
311
+ // ── Voice transcription ───────────────────────────────────────────────────────
312
+
313
+ async function transcribeVoice(audioUrl: string, groqApiKey: string): Promise<string> {
314
+ const response = await fetch(audioUrl)
315
+ if (!response.ok) throw new Error(`Failed to download audio: ${response.status}`)
316
+ const blob = await response.blob()
317
+
318
+ const form = new FormData()
319
+ form.append("file", blob, "voice.ogg")
320
+ form.append("model", "whisper-large-v3-turbo")
321
+ form.append("response_format", "json")
322
+
323
+ const res = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
324
+ method: "POST",
325
+ headers: { Authorization: `Bearer ${groqApiKey}` },
326
+ body: form,
327
+ })
328
+
329
+ if (!res.ok) {
330
+ const err = await res.text()
331
+ throw new Error(`Groq API error ${res.status}: ${err}`)
332
+ }
333
+ const data = (await res.json()) as { text: string }
334
+ return data.text.trim()
335
+ }
336
+
337
+ // ── File download helper ──────────────────────────────────────────────────────
338
+
339
+ async function downloadDiscordFile(url: string, filename: string): Promise<FilePart> {
340
+ const response = await fetch(url)
341
+ if (!response.ok) throw new Error(`Failed to download file: ${response.status}`)
342
+
343
+ const blob = await response.blob()
344
+
345
+ // File size check
346
+ if (blob.size > MAX_FILE_SIZE) {
347
+ throw new Error(`File too large: ${(blob.size / 1024 / 1024).toFixed(2)}MB (max 25MB)`)
348
+ }
349
+
350
+ const buffer = Buffer.from(await blob.arrayBuffer())
351
+ const tmpPath = path.join(tmpdir(), `discord-${Date.now()}-${filename}`)
352
+ // @ts-ignore - Bun.write is available in Bun runtime
353
+ await Bun.write(tmpPath, buffer)
354
+
355
+ // Detect MIME type from extension
356
+ const ext = path.extname(filename).toLowerCase()
357
+ const mimeMap: Record<string, string> = {
358
+ ".jpg": "image/jpeg",
359
+ ".jpeg": "image/jpeg",
360
+ ".png": "image/png",
361
+ ".gif": "image/gif",
362
+ ".webp": "image/webp",
363
+ ".pdf": "application/pdf",
364
+ ".txt": "text/plain",
365
+ ".md": "text/markdown",
366
+ ".json": "application/json",
367
+ ".js": "text/javascript",
368
+ ".ts": "text/typescript",
369
+ ".py": "text/x-python",
370
+ }
371
+
372
+ return {
373
+ type: "file",
374
+ url: pathToFileURL(tmpPath).href,
375
+ filename,
376
+ mime: mimeMap[ext] || "application/octet-stream",
377
+ }
378
+ }
379
+
380
+ // ── Main message handler ──────────────────────────────────────────────────────
381
+
382
+ async function handleMessage(message: any, text: string, files?: FilePart[]) {
383
+ const channelId = message.channelId
384
+ let state = sessions.get(channelId)
385
+
386
+ if (!state) {
387
+ const res = await ironcodeClient.session.create({
388
+ body: { title: `Discord ${message.guild?.name ?? "DM"} #${message.channel.name ?? channelId}` },
389
+ })
390
+ if (res.error) {
391
+ await message.reply(`❌ Failed to create session: ${JSON.stringify(res.error)}`)
392
+ return
393
+ }
394
+ state = { sessionId: res.data.id, channelId, liveText: "", lastEditMs: 0 }
395
+ sessions.set(channelId, state)
396
+
397
+ const share = await ironcodeClient.session.share({ path: { id: res.data.id } })
398
+ if (!share.error && share.data?.share?.url) {
399
+ await message.reply(`🔗 Session: ${share.data.share.url}`)
400
+ }
401
+ }
402
+
403
+ const placeholder = await message.reply("🤔 Thinking...")
404
+ state.liveMessageId = placeholder.id
405
+ state.liveText = ""
406
+ state.lastEditMs = 0
407
+ state.currentTool = undefined
408
+
409
+ const model = cfg?.model ? parseModel(cfg.model) : undefined
410
+
411
+ // Build parts array: files first, then text
412
+ const parts: Array<{ type: "text"; text: string } | FilePart> = []
413
+ if (files && files.length > 0) {
414
+ parts.push(...files)
415
+ }
416
+ parts.push({ type: "text", text })
417
+
418
+ const result = await ironcodeClient.session.promptAsync({
419
+ path: { id: state.sessionId },
420
+ body: { parts, model },
421
+ })
422
+
423
+ if (result.error) {
424
+ try {
425
+ await placeholder.edit(`❌ ${JSON.stringify(result.error)}`)
426
+ } catch {}
427
+ state.liveMessageId = undefined
428
+ }
429
+ }
430
+
431
+ // ── Slash Command handlers ────────────────────────────────────────────────────
432
+
433
+ client.on("interactionCreate", async (interaction) => {
434
+ if (!interaction.isChatInputCommand()) return
435
+
436
+ const { commandName } = interaction
437
+
438
+ if (commandName === "start") {
439
+ const embed = new EmbedBuilder()
440
+ .setColor(0x5865f2)
441
+ .setTitle("👋 IronCode Bot")
442
+ .setDescription("Send a message to start coding with the AI agent.")
443
+ .addFields(
444
+ {
445
+ name: "Commands",
446
+ value:
447
+ "`/sessions` — list sessions\n`/new` — start new session\n`/info` — session details\n`/init` — create AGENTS.md\n`/diff` — show code changes",
448
+ },
449
+ {
450
+ name: "Upload Support",
451
+ value:
452
+ "📸 Images — screenshots, diagrams\n📄 Files — code, PDFs, text (up to 25MB)\n🎤 Voice — transcribed via Groq",
453
+ },
454
+ )
455
+ await interaction.reply({ embeds: [embed] })
456
+ } else if (commandName === "new") {
457
+ const channelId = interaction.channelId
458
+ sessions.delete(channelId)
459
+ await interaction.reply("✨ New session will be created on your next message.")
460
+ } else if (commandName === "info") {
461
+ const channelId = interaction.channelId
462
+ const state = sessions.get(channelId)
463
+
464
+ if (!state) {
465
+ await interaction.reply("No active session. Send a message to create one.")
466
+ return
467
+ }
468
+
469
+ const res = await ironcodeClient.session.get({ path: { id: state.sessionId } })
470
+ if (res.error) {
471
+ await interaction.reply(`❌ ${JSON.stringify(res.error)}`)
472
+ return
473
+ }
474
+
475
+ const s = res.data
476
+ const changes = s.summary
477
+ ? `📊 ${s.summary.files} files · +${s.summary.additions}/-${s.summary.deletions}`
478
+ : "📊 No changes yet"
479
+
480
+ const embed = new EmbedBuilder()
481
+ .setColor(0x5865f2)
482
+ .setTitle(s.title)
483
+ .addFields(
484
+ { name: "Session ID", value: s.id },
485
+ { name: "Created", value: relativeTime(s.time.created) },
486
+ { name: "Updated", value: relativeTime(s.time.updated) },
487
+ { name: "Changes", value: changes },
488
+ )
489
+ await interaction.reply({ embeds: [embed] })
490
+ } else if (commandName === "sessions") {
491
+ const channelId = interaction.channelId
492
+ const currentState = sessions.get(channelId)
493
+
494
+ const res = await ironcodeClient.session.list()
495
+ if (res.error) {
496
+ await interaction.reply(`❌ ${JSON.stringify(res.error)}`)
497
+ return
498
+ }
499
+
500
+ const list = res
501
+ .data!.filter((s: any) => !s.time?.archived)
502
+ .sort((a: any, b: any) => b.time.updated - a.time.updated)
503
+ .slice(0, 10)
504
+
505
+ if (list.length === 0) {
506
+ await interaction.reply("No sessions yet.")
507
+ return
508
+ }
509
+
510
+ const embed = new EmbedBuilder()
511
+ .setColor(0x5865f2)
512
+ .setTitle("Available Sessions")
513
+ .setDescription(
514
+ list.map((s: any, i: number) => `${i + 1}. ${sessionLabel(s, s.id === currentState?.sessionId)}`).join("\n"),
515
+ )
516
+ await interaction.reply({ embeds: [embed] })
517
+ } else if (commandName === "diff") {
518
+ const channelId = interaction.channelId
519
+ const state = sessions.get(channelId)
520
+
521
+ if (!state) {
522
+ await interaction.reply("No active session. Send a message to create one.")
523
+ return
524
+ }
525
+
526
+ const res = await ironcodeClient.session.diff({ path: { id: state.sessionId } })
527
+ if (res.error) {
528
+ await interaction.reply(`❌ ${JSON.stringify(res.error)}`)
529
+ return
530
+ }
531
+
532
+ const diffs = res.data ?? []
533
+
534
+ if (diffs.length === 0) {
535
+ await interaction.reply("📊 No code changes in this session.")
536
+ return
537
+ }
538
+
539
+ const totalAdd = diffs.reduce((s, d) => s + d.additions, 0)
540
+ const totalDel = diffs.reduce((s, d) => s + d.deletions, 0)
541
+
542
+ const embed = new EmbedBuilder()
543
+ .setColor(0x5865f2)
544
+ .setTitle(`📝 Code Changes — ${diffs.length} files · +${totalAdd}/-${totalDel}`)
545
+ .setDescription(diffs.map((d) => `✏️ \`${d.file}\` (+${d.additions}/-${d.deletions})`).join("\n"))
546
+ await interaction.reply({ embeds: [embed] })
547
+ } else if (commandName === "init") {
548
+ const channelId = interaction.channelId
549
+ let state = sessions.get(channelId)
550
+
551
+ if (!state) {
552
+ const res = await ironcodeClient.session.create({
553
+ body: { title: `Discord ${interaction.guild?.name ?? "DM"}` },
554
+ })
555
+ if (res.error) {
556
+ await interaction.reply(`❌ Failed to create session: ${JSON.stringify(res.error)}`)
557
+ return
558
+ }
559
+ state = { sessionId: res.data.id, channelId, liveText: "", lastEditMs: 0 }
560
+ sessions.set(channelId, state)
561
+ }
562
+
563
+ await interaction.reply("⏳ Analyzing project and creating AGENTS.md...")
564
+
565
+ const model = cfg.model ? parseModel(cfg.model) : undefined
566
+ const res = await ironcodeClient.session.command({
567
+ path: { id: state.sessionId },
568
+ body: { command: "init", arguments: "", ...(model ? { model: `${model.providerID}/${model.modelID}` } : {}) },
569
+ })
570
+
571
+ if (res.error) {
572
+ await interaction.followUp(`❌ ${JSON.stringify(res.error)}`)
573
+ return
574
+ }
575
+
576
+ await interaction.followUp("✅ **AGENTS.md created!**\n\nThe AI agent has analyzed your project.")
577
+ }
578
+ })
579
+
580
+ // ── Regular message handler ───────────────────────────────────────────────────
581
+
582
+ client.on("messageCreate", async (message) => {
583
+ if (message.author.bot) return
584
+ if (!message.content && message.attachments.size === 0) return
585
+
586
+ const text = message.content || "Analyze these files"
587
+ const files: FilePart[] = []
588
+
589
+ // Handle attachments (images, documents, audio)
590
+ if (message.attachments.size > 0) {
591
+ const statusMsg = await message.reply(`📥 Processing ${message.attachments.size} file(s)...`)
592
+
593
+ try {
594
+ for (const [, attachment] of message.attachments) {
595
+ // Voice/audio files
596
+ if (attachment.contentType?.startsWith("audio/") && cfg.groqApiKey) {
597
+ const transcribedText = await transcribeVoice(attachment.url, cfg.groqApiKey)
598
+ await statusMsg.edit(`🎤 _${transcribedText}_`)
599
+ await handleMessage(message, transcribedText)
600
+ return
601
+ }
602
+
603
+ // Images & documents
604
+ const file = await downloadDiscordFile(attachment.url, attachment.name)
605
+ files.push(file)
606
+ }
607
+
608
+ await statusMsg.delete()
609
+ } catch (err: any) {
610
+ await statusMsg.edit(`❌ ${err.message}`)
611
+ return
612
+ }
613
+ }
614
+
615
+ // Send to IronCode
616
+ await handleMessage(message, text, files.length > 0 ? files : undefined)
617
+ })
618
+
619
+ console.log("⚡️ Discord bot is running!")