feat: communication spine — gateway, TUI, Discord
Gateway: - Agent service wrapping Pi SDK createAgentSession (in-process) - Chat WebSocket gateway (Socket.IO) streaming agent events - Chat REST controller for synchronous requests - NestJS module structure: AgentModule (global), ChatModule CLI: - Ink-based TUI client connecting to gateway via WebSocket - Commander-based CLI with `mosaic tui` command - Streaming message display with React components Discord: - Discord.js bot with mention-based activation + DM support - Routes messages through gateway WebSocket - Chunked response delivery (2000-char Discord limit) - Single-guild binding for v0.1.0 Architecture: All channels → Gateway WebSocket → Pi SDK → LLM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,17 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,153 @@
|
||||
export const VERSION = '0.0.0';
|
||||
import { Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
export interface DiscordPluginConfig {
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
/** Which guild to bind to (single-guild only for v0.1.0) */
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
export class DiscordPlugin {
|
||||
private client: Client;
|
||||
private socket: Socket | null = null;
|
||||
private config: DiscordPluginConfig;
|
||||
/** Map Discord channel ID → Mosaic conversation ID */
|
||||
private channelConversations = new Map<string, string>();
|
||||
/** Track in-flight responses to avoid duplicate streaming */
|
||||
private pendingResponses = new Map<string, string>();
|
||||
|
||||
constructor(config: DiscordPluginConfig) {
|
||||
this.config = config;
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to gateway WebSocket
|
||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[discord] Connected to gateway');
|
||||
});
|
||||
|
||||
// Handle streaming text from gateway
|
||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||
const pending = this.pendingResponses.get(data.conversationId);
|
||||
if (pending !== undefined) {
|
||||
this.pendingResponses.set(data.conversationId, pending + data.text);
|
||||
}
|
||||
});
|
||||
|
||||
// When agent finishes, send the accumulated response
|
||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||
const text = this.pendingResponses.get(data.conversationId);
|
||||
if (text) {
|
||||
this.sendToDiscord(data.conversationId, text);
|
||||
this.pendingResponses.delete(data.conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('agent:start', (data: { conversationId: string }) => {
|
||||
this.pendingResponses.set(data.conversationId, '');
|
||||
});
|
||||
|
||||
// Set up Discord message handler
|
||||
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
||||
|
||||
this.client.on('ready', () => {
|
||||
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||
});
|
||||
|
||||
await this.client.login(this.config.token);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.socket?.disconnect();
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private handleDiscordMessage(message: DiscordMessage): void {
|
||||
// Ignore bot messages
|
||||
if (message.author.bot) return;
|
||||
|
||||
// Check guild binding
|
||||
if (this.config.guildId && message.guildId !== this.config.guildId) return;
|
||||
|
||||
// Respond to DMs always, or mentions in channels
|
||||
const isDM = !message.guildId;
|
||||
const isMention = message.mentions.has(this.client.user!);
|
||||
|
||||
if (!isDM && !isMention) return;
|
||||
|
||||
// Strip bot mention from message content
|
||||
const content = message.content
|
||||
.replace(new RegExp(`<@!?${this.client.user!.id}>`, 'g'), '')
|
||||
.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
// Get or create conversation for this Discord channel
|
||||
const channelId = message.channelId;
|
||||
let conversationId = this.channelConversations.get(channelId);
|
||||
if (!conversationId) {
|
||||
conversationId = `discord-${channelId}`;
|
||||
this.channelConversations.set(channelId, conversationId);
|
||||
}
|
||||
|
||||
// Send to gateway
|
||||
this.socket?.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
private sendToDiscord(conversationId: string, text: string): void {
|
||||
// Find the Discord channel for this conversation
|
||||
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||
([, convId]) => convId === conversationId,
|
||||
)?.[0];
|
||||
|
||||
if (!channelId) return;
|
||||
|
||||
const channel = this.client.channels.cache.get(channelId);
|
||||
if (!channel || !('send' in channel)) return;
|
||||
|
||||
// Chunk responses for Discord's 2000-char limit
|
||||
const chunks = this.chunkText(text, 1900);
|
||||
for (const chunk of chunks) {
|
||||
(channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private chunkText(text: string, maxLength: number): string[] {
|
||||
if (text.length <= maxLength) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to break at a newline
|
||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||
if (breakPoint <= 0) breakPoint = maxLength;
|
||||
|
||||
chunks.push(remaining.slice(0, breakPoint));
|
||||
remaining = remaining.slice(breakPoint).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user