@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.
- package/README.md +267 -0
- package/package.json +25 -0
- 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!")
|