@animalabs/agent-framework 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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/src/agent.d.ts +130 -0
- package/dist/src/agent.d.ts.map +1 -0
- package/dist/src/agent.js +406 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +37 -0
- package/dist/src/api/mcp-server.d.ts.map +1 -0
- package/dist/src/api/mcp-server.js +598 -0
- package/dist/src/api/mcp-server.js.map +1 -0
- package/dist/src/api/server.d.ts +83 -0
- package/dist/src/api/server.d.ts.map +1 -0
- package/dist/src/api/server.js +785 -0
- package/dist/src/api/server.js.map +1 -0
- package/dist/src/api/types.d.ts +395 -0
- package/dist/src/api/types.d.ts.map +1 -0
- package/dist/src/api/types.js +5 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/framework.d.ts +309 -0
- package/dist/src/framework.d.ts.map +1 -0
- package/dist/src/framework.js +2309 -0
- package/dist/src/framework.js.map +1 -0
- package/dist/src/gate/event-gate.d.ts +75 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -0
- package/dist/src/gate/event-gate.js +503 -0
- package/dist/src/gate/event-gate.js.map +1 -0
- package/dist/src/gate/index.d.ts +3 -0
- package/dist/src/gate/index.d.ts.map +1 -0
- package/dist/src/gate/index.js +2 -0
- package/dist/src/gate/index.js.map +1 -0
- package/dist/src/gate/types.d.ts +82 -0
- package/dist/src/gate/types.d.ts.map +1 -0
- package/dist/src/gate/types.js +9 -0
- package/dist/src/gate/types.js.map +1 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +18 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +141 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
- package/dist/src/mcpl/channel-registry.js +647 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
- package/dist/src/mcpl/checkpoint-manager.js +430 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
- package/dist/src/mcpl/errors.d.ts +52 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -0
- package/dist/src/mcpl/errors.js +93 -0
- package/dist/src/mcpl/errors.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
- package/dist/src/mcpl/feature-set-manager.js +250 -0
- package/dist/src/mcpl/feature-set-manager.js.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
- package/dist/src/mcpl/hook-orchestrator.js +217 -0
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
- package/dist/src/mcpl/index.d.ts +14 -0
- package/dist/src/mcpl/index.d.ts.map +1 -0
- package/dist/src/mcpl/index.js +21 -0
- package/dist/src/mcpl/index.js.map +1 -0
- package/dist/src/mcpl/inference-router.d.ts +67 -0
- package/dist/src/mcpl/inference-router.d.ts.map +1 -0
- package/dist/src/mcpl/inference-router.js +249 -0
- package/dist/src/mcpl/inference-router.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +60 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -0
- package/dist/src/mcpl/push-handler.js +162 -0
- package/dist/src/mcpl/push-handler.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts +76 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
- package/dist/src/mcpl/scope-manager.js +213 -0
- package/dist/src/mcpl/scope-manager.js.map +1 -0
- package/dist/src/mcpl/server-connection.d.ts +174 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -0
- package/dist/src/mcpl/server-connection.js +559 -0
- package/dist/src/mcpl/server-connection.js.map +1 -0
- package/dist/src/mcpl/server-registry.d.ts +63 -0
- package/dist/src/mcpl/server-registry.d.ts.map +1 -0
- package/dist/src/mcpl/server-registry.js +119 -0
- package/dist/src/mcpl/server-registry.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +755 -0
- package/dist/src/mcpl/types.d.ts.map +1 -0
- package/dist/src/mcpl/types.js +48 -0
- package/dist/src/mcpl/types.js.map +1 -0
- package/dist/src/module-registry.d.ts +93 -0
- package/dist/src/module-registry.d.ts.map +1 -0
- package/dist/src/module-registry.js +386 -0
- package/dist/src/module-registry.js.map +1 -0
- package/dist/src/modules/api/index.d.ts +22 -0
- package/dist/src/modules/api/index.d.ts.map +1 -0
- package/dist/src/modules/api/index.js +63 -0
- package/dist/src/modules/api/index.js.map +1 -0
- package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
- package/dist/src/modules/discord/discord-js-client.js +273 -0
- package/dist/src/modules/discord/discord-js-client.js.map +1 -0
- package/dist/src/modules/discord/index.d.ts +82 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -0
- package/dist/src/modules/discord/index.js +959 -0
- package/dist/src/modules/discord/index.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +327 -0
- package/dist/src/modules/discord/types.d.ts.map +1 -0
- package/dist/src/modules/discord/types.js +5 -0
- package/dist/src/modules/discord/types.js.map +1 -0
- package/dist/src/modules/index.d.ts +11 -0
- package/dist/src/modules/index.d.ts.map +1 -0
- package/dist/src/modules/index.js +7 -0
- package/dist/src/modules/index.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +59 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -0
- package/dist/src/modules/workspace/index.js +831 -0
- package/dist/src/modules/workspace/index.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +48 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -0
- package/dist/src/modules/workspace/sync.js +253 -0
- package/dist/src/modules/workspace/sync.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +166 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -0
- package/dist/src/modules/workspace/types.js +5 -0
- package/dist/src/modules/workspace/types.js.map +1 -0
- package/dist/src/modules/workspace/watcher.d.ts +43 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
- package/dist/src/modules/workspace/watcher.js +120 -0
- package/dist/src/modules/workspace/watcher.js.map +1 -0
- package/dist/src/queue.d.ts +50 -0
- package/dist/src/queue.d.ts.map +1 -0
- package/dist/src/queue.js +95 -0
- package/dist/src/queue.js.map +1 -0
- package/dist/src/safe-slice.d.ts +8 -0
- package/dist/src/safe-slice.d.ts.map +1 -0
- package/dist/src/safe-slice.js +18 -0
- package/dist/src/safe-slice.js.map +1 -0
- package/dist/src/types/agent.d.ts +118 -0
- package/dist/src/types/agent.d.ts.map +1 -0
- package/dist/src/types/agent.js +2 -0
- package/dist/src/types/agent.js.map +1 -0
- package/dist/src/types/events.d.ts +220 -0
- package/dist/src/types/events.d.ts.map +1 -0
- package/dist/src/types/events.js +2 -0
- package/dist/src/types/events.js.map +1 -0
- package/dist/src/types/framework.d.ts +184 -0
- package/dist/src/types/framework.d.ts.map +1 -0
- package/dist/src/types/framework.js +2 -0
- package/dist/src/types/framework.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/inference-log.d.ts +64 -0
- package/dist/src/types/inference-log.d.ts.map +1 -0
- package/dist/src/types/inference-log.js +5 -0
- package/dist/src/types/inference-log.js.map +1 -0
- package/dist/src/types/module.d.ts +302 -0
- package/dist/src/types/module.d.ts.map +1 -0
- package/dist/src/types/module.js +2 -0
- package/dist/src/types/module.js.map +1 -0
- package/dist/src/types/trace.d.ts +173 -0
- package/dist/src/types/trace.d.ts.map +1 -0
- package/dist/src/types/trace.js +2 -0
- package/dist/src/types/trace.js.map +1 -0
- package/dist/test/event-gate.test.d.ts +2 -0
- package/dist/test/event-gate.test.d.ts.map +1 -0
- package/dist/test/event-gate.test.js +535 -0
- package/dist/test/event-gate.test.js.map +1 -0
- package/dist/test/framework.test.d.ts +5 -0
- package/dist/test/framework.test.d.ts.map +1 -0
- package/dist/test/framework.test.js +738 -0
- package/dist/test/framework.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +58 -0
- package/src/agent.ts +512 -0
- package/src/api/mcp-server.ts +687 -0
- package/src/api/server.ts +1023 -0
- package/src/api/types.ts +542 -0
- package/src/framework.ts +2784 -0
- package/src/gate/event-gate.ts +623 -0
- package/src/gate/index.ts +12 -0
- package/src/gate/types.ts +112 -0
- package/src/index.ts +63 -0
- package/src/mcpl/channel-registry.ts +824 -0
- package/src/mcpl/checkpoint-manager.ts +545 -0
- package/src/mcpl/errors.ts +114 -0
- package/src/mcpl/feature-set-manager.ts +333 -0
- package/src/mcpl/hook-orchestrator.ts +281 -0
- package/src/mcpl/index.ts +134 -0
- package/src/mcpl/inference-router.ts +335 -0
- package/src/mcpl/push-handler.ts +232 -0
- package/src/mcpl/scope-manager.ts +262 -0
- package/src/mcpl/server-connection.ts +694 -0
- package/src/mcpl/server-registry.ts +144 -0
- package/src/mcpl/types.ts +991 -0
- package/src/module-registry.ts +547 -0
- package/src/modules/api/index.ts +94 -0
- package/src/modules/discord/discord-js-client.ts +353 -0
- package/src/modules/discord/index.ts +1146 -0
- package/src/modules/discord/types.ts +465 -0
- package/src/modules/index.ts +43 -0
- package/src/modules/workspace/index.ts +969 -0
- package/src/modules/workspace/sync.ts +314 -0
- package/src/modules/workspace/types.ts +202 -0
- package/src/modules/workspace/watcher.ts +142 -0
- package/src/queue.ts +106 -0
- package/src/safe-slice.ts +18 -0
- package/src/types/agent.ts +119 -0
- package/src/types/events.ts +252 -0
- package/src/types/framework.ts +211 -0
- package/src/types/index.ts +6 -0
- package/src/types/inference-log.ts +64 -0
- package/src/types/module.ts +367 -0
- package/src/types/trace.ts +196 -0
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord Module - Discord integration for agent framework
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Listening to Discord messages and routing to agents
|
|
6
|
+
* - Sending agent speech to Discord channels
|
|
7
|
+
* - Tools for explicit Discord operations (send, DM, react, etc.)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
11
|
+
import type {
|
|
12
|
+
Module,
|
|
13
|
+
ModuleContext,
|
|
14
|
+
ProcessState,
|
|
15
|
+
ProcessEvent,
|
|
16
|
+
EventResponse,
|
|
17
|
+
ToolDefinition,
|
|
18
|
+
ToolCall,
|
|
19
|
+
ToolResult,
|
|
20
|
+
SpeechContext,
|
|
21
|
+
} from '../../types/index.js';
|
|
22
|
+
import type {
|
|
23
|
+
DiscordModuleConfig,
|
|
24
|
+
DiscordModuleState,
|
|
25
|
+
ConversationContext,
|
|
26
|
+
DiscordClientInterface,
|
|
27
|
+
DiscordMessageData,
|
|
28
|
+
DiscordMessageEvent,
|
|
29
|
+
DiscordEditEvent,
|
|
30
|
+
DiscordDeleteEvent,
|
|
31
|
+
SendInput,
|
|
32
|
+
SendDMInput,
|
|
33
|
+
ReplyInput,
|
|
34
|
+
ReactInput,
|
|
35
|
+
EditMessageInput,
|
|
36
|
+
DeleteMessageInput,
|
|
37
|
+
CreateThreadInput,
|
|
38
|
+
SetReplyContextInput,
|
|
39
|
+
ListGuildsInput,
|
|
40
|
+
ListChannelsInput,
|
|
41
|
+
FetchHistoryInput,
|
|
42
|
+
} from './types.js';
|
|
43
|
+
|
|
44
|
+
// Re-export event types for consumers who want to handle them
|
|
45
|
+
export type { DiscordMessageEvent, DiscordEditEvent, DiscordDeleteEvent } from './types.js';
|
|
46
|
+
|
|
47
|
+
export * from './types.js';
|
|
48
|
+
export { DiscordJsClient, type DiscordJsClientConfig } from './discord-js-client.js';
|
|
49
|
+
|
|
50
|
+
const DEFAULT_CONFIG: Partial<DiscordModuleConfig> = {
|
|
51
|
+
handleDMs: true,
|
|
52
|
+
ignoreBots: true,
|
|
53
|
+
rateLimitPerMinute: 30,
|
|
54
|
+
triggerOn: 'mention_or_reply',
|
|
55
|
+
autoTyping: true,
|
|
56
|
+
historyScrollback: 50,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Image MIME types that Claude can process
|
|
60
|
+
const SUPPORTED_IMAGE_TYPES = [
|
|
61
|
+
'image/jpeg',
|
|
62
|
+
'image/png',
|
|
63
|
+
'image/gif',
|
|
64
|
+
'image/webp',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Text file extensions for inline reading
|
|
68
|
+
const TEXT_FILE_EXTENSIONS = [
|
|
69
|
+
'.txt', '.md', '.json', '.yaml', '.yml', '.toml',
|
|
70
|
+
'.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs',
|
|
71
|
+
'.html', '.css', '.xml', '.csv', '.log', '.sh', '.bash',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const CONVERSATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Discord module for the agent framework.
|
|
78
|
+
*/
|
|
79
|
+
export class DiscordModule implements Module {
|
|
80
|
+
readonly name = 'discord';
|
|
81
|
+
|
|
82
|
+
private config: DiscordModuleConfig;
|
|
83
|
+
private ctx: ModuleContext | null = null;
|
|
84
|
+
private client: DiscordClientInterface;
|
|
85
|
+
private state: DiscordModuleState = {
|
|
86
|
+
connectedGuilds: [],
|
|
87
|
+
conversations: {},
|
|
88
|
+
rateLimits: {},
|
|
89
|
+
lastReadMessageId: {},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Track current reply context per agent
|
|
93
|
+
private replyContexts: Map<string, ConversationContext> = new Map();
|
|
94
|
+
|
|
95
|
+
// Last message sent by a tool (for thought-editing)
|
|
96
|
+
private lastSentMessage: { channelId: string; messageId: string } | null = null;
|
|
97
|
+
|
|
98
|
+
constructor(client: DiscordClientInterface, config: DiscordModuleConfig) {
|
|
99
|
+
this.client = client;
|
|
100
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
104
|
+
this.ctx = ctx;
|
|
105
|
+
|
|
106
|
+
// Load persisted state
|
|
107
|
+
const savedState = ctx.getState<DiscordModuleState>();
|
|
108
|
+
if (savedState) {
|
|
109
|
+
this.state = savedState;
|
|
110
|
+
// Clean up old conversations
|
|
111
|
+
this.cleanupOldConversations();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Register as speech handler
|
|
115
|
+
ctx.registerSpeechHandler('*');
|
|
116
|
+
|
|
117
|
+
// Set up Discord event handlers
|
|
118
|
+
this.client.onMessage((message) => this.handleDiscordMessage(message));
|
|
119
|
+
this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
|
|
120
|
+
this.client.onMessageDelete((id) => this.handleDiscordMessageDelete(id));
|
|
121
|
+
|
|
122
|
+
// Set up ready handler for history sync on (re)connect
|
|
123
|
+
if (this.client.onReady) {
|
|
124
|
+
this.client.onReady(() => this.handleReady());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Connect to Discord
|
|
128
|
+
if (!ctx.isRestart || !this.client.isConnected()) {
|
|
129
|
+
await this.client.connect();
|
|
130
|
+
} else {
|
|
131
|
+
// Already connected (restart) - sync history now
|
|
132
|
+
await this.syncAllChannelHistory();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Get bot user ID after connect
|
|
136
|
+
const botUserId = this.client.getBotUserId();
|
|
137
|
+
if (botUserId) {
|
|
138
|
+
this.state.botUserId = botUserId;
|
|
139
|
+
ctx.setState(this.state);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async stop(): Promise<void> {
|
|
144
|
+
// Save state
|
|
145
|
+
if (this.ctx) {
|
|
146
|
+
this.ctx.setState(this.state);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await this.client.disconnect();
|
|
150
|
+
this.ctx = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getTools(): ToolDefinition[] {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
name: 'send',
|
|
157
|
+
description: 'Send a message to a specific Discord channel',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
channelId: { type: 'string', description: 'Channel ID to send to' },
|
|
162
|
+
content: { type: 'string', description: 'Message content' },
|
|
163
|
+
replyTo: { type: 'string', description: 'Message ID to reply to (optional)' },
|
|
164
|
+
createThread: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
description: 'Create a thread with this message',
|
|
167
|
+
properties: {
|
|
168
|
+
name: { type: 'string', description: 'Thread name' },
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
required: ['channelId', 'content'],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'dm',
|
|
177
|
+
description: 'Send a direct message to a user',
|
|
178
|
+
inputSchema: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
userId: { type: 'string', description: 'User ID to DM' },
|
|
182
|
+
content: { type: 'string', description: 'Message content' },
|
|
183
|
+
},
|
|
184
|
+
required: ['userId', 'content'],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'reply',
|
|
189
|
+
description:
|
|
190
|
+
'Reply in the current conversation context. Use this for normal responses.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
content: { type: 'string', description: 'Message content' },
|
|
195
|
+
mention: { type: 'boolean', description: 'Whether to mention the user' },
|
|
196
|
+
},
|
|
197
|
+
required: ['content'],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'react',
|
|
202
|
+
description: 'Add a reaction to a message',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
channelId: { type: 'string', description: 'Channel ID' },
|
|
207
|
+
messageId: { type: 'string', description: 'Message ID to react to' },
|
|
208
|
+
emoji: { type: 'string', description: 'Emoji (unicode or custom emoji ID)' },
|
|
209
|
+
},
|
|
210
|
+
required: ['channelId', 'messageId', 'emoji'],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'edit',
|
|
215
|
+
description: 'Edit a message sent by this bot',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
channelId: { type: 'string', description: 'Channel ID' },
|
|
220
|
+
messageId: { type: 'string', description: 'Message ID to edit' },
|
|
221
|
+
content: { type: 'string', description: 'New content' },
|
|
222
|
+
},
|
|
223
|
+
required: ['channelId', 'messageId', 'content'],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'delete',
|
|
228
|
+
description: 'Delete a message sent by this bot',
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: 'object',
|
|
231
|
+
properties: {
|
|
232
|
+
channelId: { type: 'string', description: 'Channel ID' },
|
|
233
|
+
messageId: { type: 'string', description: 'Message ID to delete' },
|
|
234
|
+
},
|
|
235
|
+
required: ['channelId', 'messageId'],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'create_thread',
|
|
240
|
+
description: 'Create a thread from a message',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
channelId: { type: 'string', description: 'Channel ID' },
|
|
245
|
+
messageId: { type: 'string', description: 'Message ID to create thread from' },
|
|
246
|
+
name: { type: 'string', description: 'Thread name' },
|
|
247
|
+
autoArchiveDuration: {
|
|
248
|
+
type: 'number',
|
|
249
|
+
description: 'Auto-archive duration in minutes (60, 1440, 4320, 10080)',
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
required: ['channelId', 'messageId', 'name'],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: 'set_reply_context',
|
|
257
|
+
description: 'Change where default speech goes for this conversation',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
properties: {
|
|
261
|
+
channelId: { type: 'string', description: 'Channel ID for replies' },
|
|
262
|
+
threadId: { type: 'string', description: 'Thread ID (optional)' },
|
|
263
|
+
replyToMessageId: { type: 'string', description: 'Message ID to reply to (optional)' },
|
|
264
|
+
},
|
|
265
|
+
required: ['channelId'],
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'activate',
|
|
270
|
+
description: 'Register Discord as the speech handler (take over from other modules)',
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
additive: {
|
|
275
|
+
type: 'boolean',
|
|
276
|
+
description: 'If true, add to existing handlers instead of replacing',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'deactivate',
|
|
283
|
+
description: 'Unregister Discord as a speech handler',
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: 'object',
|
|
286
|
+
properties: {},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
// ========== Discovery & Utility Tools ==========
|
|
290
|
+
{
|
|
291
|
+
name: 'list_guilds',
|
|
292
|
+
description: 'List all Discord servers (guilds) the bot is in',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: 'list_channels',
|
|
300
|
+
description: 'List all channels in a Discord server',
|
|
301
|
+
inputSchema: {
|
|
302
|
+
type: 'object',
|
|
303
|
+
properties: {
|
|
304
|
+
guildId: { type: 'string', description: 'Guild/Server ID' },
|
|
305
|
+
},
|
|
306
|
+
required: ['guildId'],
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'fetch_history',
|
|
311
|
+
description: 'Fetch recent message history from a channel',
|
|
312
|
+
inputSchema: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
channelId: { type: 'string', description: 'Channel ID' },
|
|
316
|
+
limit: { type: 'number', description: 'Number of messages to fetch (default: 50, max: 100)' },
|
|
317
|
+
before: { type: 'string', description: 'Fetch messages before this message ID' },
|
|
318
|
+
},
|
|
319
|
+
required: ['channelId'],
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
326
|
+
try {
|
|
327
|
+
switch (call.name) {
|
|
328
|
+
case 'send':
|
|
329
|
+
return await this.handleSend(call.input as SendInput);
|
|
330
|
+
case 'dm':
|
|
331
|
+
return await this.handleDM(call.input as SendDMInput);
|
|
332
|
+
case 'reply':
|
|
333
|
+
return await this.handleReply(call.input as ReplyInput);
|
|
334
|
+
case 'react':
|
|
335
|
+
return await this.handleReact(call.input as ReactInput);
|
|
336
|
+
case 'edit':
|
|
337
|
+
return await this.handleEdit(call.input as EditMessageInput);
|
|
338
|
+
case 'delete':
|
|
339
|
+
return await this.handleDelete(call.input as DeleteMessageInput);
|
|
340
|
+
case 'create_thread':
|
|
341
|
+
return await this.handleCreateThread(call.input as CreateThreadInput);
|
|
342
|
+
case 'set_reply_context':
|
|
343
|
+
return await this.handleSetReplyContext(call.input as SetReplyContextInput);
|
|
344
|
+
case 'activate':
|
|
345
|
+
return await this.handleActivate(call.input as { additive?: boolean });
|
|
346
|
+
case 'deactivate':
|
|
347
|
+
return await this.handleDeactivate();
|
|
348
|
+
case 'list_guilds':
|
|
349
|
+
return await this.handleListGuilds();
|
|
350
|
+
case 'list_channels':
|
|
351
|
+
return await this.handleListChannels(call.input as ListChannelsInput);
|
|
352
|
+
case 'fetch_history':
|
|
353
|
+
return await this.handleFetchHistory(call.input as FetchHistoryInput);
|
|
354
|
+
default:
|
|
355
|
+
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: error instanceof Error ? error.message : String(error),
|
|
361
|
+
isError: true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async onProcess(event: ProcessEvent, state: ProcessState): Promise<EventResponse> {
|
|
367
|
+
// Handle new Discord message
|
|
368
|
+
if (event.type === 'discord:message') {
|
|
369
|
+
const msg = event as DiscordMessageEvent;
|
|
370
|
+
const currentState = state.getState<DiscordModuleState>() ?? {
|
|
371
|
+
connectedGuilds: [],
|
|
372
|
+
conversations: {},
|
|
373
|
+
rateLimits: {},
|
|
374
|
+
lastReadMessageId: {},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Build conversation state update
|
|
378
|
+
const convKey = msg.guildId ? `${msg.guildId}:${msg.channelId}` : `dm:${msg.authorId}`;
|
|
379
|
+
const conversation: ConversationContext = {
|
|
380
|
+
channelId: msg.channelId,
|
|
381
|
+
userId: msg.authorId,
|
|
382
|
+
guildId: msg.guildId ?? null,
|
|
383
|
+
replyToMessageId: msg.discordMessageId,
|
|
384
|
+
lastActivity: Date.now(),
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Update in-memory reply context (ephemeral, not persisted)
|
|
388
|
+
this.replyContexts.set('default', conversation);
|
|
389
|
+
|
|
390
|
+
// Build new state with updated conversation
|
|
391
|
+
const newState: DiscordModuleState = {
|
|
392
|
+
...currentState,
|
|
393
|
+
conversations: {
|
|
394
|
+
...currentState.conversations,
|
|
395
|
+
[convKey]: conversation,
|
|
396
|
+
},
|
|
397
|
+
lastReadMessageId: {
|
|
398
|
+
...currentState.lastReadMessageId,
|
|
399
|
+
[msg.channelId]: msg.discordMessageId,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Update local state reference for tool handlers
|
|
404
|
+
this.state = newState;
|
|
405
|
+
|
|
406
|
+
// Auto-send typing indicator if configured
|
|
407
|
+
if (this.config.autoTyping) {
|
|
408
|
+
// Fire and forget - don't await
|
|
409
|
+
this.client.sendTyping(msg.channelId).catch((err) => {
|
|
410
|
+
console.warn('Discord: Failed to send typing indicator:', err);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Build content blocks from message and attachments
|
|
415
|
+
const contentBlocks = msg.contentBlocks ?? [{ type: 'text' as const, text: msg.content }];
|
|
416
|
+
|
|
417
|
+
// Return declarative response - framework applies all writes atomically
|
|
418
|
+
return {
|
|
419
|
+
addMessages: [
|
|
420
|
+
{
|
|
421
|
+
participant: msg.authorName,
|
|
422
|
+
content: contentBlocks,
|
|
423
|
+
metadata: {
|
|
424
|
+
external: { source: 'discord', id: msg.discordMessageId },
|
|
425
|
+
channelId: msg.channelId,
|
|
426
|
+
timestamp: msg.timestamp,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
stateUpdate: newState,
|
|
431
|
+
requestInference: msg.shouldTriggerInference ?? true,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Handle Discord message edit
|
|
436
|
+
if (event.type === 'discord:edit') {
|
|
437
|
+
const edit = event as DiscordEditEvent;
|
|
438
|
+
const internalId = state.findMessageByExternalId('discord', edit.discordMessageId);
|
|
439
|
+
if (!internalId) {
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
editMessages: [
|
|
444
|
+
{
|
|
445
|
+
messageId: internalId,
|
|
446
|
+
content: [{ type: 'text', text: edit.newContent }],
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Handle Discord message delete
|
|
453
|
+
if (event.type === 'discord:delete') {
|
|
454
|
+
const del = event as DiscordDeleteEvent;
|
|
455
|
+
const internalId = state.findMessageByExternalId('discord', del.discordMessageId);
|
|
456
|
+
if (!internalId) {
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
removeMessages: [internalId],
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Handle agent speech - send to Discord.
|
|
469
|
+
*/
|
|
470
|
+
async onAgentSpeech(
|
|
471
|
+
agentName: string,
|
|
472
|
+
content: ContentBlock[],
|
|
473
|
+
context: SpeechContext
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
// If thoughts are present and we have a recently sent message, edit it
|
|
476
|
+
// to prepend the thoughts as spoiler text.
|
|
477
|
+
if (context.thoughts && context.thoughts.length > 0 && this.lastSentMessage) {
|
|
478
|
+
const thoughtText = this.extractText(context.thoughts);
|
|
479
|
+
if (thoughtText) {
|
|
480
|
+
try {
|
|
481
|
+
const { channelId, messageId } = this.lastSentMessage;
|
|
482
|
+
// Fetch the current message content via the client isn't available,
|
|
483
|
+
// but we know the tool just sent it — reconstruct by editing with
|
|
484
|
+
// spoiler-wrapped thoughts prepended.
|
|
485
|
+
// Discord API edit replaces content, so we need the original text.
|
|
486
|
+
// We don't have it here, so use a get-then-edit pattern via the API.
|
|
487
|
+
// For now, the simplest approach: the discord.js client can fetch & edit.
|
|
488
|
+
await this.editWithThoughts(channelId, messageId, thoughtText);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
console.warn('Discord: Failed to edit thoughts into message:', err);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this.lastSentMessage = null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Get reply context for this agent
|
|
497
|
+
const replyCtx = this.replyContexts.get(agentName) ?? this.replyContexts.get('default');
|
|
498
|
+
if (!replyCtx) {
|
|
499
|
+
// No conversation context - nowhere to send
|
|
500
|
+
console.warn(`Discord: No reply context for agent ${agentName}`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Extract text content
|
|
505
|
+
const text = this.extractText(content);
|
|
506
|
+
if (!text) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Check rate limit
|
|
511
|
+
if (!this.checkRateLimit(replyCtx.channelId)) {
|
|
512
|
+
console.warn(`Discord: Rate limited for channel ${replyCtx.channelId}`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Send to Discord
|
|
517
|
+
const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
|
|
518
|
+
const result = await this.client.sendMessage(targetChannel, text, {
|
|
519
|
+
replyTo: replyCtx.replyToMessageId,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Track for thought-editing (in case of chained speech)
|
|
523
|
+
this.lastSentMessage = { channelId: targetChannel, messageId: result.messageId };
|
|
524
|
+
|
|
525
|
+
// Clear reply-to after sending (don't keep replying to same message)
|
|
526
|
+
replyCtx.replyToMessageId = undefined;
|
|
527
|
+
replyCtx.lastActivity = Date.now();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Edit a sent message to prepend thought text as spoilers.
|
|
532
|
+
*/
|
|
533
|
+
private async editWithThoughts(
|
|
534
|
+
channelId: string,
|
|
535
|
+
messageId: string,
|
|
536
|
+
thoughtText: string
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
// Fetch current message content
|
|
539
|
+
const currentContent = await this.client.fetchMessage(channelId, messageId);
|
|
540
|
+
// Prepend spoiler-wrapped thoughts
|
|
541
|
+
const spoiler = `||${thoughtText}||\n`;
|
|
542
|
+
await this.client.editMessage(channelId, messageId, spoiler + currentContent);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ==========================================================================
|
|
546
|
+
// Discord Event Handlers
|
|
547
|
+
// ==========================================================================
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Handle Discord ready event - sync history for all tracked channels.
|
|
551
|
+
*/
|
|
552
|
+
private async handleReady(): Promise<void> {
|
|
553
|
+
console.log('[Discord] Ready - syncing history for tracked channels');
|
|
554
|
+
|
|
555
|
+
// Get bot user ID
|
|
556
|
+
const botUserId = this.client.getBotUserId();
|
|
557
|
+
if (botUserId) {
|
|
558
|
+
this.state.botUserId = botUserId;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
await this.syncAllChannelHistory();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Sync history for all channels we have conversations in.
|
|
566
|
+
*/
|
|
567
|
+
private async syncAllChannelHistory(): Promise<void> {
|
|
568
|
+
if (!this.ctx) return;
|
|
569
|
+
|
|
570
|
+
// Get unique channel IDs from conversations
|
|
571
|
+
const channelIds = new Set<string>();
|
|
572
|
+
for (const conv of Object.values(this.state.conversations)) {
|
|
573
|
+
channelIds.add(conv.channelId);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (channelIds.size === 0) {
|
|
577
|
+
console.log('[Discord] No channels to sync');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
console.log(`[Discord] Syncing history for ${channelIds.size} channel(s)`);
|
|
582
|
+
|
|
583
|
+
const syncResults: Array<{
|
|
584
|
+
channelId: string;
|
|
585
|
+
newMessages: number;
|
|
586
|
+
editedMessages: number;
|
|
587
|
+
deletedMessages: number;
|
|
588
|
+
}> = [];
|
|
589
|
+
|
|
590
|
+
for (const channelId of channelIds) {
|
|
591
|
+
try {
|
|
592
|
+
const result = await this.syncChannelHistory(channelId);
|
|
593
|
+
if (result.newMessages > 0 || result.editedMessages > 0 || result.deletedMessages > 0) {
|
|
594
|
+
syncResults.push({ channelId, ...result });
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.error(`[Discord] Failed to sync channel ${channelId}:`, error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Emit sync complete event if there were any changes
|
|
602
|
+
if (syncResults.length > 0 && this.ctx) {
|
|
603
|
+
const totalNew = syncResults.reduce((sum, r) => sum + r.newMessages, 0);
|
|
604
|
+
const totalEdited = syncResults.reduce((sum, r) => sum + r.editedMessages, 0);
|
|
605
|
+
const totalDeleted = syncResults.reduce((sum, r) => sum + r.deletedMessages, 0);
|
|
606
|
+
|
|
607
|
+
console.log(`[Discord] History sync complete: ${totalNew} new, ${totalEdited} edited, ${totalDeleted} deleted`);
|
|
608
|
+
|
|
609
|
+
// Push a notification event (doesn't trigger inference, just informs)
|
|
610
|
+
this.ctx.pushEvent({
|
|
611
|
+
type: 'module-event',
|
|
612
|
+
source: 'discord',
|
|
613
|
+
eventType: 'history-sync-complete',
|
|
614
|
+
payload: {
|
|
615
|
+
channels: syncResults.length,
|
|
616
|
+
newMessages: totalNew,
|
|
617
|
+
editedMessages: totalEdited,
|
|
618
|
+
deletedMessages: totalDeleted,
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
this.ctx?.setState(this.state);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Sync history for a specific channel - compare with Context Manager and update.
|
|
628
|
+
* Uses queryMessages() to enumerate stored messages for proper edit/delete detection.
|
|
629
|
+
*/
|
|
630
|
+
private async syncChannelHistory(channelId: string): Promise<{
|
|
631
|
+
newMessages: number;
|
|
632
|
+
editedMessages: number;
|
|
633
|
+
deletedMessages: number;
|
|
634
|
+
}> {
|
|
635
|
+
if (!this.ctx) {
|
|
636
|
+
return { newMessages: 0, editedMessages: 0, deletedMessages: 0 };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const scrollback = this.config.historyScrollback ?? 50;
|
|
640
|
+
|
|
641
|
+
// Fetch recent history from Discord
|
|
642
|
+
const discordMessages = await this.client.fetchHistory(channelId, { limit: scrollback });
|
|
643
|
+
|
|
644
|
+
// Build a map of Discord message ID -> content for comparison
|
|
645
|
+
const discordMessageMap = new Map<string, { content: string; authorName: string; timestamp: Date }>();
|
|
646
|
+
for (const msg of discordMessages) {
|
|
647
|
+
discordMessageMap.set(msg.id, {
|
|
648
|
+
content: msg.content,
|
|
649
|
+
authorName: msg.authorName,
|
|
650
|
+
timestamp: msg.timestamp,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Query Context Manager for all Discord messages from this channel
|
|
655
|
+
const { messages: storedMessages } = this.ctx.queryMessages({
|
|
656
|
+
source: 'discord',
|
|
657
|
+
metadata: { channelId },
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// Build a map of stored message external IDs -> internal data
|
|
661
|
+
const storedMessageMap = new Map<string, { internalId: string; content: string }>();
|
|
662
|
+
for (const msg of storedMessages) {
|
|
663
|
+
const external = msg.metadata?.external as { id?: string } | undefined;
|
|
664
|
+
if (external?.id) {
|
|
665
|
+
// Extract text content for comparison
|
|
666
|
+
const textContent = msg.content
|
|
667
|
+
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
|
668
|
+
.map((block) => block.text)
|
|
669
|
+
.join('\n');
|
|
670
|
+
storedMessageMap.set(external.id, {
|
|
671
|
+
internalId: msg.id,
|
|
672
|
+
content: textContent,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let newMessages = 0;
|
|
678
|
+
let editedMessages = 0;
|
|
679
|
+
let deletedMessages = 0;
|
|
680
|
+
|
|
681
|
+
// Process Discord messages - check for new/edited
|
|
682
|
+
for (const discordMsg of discordMessages) {
|
|
683
|
+
const stored = storedMessageMap.get(discordMsg.id);
|
|
684
|
+
|
|
685
|
+
if (!stored) {
|
|
686
|
+
// New message we haven't seen
|
|
687
|
+
// For bot's own messages (from previous sessions), use the agent name
|
|
688
|
+
// This ensures continuity of conversation history
|
|
689
|
+
const isBotMessage = discordMsg.authorId === this.state.botUserId;
|
|
690
|
+
const agentName = this.ctx.getAgents()[0]?.name;
|
|
691
|
+
const participantName = isBotMessage
|
|
692
|
+
? (agentName ?? discordMsg.authorName)
|
|
693
|
+
: discordMsg.authorName;
|
|
694
|
+
|
|
695
|
+
this.ctx.addMessage(
|
|
696
|
+
participantName,
|
|
697
|
+
[{ type: 'text', text: discordMsg.content }],
|
|
698
|
+
{
|
|
699
|
+
external: { source: 'discord', id: discordMsg.id },
|
|
700
|
+
channelId,
|
|
701
|
+
timestamp: discordMsg.timestamp.getTime(),
|
|
702
|
+
isOwnMessage: isBotMessage, // Mark as our own for reference
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
newMessages++;
|
|
706
|
+
} else {
|
|
707
|
+
// We have this message - check if content changed (offline edit detection)
|
|
708
|
+
if (stored.content !== discordMsg.content) {
|
|
709
|
+
this.ctx.editMessage(stored.internalId, [
|
|
710
|
+
{ type: 'text', text: discordMsg.content },
|
|
711
|
+
]);
|
|
712
|
+
editedMessages++;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Check for deletes - messages we have that Discord doesn't
|
|
718
|
+
// Only consider messages within the scrollback window (recent ones)
|
|
719
|
+
for (const [externalId, stored] of storedMessageMap) {
|
|
720
|
+
if (!discordMessageMap.has(externalId)) {
|
|
721
|
+
// Message exists in our store but not in Discord - it was deleted
|
|
722
|
+
// Note: This only catches deletes within the scrollback window
|
|
723
|
+
this.ctx.removeMessage(stored.internalId);
|
|
724
|
+
deletedMessages++;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Update last read
|
|
729
|
+
if (discordMessages.length > 0) {
|
|
730
|
+
// Messages are typically newest first
|
|
731
|
+
const newestId = discordMessages[0].id;
|
|
732
|
+
this.state.lastReadMessageId[channelId] = newestId;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { newMessages, editedMessages, deletedMessages };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private async handleDiscordMessage(message: DiscordMessageData): Promise<void> {
|
|
739
|
+
if (!this.ctx) return;
|
|
740
|
+
|
|
741
|
+
// ALWAYS ignore our own messages to prevent feedback loops
|
|
742
|
+
if (this.state.botUserId && message.authorId === this.state.botUserId) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Optionally ignore other bots
|
|
747
|
+
if (this.config.ignoreBots && message.isBot) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Check if we should handle this channel
|
|
752
|
+
if (this.config.channelIds && !this.config.channelIds.includes(message.channelId)) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Check if we should handle this guild
|
|
757
|
+
if (
|
|
758
|
+
message.guildId &&
|
|
759
|
+
this.config.guildIds &&
|
|
760
|
+
!this.config.guildIds.includes(message.guildId)
|
|
761
|
+
) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Check if we should handle DMs
|
|
766
|
+
const isDM = !message.guildId;
|
|
767
|
+
if (isDM && !this.config.handleDMs) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Determine if this message should trigger inference
|
|
772
|
+
const shouldTrigger = this.shouldTriggerInference(message, isDM);
|
|
773
|
+
|
|
774
|
+
// Convert message content including attachments
|
|
775
|
+
const contentBlocks = await this.convertMessageToContent(message);
|
|
776
|
+
|
|
777
|
+
// Only queue the event - all state writes happen in onProcess()
|
|
778
|
+
this.ctx.pushEvent({
|
|
779
|
+
type: 'discord:message',
|
|
780
|
+
discordMessageId: message.id,
|
|
781
|
+
channelId: message.channelId,
|
|
782
|
+
guildId: message.guildId,
|
|
783
|
+
authorId: message.authorId,
|
|
784
|
+
authorName: message.authorName,
|
|
785
|
+
content: message.content,
|
|
786
|
+
contentBlocks,
|
|
787
|
+
timestamp: message.timestamp.getTime(),
|
|
788
|
+
attachments: message.attachments,
|
|
789
|
+
shouldTriggerInference: shouldTrigger,
|
|
790
|
+
} as DiscordMessageEvent);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Convert Discord message to ContentBlock array.
|
|
795
|
+
* Includes text content and any supported attachments (images, text files).
|
|
796
|
+
*/
|
|
797
|
+
private async convertMessageToContent(message: DiscordMessageData): Promise<ContentBlock[]> {
|
|
798
|
+
const blocks: ContentBlock[] = [];
|
|
799
|
+
|
|
800
|
+
// Add text content if present
|
|
801
|
+
if (message.content.trim()) {
|
|
802
|
+
blocks.push({ type: 'text', text: message.content });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Process attachments
|
|
806
|
+
for (const attachment of message.attachments) {
|
|
807
|
+
const contentType = attachment.contentType?.toLowerCase() ?? '';
|
|
808
|
+
const filename = attachment.filename.toLowerCase();
|
|
809
|
+
|
|
810
|
+
// Check for supported image types
|
|
811
|
+
if (SUPPORTED_IMAGE_TYPES.some(type => contentType.startsWith(type))) {
|
|
812
|
+
blocks.push({
|
|
813
|
+
type: 'image',
|
|
814
|
+
source: {
|
|
815
|
+
type: 'url',
|
|
816
|
+
url: attachment.url,
|
|
817
|
+
},
|
|
818
|
+
} as ContentBlock);
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Check for text files - fetch and include content
|
|
823
|
+
const isTextFile = TEXT_FILE_EXTENSIONS.some(ext => filename.endsWith(ext));
|
|
824
|
+
if (isTextFile && attachment.size && attachment.size < 100_000) { // < 100KB
|
|
825
|
+
try {
|
|
826
|
+
const response = await fetch(attachment.url);
|
|
827
|
+
if (response.ok) {
|
|
828
|
+
const textContent = await response.text();
|
|
829
|
+
blocks.push({
|
|
830
|
+
type: 'text',
|
|
831
|
+
text: `\n--- File: ${attachment.filename} ---\n${textContent}\n--- End of ${attachment.filename} ---\n`,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
// If fetch fails, just note the attachment
|
|
836
|
+
blocks.push({
|
|
837
|
+
type: 'text',
|
|
838
|
+
text: `[Attachment: ${attachment.filename} (failed to fetch)]`,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// For unsupported attachments, add a note
|
|
845
|
+
blocks.push({
|
|
846
|
+
type: 'text',
|
|
847
|
+
text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'})]`,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Ensure we have at least empty text if nothing else
|
|
852
|
+
if (blocks.length === 0) {
|
|
853
|
+
blocks.push({ type: 'text', text: '[Empty message]' });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return blocks;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Determine if a message should trigger agent inference based on config.
|
|
861
|
+
*/
|
|
862
|
+
private shouldTriggerInference(message: DiscordMessageData, isDM: boolean): boolean {
|
|
863
|
+
const triggerMode = this.config.triggerOn ?? 'mention_or_reply';
|
|
864
|
+
const botUserId = this.state.botUserId;
|
|
865
|
+
|
|
866
|
+
// Check if bot is mentioned
|
|
867
|
+
const isMentioned = botUserId
|
|
868
|
+
? message.mentionedUserIds.includes(botUserId)
|
|
869
|
+
: false;
|
|
870
|
+
|
|
871
|
+
// Check if this is a reply to bot's message
|
|
872
|
+
const isReplyToBot = botUserId
|
|
873
|
+
? message.replyToAuthorId === botUserId
|
|
874
|
+
: false;
|
|
875
|
+
|
|
876
|
+
switch (triggerMode) {
|
|
877
|
+
case 'all':
|
|
878
|
+
// Always trigger (use with caution!)
|
|
879
|
+
return true;
|
|
880
|
+
|
|
881
|
+
case 'mention':
|
|
882
|
+
// Only trigger on direct @mention
|
|
883
|
+
return isMentioned;
|
|
884
|
+
|
|
885
|
+
case 'mention_or_reply':
|
|
886
|
+
// Trigger on @mention OR when replying to bot
|
|
887
|
+
return isMentioned || isReplyToBot;
|
|
888
|
+
|
|
889
|
+
case 'dm_or_mention':
|
|
890
|
+
// DMs always trigger, channels require mention
|
|
891
|
+
return isDM || isMentioned;
|
|
892
|
+
|
|
893
|
+
default:
|
|
894
|
+
// Fallback: mention_or_reply behavior
|
|
895
|
+
return isMentioned || isReplyToBot;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private handleDiscordMessageEdit(messageId: string, newContent: string): void {
|
|
900
|
+
if (!this.ctx) return;
|
|
901
|
+
|
|
902
|
+
// Only queue the event - state writes happen in onProcess()
|
|
903
|
+
this.ctx.pushEvent({
|
|
904
|
+
type: 'discord:edit',
|
|
905
|
+
discordMessageId: messageId,
|
|
906
|
+
newContent,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private handleDiscordMessageDelete(messageId: string): void {
|
|
911
|
+
if (!this.ctx) return;
|
|
912
|
+
|
|
913
|
+
// Only queue the event - state writes happen in onProcess()
|
|
914
|
+
this.ctx.pushEvent({
|
|
915
|
+
type: 'discord:delete',
|
|
916
|
+
discordMessageId: messageId,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ==========================================================================
|
|
921
|
+
// Tool Handlers
|
|
922
|
+
// ==========================================================================
|
|
923
|
+
|
|
924
|
+
private async handleSend(input: SendInput): Promise<ToolResult> {
|
|
925
|
+
if (!this.checkRateLimit(input.channelId)) {
|
|
926
|
+
return { success: false, error: 'Rate limited', isError: true };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const result = await this.client.sendMessage(input.channelId, input.content, {
|
|
930
|
+
replyTo: input.replyTo,
|
|
931
|
+
createThread: input.createThread,
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// Track for thought-editing
|
|
935
|
+
this.lastSentMessage = { channelId: input.channelId, messageId: result.messageId };
|
|
936
|
+
|
|
937
|
+
return { success: true, data: { messageId: result.messageId } };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private async handleDM(input: SendDMInput): Promise<ToolResult> {
|
|
941
|
+
const result = await this.client.sendDM(input.userId, input.content);
|
|
942
|
+
return { success: true, data: { messageId: result.messageId } };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private async handleReply(input: ReplyInput): Promise<ToolResult> {
|
|
946
|
+
const replyCtx = this.replyContexts.get('default');
|
|
947
|
+
if (!replyCtx) {
|
|
948
|
+
return { success: false, error: 'No conversation context', isError: true };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!this.checkRateLimit(replyCtx.channelId)) {
|
|
952
|
+
return { success: false, error: 'Rate limited', isError: true };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
let content = input.content;
|
|
956
|
+
if (input.mention && replyCtx.userId) {
|
|
957
|
+
content = `<@${replyCtx.userId}> ${content}`;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
|
|
961
|
+
const result = await this.client.sendMessage(targetChannel, content, {
|
|
962
|
+
replyTo: replyCtx.replyToMessageId,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Track for thought-editing
|
|
966
|
+
this.lastSentMessage = { channelId: targetChannel, messageId: result.messageId };
|
|
967
|
+
|
|
968
|
+
// Clear reply-to after sending
|
|
969
|
+
replyCtx.replyToMessageId = undefined;
|
|
970
|
+
replyCtx.lastActivity = Date.now();
|
|
971
|
+
|
|
972
|
+
return { success: true, data: { messageId: result.messageId } };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async handleReact(input: ReactInput): Promise<ToolResult> {
|
|
976
|
+
await this.client.addReaction(input.channelId, input.messageId, input.emoji);
|
|
977
|
+
return { success: true };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private async handleEdit(input: EditMessageInput): Promise<ToolResult> {
|
|
981
|
+
await this.client.editMessage(input.channelId, input.messageId, input.content);
|
|
982
|
+
return { success: true };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private async handleDelete(input: DeleteMessageInput): Promise<ToolResult> {
|
|
986
|
+
await this.client.deleteMessage(input.channelId, input.messageId);
|
|
987
|
+
return { success: true };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private async handleCreateThread(input: CreateThreadInput): Promise<ToolResult> {
|
|
991
|
+
const result = await this.client.createThread(
|
|
992
|
+
input.channelId,
|
|
993
|
+
input.messageId,
|
|
994
|
+
input.name,
|
|
995
|
+
input.autoArchiveDuration
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
// Update reply context to use the new thread
|
|
999
|
+
const replyCtx = this.replyContexts.get('default');
|
|
1000
|
+
if (replyCtx) {
|
|
1001
|
+
replyCtx.threadId = result.threadId;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { success: true, data: { threadId: result.threadId } };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
private async handleSetReplyContext(input: SetReplyContextInput): Promise<ToolResult> {
|
|
1008
|
+
const replyCtx = this.replyContexts.get('default');
|
|
1009
|
+
if (replyCtx) {
|
|
1010
|
+
replyCtx.channelId = input.channelId;
|
|
1011
|
+
replyCtx.threadId = input.threadId;
|
|
1012
|
+
replyCtx.replyToMessageId = input.replyToMessageId;
|
|
1013
|
+
} else {
|
|
1014
|
+
this.replyContexts.set('default', {
|
|
1015
|
+
channelId: input.channelId,
|
|
1016
|
+
threadId: input.threadId,
|
|
1017
|
+
replyToMessageId: input.replyToMessageId,
|
|
1018
|
+
userId: '',
|
|
1019
|
+
guildId: null,
|
|
1020
|
+
lastActivity: Date.now(),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return { success: true };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private async handleActivate(input: { additive?: boolean }): Promise<ToolResult> {
|
|
1028
|
+
if (!this.ctx) {
|
|
1029
|
+
return { success: false, error: 'Module not started', isError: true };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
this.ctx.registerSpeechHandler('*', { additive: input.additive });
|
|
1033
|
+
return { success: true, data: { message: 'Discord is now handling speech' } };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private async handleDeactivate(): Promise<ToolResult> {
|
|
1037
|
+
if (!this.ctx) {
|
|
1038
|
+
return { success: false, error: 'Module not started', isError: true };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
this.ctx.unregisterSpeechHandler();
|
|
1042
|
+
return { success: true, data: { message: 'Discord is no longer handling speech' } };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private async handleListGuilds(): Promise<ToolResult> {
|
|
1046
|
+
const guilds = await this.client.listGuilds();
|
|
1047
|
+
return {
|
|
1048
|
+
success: true,
|
|
1049
|
+
data: {
|
|
1050
|
+
guilds: guilds.map((g) => ({
|
|
1051
|
+
id: g.id,
|
|
1052
|
+
name: g.name,
|
|
1053
|
+
memberCount: g.memberCount,
|
|
1054
|
+
})),
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private async handleListChannels(input: ListChannelsInput): Promise<ToolResult> {
|
|
1060
|
+
const channels = await this.client.listChannels(input.guildId);
|
|
1061
|
+
return {
|
|
1062
|
+
success: true,
|
|
1063
|
+
data: {
|
|
1064
|
+
guildId: input.guildId,
|
|
1065
|
+
channels: channels.map((c) => ({
|
|
1066
|
+
id: c.id,
|
|
1067
|
+
name: c.name,
|
|
1068
|
+
type: c.type,
|
|
1069
|
+
parentId: c.parentId,
|
|
1070
|
+
})),
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
private async handleFetchHistory(input: FetchHistoryInput): Promise<ToolResult> {
|
|
1076
|
+
const limit = Math.min(input.limit ?? 50, 100); // Cap at 100
|
|
1077
|
+
const messages = await this.client.fetchHistory(input.channelId, {
|
|
1078
|
+
limit,
|
|
1079
|
+
before: input.before,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// Update last read if we got messages
|
|
1083
|
+
if (messages.length > 0) {
|
|
1084
|
+
const latestId = messages[0].id; // Assuming sorted newest first
|
|
1085
|
+
this.state.lastReadMessageId[input.channelId] = latestId;
|
|
1086
|
+
this.ctx?.setState(this.state);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return {
|
|
1090
|
+
success: true,
|
|
1091
|
+
data: {
|
|
1092
|
+
channelId: input.channelId,
|
|
1093
|
+
messageCount: messages.length,
|
|
1094
|
+
messages: messages.map((m) => ({
|
|
1095
|
+
id: m.id,
|
|
1096
|
+
author: m.authorName,
|
|
1097
|
+
authorId: m.authorId,
|
|
1098
|
+
isBot: m.isBot,
|
|
1099
|
+
content: m.content,
|
|
1100
|
+
timestamp: m.timestamp.toISOString(),
|
|
1101
|
+
replyTo: m.replyTo,
|
|
1102
|
+
})),
|
|
1103
|
+
},
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ==========================================================================
|
|
1108
|
+
// Helpers
|
|
1109
|
+
// ==========================================================================
|
|
1110
|
+
|
|
1111
|
+
private extractText(content: ContentBlock[]): string {
|
|
1112
|
+
return content
|
|
1113
|
+
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
|
1114
|
+
.map((block) => block.text)
|
|
1115
|
+
.join('\n');
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private checkRateLimit(channelId: string): boolean {
|
|
1119
|
+
const limit = this.config.rateLimitPerMinute ?? 30;
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
const windowMs = 60 * 1000;
|
|
1122
|
+
|
|
1123
|
+
let info = this.state.rateLimits[channelId];
|
|
1124
|
+
if (!info || now - info.windowStart > windowMs) {
|
|
1125
|
+
// New window
|
|
1126
|
+
info = { count: 0, windowStart: now };
|
|
1127
|
+
this.state.rateLimits[channelId] = info;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (info.count >= limit) {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
info.count++;
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private cleanupOldConversations(): void {
|
|
1139
|
+
const now = Date.now();
|
|
1140
|
+
for (const [key, conv] of Object.entries(this.state.conversations)) {
|
|
1141
|
+
if (now - conv.lastActivity > CONVERSATION_TIMEOUT_MS) {
|
|
1142
|
+
delete this.state.conversations[key];
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|