emdash-cli 0.1.46__py3-none-any.whl → 0.1.70__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.
- emdash_cli/client.py +12 -28
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/constants.py +78 -0
- emdash_cli/commands/agent/handlers/__init__.py +10 -0
- emdash_cli/commands/agent/handlers/agents.py +67 -39
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +119 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +48 -31
- emdash_cli/commands/agent/handlers/sessions.py +1 -1
- emdash_cli/commands/agent/handlers/setup.py +187 -54
- emdash_cli/commands/agent/handlers/skills.py +42 -4
- emdash_cli/commands/agent/handlers/telegram.py +523 -0
- emdash_cli/commands/agent/handlers/todos.py +55 -34
- emdash_cli/commands/agent/handlers/verify.py +10 -5
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +278 -47
- emdash_cli/commands/agent/menus.py +116 -84
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +980 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +392 -0
- emdash_cli/main.py +52 -2
- emdash_cli/sse_renderer.py +632 -171
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
- emdash_cli-0.1.70.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.46.dist-info/RECORD +0 -49
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"""Telegram-EmDash bridge for SSE streaming.
|
|
2
|
+
|
|
3
|
+
Connects Telegram messages to the EmDash agent and streams
|
|
4
|
+
SSE responses back to Telegram as message updates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, AsyncIterator
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .bot import TelegramBot, TelegramMessage, TelegramUpdate
|
|
17
|
+
from .config import TelegramConfig, TelegramSettings
|
|
18
|
+
from .formatter import SSEEventFormatter, TelegramMessage as FormattedMessage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Default EmDash server URL
|
|
22
|
+
DEFAULT_SERVER_URL = "http://localhost:8765"
|
|
23
|
+
|
|
24
|
+
# Telegram-only commands (not forwarded to agent)
|
|
25
|
+
# All other /commands are forwarded to the EmDash agent
|
|
26
|
+
TELEGRAM_COMMANDS = {
|
|
27
|
+
"/start", # Telegram welcome message
|
|
28
|
+
"/stop", # Cancel current operation
|
|
29
|
+
"/cancel", # Cancel pending interaction
|
|
30
|
+
"/tgstatus", # Telegram bot status
|
|
31
|
+
"/tgsettings", # Telegram display settings
|
|
32
|
+
"/tghelp", # Telegram help
|
|
33
|
+
"/thinking", # Toggle thinking display
|
|
34
|
+
"/tools", # Toggle tool calls display
|
|
35
|
+
"/plan", # Switch to plan mode
|
|
36
|
+
"/code", # Switch to code mode
|
|
37
|
+
"/mode", # Show current mode
|
|
38
|
+
"/reset", # Reset session
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Map BotFather command format (underscores) to EmDash format (hyphens)
|
|
42
|
+
# BotFather doesn't allow hyphens in command names
|
|
43
|
+
COMMAND_ALIASES = {
|
|
44
|
+
"/todo_add": "/todo-add",
|
|
45
|
+
"/verify_loop": "/verify-loop",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class PendingInteraction:
|
|
51
|
+
"""Tracks a pending interaction requiring user response."""
|
|
52
|
+
|
|
53
|
+
type: str # "clarification", "plan_approval", "planmode_request"
|
|
54
|
+
data: dict = field(default_factory=dict)
|
|
55
|
+
options: list = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class BridgeState:
|
|
60
|
+
"""Tracks the state of the bridge."""
|
|
61
|
+
|
|
62
|
+
# Current session ID (one per chat)
|
|
63
|
+
sessions: dict[int, str] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
# Last message ID sent to each chat (for editing)
|
|
66
|
+
last_message_ids: dict[int, int] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
# Timestamp of last message sent to each chat (for rate limiting)
|
|
69
|
+
last_message_times: dict[int, float] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
# Whether we're currently processing a request for each chat
|
|
72
|
+
processing: dict[int, bool] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
# Pending interactions requiring user response (per chat)
|
|
75
|
+
pending: dict[int, PendingInteraction] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
# Current mode per chat (code or plan)
|
|
78
|
+
modes: dict[int, str] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TelegramBridge:
|
|
82
|
+
"""Bridge between Telegram and EmDash agent.
|
|
83
|
+
|
|
84
|
+
Receives messages from Telegram, sends them to the EmDash agent,
|
|
85
|
+
and streams SSE responses back as Telegram messages.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
config: TelegramConfig,
|
|
91
|
+
server_url: str | None = None,
|
|
92
|
+
on_message: Any = None,
|
|
93
|
+
):
|
|
94
|
+
"""Initialize the bridge.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
config: Telegram configuration
|
|
98
|
+
server_url: EmDash server URL (default: localhost:8765)
|
|
99
|
+
on_message: Optional callback for logging/debugging
|
|
100
|
+
"""
|
|
101
|
+
self.config = config
|
|
102
|
+
self.server_url = server_url or os.getenv("EMDASH_SERVER_URL", DEFAULT_SERVER_URL)
|
|
103
|
+
self.on_message = on_message
|
|
104
|
+
|
|
105
|
+
self.state = BridgeState()
|
|
106
|
+
self._running = False
|
|
107
|
+
self._bot: TelegramBot | None = None
|
|
108
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
109
|
+
|
|
110
|
+
async def start(self) -> None:
|
|
111
|
+
"""Start the bridge.
|
|
112
|
+
|
|
113
|
+
Begins listening for Telegram messages and processing them.
|
|
114
|
+
"""
|
|
115
|
+
if not self.config.bot_token:
|
|
116
|
+
raise ValueError("Bot token not configured")
|
|
117
|
+
|
|
118
|
+
self._running = True
|
|
119
|
+
self._bot = TelegramBot(self.config.bot_token)
|
|
120
|
+
self._http_client = httpx.AsyncClient(timeout=None)
|
|
121
|
+
|
|
122
|
+
await self._bot.__aenter__()
|
|
123
|
+
|
|
124
|
+
# Get bot info
|
|
125
|
+
bot_info = await self._bot.get_me()
|
|
126
|
+
if self.on_message:
|
|
127
|
+
self.on_message("bridge_started", {"bot": bot_info.username})
|
|
128
|
+
|
|
129
|
+
# Start polling loop
|
|
130
|
+
try:
|
|
131
|
+
async for update in self._bot.poll_updates(
|
|
132
|
+
offset=self.config.state.last_update_id
|
|
133
|
+
):
|
|
134
|
+
if not self._running:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
# Update offset
|
|
138
|
+
self.config.state.last_update_id = update.update_id
|
|
139
|
+
|
|
140
|
+
# Process the update
|
|
141
|
+
await self._handle_update(update)
|
|
142
|
+
|
|
143
|
+
finally:
|
|
144
|
+
await self.stop()
|
|
145
|
+
|
|
146
|
+
async def stop(self) -> None:
|
|
147
|
+
"""Stop the bridge."""
|
|
148
|
+
self._running = False
|
|
149
|
+
|
|
150
|
+
if self._bot:
|
|
151
|
+
await self._bot.__aexit__(None, None, None)
|
|
152
|
+
self._bot = None
|
|
153
|
+
|
|
154
|
+
if self._http_client:
|
|
155
|
+
await self._http_client.aclose()
|
|
156
|
+
self._http_client = None
|
|
157
|
+
|
|
158
|
+
async def _handle_update(self, update: TelegramUpdate) -> None:
|
|
159
|
+
"""Handle an incoming Telegram update.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
update: The Telegram update to process
|
|
163
|
+
"""
|
|
164
|
+
if not update.message or not update.message.text:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
chat_id = update.message.chat.id
|
|
168
|
+
text = update.message.text.strip()
|
|
169
|
+
user = update.message.from_user
|
|
170
|
+
|
|
171
|
+
# Log the incoming message
|
|
172
|
+
if self.on_message:
|
|
173
|
+
user_name = user.display_name if user else "Unknown"
|
|
174
|
+
self.on_message("message_received", {
|
|
175
|
+
"chat_id": chat_id,
|
|
176
|
+
"user": user_name,
|
|
177
|
+
"text": text[:100],
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
# Check authorization
|
|
181
|
+
if not self.config.is_chat_authorized(chat_id):
|
|
182
|
+
await self._send_message(chat_id, "Sorry, this chat is not authorized.")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Check if already processing
|
|
186
|
+
if self.state.processing.get(chat_id):
|
|
187
|
+
await self._send_message(chat_id, "Please wait, still processing previous request...")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Check if there's a pending interaction waiting for response
|
|
191
|
+
pending = self.state.pending.get(chat_id)
|
|
192
|
+
if pending:
|
|
193
|
+
await self._handle_pending_response(chat_id, text, pending)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Handle special commands
|
|
197
|
+
if text.startswith("/"):
|
|
198
|
+
await self._handle_command(chat_id, text)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Process as agent message
|
|
202
|
+
await self._process_agent_message(chat_id, text)
|
|
203
|
+
|
|
204
|
+
async def _handle_command(self, chat_id: int, text: str) -> None:
|
|
205
|
+
"""Handle slash commands.
|
|
206
|
+
|
|
207
|
+
Telegram-specific commands (in TELEGRAM_COMMANDS) are handled locally.
|
|
208
|
+
All other slash commands are forwarded to the EmDash agent.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
chat_id: Chat ID
|
|
212
|
+
text: Command text (e.g., "/plan" or "/todo_add Fix tests")
|
|
213
|
+
"""
|
|
214
|
+
parts = text.split(maxsplit=1)
|
|
215
|
+
command = parts[0].lower()
|
|
216
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
217
|
+
|
|
218
|
+
# Handle Telegram-specific commands locally
|
|
219
|
+
if command in TELEGRAM_COMMANDS:
|
|
220
|
+
await self._handle_telegram_command(chat_id, command, args)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Apply command aliases (BotFather format -> EmDash format)
|
|
224
|
+
if command in COMMAND_ALIASES:
|
|
225
|
+
command = COMMAND_ALIASES[command]
|
|
226
|
+
|
|
227
|
+
# Forward to agent as a slash command
|
|
228
|
+
message = f"{command} {args}".strip() if args else command
|
|
229
|
+
await self._process_agent_message(chat_id, message)
|
|
230
|
+
|
|
231
|
+
async def _handle_telegram_command(self, chat_id: int, command: str, args: str) -> None:
|
|
232
|
+
"""Handle Telegram-specific commands.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
chat_id: Chat ID
|
|
236
|
+
command: Command name (e.g., "/start")
|
|
237
|
+
args: Command arguments
|
|
238
|
+
"""
|
|
239
|
+
if command == "/start":
|
|
240
|
+
await self._send_message(
|
|
241
|
+
chat_id,
|
|
242
|
+
"*EmDash Bot*\n\n"
|
|
243
|
+
"Send me a message and I'll process it with the EmDash agent.\n\n"
|
|
244
|
+
"*Mode commands:*\n"
|
|
245
|
+
"/plan - Switch to plan mode\n"
|
|
246
|
+
"/code - Switch to code mode\n"
|
|
247
|
+
"/mode - Show current mode\n"
|
|
248
|
+
"/reset - Reset session\n\n"
|
|
249
|
+
"*Telegram-only commands:*\n"
|
|
250
|
+
"/stop - Cancel current operation\n"
|
|
251
|
+
"/cancel - Cancel pending interaction\n"
|
|
252
|
+
"/tgstatus - Show bot connection status\n"
|
|
253
|
+
"/tgsettings - Show display settings\n"
|
|
254
|
+
"/thinking - Toggle showing agent thinking\n"
|
|
255
|
+
"/tools - Toggle showing tool calls\n"
|
|
256
|
+
"/tghelp - Show this help",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
elif command == "/tgstatus":
|
|
260
|
+
session_id = self.state.sessions.get(chat_id)
|
|
261
|
+
pending = self.state.pending.get(chat_id)
|
|
262
|
+
current_mode = self.state.modes.get(chat_id, "code")
|
|
263
|
+
status = "Connected" if session_id else "No active session"
|
|
264
|
+
pending_status = f"\n*Pending:* {pending.type}" if pending else ""
|
|
265
|
+
mode_emoji = "📋" if current_mode == "plan" else "💻"
|
|
266
|
+
await self._send_message(
|
|
267
|
+
chat_id,
|
|
268
|
+
f"*Status:* {status}\n"
|
|
269
|
+
f"*Mode:* {mode_emoji} {current_mode}\n"
|
|
270
|
+
f"*Server:* `{self.server_url}`"
|
|
271
|
+
f"{pending_status}",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
elif command == "/stop":
|
|
275
|
+
if self.state.processing.get(chat_id):
|
|
276
|
+
self.state.processing[chat_id] = False
|
|
277
|
+
await self._send_message(chat_id, "Operation cancelled.")
|
|
278
|
+
else:
|
|
279
|
+
await self._send_message(chat_id, "No operation in progress.")
|
|
280
|
+
|
|
281
|
+
elif command == "/cancel":
|
|
282
|
+
if chat_id in self.state.pending:
|
|
283
|
+
del self.state.pending[chat_id]
|
|
284
|
+
await self._send_message(chat_id, "Pending interaction cancelled.")
|
|
285
|
+
else:
|
|
286
|
+
await self._send_message(chat_id, "No pending interaction.")
|
|
287
|
+
|
|
288
|
+
elif command == "/thinking":
|
|
289
|
+
self.config.settings.show_thinking = not self.config.settings.show_thinking
|
|
290
|
+
status = "enabled" if self.config.settings.show_thinking else "disabled"
|
|
291
|
+
await self._send_message(chat_id, f"Show thinking {status}.")
|
|
292
|
+
|
|
293
|
+
elif command == "/tools":
|
|
294
|
+
self.config.settings.show_tool_calls = not self.config.settings.show_tool_calls
|
|
295
|
+
status = "enabled" if self.config.settings.show_tool_calls else "disabled"
|
|
296
|
+
await self._send_message(chat_id, f"Show tool calls {status}.")
|
|
297
|
+
|
|
298
|
+
elif command == "/tgsettings":
|
|
299
|
+
await self._send_message(
|
|
300
|
+
chat_id,
|
|
301
|
+
"*Telegram Display Settings:*\n\n"
|
|
302
|
+
f"Show thinking: `{self.config.settings.show_thinking}`\n"
|
|
303
|
+
f"Show tools: `{self.config.settings.show_tool_calls}`\n"
|
|
304
|
+
f"Compact mode: `{self.config.settings.compact_mode}`\n"
|
|
305
|
+
f"Update interval: `{self.config.settings.update_interval_ms}ms`",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
elif command == "/tghelp":
|
|
309
|
+
await self._send_message(
|
|
310
|
+
chat_id,
|
|
311
|
+
"*EmDash Telegram Bot*\n\n"
|
|
312
|
+
"Send any message or slash command to interact with the EmDash agent.\n\n"
|
|
313
|
+
"*Mode commands:*\n"
|
|
314
|
+
"/plan - Switch to plan mode (read-only exploration)\n"
|
|
315
|
+
"/code - Switch to code mode (execute changes)\n"
|
|
316
|
+
"/mode - Show current mode\n"
|
|
317
|
+
"/reset - Reset session\n\n"
|
|
318
|
+
"*Agent commands (forwarded):*\n"
|
|
319
|
+
"/todos - Show todo list\n"
|
|
320
|
+
"/status - Show project status\n"
|
|
321
|
+
"/help - Show all agent commands\n\n"
|
|
322
|
+
"*Telegram-only commands:*\n"
|
|
323
|
+
"/stop - Cancel current operation\n"
|
|
324
|
+
"/cancel - Cancel pending interaction\n"
|
|
325
|
+
"/tgstatus - Bot connection status\n"
|
|
326
|
+
"/tgsettings - Display settings\n"
|
|
327
|
+
"/thinking - Toggle thinking display\n"
|
|
328
|
+
"/tools - Toggle tool calls display\n"
|
|
329
|
+
"/tghelp - This help message\n\n"
|
|
330
|
+
"*Responding to questions:*\n"
|
|
331
|
+
"Reply with option number (1, 2, 3...) or type your answer.\n\n"
|
|
332
|
+
"*Plan approval:*\n"
|
|
333
|
+
'Reply "yes" to approve, "no" to reject, or type feedback.',
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
elif command == "/plan":
|
|
337
|
+
# Switch to plan mode and reset session
|
|
338
|
+
self.state.modes[chat_id] = "plan"
|
|
339
|
+
if chat_id in self.state.sessions:
|
|
340
|
+
del self.state.sessions[chat_id]
|
|
341
|
+
await self._send_message(
|
|
342
|
+
chat_id,
|
|
343
|
+
"✅ Switched to *plan mode* (session reset)\n\n"
|
|
344
|
+
"_Plan mode explores the codebase and creates plans without making changes._",
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
await self._send_message(
|
|
348
|
+
chat_id,
|
|
349
|
+
"✅ Switched to *plan mode*\n\n"
|
|
350
|
+
"_Plan mode explores the codebase and creates plans without making changes._",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
elif command == "/code":
|
|
354
|
+
# Switch to code mode and reset session
|
|
355
|
+
self.state.modes[chat_id] = "code"
|
|
356
|
+
if chat_id in self.state.sessions:
|
|
357
|
+
del self.state.sessions[chat_id]
|
|
358
|
+
await self._send_message(
|
|
359
|
+
chat_id,
|
|
360
|
+
"✅ Switched to *code mode* (session reset)\n\n"
|
|
361
|
+
"_Code mode can execute changes and modify files._",
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
await self._send_message(
|
|
365
|
+
chat_id,
|
|
366
|
+
"✅ Switched to *code mode*\n\n"
|
|
367
|
+
"_Code mode can execute changes and modify files._",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
elif command == "/mode":
|
|
371
|
+
current_mode = self.state.modes.get(chat_id, "code")
|
|
372
|
+
if current_mode == "plan":
|
|
373
|
+
await self._send_message(
|
|
374
|
+
chat_id,
|
|
375
|
+
"📋 Current mode: *plan*\n\n"
|
|
376
|
+
"_Use /code to switch to code mode._",
|
|
377
|
+
)
|
|
378
|
+
else:
|
|
379
|
+
await self._send_message(
|
|
380
|
+
chat_id,
|
|
381
|
+
"💻 Current mode: *code*\n\n"
|
|
382
|
+
"_Use /plan to switch to plan mode._",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
elif command == "/reset":
|
|
386
|
+
# Reset session for this chat
|
|
387
|
+
if chat_id in self.state.sessions:
|
|
388
|
+
del self.state.sessions[chat_id]
|
|
389
|
+
await self._send_message(chat_id, "🔄 Session reset.")
|
|
390
|
+
|
|
391
|
+
async def _process_agent_message(self, chat_id: int, message: str) -> None:
|
|
392
|
+
"""Process a message through the EmDash agent.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
chat_id: Telegram chat ID
|
|
396
|
+
message: User message to process
|
|
397
|
+
"""
|
|
398
|
+
self.state.processing[chat_id] = True
|
|
399
|
+
|
|
400
|
+
# Get or create session
|
|
401
|
+
session_id = self.state.sessions.get(chat_id)
|
|
402
|
+
|
|
403
|
+
# Create formatter with settings
|
|
404
|
+
formatter = SSEEventFormatter(
|
|
405
|
+
show_thinking=self.config.settings.show_thinking,
|
|
406
|
+
show_tools=self.config.settings.show_tool_calls,
|
|
407
|
+
compact=self.config.settings.compact_mode,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Send typing indicator
|
|
411
|
+
if self._bot:
|
|
412
|
+
try:
|
|
413
|
+
await self._bot.send_chat_action(chat_id, "typing")
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
# Stream from agent
|
|
419
|
+
last_update_time = 0.0
|
|
420
|
+
update_interval = self.config.settings.update_interval_ms / 1000.0
|
|
421
|
+
has_sent_response = False
|
|
422
|
+
|
|
423
|
+
# Get current mode for this chat (default to code)
|
|
424
|
+
current_mode = self.state.modes.get(chat_id, "code")
|
|
425
|
+
|
|
426
|
+
async for event_type, data in self._stream_agent_chat(message, session_id, current_mode):
|
|
427
|
+
# Check if cancelled
|
|
428
|
+
if not self.state.processing.get(chat_id):
|
|
429
|
+
break
|
|
430
|
+
|
|
431
|
+
# Update session ID if provided (don't send session_start as separate message)
|
|
432
|
+
if event_type == "session_start" and data.get("session_id"):
|
|
433
|
+
self.state.sessions[chat_id] = data["session_id"]
|
|
434
|
+
continue # Skip sending session_start notification
|
|
435
|
+
|
|
436
|
+
# Skip session_end notifications (clutters the chat)
|
|
437
|
+
if event_type == "session_end":
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
# Handle interactive events - store pending state
|
|
441
|
+
if event_type == "clarification":
|
|
442
|
+
await self._handle_clarification_event(chat_id, data)
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
if event_type == "plan_submitted":
|
|
446
|
+
await self._handle_plan_submitted_event(chat_id, data)
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
if event_type == "plan_mode_requested":
|
|
450
|
+
await self._handle_plan_mode_event(chat_id, data)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
# Format the event
|
|
454
|
+
formatted = formatter.format_event(event_type, data)
|
|
455
|
+
|
|
456
|
+
# Track if we've sent a response (to avoid duplicates)
|
|
457
|
+
if event_type == "response":
|
|
458
|
+
has_sent_response = True
|
|
459
|
+
|
|
460
|
+
# Send formatted message
|
|
461
|
+
if formatted:
|
|
462
|
+
now = time.time()
|
|
463
|
+
|
|
464
|
+
# Rate limit updates
|
|
465
|
+
if formatted.is_update and (now - last_update_time) < update_interval:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
last_update_time = now
|
|
469
|
+
|
|
470
|
+
# Edit or send new message
|
|
471
|
+
if formatted.is_update and chat_id in self.state.last_message_ids:
|
|
472
|
+
await self._edit_message(
|
|
473
|
+
chat_id,
|
|
474
|
+
self.state.last_message_ids[chat_id],
|
|
475
|
+
formatted.text,
|
|
476
|
+
formatted.parse_mode,
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
msg = await self._send_message(
|
|
480
|
+
chat_id, formatted.text, formatted.parse_mode
|
|
481
|
+
)
|
|
482
|
+
if msg:
|
|
483
|
+
self.state.last_message_ids[chat_id] = msg.message_id
|
|
484
|
+
|
|
485
|
+
# Flush any pending partial content (only if we haven't sent a full response)
|
|
486
|
+
if not has_sent_response:
|
|
487
|
+
pending_content = formatter.get_pending_content()
|
|
488
|
+
if pending_content:
|
|
489
|
+
await self._send_message(chat_id, pending_content.text, pending_content.parse_mode)
|
|
490
|
+
|
|
491
|
+
except Exception as e:
|
|
492
|
+
if self.on_message:
|
|
493
|
+
self.on_message("error", {"error": str(e)})
|
|
494
|
+
await self._send_message(chat_id, f"Error: {str(e)}")
|
|
495
|
+
|
|
496
|
+
finally:
|
|
497
|
+
self.state.processing[chat_id] = False
|
|
498
|
+
|
|
499
|
+
async def _stream_agent_chat(
|
|
500
|
+
self,
|
|
501
|
+
message: str,
|
|
502
|
+
session_id: str | None = None,
|
|
503
|
+
mode: str = "code",
|
|
504
|
+
) -> AsyncIterator[tuple[str, dict]]:
|
|
505
|
+
"""Stream agent chat response via SSE.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
message: User message
|
|
509
|
+
session_id: Optional session ID for continuity
|
|
510
|
+
mode: Agent mode (code or plan)
|
|
511
|
+
|
|
512
|
+
Yields:
|
|
513
|
+
Tuples of (event_type, data)
|
|
514
|
+
"""
|
|
515
|
+
if not self._http_client:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
payload = {
|
|
519
|
+
"message": message,
|
|
520
|
+
"options": {
|
|
521
|
+
"max_iterations": 50,
|
|
522
|
+
"verbose": True,
|
|
523
|
+
"mode": mode,
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if session_id:
|
|
528
|
+
payload["session_id"] = session_id
|
|
529
|
+
|
|
530
|
+
url = f"{self.server_url}/api/agent/chat"
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
async with self._http_client.stream("POST", url, json=payload) as response:
|
|
534
|
+
response.raise_for_status()
|
|
535
|
+
|
|
536
|
+
current_event = None
|
|
537
|
+
|
|
538
|
+
async for line in response.aiter_lines():
|
|
539
|
+
line = line.strip()
|
|
540
|
+
|
|
541
|
+
if line.startswith("event: "):
|
|
542
|
+
current_event = line[7:]
|
|
543
|
+
elif line.startswith("data: "):
|
|
544
|
+
if current_event:
|
|
545
|
+
try:
|
|
546
|
+
data = json.loads(line[6:])
|
|
547
|
+
if data is None:
|
|
548
|
+
data = {}
|
|
549
|
+
yield current_event, data
|
|
550
|
+
except json.JSONDecodeError:
|
|
551
|
+
pass
|
|
552
|
+
elif line == ": ping":
|
|
553
|
+
# Keep-alive ping
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
except httpx.HTTPError as e:
|
|
557
|
+
yield "error", {"message": f"HTTP error: {str(e)}"}
|
|
558
|
+
except Exception as e:
|
|
559
|
+
yield "error", {"message": str(e)}
|
|
560
|
+
|
|
561
|
+
async def _send_message(
|
|
562
|
+
self,
|
|
563
|
+
chat_id: int,
|
|
564
|
+
text: str,
|
|
565
|
+
parse_mode: str | None = "Markdown",
|
|
566
|
+
) -> TelegramMessage | None:
|
|
567
|
+
"""Send a message to a chat.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
chat_id: Target chat ID
|
|
571
|
+
text: Message text
|
|
572
|
+
parse_mode: Parse mode for formatting
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Sent message, or None on error
|
|
576
|
+
"""
|
|
577
|
+
if not self._bot:
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
return await self._bot.send_message(
|
|
582
|
+
chat_id, text, parse_mode=parse_mode
|
|
583
|
+
)
|
|
584
|
+
except Exception as e:
|
|
585
|
+
# Try sending without parse mode if Markdown fails
|
|
586
|
+
if parse_mode:
|
|
587
|
+
try:
|
|
588
|
+
return await self._bot.send_message(
|
|
589
|
+
chat_id, text, parse_mode=None
|
|
590
|
+
)
|
|
591
|
+
except Exception:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
if self.on_message:
|
|
595
|
+
self.on_message("send_error", {"error": str(e)})
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
async def _edit_message(
|
|
599
|
+
self,
|
|
600
|
+
chat_id: int,
|
|
601
|
+
message_id: int,
|
|
602
|
+
text: str,
|
|
603
|
+
parse_mode: str | None = "Markdown",
|
|
604
|
+
) -> None:
|
|
605
|
+
"""Edit an existing message.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
chat_id: Chat containing the message
|
|
609
|
+
message_id: Message ID to edit
|
|
610
|
+
text: New text
|
|
611
|
+
parse_mode: Parse mode for formatting
|
|
612
|
+
"""
|
|
613
|
+
if not self._bot:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
await self._bot.edit_message_text(
|
|
618
|
+
chat_id, message_id, text, parse_mode=parse_mode
|
|
619
|
+
)
|
|
620
|
+
except Exception:
|
|
621
|
+
# Editing can fail if message is identical - ignore
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
async def _send_long_message(self, chat_id: int, text: str) -> None:
|
|
625
|
+
"""Send a long message, splitting if necessary.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
chat_id: Target chat ID
|
|
629
|
+
text: Message text (can be longer than 4096 chars)
|
|
630
|
+
"""
|
|
631
|
+
max_len = self.config.settings.max_message_length
|
|
632
|
+
|
|
633
|
+
# If short enough, send as-is
|
|
634
|
+
if len(text) <= max_len:
|
|
635
|
+
await self._send_message(chat_id, text)
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
# Split into chunks at paragraph boundaries
|
|
639
|
+
chunks = []
|
|
640
|
+
current_chunk = ""
|
|
641
|
+
|
|
642
|
+
paragraphs = text.split("\n\n")
|
|
643
|
+
for para in paragraphs:
|
|
644
|
+
if len(current_chunk) + len(para) + 2 <= max_len:
|
|
645
|
+
if current_chunk:
|
|
646
|
+
current_chunk += "\n\n"
|
|
647
|
+
current_chunk += para
|
|
648
|
+
else:
|
|
649
|
+
if current_chunk:
|
|
650
|
+
chunks.append(current_chunk)
|
|
651
|
+
# If paragraph itself is too long, split it
|
|
652
|
+
if len(para) > max_len:
|
|
653
|
+
words = para.split()
|
|
654
|
+
current_chunk = ""
|
|
655
|
+
for word in words:
|
|
656
|
+
if len(current_chunk) + len(word) + 1 <= max_len:
|
|
657
|
+
if current_chunk:
|
|
658
|
+
current_chunk += " "
|
|
659
|
+
current_chunk += word
|
|
660
|
+
else:
|
|
661
|
+
chunks.append(current_chunk)
|
|
662
|
+
current_chunk = word
|
|
663
|
+
else:
|
|
664
|
+
current_chunk = para
|
|
665
|
+
|
|
666
|
+
if current_chunk:
|
|
667
|
+
chunks.append(current_chunk)
|
|
668
|
+
|
|
669
|
+
# Send each chunk
|
|
670
|
+
for i, chunk in enumerate(chunks):
|
|
671
|
+
if len(chunks) > 1:
|
|
672
|
+
chunk = f"*({i + 1}/{len(chunks)})*\n\n{chunk}"
|
|
673
|
+
await self._send_message(chat_id, chunk)
|
|
674
|
+
# Small delay between chunks to avoid rate limits
|
|
675
|
+
if i < len(chunks) - 1:
|
|
676
|
+
await asyncio.sleep(0.5)
|
|
677
|
+
|
|
678
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
679
|
+
# Interactive event handlers
|
|
680
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
async def _handle_clarification_event(self, chat_id: int, data: dict) -> None:
|
|
683
|
+
"""Handle clarification event - prompt user for response.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
chat_id: Telegram chat ID
|
|
687
|
+
data: Clarification event data
|
|
688
|
+
"""
|
|
689
|
+
question = data.get("question", "")
|
|
690
|
+
options = data.get("options", [])
|
|
691
|
+
context = data.get("context", "")
|
|
692
|
+
|
|
693
|
+
# Ensure options is a list
|
|
694
|
+
if isinstance(options, str):
|
|
695
|
+
options = [options] if options else []
|
|
696
|
+
|
|
697
|
+
# Store pending interaction
|
|
698
|
+
self.state.pending[chat_id] = PendingInteraction(
|
|
699
|
+
type="clarification",
|
|
700
|
+
data=data,
|
|
701
|
+
options=options,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Build message
|
|
705
|
+
text = f"❓ *Question:*\n{question}"
|
|
706
|
+
|
|
707
|
+
if options:
|
|
708
|
+
text += "\n\n*Options:*"
|
|
709
|
+
for i, opt in enumerate(options, 1):
|
|
710
|
+
text += f"\n{i}. {opt}"
|
|
711
|
+
text += "\n\n_Reply with option number (1-{}) or type your answer_".format(len(options))
|
|
712
|
+
else:
|
|
713
|
+
text += "\n\n_Type your answer_"
|
|
714
|
+
|
|
715
|
+
await self._send_message(chat_id, text)
|
|
716
|
+
|
|
717
|
+
async def _handle_plan_submitted_event(self, chat_id: int, data: dict) -> None:
|
|
718
|
+
"""Handle plan submitted event - prompt user for approval.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
chat_id: Telegram chat ID
|
|
722
|
+
data: Plan submitted event data
|
|
723
|
+
"""
|
|
724
|
+
plan = data.get("plan", "")
|
|
725
|
+
|
|
726
|
+
# Store pending interaction
|
|
727
|
+
self.state.pending[chat_id] = PendingInteraction(
|
|
728
|
+
type="plan_approval",
|
|
729
|
+
data=data,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# Build message
|
|
733
|
+
text = f"📋 *Plan Submitted:*\n\n{plan}"
|
|
734
|
+
text += "\n\n_Reply:_"
|
|
735
|
+
text += '\n• "approve" or "yes" to proceed'
|
|
736
|
+
text += '\n• "reject" or "no" to cancel'
|
|
737
|
+
text += "\n• Or type feedback to request changes"
|
|
738
|
+
|
|
739
|
+
await self._send_long_message(chat_id, text)
|
|
740
|
+
|
|
741
|
+
async def _handle_plan_mode_event(self, chat_id: int, data: dict) -> None:
|
|
742
|
+
"""Handle plan mode request event - prompt user for approval.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
chat_id: Telegram chat ID
|
|
746
|
+
data: Plan mode request event data
|
|
747
|
+
"""
|
|
748
|
+
reason = data.get("reason", "")
|
|
749
|
+
|
|
750
|
+
# Store pending interaction
|
|
751
|
+
self.state.pending[chat_id] = PendingInteraction(
|
|
752
|
+
type="planmode_request",
|
|
753
|
+
data=data,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Build message
|
|
757
|
+
text = "🗺️ *Plan Mode Requested*"
|
|
758
|
+
if reason:
|
|
759
|
+
text += f"\n\n{reason}"
|
|
760
|
+
text += "\n\n_Reply:_"
|
|
761
|
+
text += '\n• "approve" or "yes" to enter plan mode'
|
|
762
|
+
text += '\n• "reject" or "no" to continue without plan'
|
|
763
|
+
|
|
764
|
+
await self._send_message(chat_id, text)
|
|
765
|
+
|
|
766
|
+
async def _handle_pending_response(
|
|
767
|
+
self, chat_id: int, text: str, pending: PendingInteraction
|
|
768
|
+
) -> None:
|
|
769
|
+
"""Handle user response to a pending interaction.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
chat_id: Telegram chat ID
|
|
773
|
+
text: User's response text
|
|
774
|
+
pending: The pending interaction
|
|
775
|
+
"""
|
|
776
|
+
# Clear pending state
|
|
777
|
+
del self.state.pending[chat_id]
|
|
778
|
+
|
|
779
|
+
if pending.type == "clarification":
|
|
780
|
+
await self._process_clarification_answer(chat_id, text, pending)
|
|
781
|
+
elif pending.type == "plan_approval":
|
|
782
|
+
await self._process_plan_approval(chat_id, text, pending)
|
|
783
|
+
elif pending.type == "planmode_request":
|
|
784
|
+
await self._process_planmode_approval(chat_id, text, pending)
|
|
785
|
+
|
|
786
|
+
async def _process_clarification_answer(
|
|
787
|
+
self, chat_id: int, text: str, pending: PendingInteraction
|
|
788
|
+
) -> None:
|
|
789
|
+
"""Process clarification answer and continue agent.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
chat_id: Telegram chat ID
|
|
793
|
+
text: User's answer
|
|
794
|
+
pending: The pending clarification
|
|
795
|
+
"""
|
|
796
|
+
session_id = self.state.sessions.get(chat_id)
|
|
797
|
+
if not session_id:
|
|
798
|
+
await self._send_message(chat_id, "Error: No active session")
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
# Check if user replied with option number
|
|
802
|
+
answer = text.strip()
|
|
803
|
+
if pending.options and answer.isdigit():
|
|
804
|
+
idx = int(answer) - 1
|
|
805
|
+
if 0 <= idx < len(pending.options):
|
|
806
|
+
answer = pending.options[idx]
|
|
807
|
+
|
|
808
|
+
await self._send_message(chat_id, f"✅ Selected: {answer}\nContinuing...")
|
|
809
|
+
|
|
810
|
+
# Call continuation endpoint and stream response
|
|
811
|
+
await self._stream_continuation(
|
|
812
|
+
chat_id,
|
|
813
|
+
f"{self.server_url}/api/agent/chat/{session_id}/clarification/answer",
|
|
814
|
+
params={"answer": answer},
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
async def _process_plan_approval(
|
|
818
|
+
self, chat_id: int, text: str, pending: PendingInteraction
|
|
819
|
+
) -> None:
|
|
820
|
+
"""Process plan approval/rejection and continue agent.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
chat_id: Telegram chat ID
|
|
824
|
+
text: User's response
|
|
825
|
+
pending: The pending plan approval
|
|
826
|
+
"""
|
|
827
|
+
session_id = self.state.sessions.get(chat_id)
|
|
828
|
+
if not session_id:
|
|
829
|
+
await self._send_message(chat_id, "Error: No active session")
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
response = text.strip().lower()
|
|
833
|
+
|
|
834
|
+
if response in ("approve", "yes", "y", "ok", "proceed"):
|
|
835
|
+
await self._send_message(chat_id, "✅ Plan approved. Executing...")
|
|
836
|
+
await self._stream_continuation(
|
|
837
|
+
chat_id,
|
|
838
|
+
f"{self.server_url}/api/agent/chat/{session_id}/plan/approve",
|
|
839
|
+
)
|
|
840
|
+
elif response in ("reject", "no", "n", "cancel"):
|
|
841
|
+
await self._send_message(chat_id, "❌ Plan rejected.")
|
|
842
|
+
await self._stream_continuation(
|
|
843
|
+
chat_id,
|
|
844
|
+
f"{self.server_url}/api/agent/chat/{session_id}/plan/reject",
|
|
845
|
+
params={"feedback": ""},
|
|
846
|
+
)
|
|
847
|
+
else:
|
|
848
|
+
# Treat as feedback
|
|
849
|
+
await self._send_message(chat_id, f"📝 Feedback sent: {text[:50]}...")
|
|
850
|
+
await self._stream_continuation(
|
|
851
|
+
chat_id,
|
|
852
|
+
f"{self.server_url}/api/agent/chat/{session_id}/plan/reject",
|
|
853
|
+
params={"feedback": text},
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
async def _process_planmode_approval(
|
|
857
|
+
self, chat_id: int, text: str, pending: PendingInteraction
|
|
858
|
+
) -> None:
|
|
859
|
+
"""Process plan mode approval/rejection and continue agent.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
chat_id: Telegram chat ID
|
|
863
|
+
text: User's response
|
|
864
|
+
pending: The pending plan mode request
|
|
865
|
+
"""
|
|
866
|
+
session_id = self.state.sessions.get(chat_id)
|
|
867
|
+
if not session_id:
|
|
868
|
+
await self._send_message(chat_id, "Error: No active session")
|
|
869
|
+
return
|
|
870
|
+
|
|
871
|
+
response = text.strip().lower()
|
|
872
|
+
|
|
873
|
+
if response in ("approve", "yes", "y", "ok"):
|
|
874
|
+
await self._send_message(chat_id, "✅ Entering plan mode...")
|
|
875
|
+
await self._stream_continuation(
|
|
876
|
+
chat_id,
|
|
877
|
+
f"{self.server_url}/api/agent/chat/{session_id}/planmode/approve",
|
|
878
|
+
)
|
|
879
|
+
else:
|
|
880
|
+
await self._send_message(chat_id, "Continuing without plan mode...")
|
|
881
|
+
await self._stream_continuation(
|
|
882
|
+
chat_id,
|
|
883
|
+
f"{self.server_url}/api/agent/chat/{session_id}/planmode/reject",
|
|
884
|
+
params={"feedback": text if response not in ("reject", "no", "n") else ""},
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
async def _stream_continuation(
|
|
888
|
+
self,
|
|
889
|
+
chat_id: int,
|
|
890
|
+
url: str,
|
|
891
|
+
params: dict | None = None,
|
|
892
|
+
) -> None:
|
|
893
|
+
"""Stream a continuation endpoint response.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
chat_id: Telegram chat ID
|
|
897
|
+
url: API endpoint URL
|
|
898
|
+
params: Optional query parameters
|
|
899
|
+
"""
|
|
900
|
+
if not self._http_client:
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
self.state.processing[chat_id] = True
|
|
904
|
+
|
|
905
|
+
# Create formatter with settings
|
|
906
|
+
formatter = SSEEventFormatter(
|
|
907
|
+
show_thinking=self.config.settings.show_thinking,
|
|
908
|
+
show_tools=self.config.settings.show_tool_calls,
|
|
909
|
+
compact=self.config.settings.compact_mode,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
try:
|
|
913
|
+
response_content = ""
|
|
914
|
+
last_update_time = 0.0
|
|
915
|
+
update_interval = self.config.settings.update_interval_ms / 1000.0
|
|
916
|
+
|
|
917
|
+
async with self._http_client.stream("POST", url, params=params) as response:
|
|
918
|
+
response.raise_for_status()
|
|
919
|
+
|
|
920
|
+
current_event = None
|
|
921
|
+
|
|
922
|
+
async for line in response.aiter_lines():
|
|
923
|
+
# Check if cancelled
|
|
924
|
+
if not self.state.processing.get(chat_id):
|
|
925
|
+
break
|
|
926
|
+
|
|
927
|
+
line = line.strip()
|
|
928
|
+
|
|
929
|
+
if line.startswith("event: "):
|
|
930
|
+
current_event = line[7:]
|
|
931
|
+
elif line.startswith("data: "):
|
|
932
|
+
if current_event:
|
|
933
|
+
try:
|
|
934
|
+
data = json.loads(line[6:])
|
|
935
|
+
if data is None:
|
|
936
|
+
data = {}
|
|
937
|
+
|
|
938
|
+
# Handle interactive events recursively
|
|
939
|
+
if current_event == "clarification":
|
|
940
|
+
await self._handle_clarification_event(chat_id, data)
|
|
941
|
+
continue
|
|
942
|
+
if current_event == "plan_submitted":
|
|
943
|
+
await self._handle_plan_submitted_event(chat_id, data)
|
|
944
|
+
continue
|
|
945
|
+
if current_event == "plan_mode_requested":
|
|
946
|
+
await self._handle_plan_mode_event(chat_id, data)
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
# Format the event
|
|
950
|
+
formatted = formatter.format_event(current_event, data)
|
|
951
|
+
|
|
952
|
+
if current_event == "response":
|
|
953
|
+
response_content = data.get("content", "")
|
|
954
|
+
|
|
955
|
+
if formatted:
|
|
956
|
+
now = time.time()
|
|
957
|
+
if formatted.is_update and (now - last_update_time) < update_interval:
|
|
958
|
+
continue
|
|
959
|
+
last_update_time = now
|
|
960
|
+
|
|
961
|
+
msg = await self._send_message(
|
|
962
|
+
chat_id, formatted.text, formatted.parse_mode
|
|
963
|
+
)
|
|
964
|
+
if msg:
|
|
965
|
+
self.state.last_message_ids[chat_id] = msg.message_id
|
|
966
|
+
|
|
967
|
+
except json.JSONDecodeError:
|
|
968
|
+
pass
|
|
969
|
+
|
|
970
|
+
# Send final response if we have one
|
|
971
|
+
if response_content and chat_id not in self.state.pending:
|
|
972
|
+
await self._send_long_message(chat_id, response_content)
|
|
973
|
+
|
|
974
|
+
except Exception as e:
|
|
975
|
+
if self.on_message:
|
|
976
|
+
self.on_message("error", {"error": str(e)})
|
|
977
|
+
await self._send_message(chat_id, f"Error: {str(e)}")
|
|
978
|
+
|
|
979
|
+
finally:
|
|
980
|
+
self.state.processing[chat_id] = False
|