@thesammykins/tether 1.0.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 +44 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tether.ts +1010 -0
- package/index.ts +32 -0
- package/package.json +64 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/opencode.ts +83 -0
- package/src/adapters/registry.ts +23 -0
- package/src/adapters/types.ts +17 -0
- package/src/api.ts +494 -0
- package/src/bot.ts +653 -0
- package/src/db.ts +123 -0
- package/src/discord.ts +80 -0
- package/src/features/ack.ts +10 -0
- package/src/features/brb.ts +79 -0
- package/src/features/channel-context.ts +23 -0
- package/src/features/pause-resume.ts +86 -0
- package/src/features/session-limits.ts +48 -0
- package/src/features/thread-naming.ts +33 -0
- package/src/middleware/allowlist.ts +64 -0
- package/src/middleware/rate-limiter.ts +46 -0
- package/src/queue.ts +43 -0
- package/src/spawner.ts +110 -0
- package/src/worker.ts +97 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database - SQLite for thread → session mappings
|
|
3
|
+
*
|
|
4
|
+
* Simple key-value store:
|
|
5
|
+
* - thread_id (Discord thread ID)
|
|
6
|
+
* - session_id (Claude session UUID)
|
|
7
|
+
*
|
|
8
|
+
* When a follow-up message comes in a thread, we look up
|
|
9
|
+
* the session ID to use --resume.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from 'bun:sqlite';
|
|
13
|
+
|
|
14
|
+
const DB_PATH = process.env.DB_PATH || './data/threads.db';
|
|
15
|
+
|
|
16
|
+
// Ensure data directory exists
|
|
17
|
+
import { mkdirSync } from 'fs';
|
|
18
|
+
import { dirname } from 'path';
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
// Open database
|
|
24
|
+
export const db = new Database(DB_PATH);
|
|
25
|
+
|
|
26
|
+
// Create tables if they don't exist
|
|
27
|
+
db.run(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
29
|
+
thread_id TEXT PRIMARY KEY,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
// Create channels config table
|
|
36
|
+
db.run(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
38
|
+
channel_id TEXT PRIMARY KEY,
|
|
39
|
+
working_dir TEXT,
|
|
40
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
// Add working_dir column to threads table (migration)
|
|
45
|
+
try {
|
|
46
|
+
db.run(`ALTER TABLE threads ADD COLUMN working_dir TEXT`);
|
|
47
|
+
} catch {} // Column may already exist
|
|
48
|
+
|
|
49
|
+
// Create index for faster lookups
|
|
50
|
+
db.run(`
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_threads_session
|
|
52
|
+
ON threads(session_id)
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
// Create paused_threads table
|
|
56
|
+
db.run(`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS paused_threads (
|
|
58
|
+
thread_id TEXT PRIMARY KEY,
|
|
59
|
+
paused_at INTEGER NOT NULL,
|
|
60
|
+
paused_by TEXT
|
|
61
|
+
)
|
|
62
|
+
`);
|
|
63
|
+
|
|
64
|
+
// Create held_messages table
|
|
65
|
+
db.run(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS held_messages (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
thread_id TEXT NOT NULL,
|
|
69
|
+
author_id TEXT NOT NULL,
|
|
70
|
+
content TEXT NOT NULL,
|
|
71
|
+
created_at INTEGER NOT NULL
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
// Create rate_limits table
|
|
76
|
+
db.run(`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
78
|
+
user_id TEXT NOT NULL,
|
|
79
|
+
timestamp INTEGER NOT NULL
|
|
80
|
+
)
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
// Create index for rate limit cleanup
|
|
84
|
+
db.run(`
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limits_timestamp
|
|
86
|
+
ON rate_limits(timestamp)
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
console.log(`[db] SQLite database ready at ${DB_PATH}`);
|
|
90
|
+
|
|
91
|
+
// In-memory cache for channel configs (TTL: 5 minutes)
|
|
92
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
93
|
+
const channelConfigCache = new Map<string, { data: { working_dir: string | null } | null; expiresAt: number }>();
|
|
94
|
+
|
|
95
|
+
// Helper functions for channel config
|
|
96
|
+
function getChannelConfig(channelId: string): { working_dir: string | null } | null {
|
|
97
|
+
return db.query('SELECT working_dir FROM channels WHERE channel_id = ?')
|
|
98
|
+
.get(channelId) as { working_dir: string | null } | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getChannelConfigCached(channelId: string): { working_dir: string | null } | null {
|
|
102
|
+
const cached = channelConfigCache.get(channelId);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
if (cached && cached.expiresAt > now) {
|
|
106
|
+
return cached.data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Cache miss or expired - fetch from DB
|
|
110
|
+
const data = getChannelConfig(channelId);
|
|
111
|
+
channelConfigCache.set(channelId, { data, expiresAt: now + CACHE_TTL_MS });
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function setChannelConfig(channelId: string, workingDir: string): void {
|
|
116
|
+
db.run(`
|
|
117
|
+
INSERT INTO channels (channel_id, working_dir) VALUES (?, ?)
|
|
118
|
+
ON CONFLICT(channel_id) DO UPDATE SET working_dir = ?, updated_at = CURRENT_TIMESTAMP
|
|
119
|
+
`, [channelId, workingDir, workingDir]);
|
|
120
|
+
|
|
121
|
+
// Invalidate cache
|
|
122
|
+
channelConfigCache.delete(channelId);
|
|
123
|
+
}
|
package/src/discord.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord utilities - Helper functions for posting to Discord
|
|
3
|
+
*
|
|
4
|
+
* Separated from bot.ts so the worker can send messages
|
|
5
|
+
* without importing the full client.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
|
9
|
+
|
|
10
|
+
if (!DISCORD_BOT_TOKEN) {
|
|
11
|
+
console.warn('[discord] DISCORD_BOT_TOKEN not set - sendToThread will fail');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Send a message to a Discord thread
|
|
16
|
+
*
|
|
17
|
+
* Uses the REST API directly so we don't need the gateway client
|
|
18
|
+
*/
|
|
19
|
+
export async function sendToThread(threadId: string, content: string): Promise<void> {
|
|
20
|
+
// Discord message limit is 2000 chars
|
|
21
|
+
const MAX_LENGTH = 2000;
|
|
22
|
+
|
|
23
|
+
// Split long messages
|
|
24
|
+
const chunks: string[] = [];
|
|
25
|
+
let remaining = content;
|
|
26
|
+
|
|
27
|
+
while (remaining.length > 0) {
|
|
28
|
+
if (remaining.length <= MAX_LENGTH) {
|
|
29
|
+
chunks.push(remaining);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find a good split point (newline or space)
|
|
34
|
+
let splitAt = remaining.lastIndexOf('\n', MAX_LENGTH);
|
|
35
|
+
if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
|
|
36
|
+
splitAt = remaining.lastIndexOf(' ', MAX_LENGTH);
|
|
37
|
+
}
|
|
38
|
+
if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
|
|
39
|
+
splitAt = MAX_LENGTH;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
43
|
+
remaining = remaining.slice(splitAt).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Send each chunk
|
|
47
|
+
for (const chunk of chunks) {
|
|
48
|
+
const response = await fetch(
|
|
49
|
+
`https://discord.com/api/v10/channels/${threadId}/messages`,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ content: chunk }),
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const error = await response.text();
|
|
62
|
+
throw new Error(`Discord API error: ${response.status} ${error}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Send typing indicator to a thread
|
|
69
|
+
*/
|
|
70
|
+
export async function sendTyping(channelId: string): Promise<void> {
|
|
71
|
+
await fetch(
|
|
72
|
+
`https://discord.com/api/v10/channels/${channelId}/typing`,
|
|
73
|
+
{
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Message } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
export async function acknowledgeMessage(message: Message): Promise<void> {
|
|
4
|
+
try {
|
|
5
|
+
// React with 👀 to show the bot has seen the message
|
|
6
|
+
await message.react('👀');
|
|
7
|
+
} catch {
|
|
8
|
+
// Silently fail if we can't react (permissions, etc.)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BRB (Be Right Back) state management
|
|
3
|
+
*
|
|
4
|
+
* Tracks which threads/DM channels have users who are temporarily away.
|
|
5
|
+
* State is ephemeral (in-memory) - if bot restarts, all users are assumed back.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// In-memory storage of away thread IDs
|
|
9
|
+
const awayThreads = new Set<string>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mark a thread as user-away
|
|
13
|
+
*/
|
|
14
|
+
export function setBrb(threadId: string): void {
|
|
15
|
+
awayThreads.add(threadId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mark a thread as user-returned
|
|
20
|
+
*/
|
|
21
|
+
export function setBack(threadId: string): void {
|
|
22
|
+
awayThreads.delete(threadId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if user is away in a thread
|
|
27
|
+
*/
|
|
28
|
+
export function isAway(threadId: string): boolean {
|
|
29
|
+
return awayThreads.has(threadId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get all away threads (for diagnostics)
|
|
34
|
+
*/
|
|
35
|
+
export function getAwayThreads(): string[] {
|
|
36
|
+
return Array.from(awayThreads);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if message content indicates user is going away
|
|
41
|
+
* Matches: 'brb', 'be right back', 'afk', 'stepping away'
|
|
42
|
+
*/
|
|
43
|
+
export function isBrbMessage(content: string): boolean {
|
|
44
|
+
const normalized = content.trim().toLowerCase();
|
|
45
|
+
|
|
46
|
+
if (normalized === '') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const patterns = [
|
|
51
|
+
'brb',
|
|
52
|
+
'be right back',
|
|
53
|
+
'afk',
|
|
54
|
+
'stepping away',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
return patterns.includes(normalized);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if message content indicates user is back
|
|
62
|
+
* Matches: 'back', 'im back', "i'm back", 'here'
|
|
63
|
+
*/
|
|
64
|
+
export function isBackMessage(content: string): boolean {
|
|
65
|
+
const normalized = content.trim().toLowerCase();
|
|
66
|
+
|
|
67
|
+
if (normalized === '') {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const patterns = [
|
|
72
|
+
'back',
|
|
73
|
+
'im back',
|
|
74
|
+
"i'm back",
|
|
75
|
+
'here',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
return patterns.includes(normalized);
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TextChannel, ThreadChannel } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
const MAX_CONTEXT_MESSAGES = 10;
|
|
4
|
+
|
|
5
|
+
export async function getChannelContext(channel: TextChannel | ThreadChannel): Promise<string> {
|
|
6
|
+
try {
|
|
7
|
+
const messages = await channel.messages.fetch({ limit: MAX_CONTEXT_MESSAGES });
|
|
8
|
+
|
|
9
|
+
if (messages.size === 0) return '';
|
|
10
|
+
|
|
11
|
+
// Build context string from most recent messages (reversed to chronological)
|
|
12
|
+
const contextLines = Array.from(messages.values())
|
|
13
|
+
.reverse()
|
|
14
|
+
.filter(m => m.content && m.content.trim().length > 0)
|
|
15
|
+
.map(m => `${m.author.tag}: ${m.content}`);
|
|
16
|
+
|
|
17
|
+
if (contextLines.length === 0) return '';
|
|
18
|
+
|
|
19
|
+
return `Recent channel context:\n${contextLines.join('\n')}`;
|
|
20
|
+
} catch {
|
|
21
|
+
return ''; // Fail silently — context is optional
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pause/Resume - Manage thread pausing and message queueing
|
|
3
|
+
*
|
|
4
|
+
* Keywords:
|
|
5
|
+
* - pause, stop, hold (or with ! prefix)
|
|
6
|
+
* - resume, continue, unpause (or with ! prefix)
|
|
7
|
+
*
|
|
8
|
+
* When paused, messages are stored in held_messages table.
|
|
9
|
+
* When resumed, held messages can be retrieved via getHeldMessages().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Message } from 'discord.js';
|
|
13
|
+
import { db } from '../db.js';
|
|
14
|
+
|
|
15
|
+
const PAUSE_KEYWORDS = ['pause', 'stop', 'hold'];
|
|
16
|
+
const RESUME_KEYWORDS = ['resume', 'continue', 'unpause'];
|
|
17
|
+
|
|
18
|
+
export function handlePauseResume(message: Message): { paused: boolean } {
|
|
19
|
+
const content = message.content.toLowerCase().trim();
|
|
20
|
+
const threadId = message.channel.isThread() ? message.channel.id : null;
|
|
21
|
+
|
|
22
|
+
if (!threadId) return { paused: false }; // Only works in threads
|
|
23
|
+
|
|
24
|
+
// Check for resume command
|
|
25
|
+
if (RESUME_KEYWORDS.some(kw => content === kw || content === `!${kw}`)) {
|
|
26
|
+
resumeThread(threadId);
|
|
27
|
+
// Don't mark as paused — let the message through to process held messages
|
|
28
|
+
return { paused: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check for pause command
|
|
32
|
+
if (PAUSE_KEYWORDS.some(kw => content === kw || content === `!${kw}`)) {
|
|
33
|
+
pauseThread(threadId, message.author.id);
|
|
34
|
+
return { paused: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check if thread is currently paused
|
|
38
|
+
if (isThreadPaused(threadId)) {
|
|
39
|
+
// Hold this message
|
|
40
|
+
holdMessage(threadId, message.author.id, message.content);
|
|
41
|
+
return { paused: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { paused: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isThreadPaused(threadId: string): boolean {
|
|
48
|
+
const row = db.query('SELECT 1 FROM paused_threads WHERE thread_id = ?').get(threadId);
|
|
49
|
+
return !!row;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pauseThread(threadId: string, userId: string): void {
|
|
53
|
+
db.run(
|
|
54
|
+
'INSERT OR REPLACE INTO paused_threads (thread_id, paused_at, paused_by) VALUES (?, ?, ?)',
|
|
55
|
+
[threadId, Date.now(), userId]
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resumeThread(threadId: string): void {
|
|
60
|
+
db.run('DELETE FROM paused_threads WHERE thread_id = ?', [threadId]);
|
|
61
|
+
// Held messages remain in the table — they can be processed by the caller
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function holdMessage(threadId: string, authorId: string, content: string): void {
|
|
65
|
+
db.run(
|
|
66
|
+
'INSERT INTO held_messages (thread_id, author_id, content, created_at) VALUES (?, ?, ?, ?)',
|
|
67
|
+
[threadId, authorId, content, Date.now()]
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Exported for use by bot when resuming
|
|
72
|
+
export function getHeldMessages(threadId: string): Array<{ author_id: string; content: string }> {
|
|
73
|
+
const rows = db.query(
|
|
74
|
+
'SELECT author_id, content FROM held_messages WHERE thread_id = ? ORDER BY created_at ASC'
|
|
75
|
+
).all(threadId) as Array<{ author_id: string; content: string }>;
|
|
76
|
+
|
|
77
|
+
// Clear held messages after retrieval
|
|
78
|
+
db.run('DELETE FROM held_messages WHERE thread_id = ?', [threadId]);
|
|
79
|
+
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Exported for testing
|
|
84
|
+
export function isThreadPausedExport(threadId: string): boolean {
|
|
85
|
+
return isThreadPaused(threadId);
|
|
86
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Limits - Track turns per session and session duration
|
|
3
|
+
*
|
|
4
|
+
* Enforces:
|
|
5
|
+
* - MAX_TURNS_PER_SESSION: Maximum messages in a thread (0 = disabled)
|
|
6
|
+
* - MAX_SESSION_DURATION_MS: Maximum session lifetime (0 = disabled)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db } from '../db.js';
|
|
10
|
+
|
|
11
|
+
// In-memory turn counter: threadId -> turn count
|
|
12
|
+
const turnCounts = new Map<string, number>();
|
|
13
|
+
|
|
14
|
+
export function checkSessionLimits(threadId: string): boolean {
|
|
15
|
+
const MAX_TURNS = parseInt(process.env.MAX_TURNS_PER_SESSION || '50');
|
|
16
|
+
const MAX_DURATION_MS = parseInt(process.env.MAX_SESSION_DURATION_MS || '3600000');
|
|
17
|
+
|
|
18
|
+
if (MAX_TURNS <= 0 && MAX_DURATION_MS <= 0) return true; // Disabled
|
|
19
|
+
|
|
20
|
+
// Check turn limit
|
|
21
|
+
if (MAX_TURNS > 0) {
|
|
22
|
+
const turns = (turnCounts.get(threadId) || 0) + 1;
|
|
23
|
+
turnCounts.set(threadId, turns);
|
|
24
|
+
if (turns > MAX_TURNS) return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check duration limit
|
|
28
|
+
if (MAX_DURATION_MS > 0) {
|
|
29
|
+
const thread = db.query('SELECT created_at FROM threads WHERE thread_id = ?')
|
|
30
|
+
.get(threadId) as { created_at: string } | null;
|
|
31
|
+
|
|
32
|
+
if (thread) {
|
|
33
|
+
const created = new Date(thread.created_at).getTime();
|
|
34
|
+
if (Date.now() - created > MAX_DURATION_MS) return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Exported for testing
|
|
42
|
+
export function resetSessionLimits(): void {
|
|
43
|
+
turnCounts.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSessionTurns(threadId: string): number {
|
|
47
|
+
return turnCounts.get(threadId) || 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const MAX_THREAD_NAME_LENGTH = 80;
|
|
2
|
+
|
|
3
|
+
export function generateThreadName(content: string): string {
|
|
4
|
+
if (!content || content.trim().length === 0) {
|
|
5
|
+
return 'New conversation';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let name = content.trim();
|
|
9
|
+
|
|
10
|
+
// Strip markdown formatting
|
|
11
|
+
name = name.replace(/[*_~`#]/g, '');
|
|
12
|
+
|
|
13
|
+
// Use first line only
|
|
14
|
+
const firstLine = name.split('\n')[0].trim();
|
|
15
|
+
name = firstLine || name;
|
|
16
|
+
|
|
17
|
+
// If short enough, return as-is
|
|
18
|
+
if (name.length <= MAX_THREAD_NAME_LENGTH) {
|
|
19
|
+
return name;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Truncate at word boundary
|
|
23
|
+
const truncated = name.slice(0, MAX_THREAD_NAME_LENGTH - 1); // -1 for ellipsis char
|
|
24
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
25
|
+
|
|
26
|
+
if (lastSpace > MAX_THREAD_NAME_LENGTH * 0.5) {
|
|
27
|
+
// Break at word boundary if it doesn't lose too much
|
|
28
|
+
return truncated.slice(0, lastSpace) + '…';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Hard truncate if no good word boundary
|
|
32
|
+
return truncated + '…';
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ChannelType, type Message } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse comma-separated environment variable into Set.
|
|
5
|
+
* Returns null if not configured (no restriction).
|
|
6
|
+
*/
|
|
7
|
+
function parseEnvList(value?: string): Set<string> | null {
|
|
8
|
+
if (!value || value.trim() === '') return null;
|
|
9
|
+
return new Set(value.split(',').map(s => s.trim()).filter(Boolean));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Parse env vars once at module load
|
|
13
|
+
const ALLOWED_USERS = parseEnvList(process.env.ALLOWED_USERS);
|
|
14
|
+
const ALLOWED_ROLES = parseEnvList(process.env.ALLOWED_ROLES);
|
|
15
|
+
const ALLOWED_CHANNELS = parseEnvList(process.env.ALLOWED_CHANNELS);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if message is from an allowed user/role/channel.
|
|
19
|
+
*
|
|
20
|
+
* Guild messages:
|
|
21
|
+
* - If ALLOWED_CHANNELS set, message must be in allowed channel
|
|
22
|
+
* - If ALLOWED_USERS set, user must be in allowlist OR have allowed role
|
|
23
|
+
* - If ALLOWED_ROLES set, user must have allowed role OR be in user allowlist
|
|
24
|
+
*
|
|
25
|
+
* DM messages:
|
|
26
|
+
* - Channel/role allowlists are ignored (DMs have no guild context)
|
|
27
|
+
* - Only ALLOWED_USERS is checked (if configured)
|
|
28
|
+
*/
|
|
29
|
+
export function checkAllowlist(message: Message): boolean {
|
|
30
|
+
const isDM = message.channel.type === ChannelType.DM;
|
|
31
|
+
|
|
32
|
+
// If no allowlists configured, allow everything
|
|
33
|
+
if (!ALLOWED_USERS && !ALLOWED_ROLES && !ALLOWED_CHANNELS) return true;
|
|
34
|
+
|
|
35
|
+
// DMs: only user allowlist applies (no channels or roles in DMs)
|
|
36
|
+
if (isDM) {
|
|
37
|
+
if (ALLOWED_USERS) return ALLOWED_USERS.has(message.author.id);
|
|
38
|
+
// No user allowlist configured — allow DMs from anyone (channel/role lists don't apply)
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Guild messages: check channel allowlist
|
|
43
|
+
if (ALLOWED_CHANNELS && !ALLOWED_CHANNELS.has(message.channelId)) {
|
|
44
|
+
// Also check parent channel for thread messages
|
|
45
|
+
const parentId = message.channel.isThread() ? message.channel.parentId : null;
|
|
46
|
+
if (!parentId || !ALLOWED_CHANNELS.has(parentId)) return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check user allowlist
|
|
50
|
+
if (ALLOWED_USERS && ALLOWED_USERS.has(message.author.id)) return true;
|
|
51
|
+
|
|
52
|
+
// Check role allowlist
|
|
53
|
+
if (ALLOWED_ROLES && message.member) {
|
|
54
|
+
const userRoles = message.member.roles.cache;
|
|
55
|
+
for (const roleId of ALLOWED_ROLES) {
|
|
56
|
+
if (userRoles.has(roleId)) return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If user/role allowlists exist but user matches neither, deny
|
|
61
|
+
if (ALLOWED_USERS || ALLOWED_ROLES) return false;
|
|
62
|
+
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter using in-memory Map.
|
|
3
|
+
*
|
|
4
|
+
* Advantages over DB:
|
|
5
|
+
* - Faster (no I/O overhead)
|
|
6
|
+
* - Simpler (no cleanup job needed)
|
|
7
|
+
* - Sufficient for single-instance bot
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const RATE_LIMIT_REQUESTS = parseInt(process.env.RATE_LIMIT_REQUESTS || '5');
|
|
11
|
+
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000');
|
|
12
|
+
|
|
13
|
+
// In-memory sliding window: userId -> array of timestamps
|
|
14
|
+
const userTimestamps = new Map<string, number[]>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if user is within rate limit.
|
|
18
|
+
* Returns true if request is allowed, false if rate limited.
|
|
19
|
+
*/
|
|
20
|
+
export function checkRateLimit(userId: string): boolean {
|
|
21
|
+
if (RATE_LIMIT_REQUESTS <= 0) return true; // Disabled
|
|
22
|
+
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
25
|
+
|
|
26
|
+
let timestamps = userTimestamps.get(userId) || [];
|
|
27
|
+
|
|
28
|
+
// Remove expired timestamps
|
|
29
|
+
timestamps = timestamps.filter(t => t > windowStart);
|
|
30
|
+
|
|
31
|
+
if (timestamps.length >= RATE_LIMIT_REQUESTS) {
|
|
32
|
+
userTimestamps.set(userId, timestamps);
|
|
33
|
+
return false; // Rate limited
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
timestamps.push(now);
|
|
37
|
+
userTimestamps.set(userId, timestamps);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reset all rate limits (exported for testing).
|
|
43
|
+
*/
|
|
44
|
+
export function resetRateLimits(): void {
|
|
45
|
+
userTimestamps.clear();
|
|
46
|
+
}
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue - BullMQ job queue for Claude processing
|
|
3
|
+
*
|
|
4
|
+
* Why a queue?
|
|
5
|
+
* 1. Claude can take minutes to respond - Discord would timeout
|
|
6
|
+
* 2. Rate limiting - don't spawn 100 Claude processes at once
|
|
7
|
+
* 3. Persistence - if the bot crashes, jobs aren't lost
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Queue } from 'bullmq';
|
|
11
|
+
import IORedis from 'ioredis';
|
|
12
|
+
|
|
13
|
+
// Redis connection for BullMQ
|
|
14
|
+
const connection = new IORedis({
|
|
15
|
+
host: process.env.REDIS_HOST || 'localhost',
|
|
16
|
+
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
17
|
+
maxRetriesPerRequest: null, // Required for BullMQ
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The queue that holds Claude processing jobs
|
|
21
|
+
export const claudeQueue = new Queue('claude', {
|
|
22
|
+
connection,
|
|
23
|
+
defaultJobOptions: {
|
|
24
|
+
attempts: 3,
|
|
25
|
+
backoff: {
|
|
26
|
+
type: 'exponential',
|
|
27
|
+
delay: 1000,
|
|
28
|
+
},
|
|
29
|
+
removeOnComplete: 100, // Keep last 100 completed jobs
|
|
30
|
+
removeOnFail: 50, // Keep last 50 failed jobs
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Job data structure
|
|
35
|
+
export interface ClaudeJob {
|
|
36
|
+
prompt: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
resume: boolean;
|
|
40
|
+
userId: string;
|
|
41
|
+
username: string;
|
|
42
|
+
workingDir?: string;
|
|
43
|
+
}
|