codetether 1.2.2__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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email Inbound Webhook Handler for A2A Server.
|
|
3
|
+
|
|
4
|
+
Handles SendGrid Inbound Parse webhooks to enable email reply-based task continuation.
|
|
5
|
+
When a worker sends an email notification (e.g., asking a question or reporting completion),
|
|
6
|
+
users can reply directly to that email and have their response continue the worker's task.
|
|
7
|
+
|
|
8
|
+
Flow:
|
|
9
|
+
1. Worker sends email with reply-to: task+{session_id}@{inbound_domain}
|
|
10
|
+
2. User replies to email
|
|
11
|
+
3. SendGrid Inbound Parse forwards to POST /v1/email/inbound
|
|
12
|
+
4. This module extracts session_id from "to" address, parses reply text
|
|
13
|
+
5. Creates a new task with same session_id to continue the conversation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Optional, Dict, Any
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Request, HTTPException, Form, UploadFile, File
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Router for email inbound endpoints
|
|
28
|
+
email_router = APIRouter(prefix='/v1/email', tags=['email'])
|
|
29
|
+
|
|
30
|
+
# Environment configuration
|
|
31
|
+
EMAIL_INBOUND_DOMAIN = os.environ.get(
|
|
32
|
+
'EMAIL_INBOUND_DOMAIN', 'inbound.codetether.run'
|
|
33
|
+
)
|
|
34
|
+
EMAIL_REPLY_PREFIX = os.environ.get('EMAIL_REPLY_PREFIX', 'task')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EmailReplyContext(BaseModel):
|
|
38
|
+
"""Parsed context from an inbound email reply."""
|
|
39
|
+
|
|
40
|
+
session_id: Optional[str] = None
|
|
41
|
+
task_id: Optional[str] = None
|
|
42
|
+
codebase_id: Optional[str] = None
|
|
43
|
+
from_email: str
|
|
44
|
+
subject: str
|
|
45
|
+
body_text: str
|
|
46
|
+
body_html: Optional[str] = None
|
|
47
|
+
received_at: datetime
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_reply_to_address(to_address: str) -> Dict[str, Optional[str]]:
|
|
51
|
+
"""
|
|
52
|
+
Parse the reply-to address to extract session/task context.
|
|
53
|
+
|
|
54
|
+
Expected formats:
|
|
55
|
+
- task+{session_id}@inbound.codetether.run
|
|
56
|
+
- task+{session_id}+{codebase_id}@inbound.codetether.run
|
|
57
|
+
|
|
58
|
+
Returns dict with session_id, task_id, codebase_id (any may be None).
|
|
59
|
+
"""
|
|
60
|
+
result: Dict[str, Optional[str]] = {
|
|
61
|
+
'session_id': None,
|
|
62
|
+
'task_id': None,
|
|
63
|
+
'codebase_id': None,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Extract local part before @
|
|
67
|
+
match = re.match(r'^([^@]+)@', to_address.lower().strip())
|
|
68
|
+
if not match:
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
local_part = match.group(1)
|
|
72
|
+
|
|
73
|
+
# Check if it starts with our prefix
|
|
74
|
+
prefix = EMAIL_REPLY_PREFIX.lower()
|
|
75
|
+
if not local_part.startswith(f'{prefix}+'):
|
|
76
|
+
logger.debug(
|
|
77
|
+
f'Email to address does not match expected prefix: {to_address}'
|
|
78
|
+
)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
# Extract the parts after the prefix
|
|
82
|
+
parts_str = local_part[len(prefix) + 1 :] # +1 for the '+'
|
|
83
|
+
parts = parts_str.split('+')
|
|
84
|
+
|
|
85
|
+
if len(parts) >= 1 and parts[0]:
|
|
86
|
+
result['session_id'] = parts[0]
|
|
87
|
+
if len(parts) >= 2 and parts[1]:
|
|
88
|
+
result['codebase_id'] = parts[1]
|
|
89
|
+
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
local_part = match.group(1)
|
|
93
|
+
|
|
94
|
+
# Check if it starts with our prefix
|
|
95
|
+
prefix = EMAIL_REPLY_PREFIX.lower()
|
|
96
|
+
if not local_part.startswith(f'{prefix}+'):
|
|
97
|
+
logger.debug(
|
|
98
|
+
f'Email to address does not match expected prefix: {to_address}'
|
|
99
|
+
)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
# Extract the parts after the prefix
|
|
103
|
+
parts_str = local_part[len(prefix) + 1 :] # +1 for the '+'
|
|
104
|
+
parts = parts_str.split('+')
|
|
105
|
+
|
|
106
|
+
if len(parts) >= 1 and parts[0]:
|
|
107
|
+
result['session_id'] = parts[0]
|
|
108
|
+
if len(parts) >= 2 and parts[1]:
|
|
109
|
+
result['codebase_id'] = parts[1]
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_reply_body(text: str, html: Optional[str] = None) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Extract the actual reply content from an email, removing quoted text.
|
|
117
|
+
|
|
118
|
+
Email clients typically include the original message when replying.
|
|
119
|
+
This function attempts to extract just the new reply content.
|
|
120
|
+
"""
|
|
121
|
+
if not text:
|
|
122
|
+
return ''
|
|
123
|
+
|
|
124
|
+
lines = text.split('\n')
|
|
125
|
+
reply_lines = []
|
|
126
|
+
|
|
127
|
+
# Common reply markers
|
|
128
|
+
reply_markers = [
|
|
129
|
+
'On ', # "On Mon, Jan 11, 2026 at..."
|
|
130
|
+
'-----Original Message-----',
|
|
131
|
+
'________________________________',
|
|
132
|
+
'From:',
|
|
133
|
+
'> ', # Quoted text
|
|
134
|
+
'wrote:',
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
for line in lines:
|
|
138
|
+
stripped = line.strip()
|
|
139
|
+
|
|
140
|
+
# Check if this line starts a quote section
|
|
141
|
+
is_quote_start = False
|
|
142
|
+
for marker in reply_markers:
|
|
143
|
+
if stripped.startswith(marker):
|
|
144
|
+
is_quote_start = True
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# Also check for "On ... wrote:" pattern spanning multiple words
|
|
148
|
+
if re.match(r'^On .+ wrote:$', stripped):
|
|
149
|
+
is_quote_start = True
|
|
150
|
+
|
|
151
|
+
if is_quote_start:
|
|
152
|
+
# Stop processing - everything after is quoted
|
|
153
|
+
break
|
|
154
|
+
|
|
155
|
+
reply_lines.append(line)
|
|
156
|
+
|
|
157
|
+
# Clean up the result
|
|
158
|
+
result = '\n'.join(reply_lines).strip()
|
|
159
|
+
|
|
160
|
+
# If we got nothing useful, fall back to the original
|
|
161
|
+
if not result or len(result) < 10:
|
|
162
|
+
result = text.strip()
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_reply_to_address(
|
|
168
|
+
session_id: str,
|
|
169
|
+
codebase_id: Optional[str] = None,
|
|
170
|
+
domain: Optional[str] = None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Build the reply-to address for outbound emails.
|
|
174
|
+
|
|
175
|
+
Format: task+{session_id}@{domain}
|
|
176
|
+
Or: task+{session_id}+{codebase_id}@{domain}
|
|
177
|
+
"""
|
|
178
|
+
domain = domain or EMAIL_INBOUND_DOMAIN
|
|
179
|
+
prefix = EMAIL_REPLY_PREFIX
|
|
180
|
+
|
|
181
|
+
if codebase_id:
|
|
182
|
+
return f'{prefix}+{session_id}+{codebase_id}@{domain}'
|
|
183
|
+
return f'{prefix}+{session_id}@{domain}'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@email_router.post('/inbound')
|
|
187
|
+
async def handle_inbound_email(
|
|
188
|
+
request: Request,
|
|
189
|
+
# SendGrid Inbound Parse sends multipart/form-data
|
|
190
|
+
# See: https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook
|
|
191
|
+
to: str = Form(default=''),
|
|
192
|
+
from_: str = Form(default='', alias='from'),
|
|
193
|
+
subject: str = Form(default=''),
|
|
194
|
+
text: str = Form(default=''),
|
|
195
|
+
html: str = Form(default=''),
|
|
196
|
+
sender_ip: str = Form(default=''),
|
|
197
|
+
envelope: str = Form(default=''),
|
|
198
|
+
charsets: str = Form(default=''),
|
|
199
|
+
SPF: str = Form(default=''),
|
|
200
|
+
):
|
|
201
|
+
"""
|
|
202
|
+
Handle inbound email from SendGrid Inbound Parse webhook.
|
|
203
|
+
|
|
204
|
+
This endpoint receives emails sent to {prefix}+{session_id}@{domain}
|
|
205
|
+
and creates a continuation task to resume the worker conversation.
|
|
206
|
+
|
|
207
|
+
SendGrid configuration:
|
|
208
|
+
1. Go to Settings > Inbound Parse
|
|
209
|
+
2. Add your domain (e.g., inbound.codetether.run)
|
|
210
|
+
3. Set destination URL to: https://api.codetether.run/v1/email/inbound
|
|
211
|
+
4. Enable "POST the raw, full MIME message"
|
|
212
|
+
"""
|
|
213
|
+
logger.info(
|
|
214
|
+
f'Received inbound email from {from_} to {to} subject: {subject}'
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Parse the reply-to address to get context
|
|
218
|
+
context = parse_reply_to_address(to)
|
|
219
|
+
session_id = context.get('session_id')
|
|
220
|
+
codebase_id = context.get('codebase_id')
|
|
221
|
+
|
|
222
|
+
if not session_id:
|
|
223
|
+
logger.warning(f'Could not extract session_id from to address: {to}')
|
|
224
|
+
# Still accept the webhook to prevent SendGrid retries
|
|
225
|
+
return {
|
|
226
|
+
'success': False,
|
|
227
|
+
'error': 'Could not parse session context from address',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Extract the actual reply content (without quoted text)
|
|
231
|
+
reply_body = extract_reply_body(text, html)
|
|
232
|
+
|
|
233
|
+
if not reply_body or len(reply_body.strip()) < 3:
|
|
234
|
+
logger.warning(f'Empty or too short reply body from {from_}')
|
|
235
|
+
return {'success': False, 'error': 'Reply body is empty or too short'}
|
|
236
|
+
|
|
237
|
+
logger.info(
|
|
238
|
+
f'Extracted reply for session {session_id}: {reply_body[:100]}...'
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Create the email reply context
|
|
242
|
+
email_context = EmailReplyContext(
|
|
243
|
+
session_id=session_id,
|
|
244
|
+
codebase_id=codebase_id,
|
|
245
|
+
from_email=from_,
|
|
246
|
+
subject=subject,
|
|
247
|
+
body_text=reply_body,
|
|
248
|
+
body_html=html if html else None,
|
|
249
|
+
received_at=datetime.utcnow(),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Create a continuation task
|
|
253
|
+
try:
|
|
254
|
+
task = await create_continuation_task(email_context)
|
|
255
|
+
logger.info(
|
|
256
|
+
f'Created continuation task {task.get("id")} from email reply'
|
|
257
|
+
)
|
|
258
|
+
return {
|
|
259
|
+
'success': True,
|
|
260
|
+
'task_id': task.get('id'),
|
|
261
|
+
'session_id': session_id,
|
|
262
|
+
'message': 'Reply received and task created',
|
|
263
|
+
}
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logger.error(f'Failed to create continuation task: {e}')
|
|
266
|
+
return {'success': False, 'error': str(e)}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def create_continuation_task(
|
|
270
|
+
context: EmailReplyContext,
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
"""
|
|
273
|
+
Create a new task that continues the conversation from an email reply.
|
|
274
|
+
|
|
275
|
+
The task will use the same session_id so the worker continues
|
|
276
|
+
the existing OpenCode session.
|
|
277
|
+
"""
|
|
278
|
+
# Import here to avoid circular imports
|
|
279
|
+
from .monitor_api import get_opencode_bridge
|
|
280
|
+
|
|
281
|
+
bridge = get_opencode_bridge()
|
|
282
|
+
if bridge is None:
|
|
283
|
+
raise RuntimeError('OpenCode bridge not available')
|
|
284
|
+
|
|
285
|
+
# Determine codebase_id - try to look it up from the session if not provided
|
|
286
|
+
codebase_id = context.codebase_id
|
|
287
|
+
|
|
288
|
+
if not codebase_id:
|
|
289
|
+
# Try to find codebase from existing sessions with this session_id
|
|
290
|
+
# This is best-effort - if we can't find it, we'll use a placeholder
|
|
291
|
+
try:
|
|
292
|
+
from . import database as db
|
|
293
|
+
|
|
294
|
+
sessions = await db.db_list_all_sessions(limit=100)
|
|
295
|
+
for session in sessions:
|
|
296
|
+
if session.get('id') == context.session_id:
|
|
297
|
+
codebase_id = session.get('codebase_id')
|
|
298
|
+
break
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.debug(f'Could not look up codebase for session: {e}')
|
|
301
|
+
|
|
302
|
+
if not codebase_id:
|
|
303
|
+
# Use 'global' codebase - workers with a global codebase can pick this up
|
|
304
|
+
codebase_id = 'global'
|
|
305
|
+
|
|
306
|
+
# Build the prompt from the email reply
|
|
307
|
+
prompt = f"""[Email Reply from {context.from_email}]
|
|
308
|
+
|
|
309
|
+
Subject: {context.subject}
|
|
310
|
+
|
|
311
|
+
{context.body_text}"""
|
|
312
|
+
|
|
313
|
+
# Create the task with resume_session_id in metadata to continue the conversation
|
|
314
|
+
task = await bridge.create_task(
|
|
315
|
+
codebase_id=codebase_id,
|
|
316
|
+
title=f'Email reply: {context.subject[:50]}',
|
|
317
|
+
prompt=prompt,
|
|
318
|
+
agent_type='build', # Default agent type
|
|
319
|
+
metadata={
|
|
320
|
+
'source': 'email_reply',
|
|
321
|
+
'from_email': context.from_email,
|
|
322
|
+
'original_subject': context.subject,
|
|
323
|
+
'received_at': context.received_at.isoformat(),
|
|
324
|
+
'resume_session_id': context.session_id, # Key: resume the existing session
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if task is None:
|
|
329
|
+
raise RuntimeError('Failed to create continuation task')
|
|
330
|
+
|
|
331
|
+
if hasattr(task, 'to_dict'):
|
|
332
|
+
return task.to_dict()
|
|
333
|
+
elif isinstance(task, dict):
|
|
334
|
+
return task
|
|
335
|
+
else:
|
|
336
|
+
# AgentTask dataclass - convert manually
|
|
337
|
+
return {
|
|
338
|
+
'id': task.id,
|
|
339
|
+
'codebase_id': task.codebase_id,
|
|
340
|
+
'title': task.title,
|
|
341
|
+
'prompt': task.prompt,
|
|
342
|
+
'agent_type': task.agent_type,
|
|
343
|
+
'status': task.status.value
|
|
344
|
+
if hasattr(task.status, 'value')
|
|
345
|
+
else str(task.status),
|
|
346
|
+
'metadata': task.metadata,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@email_router.get('/test-reply-address')
|
|
351
|
+
async def test_reply_address(
|
|
352
|
+
session_id: str,
|
|
353
|
+
codebase_id: Optional[str] = None,
|
|
354
|
+
):
|
|
355
|
+
"""
|
|
356
|
+
Test endpoint to generate a reply-to address.
|
|
357
|
+
|
|
358
|
+
Useful for debugging the email reply flow.
|
|
359
|
+
"""
|
|
360
|
+
address = build_reply_to_address(session_id, codebase_id)
|
|
361
|
+
parsed = parse_reply_to_address(address)
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
'reply_to_address': address,
|
|
365
|
+
'parsed_back': parsed,
|
|
366
|
+
'domain': EMAIL_INBOUND_DOMAIN,
|
|
367
|
+
'prefix': EMAIL_REPLY_PREFIX,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@email_router.post('/test-inbound')
|
|
372
|
+
async def test_inbound_parse(
|
|
373
|
+
request: Request,
|
|
374
|
+
):
|
|
375
|
+
"""
|
|
376
|
+
Debug endpoint to inspect raw inbound parse payload.
|
|
377
|
+
|
|
378
|
+
Use this to troubleshoot SendGrid webhook configuration.
|
|
379
|
+
"""
|
|
380
|
+
# Get form data
|
|
381
|
+
form_data = await request.form()
|
|
382
|
+
|
|
383
|
+
# Log all fields
|
|
384
|
+
fields = {}
|
|
385
|
+
for key in form_data.keys():
|
|
386
|
+
value = form_data.get(key)
|
|
387
|
+
if isinstance(value, UploadFile):
|
|
388
|
+
fields[key] = f'<UploadFile: {value.filename}>'
|
|
389
|
+
else:
|
|
390
|
+
fields[key] = str(value)[:500] # Truncate long values
|
|
391
|
+
|
|
392
|
+
logger.info(f'Test inbound parse received fields: {list(fields.keys())}')
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
'success': True,
|
|
396
|
+
'fields_received': list(fields.keys()),
|
|
397
|
+
'sample_data': fields,
|
|
398
|
+
}
|