@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/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
+ }