slide-space-monkey 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,418 @@
1
+ Metadata-Version: 2.4
2
+ Name: slide-space-monkey
3
+ Version: 0.1.1
4
+ Summary: A simple, powerful way to deploy Tyler AI agents as Slack bots
5
+ Project-URL: Homepage, https://github.com/adamwdraper/slide
6
+ Project-URL: Repository, https://github.com/adamwdraper/slide
7
+ Project-URL: Bug Tracker, https://github.com/adamwdraper/slide/issues
8
+ Author: adamwdraper
9
+ License: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Communications :: Chat
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: fastapi>=0.104.0
21
+ Requires-Dist: pydantic>=2.10.0
22
+ Requires-Dist: python-dotenv>=1.0.0
23
+ Requires-Dist: requests>=2.32.0
24
+ Requires-Dist: slack-bolt>=1.18.0
25
+ Requires-Dist: slide-narrator>=0.2.0
26
+ Requires-Dist: slide-tyler>=1.1.0
27
+ Requires-Dist: uvicorn[standard]>=0.24.0
28
+ Requires-Dist: weave>=0.51.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: coverage>=7.6.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
33
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # Space Monkey - Tyler Slack Bot
37
+
38
+ A simple, powerful way to deploy Tyler AI agents as Slack bots with just a few lines of code.
39
+
40
+ ## Overview
41
+
42
+ Space Monkey provides a clean, class-based API for creating and deploying Tyler agents as Slack bots. It handles all the complexity of Slack integration, message routing, thread management, and storage while exposing a simple interface for agent configuration.
43
+
44
+ ## Features
45
+
46
+ - **Simple API**: Just import `SlackApp`, `Agent`, and stores from one package
47
+ - **Intelligent Message Routing**: Automatically classifies messages and routes appropriately
48
+ - **Thread Management**: Persistent conversation threads with configurable storage backends
49
+ - **File Handling**: Built-in support for file attachments and processing
50
+ - **Health Monitoring**: Optional health check endpoints for production deployments
51
+ - **Weave Integration**: Built-in tracing and monitoring support
52
+
53
+ ## Quick Start
54
+
55
+ ### 1. Install the Package
56
+
57
+ ```bash
58
+ pip install slide-space-monkey
59
+ ```
60
+
61
+ ### 2. Set Up Environment Variables
62
+
63
+ Create a `.env` file:
64
+
65
+ ```bash
66
+ # Required: Slack Configuration
67
+ SLACK_BOT_TOKEN=xoxb-your-bot-token
68
+ SLACK_APP_TOKEN=xapp-your-app-token
69
+
70
+ # Optional: Weave Monitoring
71
+ WANDB_API_KEY=your-wandb-key
72
+ WANDB_PROJECT=your-project-name
73
+
74
+ # Optional: Health Check
75
+ HEALTH_CHECK_URL=http://healthcheck:8000/ping-receiver
76
+
77
+ # Optional: Database (defaults to in-memory)
78
+ NARRATOR_DB_TYPE=postgresql
79
+ NARRATOR_DB_USER=tyler
80
+ NARRATOR_DB_PASSWORD=password
81
+ NARRATOR_DB_HOST=localhost
82
+ NARRATOR_DB_PORT=5432
83
+ NARRATOR_DB_NAME=tyler
84
+
85
+ # Optional: File Storage (defaults to ~/.tyler/files)
86
+ NARRATOR_FILE_STORAGE_PATH=/data/files
87
+ ```
88
+
89
+ ### 3. Create Your Bot
90
+
91
+ ```python
92
+ import asyncio
93
+ from space_monkey import SlackApp, ThreadStore, FileStore, Agent
94
+
95
+ async def main():
96
+ # Create stores
97
+ thread_store = await ThreadStore.create() # In-memory by default
98
+ file_store = await FileStore.create() # Default file storage
99
+
100
+ # Create your agent
101
+ agent = Agent(
102
+ name="SlackBot",
103
+ model_name="gpt-4.1",
104
+ purpose="To be a helpful assistant in Slack",
105
+ tools=["web", "files"],
106
+ temperature=0.7
107
+ )
108
+
109
+ # Start the Slack app
110
+ app = SlackApp(
111
+ agent=agent,
112
+ thread_store=thread_store,
113
+ file_store=file_store
114
+ )
115
+
116
+ await app.start()
117
+
118
+ if __name__ == "__main__":
119
+ asyncio.run(main())
120
+ ```
121
+
122
+ That's it! Your Tyler agent is now running as a Slack bot.
123
+
124
+ ## Advanced Configuration
125
+
126
+ ### Database Storage
127
+
128
+ For production deployments, use a persistent database:
129
+
130
+ ```python
131
+ from space_monkey import ThreadStore, FileStore
132
+
133
+ # PostgreSQL
134
+ thread_store = await ThreadStore.create(
135
+ "postgresql+asyncpg://user:pass@localhost/db"
136
+ )
137
+
138
+ # SQLite
139
+ thread_store = await ThreadStore.create(
140
+ "sqlite+aiosqlite:///path/to/db.sqlite"
141
+ )
142
+
143
+ # Custom file storage
144
+ file_store = await FileStore.create(
145
+ base_path="/data/files",
146
+ max_file_size=100 * 1024 * 1024, # 100MB
147
+ max_storage_size=10 * 1024 * 1024 * 1024 # 10GB
148
+ )
149
+ ```
150
+
151
+ ### Custom Agent Configuration
152
+
153
+ ```python
154
+ # Create a custom agent with specific tools and settings
155
+ agent = Agent(
156
+ name="CustomBot",
157
+ model_name="gpt-4.1",
158
+ purpose="Your custom agent purpose",
159
+ tools=["notion:notion-search", "web", "files"],
160
+ temperature=0.7,
161
+ version="1.0.0"
162
+ )
163
+
164
+ # Tyler Agent with custom settings
165
+ agent = Agent(
166
+ name="CustomBot",
167
+ model_name="gpt-4.1",
168
+ purpose="Your custom agent purpose",
169
+ tools=["notion:notion-search"],
170
+ temperature=0.5
171
+ )
172
+ ```
173
+
174
+ ### App Configuration
175
+
176
+ ```python
177
+ app = SlackApp(
178
+ agent=agent,
179
+ thread_store=thread_store,
180
+ file_store=file_store,
181
+ health_check_url="http://healthcheck:8000/ping-receiver",
182
+ weave_project="my-slack-bot"
183
+ )
184
+
185
+ # Start with custom host/port
186
+ await app.start(host="0.0.0.0", port=8080)
187
+ ```
188
+
189
+ ## Agent Configuration
190
+
191
+ Space Monkey uses Tyler Agent directly, giving you full access to all Tyler's capabilities:
192
+
193
+ ```python
194
+ agent = Agent(
195
+ name="MyBot", # Agent name
196
+ model_name="gpt-4.1", # Model to use
197
+ purpose="Your agent's purpose and instructions",
198
+ tools=["web", "files"], # Available tools
199
+ temperature=0.7, # Model temperature
200
+ version="1.0.0" # Agent version
201
+ )
202
+ ```
203
+
204
+ You can configure agents for any use case by adjusting the purpose and tools:
205
+ - HR/People assistance with Notion integration
206
+ - Customer support with web search
207
+ - Technical assistance with file processing
208
+ - And any other conversational AI use case
209
+
210
+ ## Message Routing
211
+
212
+ Tyler Slack Bot automatically handles intelligent message routing:
213
+
214
+ 1. **Direct Messages**: All DM messages are processed by your agent
215
+ 2. **@Mentions**: Messages that mention the bot are always processed
216
+ 3. **Channel Messages**: Automatically classified to determine if they need a response
217
+ 4. **Thread Replies**: Continues conversations in threads appropriately
218
+ 5. **Reactions**: Simple acknowledgments get emoji reactions instead of text responses
219
+
220
+ This happens automatically - you just define your agent's behavior and the bot handles the rest.
221
+
222
+ ## Storage Backends
223
+
224
+ ### Thread Storage
225
+
226
+ - **In-Memory**: Fast, ephemeral (default for development)
227
+ - **SQLite**: Local persistence (good for single-instance deployments)
228
+ - **PostgreSQL**: Production-ready with full ACID compliance
229
+
230
+ ### File Storage
231
+
232
+ - **Local File System**: Sharded directory structure for efficient file organization
233
+ - **Configurable Limits**: Set maximum file sizes and total storage limits
234
+ - **MIME Type Validation**: Automatic file type detection and validation
235
+
236
+ ## Production Deployment
237
+
238
+ ### Docker
239
+
240
+ ```dockerfile
241
+ FROM python:3.11-slim
242
+
243
+ WORKDIR /app
244
+ COPY requirements.txt .
245
+ RUN pip install -r requirements.txt
246
+
247
+ COPY . .
248
+ CMD ["python", "bot.py"]
249
+ ```
250
+
251
+ ### Environment Configuration
252
+
253
+ ```bash
254
+ # Production .env
255
+ SLACK_BOT_TOKEN=xoxb-prod-token
256
+ SLACK_APP_TOKEN=xapp-prod-token
257
+
258
+ # Database
259
+ TYLER_DB_TYPE=postgresql
260
+ TYLER_DB_USER=tyler_prod
261
+ TYLER_DB_PASSWORD=secure_password
262
+ TYLER_DB_HOST=db.example.com
263
+ TYLER_DB_PORT=5432
264
+ TYLER_DB_NAME=tyler_prod
265
+
266
+ # File Storage
267
+ TYLER_FILE_STORAGE_PATH=/data/files
268
+
269
+ # Monitoring
270
+ WANDB_API_KEY=prod-key
271
+ WANDB_PROJECT=slack-bot-prod
272
+ HEALTH_CHECK_URL=http://healthcheck:8000/ping-receiver
273
+ ```
274
+
275
+ ### Health Monitoring
276
+
277
+ The bot includes built-in health check endpoints and optional external health monitoring:
278
+
279
+ ```python
280
+ app = SlackApp(
281
+ agent=agent,
282
+ thread_store=thread_store,
283
+ file_store=file_store,
284
+ health_check_url="http://healthcheck:8000/ping-receiver"
285
+ )
286
+ ```
287
+
288
+ ## API Reference
289
+
290
+ ### SlackApp
291
+
292
+ ```python
293
+ class SlackApp:
294
+ def __init__(
295
+ self,
296
+ agent: Agent,
297
+ thread_store: ThreadStore,
298
+ file_store: FileStore,
299
+ health_check_url: Optional[str] = None,
300
+ weave_project: Optional[str] = None
301
+ ):
302
+ """Initialize Slack app with agent and stores."""
303
+
304
+ async def start(
305
+ self,
306
+ host: str = "0.0.0.0",
307
+ port: int = 8000
308
+ ) -> None:
309
+ """Start the Slack bot app."""
310
+ ```
311
+
312
+ ### Agent
313
+
314
+ ```python
315
+ class Agent:
316
+ def __init__(
317
+ self,
318
+ name: str,
319
+ model_name: str,
320
+ purpose: str,
321
+ tools: Optional[List[str]] = None,
322
+ temperature: Optional[float] = None,
323
+ version: str = "1.0.0",
324
+ thread_store: Optional[ThreadStore] = None,
325
+ file_store: Optional[FileStore] = None
326
+ ):
327
+ """Create a Tyler agent with full configuration options."""
328
+ ```
329
+
330
+ ### Stores
331
+
332
+ ```python
333
+ # ThreadStore
334
+ thread_store = await ThreadStore.create() # In-memory
335
+ thread_store = await ThreadStore.create(database_url) # Database
336
+
337
+ # FileStore
338
+ file_store = await FileStore.create() # Default settings
339
+ file_store = await FileStore.create(
340
+ base_path="/path/to/files",
341
+ max_file_size=100 * 1024 * 1024,
342
+ max_storage_size=10 * 1024 * 1024 * 1024
343
+ )
344
+ ```
345
+
346
+ ## Examples
347
+
348
+ ### Simple HR Bot
349
+
350
+ ```python
351
+ import asyncio
352
+ from space_monkey import SlackApp, Agent, ThreadStore, FileStore
353
+
354
+ async def main():
355
+ # Simple setup for an HR assistant
356
+ thread_store = await ThreadStore.create()
357
+ file_store = await FileStore.create()
358
+
359
+ agent = Agent(
360
+ name="HRBot",
361
+ model_name="gpt-4.1",
362
+ purpose="To help with HR and people team questions",
363
+ tools=["notion:notion-search"],
364
+ temperature=0.7
365
+ )
366
+
367
+ app = SlackApp(
368
+ agent=agent,
369
+ thread_store=thread_store,
370
+ file_store=file_store
371
+ )
372
+
373
+ await app.start()
374
+
375
+ asyncio.run(main())
376
+ ```
377
+
378
+ ### Custom Agent with Database
379
+
380
+ ```python
381
+ import asyncio
382
+ from space_monkey import SlackApp, Agent, ThreadStore, FileStore
383
+
384
+ async def main():
385
+ # Production setup with PostgreSQL
386
+ thread_store = await ThreadStore.create(
387
+ "postgresql+asyncpg://user:pass@localhost/db"
388
+ )
389
+ file_store = await FileStore.create(base_path="/data/files")
390
+
391
+ # Custom agent
392
+ agent = Agent(
393
+ name="SupportBot",
394
+ model_name="gpt-4.1",
395
+ purpose="Help users with technical support questions",
396
+ tools=["web", "files"],
397
+ temperature=0.3
398
+ )
399
+
400
+ app = SlackApp(
401
+ agent=agent,
402
+ thread_store=thread_store,
403
+ file_store=file_store,
404
+ weave_project="support-bot"
405
+ )
406
+
407
+ await app.start(port=8080)
408
+
409
+ asyncio.run(main())
410
+ ```
411
+
412
+ ## Contributing
413
+
414
+ Tyler Slack Bot is part of the Tyler ecosystem. See the main Tyler documentation for information about contributing to the project.
415
+
416
+ ## License
417
+
418
+ MIT License - see LICENSE file for details.
@@ -0,0 +1,6 @@
1
+ space_monkey/__init__.py,sha256=U0KBTXnV9ITv_kRCEUydQNOHeBeJBq41Jvknh9UAY88,407
2
+ space_monkey/classifier.py,sha256=qJiW0cllmJdb9UvQi68cwJOEhhSJ3OeD52N6aCG8JQ8,1208
3
+ space_monkey/slack_app.py,sha256=Uz0cQ1Xr8u1um_Y0QxTctR7rrS9Bn5T_t-fwh0AnO9U,23366
4
+ slide_space_monkey-0.1.1.dist-info/METADATA,sha256=xe7DjQnyGO2igBSV6BZ1HZmGn7yNwGaW6kBokgqQ59M,10830
5
+ slide_space_monkey-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ slide_space_monkey-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,23 @@
1
+ """
2
+ Space Monkey - Tyler Slack Bot
3
+
4
+ A simple, powerful way to deploy Tyler AI agents as Slack bots.
5
+ """
6
+
7
+ # Re-export core components from other packages
8
+ from narrator import ThreadStore, FileStore
9
+ from tyler import Agent
10
+
11
+ # Import our own classes
12
+ from .slack_app import SlackApp
13
+
14
+ # Version
15
+ __version__ = "0.1.1"
16
+
17
+ # Main exports
18
+ __all__ = [
19
+ "SlackApp",
20
+ "Agent",
21
+ "ThreadStore",
22
+ "FileStore"
23
+ ]
@@ -0,0 +1,35 @@
1
+ """
2
+ Message classifier for Space Monkey internal use
3
+ """
4
+ import logging
5
+ import weave
6
+ from tyler import Agent
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ @weave.op()
11
+ def initialize_message_classifier_agent(thread_store=None, file_store=None, model_name="gpt-4.1", purpose_prompt=None, bot_user_id=None):
12
+ """
13
+ Initialize and return a classifier agent that determines if/how the main agent should respond.
14
+
15
+ Args:
16
+ thread_store: The thread store instance for the agent to use (optional)
17
+ file_store: The file store instance for the agent to use (optional)
18
+ model_name: The model to use for the agent (default: gpt-4.1)
19
+ purpose_prompt: The purpose prompt for the agent
20
+ bot_user_id: The Slack user ID of the bot (optional)
21
+
22
+ Returns:
23
+ Agent: An initialized message classifier agent
24
+ """
25
+ message_classifier_agent = Agent(
26
+ name="MessageClassifier",
27
+ model_name=model_name,
28
+ version="2.0.0",
29
+ purpose=purpose_prompt.format(bot_user_id=bot_user_id),
30
+ thread_store=thread_store,
31
+ file_store=file_store
32
+ )
33
+
34
+ logger.info("MessageClassifier agent initialized")
35
+ return message_classifier_agent
@@ -0,0 +1,576 @@
1
+ """
2
+ SlackApp class for Slack App integration
3
+ """
4
+ import os
5
+ import asyncio
6
+ import logging
7
+ import copy
8
+ import json
9
+ import time
10
+ import threading
11
+ import requests
12
+ import weave
13
+ from contextlib import asynccontextmanager
14
+ from dotenv import load_dotenv
15
+ from slack_bolt.async_app import AsyncApp
16
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
17
+ from fastapi import FastAPI
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ import uvicorn
20
+
21
+ from narrator import Thread, Message
22
+ from tyler.tools.slack import generate_slack_blocks
23
+
24
+ # Set up logging
25
+ logger = logging.getLogger(__name__)
26
+
27
+ class SlackApp:
28
+ """
29
+ Main SlackApp class that encapsulates all Slack integration logic.
30
+
31
+ This class provides a clean interface for running Tyler agents as Slack bots
32
+ with intelligent message routing, thread management, and health monitoring.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ agent,
38
+ thread_store,
39
+ file_store,
40
+ health_check_url: str = None,
41
+ weave_project: str = None
42
+ ):
43
+ """
44
+ Initialize SlackApp with agent and stores.
45
+
46
+ Args:
47
+ agent: The main Tyler agent to handle conversations
48
+ thread_store: ThreadStore instance for conversation persistence
49
+ file_store: FileStore instance for file handling
50
+ health_check_url: Optional URL for health check pings
51
+ weave_project: Optional Weave project name for tracing
52
+ """
53
+ # Load environment variables
54
+ load_dotenv()
55
+
56
+ # Store configuration
57
+ self.agent = agent
58
+ self.thread_store = thread_store
59
+ self.file_store = file_store
60
+ self.health_check_url = health_check_url or os.getenv("HEALTH_CHECK_URL")
61
+ self.weave_project = weave_project or os.getenv("WANDB_PROJECT")
62
+
63
+ # Internal state
64
+ self.slack_app = None
65
+ self.socket_handler = None
66
+ self.bot_user_id = None
67
+ self.message_classifier_agent = None
68
+ self.health_thread = None
69
+
70
+ # FastAPI app for server functionality
71
+ self.fastapi_app = FastAPI(
72
+ title="Space Monkey Slack Agent",
73
+ lifespan=self._lifespan
74
+ )
75
+
76
+ # Add CORS middleware
77
+ self.fastapi_app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins=["*"],
80
+ allow_credentials=True,
81
+ allow_methods=["*"],
82
+ allow_headers=["*"],
83
+ )
84
+
85
+ @asynccontextmanager
86
+ async def _lifespan(self, app: FastAPI):
87
+ """FastAPI lifespan context manager for startup/shutdown logic."""
88
+ # Startup
89
+ await self._startup()
90
+ yield
91
+ # Shutdown
92
+ await self._shutdown()
93
+
94
+ async def _startup(self):
95
+ """Initialize all components during startup."""
96
+ logger.info("Starting Space Monkey Slack Agent...")
97
+
98
+ # Initialize Weave if configured
99
+ await self._init_weave()
100
+
101
+ # Initialize Slack app and get bot user ID
102
+ await self._init_slack_app()
103
+
104
+ # Initialize message classifier
105
+ await self._init_classifier()
106
+
107
+ # Register event handlers
108
+ self._register_event_handlers()
109
+
110
+ # Start Slack socket connection
111
+ await self._start_slack()
112
+
113
+ # Start health monitoring
114
+ self._start_health_monitoring()
115
+
116
+ logger.info("Space Monkey Slack Agent started successfully")
117
+
118
+ async def _shutdown(self):
119
+ """Clean shutdown logic."""
120
+ logger.info("Shutting down Space Monkey Slack Agent...")
121
+
122
+ if self.socket_handler:
123
+ await self.socket_handler.close_async()
124
+
125
+ # Close database connections if available
126
+ if (self.thread_store and
127
+ hasattr(self.thread_store, '_backend') and
128
+ hasattr(self.thread_store._backend, 'engine')):
129
+ try:
130
+ await self.thread_store._backend.engine.dispose()
131
+ logger.info("Database connections closed")
132
+ except Exception as e:
133
+ logger.error(f"Error closing database connections: {e}")
134
+
135
+ async def _init_weave(self):
136
+ """Initialize Weave monitoring if configured."""
137
+ try:
138
+ if os.getenv("WANDB_API_KEY") and self.weave_project:
139
+ weave.init(self.weave_project)
140
+ logger.info("Weave tracing initialized successfully")
141
+ except Exception as e:
142
+ logger.warning(f"Failed to initialize weave tracing: {e}")
143
+
144
+ async def _init_slack_app(self):
145
+ """Initialize the Slack app and get bot user ID."""
146
+ # Create Slack app
147
+ self.slack_app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"])
148
+
149
+ # Get bot user ID for mention detection
150
+ auth_response = await self.slack_app.client.auth_test()
151
+ self.bot_user_id = auth_response["user_id"]
152
+ logger.info(f"Agent initialized with user ID: {self.bot_user_id}")
153
+
154
+ async def _init_classifier(self):
155
+ """Initialize the message classifier agent."""
156
+ # Import here to avoid circular imports
157
+ from .classifier import initialize_message_classifier_agent
158
+
159
+ # Get classifier prompt version from environment
160
+ classifier_prompt_version = os.getenv("MESSAGE_CLASSIFIER_PROMPT_VERSION")
161
+ if not classifier_prompt_version:
162
+ logger.error("MESSAGE_CLASSIFIER_PROMPT_VERSION not set")
163
+ raise RuntimeError("MESSAGE_CLASSIFIER_PROMPT_VERSION not set")
164
+
165
+ # Fetch prompt from Weave
166
+ try:
167
+ classifier_prompt_obj = weave.ref(f"MessageClassifierPurposePrompt:{classifier_prompt_version}").get()
168
+ logger.info(f"Fetched classifier prompt: {classifier_prompt_version}")
169
+ except Exception as e:
170
+ logger.error(f"Failed to load classifier prompt: {e}")
171
+ raise RuntimeError(f"Failed to load classifier prompt: {e}")
172
+
173
+ # Initialize classifier agent
174
+ self.message_classifier_agent = initialize_message_classifier_agent(
175
+ model_name="gpt-4.1",
176
+ purpose_prompt=classifier_prompt_obj,
177
+ thread_store=self.thread_store,
178
+ file_store=self.file_store,
179
+ bot_user_id=self.bot_user_id
180
+ )
181
+ logger.info("Message classifier agent initialized")
182
+
183
+ def _register_event_handlers(self):
184
+ """Register Slack event handlers."""
185
+ # Global middleware for logging
186
+ @self.slack_app.use
187
+ async def log_all_events(client, context, logger, payload, next):
188
+ try:
189
+ logger.info(f"MIDDLEWARE: Received payload: {list(payload.keys())}")
190
+ if isinstance(payload, dict) and "type" in payload:
191
+ event_type = payload["type"]
192
+ logger.critical(f"MIDDLEWARE: Event type '{event_type}'")
193
+
194
+ if event_type in ["reaction_added", "reaction_removed"]:
195
+ logger.critical(f"MIDDLEWARE: REACTION EVENT: {json.dumps(payload)}")
196
+ elif event_type == "message":
197
+ logger.critical(f"MIDDLEWARE: MESSAGE EVENT: channel={payload.get('channel')}, user={payload.get('user')}, ts={payload.get('ts')}")
198
+
199
+ await next()
200
+ except Exception as e:
201
+ logger.error(f"Error in middleware: {str(e)}")
202
+ await next()
203
+
204
+ # Register event handlers
205
+ self.slack_app.event({"type": "message", "subtype": None})(self._handle_user_message)
206
+ self.slack_app.event("app_mention")(self._handle_app_mention)
207
+ self.slack_app.event("reaction_added")(self._handle_reaction_added)
208
+ self.slack_app.event("reaction_removed")(self._handle_reaction_removed)
209
+
210
+ async def _start_slack(self):
211
+ """Start the Slack socket connection."""
212
+ self.socket_handler = AsyncSocketModeHandler(self.slack_app, os.environ["SLACK_APP_TOKEN"])
213
+ await self.socket_handler.start_async()
214
+ logger.info("Slack socket connection established")
215
+
216
+ def _start_health_monitoring(self):
217
+ """Start health monitoring thread if configured."""
218
+ if not self.health_check_url:
219
+ return
220
+
221
+ def health_ping_loop():
222
+ """Health ping loop in background thread."""
223
+ ping_interval = int(os.getenv("HEALTH_PING_INTERVAL_SECONDS", "120"))
224
+ logger.info(f"Starting health ping to {self.health_check_url} every {ping_interval}s")
225
+
226
+ while True:
227
+ try:
228
+ response = requests.get(self.health_check_url, timeout=5)
229
+ if response.status_code == 200:
230
+ logger.info(f"Health ping successful: {response.text}")
231
+ else:
232
+ logger.warning(f"Health ping returned status: {response.status_code}")
233
+ except Exception as e:
234
+ logger.error(f"Health ping failed: {str(e)}")
235
+
236
+ time.sleep(ping_interval)
237
+
238
+ self.health_thread = threading.Thread(target=health_ping_loop, daemon=True)
239
+ self.health_thread.start()
240
+ logger.info("Health monitoring started")
241
+
242
+ # Event handlers
243
+ async def _handle_app_mention(self, event, say):
244
+ """Handle app mention events."""
245
+ logger.info(f"App mention event: ts={event.get('ts')}")
246
+
247
+ async def _handle_user_message(self, event, say):
248
+ """Handle user messages with intelligent routing."""
249
+ ts = event.get("ts")
250
+ thread_ts = event.get("thread_ts")
251
+ channel = event.get("channel")
252
+ channel_type = event.get("channel_type")
253
+
254
+ logger.info(f"Message: ts={ts}, thread_ts={thread_ts}, channel={channel}, type={channel_type}")
255
+
256
+ # Check if message should be processed
257
+ if not await self._should_process_message(event):
258
+ logger.info("Skipping message processing")
259
+ return
260
+
261
+ text = event.get("text", "")
262
+
263
+ # Process the message
264
+ response_type, content = await self._process_message(text, event)
265
+
266
+ # Handle different response types
267
+ if response_type == "none":
268
+ logger.info("No response needed")
269
+ return
270
+ elif response_type == "emoji":
271
+ await self._send_emoji_reaction(content)
272
+ elif response_type == "message":
273
+ await self._send_text_response(content, event, say)
274
+ else:
275
+ logger.warning(f"Unknown response type: {response_type}")
276
+
277
+ async def _handle_reaction_added(self, event, say):
278
+ """Handle reaction added events."""
279
+ try:
280
+ user = event.get("user")
281
+ emoji = event.get("reaction")
282
+ item_ts = event.get("item", {}).get("ts")
283
+
284
+ logger.info(f"Reaction added: {emoji} by {user} on {item_ts}")
285
+
286
+ # Find message and thread
287
+ message, thread = await self._find_message_and_thread(item_ts)
288
+ if message and thread:
289
+ if thread.add_reaction(message.id, emoji, user):
290
+ await self.thread_store.save(thread)
291
+ logger.info(f"Stored reaction {emoji}")
292
+ except Exception as e:
293
+ logger.error(f"Error handling reaction: {str(e)}")
294
+
295
+ async def _handle_reaction_removed(self, event, say):
296
+ """Handle reaction removed events."""
297
+ try:
298
+ user = event.get("user")
299
+ emoji = event.get("reaction")
300
+ item_ts = event.get("item", {}).get("ts")
301
+
302
+ logger.info(f"Reaction removed: {emoji} by {user} on {item_ts}")
303
+
304
+ # Find message and thread
305
+ message, thread = await self._find_message_and_thread(item_ts)
306
+ if message and thread:
307
+ if thread.remove_reaction(message.id, emoji, user):
308
+ await self.thread_store.save(thread)
309
+ logger.info(f"Removed reaction {emoji}")
310
+ except Exception as e:
311
+ logger.error(f"Error handling reaction removal: {str(e)}")
312
+
313
+ # Helper methods
314
+ async def _should_process_message(self, event):
315
+ """Determine if a message should be processed."""
316
+ ts = str(event.get("ts"))
317
+ channel_type = event.get("channel_type")
318
+ thread_ts = event.get("thread_ts")
319
+
320
+ # Check if already processed
321
+ try:
322
+ messages = await self.thread_store.find_messages_by_attribute("platforms.slack.ts", ts)
323
+ if messages:
324
+ logger.info(f"Message {ts} already processed")
325
+ return False
326
+ except Exception as e:
327
+ logger.warning(f"Error checking message: {str(e)}")
328
+
329
+ # Process DMs, threaded messages, and channel messages
330
+ if channel_type == "im" or thread_ts:
331
+ return True
332
+
333
+ return True # For now, process all channel messages
334
+
335
+ async def _process_message(self, text: str, event: dict):
336
+ """Process a message and return (type, content) tuple."""
337
+ try:
338
+ # Get or create thread
339
+ thread = await self._get_or_create_thread(event)
340
+
341
+ # Create user message
342
+ user_id = event.get("user", "unknown_user")
343
+ user_message = Message(
344
+ role="user",
345
+ content=text,
346
+ source={"id": user_id, "type": "user"},
347
+ platforms={
348
+ "slack": {
349
+ "channel": event.get("channel"),
350
+ "ts": event.get("ts"),
351
+ "thread_ts": event.get("thread_ts") or event.get("ts")
352
+ }
353
+ }
354
+ )
355
+
356
+ # Add message to thread and save
357
+ thread.add_message(user_message)
358
+ await self.thread_store.save(thread)
359
+
360
+ # Run message classifier
361
+ classifier_thread = copy.deepcopy(thread)
362
+ thread_ts = event.get("thread_ts") or event.get("ts")
363
+
364
+ with weave.attributes({'env': os.getenv("ENV", "development"), 'event_id': thread_ts}):
365
+ _, classify_messages = await self.message_classifier_agent.go(classifier_thread)
366
+
367
+ # Parse classification result
368
+ classify_result = classify_messages[-1].content if classify_messages else None
369
+ if classify_result:
370
+ try:
371
+ classification = json.loads(classify_result)
372
+ response_type = classification.get("response_type", "full_response")
373
+
374
+ if response_type == "ignore":
375
+ logger.info(f"Classification: IGNORE - {classification.get('reasoning', '')}")
376
+ return ("none", "")
377
+ elif response_type == "emoji_reaction":
378
+ emoji = classification.get("suggested_emoji", "thumbsup")
379
+ logger.info(f"Classification: EMOJI ({emoji}) - {classification.get('reasoning', '')}")
380
+ return ("emoji", {
381
+ "ts": event.get("ts"),
382
+ "channel": event.get("channel"),
383
+ "emoji": emoji
384
+ })
385
+ except json.JSONDecodeError:
386
+ logger.warning(f"Failed to parse classification: {classify_result}")
387
+
388
+ # Add thinking emoji while processing
389
+ try:
390
+ await self.slack_app.client.reactions_add(
391
+ channel=event.get("channel"),
392
+ timestamp=event.get("ts"),
393
+ name="thinking_face"
394
+ )
395
+ except Exception as e:
396
+ logger.warning(f"Failed to add thinking emoji: {str(e)}")
397
+
398
+ # Process with main agent
399
+ with weave.attributes({'env': os.getenv("ENV", "development"), 'event_id': thread_ts}):
400
+ _, new_messages = await self.agent.go(thread.id)
401
+
402
+ # Get assistant response
403
+ assistant_messages = [m for m in new_messages if m.role == "assistant"]
404
+ assistant_message = assistant_messages[-1] if assistant_messages else None
405
+
406
+ if not assistant_message:
407
+ return ("message", "I apologize, but I couldn't generate a response.")
408
+
409
+ response_content = assistant_message.content
410
+
411
+ # Add dev footer if metrics available
412
+ if hasattr(assistant_message, 'metrics') and assistant_message.metrics:
413
+ footer = self._get_dev_footer(assistant_message.metrics)
414
+ if footer:
415
+ response_content += footer
416
+
417
+ return ("message", response_content)
418
+
419
+ except Exception as e:
420
+ logger.error(f"Error processing message: {e}", exc_info=True)
421
+ return ("message", f"I apologize, but I encountered an error: {str(e)}")
422
+
423
+ async def _get_or_create_thread(self, event):
424
+ """Get or create a thread based on Slack event data."""
425
+ slack_platform_data = {
426
+ "channel": event.get("channel"),
427
+ "thread_ts": event.get("thread_ts") or event.get("ts"),
428
+ }
429
+
430
+ # Try to find existing thread
431
+ if event.get("thread_ts"):
432
+ try:
433
+ threads = await self.thread_store.find_by_platform("slack", {"thread_ts": str(event.get("thread_ts"))})
434
+ if threads:
435
+ return threads[0]
436
+ except Exception as e:
437
+ logger.warning(f"Error finding thread: {str(e)}")
438
+
439
+ # Try by ts
440
+ ts = event.get("ts")
441
+ if ts:
442
+ try:
443
+ threads = await self.thread_store.find_by_platform("slack", {"thread_ts": str(ts)})
444
+ if threads:
445
+ return threads[0]
446
+ except Exception as e:
447
+ logger.warning(f"Error finding thread by ts: {str(e)}")
448
+
449
+ # Create new thread
450
+ thread = Thread(platforms={"slack": slack_platform_data})
451
+ await self.thread_store.save(thread)
452
+ logger.info(f"Created new thread {thread.id}")
453
+ return thread
454
+
455
+ async def _find_message_and_thread(self, item_ts):
456
+ """Find a message and its thread by timestamp."""
457
+ try:
458
+ messages = await self.thread_store.find_messages_by_attribute("platforms.slack.ts", item_ts)
459
+ if not messages:
460
+ return None, None
461
+
462
+ message = messages[0]
463
+ thread = await self.thread_store.get_thread_by_message_id(message.id)
464
+ return message, thread
465
+ except Exception as e:
466
+ logger.error(f"Error finding message: {str(e)}")
467
+ return None, None
468
+
469
+ async def _send_emoji_reaction(self, reaction_info):
470
+ """Send an emoji reaction."""
471
+ try:
472
+ await self.slack_app.client.reactions_add(
473
+ channel=reaction_info["channel"],
474
+ timestamp=reaction_info["ts"],
475
+ name=reaction_info["emoji"]
476
+ )
477
+ except Exception as e:
478
+ logger.error(f"Error sending emoji reaction: {str(e)}")
479
+
480
+ async def _send_text_response(self, text, event, say):
481
+ """Send a text response."""
482
+ try:
483
+ # Convert to Slack blocks
484
+ thread_ts = event.get("thread_ts") or event.get("ts")
485
+ response_blocks = await self._convert_to_slack_blocks(text, thread_ts)
486
+
487
+ # Send response
488
+ response = await say(
489
+ thread_ts=thread_ts,
490
+ text=response_blocks["text"],
491
+ blocks=response_blocks["blocks"]
492
+ )
493
+
494
+ # Update assistant message with Slack timestamp
495
+ if response and "ts" in response:
496
+ thread = await self._get_or_create_thread(event)
497
+ await self._update_assistant_message_with_slack_ts(
498
+ thread,
499
+ event.get("channel"),
500
+ response["ts"],
501
+ thread_ts
502
+ )
503
+ except Exception as e:
504
+ logger.error(f"Error sending text response: {str(e)}")
505
+
506
+ async def _convert_to_slack_blocks(self, text, thread_ts=None):
507
+ """Convert markdown text to Slack blocks."""
508
+ try:
509
+ with weave.attributes({'env': os.getenv("ENV", "development"), 'event_id': thread_ts}):
510
+ result = await generate_slack_blocks(content=text)
511
+
512
+ if result and isinstance(result, dict) and "blocks" in result:
513
+ return {"blocks": result["blocks"], "text": result.get("text", text)}
514
+ else:
515
+ return {"text": text}
516
+ except Exception as e:
517
+ logger.error(f"Error converting to Slack blocks: {e}")
518
+ return {"text": text}
519
+
520
+ async def _update_assistant_message_with_slack_ts(self, thread, channel, response_ts, thread_ts):
521
+ """Update assistant message with Slack timestamp."""
522
+ try:
523
+ for message in reversed(thread.messages):
524
+ if message.role == "assistant" and (not message.platforms or "slack" not in message.platforms):
525
+ message.platforms = message.platforms or {}
526
+ message.platforms["slack"] = {
527
+ "channel": channel,
528
+ "ts": response_ts,
529
+ "thread_ts": thread_ts
530
+ }
531
+ await self.thread_store.save(thread)
532
+ logger.info(f"Updated assistant message with ts={response_ts}")
533
+ return True
534
+ return False
535
+ except Exception as e:
536
+ logger.error(f"Error updating assistant message: {str(e)}")
537
+ return False
538
+
539
+ def _get_dev_footer(self, metrics):
540
+ """Generate dev footer from metrics."""
541
+ footer = f"\n\n{self.agent.name}: v{getattr(self.agent, 'version', '1.0.0')}"
542
+
543
+ model = metrics.get('model', 'N/A')
544
+ weave_url = None
545
+ if 'weave_call' in metrics:
546
+ weave_url = metrics['weave_call'].get('ui_url')
547
+
548
+ if model:
549
+ footer += f" {model}"
550
+ if weave_url:
551
+ footer += f" | [Weave trace]({weave_url})"
552
+
553
+ return footer if (weave_url or model) else ""
554
+
555
+ async def start(self, host: str = "0.0.0.0", port: int = 8000):
556
+ """Start the Slack bot server."""
557
+ config = uvicorn.Config(
558
+ app=self.fastapi_app,
559
+ host=host,
560
+ port=port,
561
+ log_level="info",
562
+ workers=1,
563
+ loop="asyncio",
564
+ timeout_keep_alive=65,
565
+ )
566
+
567
+ server = uvicorn.Server(config)
568
+
569
+ try:
570
+ logger.info(f"Starting server on {host}:{port}")
571
+ await server.serve()
572
+ except Exception as e:
573
+ logger.error(f"Server error: {str(e)}", exc_info=True)
574
+ raise
575
+ finally:
576
+ logger.info("Server shutting down")