agentcord 0.1.0
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/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/agentcord.js +21 -0
- package/package.json +60 -0
- package/src/agents.ts +90 -0
- package/src/bot.ts +193 -0
- package/src/button-handler.ts +153 -0
- package/src/cli.ts +50 -0
- package/src/command-handlers.ts +623 -0
- package/src/commands.ts +166 -0
- package/src/config.ts +45 -0
- package/src/index.ts +18 -0
- package/src/message-handler.ts +60 -0
- package/src/output-handler.ts +515 -0
- package/src/persistence.ts +33 -0
- package/src/project-manager.ts +165 -0
- package/src/session-manager.ts +407 -0
- package/src/setup.ts +381 -0
- package/src/shell-handler.ts +91 -0
- package/src/types.ts +80 -0
- package/src/utils.ts +112 -0
- package/tsconfig.json +17 -0
package/.env.example
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Required
|
|
2
|
+
DISCORD_TOKEN=your-bot-token-here
|
|
3
|
+
DISCORD_CLIENT_ID=your-application-client-id
|
|
4
|
+
|
|
5
|
+
# Optional: Guild ID for instant command registration (otherwise global, ~1hr delay)
|
|
6
|
+
# DISCORD_GUILD_ID=your-guild-id
|
|
7
|
+
|
|
8
|
+
# Security: Comma-separated Discord user IDs allowed to use the bot
|
|
9
|
+
# ALLOWED_USERS=123456789,987654321
|
|
10
|
+
# Or set this to true to allow everyone (not recommended)
|
|
11
|
+
# ALLOW_ALL_USERS=true
|
|
12
|
+
|
|
13
|
+
# Optional: Restrict which directories sessions can access
|
|
14
|
+
# ALLOWED_PATHS=/Users/me/Dev,/Users/me/Projects
|
|
15
|
+
|
|
16
|
+
# Optional: Default working directory for new sessions
|
|
17
|
+
# DEFAULT_DIRECTORY=/Users/me/Dev
|
|
18
|
+
|
|
19
|
+
# Optional: Auto-delete messages older than N days
|
|
20
|
+
# MESSAGE_RETENTION_DAYS=7
|
|
21
|
+
|
|
22
|
+
# Optional: Rate limit in ms between messages per user (default 1000)
|
|
23
|
+
# RATE_LIMIT_MS=1000
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 discord-friends contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# agentcord
|
|
2
|
+
|
|
3
|
+
Run and manage AI coding agent sessions on your machine through Discord. Currently supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), with more agents coming soon.
|
|
4
|
+
|
|
5
|
+
Each session gets a Discord channel for chatting with the agent and a tmux session for direct terminal access. Sessions are organized by project — create multiple sessions in the same codebase, each with their own channel.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g agentcord
|
|
11
|
+
mkdir my-bot && cd my-bot
|
|
12
|
+
agentcord setup
|
|
13
|
+
agentcord
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The setup wizard walks you through creating a Discord app, configuring the bot token, and adding it to your server.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- **Node.js 22.6+** (uses native TypeScript execution)
|
|
21
|
+
- **tmux** (for terminal session access)
|
|
22
|
+
- **Claude Code** installed on the machine (`@anthropic-ai/claude-agent-sdk`)
|
|
23
|
+
|
|
24
|
+
## How It Works
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
Discord message → SDK query() → coding agent
|
|
28
|
+
↓
|
|
29
|
+
Discord embeds ← stream processing ← async iterator
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Hybrid architecture**: The agent SDK handles structured streaming for Discord interaction, while each session also gets a tmux session. You can `tmux attach` and run `claude --resume <session-id>` to take over the same conversation in your terminal.
|
|
33
|
+
|
|
34
|
+
**Project-based organization**:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Discord Server
|
|
38
|
+
└── my-api (category)
|
|
39
|
+
│ ├── #claude-fix-auth ← session in ~/Dev/my-api
|
|
40
|
+
│ ├── #claude-add-tests ← another session, same project
|
|
41
|
+
│ └── #project-logs
|
|
42
|
+
└── frontend (category)
|
|
43
|
+
├── #claude-redesign ← session in ~/Dev/frontend
|
|
44
|
+
└── #project-logs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Discord Commands
|
|
48
|
+
|
|
49
|
+
### Sessions
|
|
50
|
+
|
|
51
|
+
| Command | Description |
|
|
52
|
+
|---------|-------------|
|
|
53
|
+
| `/claude new <name> [directory]` | Create a session with a Discord channel + tmux session |
|
|
54
|
+
| `/claude list` | List active sessions grouped by project |
|
|
55
|
+
| `/claude end` | End the session in the current channel |
|
|
56
|
+
| `/claude continue` | Continue the conversation |
|
|
57
|
+
| `/claude stop` | Abort current generation |
|
|
58
|
+
| `/claude attach` | Show tmux attach command for terminal access |
|
|
59
|
+
| `/claude model <model>` | Change model for the session |
|
|
60
|
+
| `/claude verbose` | Toggle tool call/result visibility |
|
|
61
|
+
| `/claude sync` | Reconnect orphaned tmux sessions |
|
|
62
|
+
|
|
63
|
+
### Shell
|
|
64
|
+
|
|
65
|
+
| Command | Description |
|
|
66
|
+
|---------|-------------|
|
|
67
|
+
| `/shell run <command>` | Execute a command in the session directory |
|
|
68
|
+
| `/shell processes` | List running background processes |
|
|
69
|
+
| `/shell kill <pid>` | Kill a process |
|
|
70
|
+
|
|
71
|
+
### Agent Personas
|
|
72
|
+
|
|
73
|
+
| Command | Description |
|
|
74
|
+
|---------|-------------|
|
|
75
|
+
| `/agent use <persona>` | Switch persona (code-reviewer, architect, debugger, security, performance, devops) |
|
|
76
|
+
| `/agent list` | List available personas |
|
|
77
|
+
| `/agent clear` | Reset to default |
|
|
78
|
+
|
|
79
|
+
### Project Config
|
|
80
|
+
|
|
81
|
+
| Command | Description |
|
|
82
|
+
|---------|-------------|
|
|
83
|
+
| `/project personality <prompt>` | Set a custom system prompt for the project |
|
|
84
|
+
| `/project personality-show` | Show current personality |
|
|
85
|
+
| `/project personality-clear` | Remove personality |
|
|
86
|
+
| `/project skill-add <name> <prompt>` | Add a reusable prompt template (`{input}` placeholder) |
|
|
87
|
+
| `/project skill-run <name> [input]` | Execute a skill |
|
|
88
|
+
| `/project skill-list` | List skills |
|
|
89
|
+
| `/project mcp-add <name> <command>` | Register an MCP server (writes `.mcp.json`) |
|
|
90
|
+
| `/project mcp-list` | List MCP servers |
|
|
91
|
+
| `/project info` | Show project config summary |
|
|
92
|
+
|
|
93
|
+
## Features
|
|
94
|
+
|
|
95
|
+
- **Real-time streaming** — Agent responses stream into Discord with edit-in-place updates
|
|
96
|
+
- **Typing indicator** — Shows "Bot is typing..." while the agent is working
|
|
97
|
+
- **Message interruption** — Send a new message to automatically interrupt and redirect the agent
|
|
98
|
+
- **Interactive prompts** — Multi-choice questions render as Discord buttons
|
|
99
|
+
- **Task board** — Agent task lists display as visual embeds with status emojis
|
|
100
|
+
- **Tool output control** — Hidden by default, toggle with `/claude verbose`
|
|
101
|
+
- **Per-project customization** — System prompts, skills, and MCP servers scoped to projects
|
|
102
|
+
- **Agent personas** — Switch between specialized roles (code reviewer, architect, etc.)
|
|
103
|
+
- **Session persistence** — Sessions survive bot restarts
|
|
104
|
+
- **Terminal access** — `tmux attach` to any session for direct CLI use
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
The setup wizard (`agentcord setup`) creates a `.env` file. You can also edit it directly:
|
|
109
|
+
|
|
110
|
+
```env
|
|
111
|
+
# Required
|
|
112
|
+
DISCORD_TOKEN=your-bot-token
|
|
113
|
+
DISCORD_CLIENT_ID=your-client-id
|
|
114
|
+
|
|
115
|
+
# Optional
|
|
116
|
+
DISCORD_GUILD_ID=your-guild-id # Instant command registration
|
|
117
|
+
ALLOWED_USERS=123456789,987654321 # Comma-separated user IDs
|
|
118
|
+
ALLOW_ALL_USERS=false # Or true to skip whitelist
|
|
119
|
+
ALLOWED_PATHS=/Users/me/Dev # Restrict accessible directories
|
|
120
|
+
DEFAULT_DIRECTORY=/Users/me/Dev # Default for new sessions
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
git clone https://github.com/manuelatravajo/agentcord.git
|
|
127
|
+
cd agentcord
|
|
128
|
+
npm install
|
|
129
|
+
cp .env.example .env # fill in your values
|
|
130
|
+
npm run dev # start with --watch
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/bin/agentcord.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const cli = join(__dirname, '..', 'src', 'cli.ts');
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
const child = spawn(
|
|
12
|
+
process.execPath,
|
|
13
|
+
['--experimental-strip-types', cli, ...args],
|
|
14
|
+
{
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
cwd: process.cwd(),
|
|
17
|
+
env: { ...process.env, NODE_NO_WARNINGS: '1' },
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentcord",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Discord bot for managing AI coding agent sessions (Claude Code, Codex, and more)",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentcord": "./bin/agentcord.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"tsconfig.json",
|
|
13
|
+
".env.example",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"setup": "node --experimental-strip-types src/setup.ts",
|
|
19
|
+
"start": "node --experimental-strip-types src/index.ts",
|
|
20
|
+
"dev": "node --experimental-strip-types --watch src/index.ts",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"discord",
|
|
27
|
+
"claude",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex",
|
|
30
|
+
"ai",
|
|
31
|
+
"agent",
|
|
32
|
+
"coding-agent",
|
|
33
|
+
"tmux",
|
|
34
|
+
"cli",
|
|
35
|
+
"bot"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.6.0"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/manuelatravajo/agentcord.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/manuelatravajo/agentcord#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/manuelatravajo/agentcord/issues"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.36",
|
|
51
|
+
"@clack/prompts": "^1.0.0",
|
|
52
|
+
"discord.js": "^14.16.3",
|
|
53
|
+
"dotenv": "^17.2.4"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^22.10.0",
|
|
57
|
+
"typescript": "^5.7.2",
|
|
58
|
+
"vitest": "^3.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { AgentPersona } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export const agents: AgentPersona[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'code-reviewer',
|
|
6
|
+
emoji: '🔍',
|
|
7
|
+
description: 'Code quality, bugs, best practices',
|
|
8
|
+
systemPrompt: `You are a senior code reviewer. Focus on:
|
|
9
|
+
- Code quality and readability
|
|
10
|
+
- Potential bugs and edge cases
|
|
11
|
+
- Security vulnerabilities
|
|
12
|
+
- Performance concerns
|
|
13
|
+
- Best practices and design patterns
|
|
14
|
+
Be specific, cite line numbers, and suggest concrete improvements.`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'architect',
|
|
18
|
+
emoji: '🏗️',
|
|
19
|
+
description: 'System design, patterns, scalability',
|
|
20
|
+
systemPrompt: `You are a software architect. Focus on:
|
|
21
|
+
- System design and architecture patterns
|
|
22
|
+
- Scalability and maintainability
|
|
23
|
+
- Component boundaries and interfaces
|
|
24
|
+
- Data flow and state management
|
|
25
|
+
- Trade-offs between different approaches
|
|
26
|
+
Think in terms of systems, not just code.`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'debugger',
|
|
30
|
+
emoji: '🐛',
|
|
31
|
+
description: 'Root cause analysis, debugging strategies',
|
|
32
|
+
systemPrompt: `You are a debugging specialist. Focus on:
|
|
33
|
+
- Root cause analysis over symptoms
|
|
34
|
+
- Systematic debugging strategies
|
|
35
|
+
- Reproducing issues reliably
|
|
36
|
+
- Tracing data flow to find where things break
|
|
37
|
+
- Suggesting targeted fixes with minimal side effects
|
|
38
|
+
Think methodically and follow the evidence.`,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'security',
|
|
42
|
+
emoji: '🔒',
|
|
43
|
+
description: 'Vulnerabilities, OWASP, secure coding',
|
|
44
|
+
systemPrompt: `You are a security analyst. Focus on:
|
|
45
|
+
- OWASP Top 10 vulnerabilities
|
|
46
|
+
- Input validation and sanitization
|
|
47
|
+
- Authentication and authorization flaws
|
|
48
|
+
- Injection attacks (SQL, XSS, command)
|
|
49
|
+
- Secure defaults and least privilege
|
|
50
|
+
Flag issues with severity ratings and remediation steps.`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'performance',
|
|
54
|
+
emoji: '🚀',
|
|
55
|
+
description: 'Optimization, profiling, bottlenecks',
|
|
56
|
+
systemPrompt: `You are a performance engineer. Focus on:
|
|
57
|
+
- Identifying bottlenecks and hot paths
|
|
58
|
+
- Algorithm and data structure choices
|
|
59
|
+
- Memory allocation and GC pressure
|
|
60
|
+
- I/O optimization and caching strategies
|
|
61
|
+
- Benchmarking and profiling recommendations
|
|
62
|
+
Quantify impact where possible.`,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'devops',
|
|
66
|
+
emoji: '⚙️',
|
|
67
|
+
description: 'CI/CD, Docker, infrastructure',
|
|
68
|
+
systemPrompt: `You are a DevOps engineer. Focus on:
|
|
69
|
+
- CI/CD pipeline design and optimization
|
|
70
|
+
- Container and orchestration best practices
|
|
71
|
+
- Infrastructure as code
|
|
72
|
+
- Monitoring, logging, and observability
|
|
73
|
+
- Deployment strategies and rollback plans
|
|
74
|
+
Prioritize reliability and automation.`,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'general',
|
|
78
|
+
emoji: '🧠',
|
|
79
|
+
description: 'Default — no specialized focus',
|
|
80
|
+
systemPrompt: '',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export function getAgent(name: string): AgentPersona | undefined {
|
|
85
|
+
return agents.find(a => a.name === name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listAgents(): AgentPersona[] {
|
|
89
|
+
return agents;
|
|
90
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Client,
|
|
3
|
+
GatewayIntentBits,
|
|
4
|
+
ActivityType,
|
|
5
|
+
InteractionType,
|
|
6
|
+
ComponentType,
|
|
7
|
+
type TextChannel,
|
|
8
|
+
ChannelType,
|
|
9
|
+
Collection,
|
|
10
|
+
} from 'discord.js';
|
|
11
|
+
import { config } from './config.ts';
|
|
12
|
+
import { registerCommands } from './commands.ts';
|
|
13
|
+
import { handleClaude, handleShell, handleAgent, handleProject, setLogger } from './command-handlers.ts';
|
|
14
|
+
import { handleMessage } from './message-handler.ts';
|
|
15
|
+
import { handleButton, handleSelectMenu } from './button-handler.ts';
|
|
16
|
+
import { loadSessions, getAllSessions, unlinkChannel } from './session-manager.ts';
|
|
17
|
+
import { loadProjects } from './project-manager.ts';
|
|
18
|
+
|
|
19
|
+
let client: Client;
|
|
20
|
+
let logChannel: TextChannel | null = null;
|
|
21
|
+
let logBuffer: string[] = [];
|
|
22
|
+
let logTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
|
|
24
|
+
function botLog(msg: string): void {
|
|
25
|
+
const timestamp = new Date().toISOString().slice(11, 19);
|
|
26
|
+
const formatted = `\`[${timestamp}]\` ${msg}`;
|
|
27
|
+
console.log(`[${timestamp}] ${msg}`);
|
|
28
|
+
|
|
29
|
+
logBuffer.push(formatted);
|
|
30
|
+
if (!logTimer) {
|
|
31
|
+
logTimer = setTimeout(flushLogs, 2000);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function flushLogs(): Promise<void> {
|
|
36
|
+
logTimer = null;
|
|
37
|
+
if (!logChannel || logBuffer.length === 0) return;
|
|
38
|
+
|
|
39
|
+
const batch = logBuffer.splice(0, logBuffer.length).join('\n');
|
|
40
|
+
try {
|
|
41
|
+
await logChannel.send(batch);
|
|
42
|
+
} catch {
|
|
43
|
+
// Log channel may have been deleted
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function updatePresence(): void {
|
|
48
|
+
const sessionCount = getAllSessions().length;
|
|
49
|
+
const generating = getAllSessions().filter(s => s.isGenerating).length;
|
|
50
|
+
|
|
51
|
+
if (sessionCount === 0) {
|
|
52
|
+
client.user?.setPresence({
|
|
53
|
+
status: 'idle',
|
|
54
|
+
activities: [{ name: 'No active sessions', type: ActivityType.Custom }],
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
const status = generating > 0 ? `${generating} generating` : `${sessionCount} sessions`;
|
|
58
|
+
client.user?.setPresence({
|
|
59
|
+
status: 'online',
|
|
60
|
+
activities: [{ name: `${status}`, type: ActivityType.Watching }],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Message retention cleanup
|
|
66
|
+
async function cleanupOldMessages(): Promise<void> {
|
|
67
|
+
if (!config.messageRetentionDays) return;
|
|
68
|
+
|
|
69
|
+
const cutoff = Date.now() - config.messageRetentionDays * 24 * 60 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
for (const session of getAllSessions()) {
|
|
72
|
+
try {
|
|
73
|
+
const channel = client.channels.cache.get(session.channelId) as TextChannel | undefined;
|
|
74
|
+
if (!channel) continue;
|
|
75
|
+
|
|
76
|
+
const messages = await channel.messages.fetch({ limit: 100 });
|
|
77
|
+
const old = messages.filter(m => m.createdTimestamp < cutoff);
|
|
78
|
+
if (old.size > 0) {
|
|
79
|
+
await channel.bulkDelete(old, true);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Channel may not exist
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function startBot(): Promise<void> {
|
|
88
|
+
client = new Client({
|
|
89
|
+
intents: [
|
|
90
|
+
GatewayIntentBits.Guilds,
|
|
91
|
+
GatewayIntentBits.GuildMessages,
|
|
92
|
+
GatewayIntentBits.MessageContent,
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
setLogger(botLog);
|
|
97
|
+
|
|
98
|
+
// Slash commands
|
|
99
|
+
client.on('interactionCreate', async interaction => {
|
|
100
|
+
try {
|
|
101
|
+
if (interaction.type === InteractionType.ApplicationCommand && interaction.isChatInputCommand()) {
|
|
102
|
+
switch (interaction.commandName) {
|
|
103
|
+
case 'claude': return await handleClaude(interaction);
|
|
104
|
+
case 'shell': return await handleShell(interaction);
|
|
105
|
+
case 'agent': return await handleAgent(interaction);
|
|
106
|
+
case 'project': return await handleProject(interaction);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (interaction.isButton()) {
|
|
111
|
+
return await handleButton(interaction);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (interaction.isStringSelectMenu()) {
|
|
115
|
+
return await handleSelectMenu(interaction);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Interaction error:', err);
|
|
119
|
+
try {
|
|
120
|
+
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
|
|
121
|
+
await interaction.reply({ content: 'An error occurred.', ephemeral: true });
|
|
122
|
+
}
|
|
123
|
+
} catch { /* can't recover */ }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Channel messages
|
|
128
|
+
client.on('messageCreate', handleMessage);
|
|
129
|
+
|
|
130
|
+
// Channel deletion cleanup
|
|
131
|
+
client.on('channelDelete', channel => {
|
|
132
|
+
if (channel.type === ChannelType.GuildText) {
|
|
133
|
+
unlinkChannel(channel.id);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Ready
|
|
138
|
+
client.once('ready', async () => {
|
|
139
|
+
console.log(`Logged in as ${client.user?.tag}`);
|
|
140
|
+
|
|
141
|
+
// Register commands
|
|
142
|
+
await registerCommands();
|
|
143
|
+
|
|
144
|
+
// Load persisted state
|
|
145
|
+
await loadProjects();
|
|
146
|
+
await loadSessions();
|
|
147
|
+
|
|
148
|
+
// Set up log channel in the first guild
|
|
149
|
+
const guild = client.guilds.cache.first();
|
|
150
|
+
if (guild) {
|
|
151
|
+
// Find existing bot-logs channel or note it doesn't exist
|
|
152
|
+
logChannel = guild.channels.cache.find(
|
|
153
|
+
ch => ch.name === 'bot-logs' && ch.type === ChannelType.GuildText,
|
|
154
|
+
) as TextChannel | undefined ?? null;
|
|
155
|
+
|
|
156
|
+
if (!logChannel) {
|
|
157
|
+
try {
|
|
158
|
+
logChannel = await guild.channels.create({
|
|
159
|
+
name: 'bot-logs',
|
|
160
|
+
type: ChannelType.GuildText,
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
console.warn('Could not create #bot-logs channel');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
botLog(`Bot online. ${getAllSessions().length} session(s) restored.`);
|
|
169
|
+
updatePresence();
|
|
170
|
+
|
|
171
|
+
// Presence update interval
|
|
172
|
+
setInterval(updatePresence, 30_000);
|
|
173
|
+
|
|
174
|
+
// Message cleanup
|
|
175
|
+
if (config.messageRetentionDays) {
|
|
176
|
+
await cleanupOldMessages();
|
|
177
|
+
setInterval(cleanupOldMessages, 60 * 60 * 1000); // hourly
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Graceful shutdown
|
|
182
|
+
const shutdown = () => {
|
|
183
|
+
botLog('Shutting down...');
|
|
184
|
+
flushLogs();
|
|
185
|
+
client.destroy();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
process.on('SIGINT', shutdown);
|
|
190
|
+
process.on('SIGTERM', shutdown);
|
|
191
|
+
|
|
192
|
+
await client.login(config.token);
|
|
193
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ButtonInteraction,
|
|
3
|
+
StringSelectMenuInteraction,
|
|
4
|
+
TextChannel,
|
|
5
|
+
} from 'discord.js';
|
|
6
|
+
import { config } from './config.ts';
|
|
7
|
+
import * as sessions from './session-manager.ts';
|
|
8
|
+
import { handleOutputStream, getExpandableContent } from './output-handler.ts';
|
|
9
|
+
import { isUserAllowed, truncate } from './utils.ts';
|
|
10
|
+
|
|
11
|
+
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
|
|
12
|
+
if (!isUserAllowed(interaction.user.id, config.allowedUsers, config.allowAllUsers)) {
|
|
13
|
+
await interaction.reply({ content: 'Not authorized.', ephemeral: true });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const customId = interaction.customId;
|
|
18
|
+
|
|
19
|
+
// Stop button
|
|
20
|
+
if (customId.startsWith('stop:')) {
|
|
21
|
+
const sessionId = customId.slice(5);
|
|
22
|
+
const stopped = sessions.abortSession(sessionId);
|
|
23
|
+
await interaction.reply({
|
|
24
|
+
content: stopped ? 'Generation stopped.' : 'Session was not generating.',
|
|
25
|
+
ephemeral: true,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Continue button
|
|
31
|
+
if (customId.startsWith('continue:')) {
|
|
32
|
+
const sessionId = customId.slice(9);
|
|
33
|
+
const session = sessions.getSession(sessionId);
|
|
34
|
+
if (!session) {
|
|
35
|
+
await interaction.reply({ content: 'Session not found.', ephemeral: true });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (session.isGenerating) {
|
|
39
|
+
await interaction.reply({ content: 'Session is already generating.', ephemeral: true });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await interaction.deferReply();
|
|
44
|
+
try {
|
|
45
|
+
const channel = interaction.channel as TextChannel;
|
|
46
|
+
const stream = sessions.continueSession(sessionId);
|
|
47
|
+
await interaction.editReply('Continuing...');
|
|
48
|
+
await handleOutputStream(stream, channel, sessionId, session.verbose);
|
|
49
|
+
} catch (err: unknown) {
|
|
50
|
+
await interaction.editReply(`Error: ${(err as Error).message}`);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Expand button
|
|
56
|
+
if (customId.startsWith('expand:')) {
|
|
57
|
+
const contentId = customId.slice(7);
|
|
58
|
+
const content = getExpandableContent(contentId);
|
|
59
|
+
if (!content) {
|
|
60
|
+
await interaction.reply({ content: 'Content expired.', ephemeral: true });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Discord max message is 2000 chars
|
|
64
|
+
const display = truncate(content, 1950);
|
|
65
|
+
await interaction.reply({ content: `\`\`\`\n${display}\n\`\`\``, ephemeral: true });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Option buttons (numbered choices)
|
|
70
|
+
if (customId.startsWith('option:')) {
|
|
71
|
+
const parts = customId.split(':');
|
|
72
|
+
const sessionId = parts[1];
|
|
73
|
+
const optionIndex = parseInt(parts[2], 10);
|
|
74
|
+
|
|
75
|
+
const session = sessions.getSession(sessionId);
|
|
76
|
+
if (!session) {
|
|
77
|
+
await interaction.reply({ content: 'Session not found.', ephemeral: true });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Send the option number as input
|
|
82
|
+
const optionText = `${optionIndex + 1}`;
|
|
83
|
+
await interaction.deferReply();
|
|
84
|
+
try {
|
|
85
|
+
const channel = interaction.channel as TextChannel;
|
|
86
|
+
const stream = sessions.sendPrompt(sessionId, optionText);
|
|
87
|
+
await interaction.editReply(`Selected option ${optionIndex + 1}`);
|
|
88
|
+
await handleOutputStream(stream, channel, sessionId, session.verbose);
|
|
89
|
+
} catch (err: unknown) {
|
|
90
|
+
await interaction.editReply(`Error: ${(err as Error).message}`);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Confirm buttons (yes/no)
|
|
96
|
+
if (customId.startsWith('confirm:')) {
|
|
97
|
+
const parts = customId.split(':');
|
|
98
|
+
const sessionId = parts[1];
|
|
99
|
+
const answer = parts[2]; // 'yes' or 'no'
|
|
100
|
+
|
|
101
|
+
const session = sessions.getSession(sessionId);
|
|
102
|
+
if (!session) {
|
|
103
|
+
await interaction.reply({ content: 'Session not found.', ephemeral: true });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await interaction.deferReply();
|
|
108
|
+
try {
|
|
109
|
+
const channel = interaction.channel as TextChannel;
|
|
110
|
+
const stream = sessions.sendPrompt(sessionId, answer);
|
|
111
|
+
await interaction.editReply(`Answered: ${answer}`);
|
|
112
|
+
await handleOutputStream(stream, channel, sessionId, session.verbose);
|
|
113
|
+
} catch (err: unknown) {
|
|
114
|
+
await interaction.editReply(`Error: ${(err as Error).message}`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await interaction.reply({ content: 'Unknown button.', ephemeral: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function handleSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
|
|
123
|
+
if (!isUserAllowed(interaction.user.id, config.allowedUsers, config.allowAllUsers)) {
|
|
124
|
+
await interaction.reply({ content: 'Not authorized.', ephemeral: true });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const customId = interaction.customId;
|
|
129
|
+
|
|
130
|
+
if (customId.startsWith('select:')) {
|
|
131
|
+
const sessionId = customId.slice(7);
|
|
132
|
+
const selected = interaction.values[0];
|
|
133
|
+
|
|
134
|
+
const session = sessions.getSession(sessionId);
|
|
135
|
+
if (!session) {
|
|
136
|
+
await interaction.reply({ content: 'Session not found.', ephemeral: true });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await interaction.deferReply();
|
|
141
|
+
try {
|
|
142
|
+
const channel = interaction.channel as TextChannel;
|
|
143
|
+
const stream = sessions.sendPrompt(sessionId, selected);
|
|
144
|
+
await interaction.editReply(`Selected: ${truncate(selected, 100)}`);
|
|
145
|
+
await handleOutputStream(stream, channel, sessionId, session.verbose);
|
|
146
|
+
} catch (err: unknown) {
|
|
147
|
+
await interaction.editReply(`Error: ${(err as Error).message}`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await interaction.reply({ content: 'Unknown selection.', ephemeral: true });
|
|
153
|
+
}
|