feat: agent-to-agent communication, dynamic agent creation, self-modification tools
Agent-to-agent: host routes messages with channel_type='agent' to target agent's inbound.db, enriches with sender info, wakes target container. Bidirectional routing works via inherited routing context. Dynamic agents: create_agent MCP tool + system action handler creates agent groups, folders, and optional CLAUDE.md on the fly. Self-modification: install_packages (apt/npm, requires admin approval), add_mcp_server (no approval), request_rebuild (builds per-agent-group Docker image with approved packages). Approval flow reuses interactive card infrastructure with pending_approvals table. Also includes fixes from prior session: attachment download, reply context extraction, message editing (platform message ID tracking), delivery retry limits, and card update on button click. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,8 +67,8 @@ export interface ChannelAdapter {
|
||||
teardown(): Promise<void>;
|
||||
isConnected(): boolean;
|
||||
|
||||
// Outbound delivery
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
|
||||
// Outbound delivery — returns the platform message ID if available
|
||||
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
|
||||
|
||||
// Optional
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
|
||||
@@ -54,8 +54,9 @@ function createMockAdapter(
|
||||
return setupConfig !== null;
|
||||
},
|
||||
|
||||
async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage) {
|
||||
async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
delivered.push(message);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async setTyping() {},
|
||||
@@ -213,8 +214,8 @@ describe('channel + router integration', () => {
|
||||
setDeliveryAdapter({
|
||||
async deliver(channelType, platformId, threadId, kind, content) {
|
||||
const adapter = getChannelAdapter(channelType);
|
||||
if (!adapter) return;
|
||||
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
|
||||
if (!adapter) return undefined;
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -30,11 +30,23 @@ interface GatewayAdapter extends Adapter {
|
||||
): Promise<Response>;
|
||||
}
|
||||
|
||||
/** Reply context extracted from a platform's raw message. */
|
||||
export interface ReplyContext {
|
||||
text: string;
|
||||
sender: string;
|
||||
}
|
||||
|
||||
/** Extract reply context from a platform-specific raw message. Return null if no reply. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ReplyContextExtractor = (raw: Record<string, any>) => ReplyContext | null;
|
||||
|
||||
export interface ChatSdkBridgeConfig {
|
||||
adapter: Adapter;
|
||||
concurrency?: ConcurrencyStrategy;
|
||||
/** Bot token for authenticating forwarded Gateway events (required for interaction handling). */
|
||||
botToken?: string;
|
||||
/** Platform-specific reply context extraction. */
|
||||
extractReplyContext?: ReplyContextExtractor;
|
||||
}
|
||||
|
||||
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
|
||||
@@ -53,11 +65,50 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
return map;
|
||||
}
|
||||
|
||||
function messageToInbound(message: ChatMessage): InboundMessage {
|
||||
async function messageToInbound(message: ChatMessage): Promise<InboundMessage> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serialized = message.toJSON() as Record<string, any>;
|
||||
|
||||
// Download attachment data before serialization loses fetchData()
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
const enriched = [];
|
||||
for (const att of message.attachments) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const entry: Record<string, any> = {
|
||||
type: att.type,
|
||||
name: att.name,
|
||||
mimeType: att.mimeType,
|
||||
size: att.size,
|
||||
width: (att as unknown as Record<string, unknown>).width,
|
||||
height: (att as unknown as Record<string, unknown>).height,
|
||||
};
|
||||
if (att.fetchData) {
|
||||
try {
|
||||
const buffer = await att.fetchData();
|
||||
entry.data = buffer.toString('base64');
|
||||
} catch (err) {
|
||||
log.warn('Failed to download attachment', { type: att.type, err });
|
||||
}
|
||||
}
|
||||
enriched.push(entry);
|
||||
}
|
||||
serialized.attachments = enriched;
|
||||
}
|
||||
|
||||
// Extract reply context via platform-specific hook
|
||||
if (config.extractReplyContext && message.raw) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const replyTo = config.extractReplyContext(message.raw as Record<string, any>);
|
||||
if (replyTo) serialized.replyTo = replyTo;
|
||||
}
|
||||
|
||||
// Drop raw to save DB space (can be very large)
|
||||
serialized.raw = undefined;
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
kind: 'chat-sdk',
|
||||
content: message.toJSON(),
|
||||
content: serialized,
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -83,20 +134,20 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// Subscribed threads — forward all messages
|
||||
chat.onSubscribedMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
|
||||
setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
});
|
||||
|
||||
// @mention in unsubscribed thread — forward + subscribe
|
||||
chat.onNewMention(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
setupConfig.onInbound(channelId, thread.id, messageToInbound(message));
|
||||
setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
await thread.subscribe();
|
||||
});
|
||||
|
||||
// DMs — always forward + subscribe
|
||||
chat.onDirectMessage(async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
setupConfig.onInbound(channelId, null, messageToInbound(message));
|
||||
setupConfig.onInbound(channelId, null, await messageToInbound(message));
|
||||
await thread.subscribe();
|
||||
});
|
||||
|
||||
@@ -108,6 +159,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const questionId = parts[1];
|
||||
const selectedOption = event.value || '';
|
||||
const userId = event.user?.userId || '';
|
||||
|
||||
// Update the card to show the selected answer and remove buttons
|
||||
try {
|
||||
const tid = event.threadId;
|
||||
await adapter.editMessage(tid, event.messageId, {
|
||||
markdown: `❓ **Question**\n\n${selectedOption ? `✅ **${selectedOption}**` : '(clicked)'}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('Failed to update card after action', { err });
|
||||
}
|
||||
|
||||
setupConfig.onAction(questionId, selectedOption, userId);
|
||||
});
|
||||
|
||||
@@ -161,7 +223,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
log.info('Chat SDK bridge initialized', { adapter: adapter.name });
|
||||
},
|
||||
|
||||
async deliver(platformId: string, threadId: string | null, message) {
|
||||
async deliver(platformId: string, threadId: string | null, message): Promise<string | undefined> {
|
||||
// platformId is already in the adapter's encoded format (e.g. "telegram:6037840640",
|
||||
// "discord:guildId:channelId") — use it directly as the thread ID
|
||||
const tid = threadId ?? platformId;
|
||||
@@ -190,24 +252,36 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))),
|
||||
],
|
||||
});
|
||||
await adapter.postMessage(tid, { card, fallbackText: `${content.question}\nOptions: ${options.join(', ')}` });
|
||||
return;
|
||||
const result = await adapter.postMessage(tid, {
|
||||
card,
|
||||
fallbackText: `${content.question}\nOptions: ${options.join(', ')}`,
|
||||
});
|
||||
return result?.id;
|
||||
}
|
||||
|
||||
// Normal message
|
||||
const text = (content.markdown as string) || (content.text as string);
|
||||
if (text) {
|
||||
// Attach files if present (FileUpload format: { data, filename })
|
||||
const fileUploads = message.files?.map((f) => ({ data: f.data, filename: f.filename }));
|
||||
const fileUploads = message.files?.map((f: { data: Buffer; filename: string }) => ({
|
||||
data: f.data,
|
||||
filename: f.filename,
|
||||
}));
|
||||
if (fileUploads && fileUploads.length > 0) {
|
||||
await adapter.postMessage(tid, { markdown: text, files: fileUploads });
|
||||
const result = await adapter.postMessage(tid, { markdown: text, files: fileUploads });
|
||||
return result?.id;
|
||||
} else {
|
||||
await adapter.postMessage(tid, { markdown: text });
|
||||
const result = await adapter.postMessage(tid, { markdown: text });
|
||||
return result?.id;
|
||||
}
|
||||
} else if (message.files && message.files.length > 0) {
|
||||
// Files only, no text
|
||||
const fileUploads = message.files.map((f) => ({ data: f.data, filename: f.filename }));
|
||||
await adapter.postMessage(tid, { markdown: '', files: fileUploads });
|
||||
const fileUploads = message.files.map((f: { data: Buffer; filename: string }) => ({
|
||||
data: f.data,
|
||||
filename: f.filename,
|
||||
}));
|
||||
const result = await adapter.postMessage(tid, { markdown: '', files: fileUploads });
|
||||
return result?.id;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
import { createDiscordAdapter } from '@chat-adapter/discord';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.referenced_message) return null;
|
||||
const reply = raw.referenced_message;
|
||||
return {
|
||||
text: reply.content || '',
|
||||
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('discord', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
|
||||
@@ -17,6 +27,11 @@ registerChannelAdapter('discord', {
|
||||
publicKey: env.DISCORD_PUBLIC_KEY,
|
||||
applicationId: env.DISCORD_APPLICATION_ID,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: discordAdapter, concurrency: 'concurrent', botToken: env.DISCORD_BOT_TOKEN });
|
||||
return createChatSdkBridge({
|
||||
adapter: discordAdapter,
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
import { createTelegramAdapter } from '@chat-adapter/telegram';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.reply_to_message) return null;
|
||||
const reply = raw.reply_to_message;
|
||||
return {
|
||||
text: reply.text || reply.caption || '',
|
||||
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('telegram', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
|
||||
@@ -16,6 +26,6 @@ registerChannelAdapter('telegram', {
|
||||
botToken: env.TELEGRAM_BOT_TOKEN,
|
||||
mode: 'polling',
|
||||
});
|
||||
return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user