openagent-framework 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.
Files changed (62) hide show
  1. openagent/__init__.py +5 -0
  2. openagent/agent.py +262 -0
  3. openagent/channels/__init__.py +3 -0
  4. openagent/channels/base.py +91 -0
  5. openagent/channels/discord.py +136 -0
  6. openagent/channels/telegram.py +161 -0
  7. openagent/channels/whatsapp.py +196 -0
  8. openagent/cli.py +430 -0
  9. openagent/config.py +74 -0
  10. openagent/mcp/__init__.py +3 -0
  11. openagent/mcp/client.py +419 -0
  12. openagent/mcps/computer-use/.gitignore +2 -0
  13. openagent/mcps/computer-use/package.json +27 -0
  14. openagent/mcps/computer-use/src/index.ts +14 -0
  15. openagent/mcps/computer-use/src/main.ts +73 -0
  16. openagent/mcps/computer-use/src/tools/computer.ts +444 -0
  17. openagent/mcps/computer-use/src/tools/index.ts +6 -0
  18. openagent/mcps/computer-use/src/utils/response.ts +8 -0
  19. openagent/mcps/computer-use/src/xdotoolStringToKeys.ts +230 -0
  20. openagent/mcps/computer-use/tsconfig.json +16 -0
  21. openagent/mcps/editor/.gitignore +2 -0
  22. openagent/mcps/editor/package.json +23 -0
  23. openagent/mcps/editor/src/index.ts +267 -0
  24. openagent/mcps/editor/tsconfig.json +14 -0
  25. openagent/mcps/messaging/requirements.txt +4 -0
  26. openagent/mcps/messaging/server.py +277 -0
  27. openagent/mcps/shell/.gitignore +2 -0
  28. openagent/mcps/shell/package.json +22 -0
  29. openagent/mcps/shell/src/index.ts +149 -0
  30. openagent/mcps/shell/tsconfig.json +14 -0
  31. openagent/mcps/web-search/.gitignore +2 -0
  32. openagent/mcps/web-search/package.json +28 -0
  33. openagent/mcps/web-search/src/browser-pool.ts +131 -0
  34. openagent/mcps/web-search/src/content-extractor.ts +260 -0
  35. openagent/mcps/web-search/src/enhanced-content-extractor.ts +590 -0
  36. openagent/mcps/web-search/src/index.ts +538 -0
  37. openagent/mcps/web-search/src/rate-limiter.ts +45 -0
  38. openagent/mcps/web-search/src/search-engine.ts +1158 -0
  39. openagent/mcps/web-search/src/types.ts +80 -0
  40. openagent/mcps/web-search/src/utils.ts +61 -0
  41. openagent/mcps/web-search/tsconfig.json +16 -0
  42. openagent/memory/__init__.py +5 -0
  43. openagent/memory/db.py +341 -0
  44. openagent/memory/knowledge.py +435 -0
  45. openagent/memory/manager.py +233 -0
  46. openagent/models/__init__.py +6 -0
  47. openagent/models/base.py +60 -0
  48. openagent/models/claude_api.py +127 -0
  49. openagent/models/claude_cli.py +169 -0
  50. openagent/models/zhipu.py +143 -0
  51. openagent/scheduler.py +133 -0
  52. openagent/scripts/restart.sh +10 -0
  53. openagent/scripts/setup.sh +65 -0
  54. openagent/scripts/start.sh +71 -0
  55. openagent/scripts/status.sh +35 -0
  56. openagent/scripts/stop.sh +25 -0
  57. openagent/service.py +224 -0
  58. openagent_framework-0.1.1.dist-info/METADATA +571 -0
  59. openagent_framework-0.1.1.dist-info/RECORD +62 -0
  60. openagent_framework-0.1.1.dist-info/WHEEL +5 -0
  61. openagent_framework-0.1.1.dist-info/entry_points.txt +2 -0
  62. openagent_framework-0.1.1.dist-info/top_level.txt +1 -0
openagent/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from openagent.agent import Agent
2
+ from openagent.config import load_config
3
+
4
+ __version__ = "0.1.1"
5
+ __all__ = ["Agent", "load_config"]
openagent/agent.py ADDED
@@ -0,0 +1,262 @@
1
+ """Core Agent class: orchestrates model, MCP tools, and memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, AsyncIterator
7
+
8
+ from openagent.models.base import BaseModel, ModelResponse
9
+ from openagent.memory.db import MemoryDB
10
+ from openagent.memory.manager import MemoryManager
11
+ from openagent.mcp.client import MCPRegistry, MCPTools
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ MAX_TOOL_ITERATIONS = 10
16
+
17
+
18
+ class Agent:
19
+ """Main agent class. Ties together a model, MCP tools, and memory.
20
+
21
+ Usage:
22
+ agent = Agent(
23
+ name="assistant",
24
+ model=ClaudeAPI(model="claude-sonnet-4-6"),
25
+ system_prompt="You are a helpful assistant.",
26
+ mcp_tools=[MCPTools(command=["npx", "..."])],
27
+ memory=MemoryDB("agent.db"),
28
+ )
29
+ response = await agent.run("Hello!", user_id="user-1")
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ name: str = "agent",
35
+ model: BaseModel | None = None,
36
+ system_prompt: str = "You are a helpful assistant.",
37
+ mcp_tools: list[MCPTools] | None = None,
38
+ mcp_registry: MCPRegistry | None = None,
39
+ memory: MemoryDB | str | None = None,
40
+ auto_extract_memory: bool = True,
41
+ history_limit: int = 50,
42
+ knowledge_dir: str = "./memories",
43
+ ):
44
+ self.name = name
45
+ self.model = model
46
+ self.system_prompt = system_prompt
47
+ self.auto_extract_memory = auto_extract_memory
48
+
49
+ # MCP
50
+ if mcp_registry:
51
+ self._mcp = mcp_registry
52
+ else:
53
+ self._mcp = MCPRegistry()
54
+ for tool in (mcp_tools or []):
55
+ self._mcp.add(tool)
56
+
57
+ # Memory
58
+ if isinstance(memory, str):
59
+ self._db = MemoryDB(memory)
60
+ elif isinstance(memory, MemoryDB):
61
+ self._db = memory
62
+ else:
63
+ self._db = None
64
+
65
+ self._memory: MemoryManager | None = None
66
+ if self._db:
67
+ self._memory = MemoryManager(
68
+ self._db, auto_extract=auto_extract_memory,
69
+ history_limit=history_limit, knowledge_dir=knowledge_dir,
70
+ )
71
+
72
+ self._initialized = False
73
+
74
+ async def initialize(self) -> None:
75
+ """Connect MCP servers and initialize memory DB."""
76
+ if self._initialized:
77
+ return
78
+ await self._mcp.connect_all()
79
+ if self._db:
80
+ await self._db.connect()
81
+ if self._memory:
82
+ await self._memory.initialize_knowledge()
83
+
84
+ # For Claude CLI: pass MCP server configs so CLI can use them
85
+ from openagent.models.claude_cli import ClaudeCLI
86
+ if isinstance(self.model, ClaudeCLI):
87
+ mcp_configs = self._build_cli_mcp_configs()
88
+ if mcp_configs:
89
+ self.model.set_mcp_servers(mcp_configs)
90
+
91
+ self._initialized = True
92
+
93
+ def _build_cli_mcp_configs(self) -> dict[str, dict]:
94
+ """Build MCP server configs in Claude CLI format for --mcp-config."""
95
+ configs = {}
96
+ for server in self._mcp._servers:
97
+ if server.command:
98
+ full_cmd = server.command + server.args
99
+ configs[server.name] = {
100
+ "command": full_cmd[0],
101
+ "args": full_cmd[1:],
102
+ }
103
+ if server.env:
104
+ configs[server.name]["env"] = server.env
105
+ if server._cwd:
106
+ configs[server.name]["cwd"] = server._cwd
107
+ return configs
108
+
109
+ async def shutdown(self) -> None:
110
+ """Close all connections."""
111
+ await self._mcp.close_all()
112
+ if self._db:
113
+ await self._db.close()
114
+ self._initialized = False
115
+
116
+ async def run(
117
+ self,
118
+ message: str,
119
+ user_id: str = "",
120
+ session_id: str | None = None,
121
+ attachments: list[dict] | None = None,
122
+ ) -> str:
123
+ """Run the agent with a user message. Returns the final text response.
124
+
125
+ Handles the full tool-use loop: send -> tool call -> execute -> send result -> repeat.
126
+ """
127
+ if not self.model:
128
+ raise RuntimeError("No model configured. Set agent.model before calling run().")
129
+
130
+ await self.initialize()
131
+
132
+ # Session + history
133
+ current_session_id = None
134
+ history: list[dict[str, Any]] = []
135
+ system = self.system_prompt
136
+
137
+ if self._memory:
138
+ current_session_id = await self._memory.ensure_session(self.name, user_id, session_id)
139
+ history = await self._memory.get_history(current_session_id)
140
+
141
+ # Inject long-term memories + relevant knowledge into system prompt
142
+ mem_context = await self._memory.build_memory_context(self.name, user_id, query=message)
143
+ if mem_context:
144
+ system = f"{system}\n\n{mem_context}"
145
+
146
+ # Build messages — prepend attachment descriptions if present
147
+ if attachments:
148
+ att_desc = " ".join(f"[Attached {a.get('type','file')}: {a.get('filename','')}]" for a in attachments)
149
+ message = f"{att_desc}\n{message}" if message else att_desc
150
+
151
+ messages = list(history)
152
+ messages.append({"role": "user", "content": message})
153
+
154
+ # Store user message
155
+ if self._memory and current_session_id:
156
+ await self._memory.store_message(current_session_id, "user", message)
157
+
158
+ # Get available tools
159
+ tools = self._mcp.all_tools() or None
160
+
161
+ # Tool-use loop
162
+ for _ in range(MAX_TOOL_ITERATIONS):
163
+ response = await self.model.generate(messages, system=system, tools=tools)
164
+
165
+ if response.tool_calls:
166
+ # Add assistant message with tool calls
167
+ assistant_msg: dict[str, Any] = {
168
+ "role": "assistant",
169
+ "content": response.content,
170
+ "tool_calls": [
171
+ {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
172
+ for tc in response.tool_calls
173
+ ],
174
+ }
175
+ messages.append(assistant_msg)
176
+ if self._memory and current_session_id:
177
+ await self._memory.store_message(
178
+ current_session_id, "assistant", response.content,
179
+ tool_calls=assistant_msg["tool_calls"],
180
+ )
181
+
182
+ # Execute each tool call
183
+ for tc in response.tool_calls:
184
+ try:
185
+ result = await self._mcp.call_tool(tc.name, tc.arguments)
186
+ except Exception as e:
187
+ result = f"Error calling tool {tc.name}: {e}"
188
+ logger.error(result)
189
+
190
+ tool_msg = {
191
+ "role": "tool",
192
+ "content": result,
193
+ "tool_call_id": tc.id,
194
+ }
195
+ messages.append(tool_msg)
196
+ if self._memory and current_session_id:
197
+ await self._memory.store_message(
198
+ current_session_id, "tool", result, tool_call_id=tc.id,
199
+ )
200
+ else:
201
+ # No tool calls — we have the final response
202
+ if self._memory and current_session_id:
203
+ await self._memory.store_message(current_session_id, "assistant", response.content)
204
+
205
+ # Extract memories in background
206
+ if self._memory and self.auto_extract_memory:
207
+ try:
208
+ await self._memory.extract_and_store_memories(
209
+ self.model, self.name, user_id, messages,
210
+ )
211
+ except Exception as e:
212
+ logger.warning(f"Memory extraction failed: {e}")
213
+
214
+ return response.content
215
+
216
+ # Exceeded max iterations
217
+ return response.content if response else "I wasn't able to complete the request."
218
+
219
+ async def stream_run(
220
+ self,
221
+ message: str,
222
+ user_id: str = "",
223
+ session_id: str | None = None,
224
+ ) -> AsyncIterator[str]:
225
+ """Stream the agent's response. Does not support tool use in streaming mode."""
226
+ if not self.model:
227
+ raise RuntimeError("No model configured.")
228
+
229
+ await self.initialize()
230
+
231
+ history: list[dict[str, Any]] = []
232
+ system = self.system_prompt
233
+ current_session_id = None
234
+
235
+ if self._memory:
236
+ current_session_id = await self._memory.ensure_session(self.name, user_id, session_id)
237
+ history = await self._memory.get_history(current_session_id)
238
+ mem_context = await self._memory.build_memory_context(self.name, user_id)
239
+ if mem_context:
240
+ system = f"{system}\n\n{mem_context}"
241
+
242
+ messages = list(history)
243
+ messages.append({"role": "user", "content": message})
244
+
245
+ if self._memory and current_session_id:
246
+ await self._memory.store_message(current_session_id, "user", message)
247
+
248
+ full_response = []
249
+ async for chunk in self.model.stream(messages, system=system):
250
+ full_response.append(chunk)
251
+ yield chunk
252
+
253
+ content = "".join(full_response)
254
+ if self._memory and current_session_id:
255
+ await self._memory.store_message(current_session_id, "assistant", content)
256
+
257
+ async def __aenter__(self):
258
+ await self.initialize()
259
+ return self
260
+
261
+ async def __aexit__(self, *args):
262
+ await self.shutdown()
@@ -0,0 +1,3 @@
1
+ from openagent.channels.base import BaseChannel
2
+
3
+ __all__ = ["BaseChannel"]
@@ -0,0 +1,91 @@
1
+ """Base channel interface and shared utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from openagent.agent import Agent
13
+
14
+
15
+ @dataclass
16
+ class Attachment:
17
+ """A file/image/voice attachment from or to a channel."""
18
+ type: str # "image", "file", "voice", "video"
19
+ path: str # local file path
20
+ filename: str
21
+ mime_type: str | None = None
22
+ caption: str | None = None
23
+
24
+
25
+ # Pattern for response markers: [IMAGE:/path/to/file.png] [FILE:/path] [VOICE:/path]
26
+ _MARKER_PATTERN = re.compile(r'\[(IMAGE|FILE|VOICE|VIDEO):([^\]]+)\]')
27
+
28
+ _MARKER_TYPE_MAP = {
29
+ "IMAGE": "image",
30
+ "FILE": "file",
31
+ "VOICE": "voice",
32
+ "VIDEO": "video",
33
+ }
34
+
35
+
36
+ def parse_response_markers(text: str) -> tuple[str, list[Attachment]]:
37
+ """Extract file markers from agent response text.
38
+
39
+ Returns (clean_text, attachments).
40
+ Markers like [IMAGE:/path/to/chart.png] are removed from text
41
+ and returned as Attachment objects.
42
+ """
43
+ attachments: list[Attachment] = []
44
+ for match in _MARKER_PATTERN.finditer(text):
45
+ marker_type = match.group(1)
46
+ file_path = match.group(2).strip()
47
+ att_type = _MARKER_TYPE_MAP.get(marker_type, "file")
48
+ filename = Path(file_path).name
49
+ attachments.append(Attachment(
50
+ type=att_type,
51
+ path=file_path,
52
+ filename=filename,
53
+ ))
54
+
55
+ clean_text = _MARKER_PATTERN.sub("", text).strip()
56
+ return clean_text, attachments
57
+
58
+
59
+ def format_attachments_for_prompt(attachments: list[Attachment], caption: str = "") -> str:
60
+ """Build a prompt string describing attachments for the agent."""
61
+ parts = []
62
+ for att in attachments:
63
+ parts.append(f"[Attached {att.type}: {att.filename}]")
64
+ prefix = " ".join(parts)
65
+ if caption:
66
+ return f"{prefix}\n{caption}"
67
+ return prefix
68
+
69
+
70
+ class BaseChannel(ABC):
71
+ """Abstract base for messaging channels (Telegram, Discord, WhatsApp, etc.).
72
+
73
+ Each channel manages per-user sessions via the agent's memory system.
74
+ """
75
+
76
+ def __init__(self, agent: Agent):
77
+ self.agent = agent
78
+
79
+ @abstractmethod
80
+ async def start(self) -> None:
81
+ """Start listening for messages."""
82
+ ...
83
+
84
+ @abstractmethod
85
+ async def stop(self) -> None:
86
+ """Stop the channel."""
87
+ ...
88
+
89
+ def _user_session_id(self, platform: str, user_id: str) -> str:
90
+ """Generate a consistent session ID from platform + user ID."""
91
+ return f"{platform}:{self.agent.name}:{user_id}"
@@ -0,0 +1,136 @@
1
+ """Discord channel using discord.py. Supports text, images, files, attachments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from openagent.channels.base import BaseChannel, Attachment, parse_response_markers
11
+
12
+ if TYPE_CHECKING:
13
+ from openagent.agent import Agent
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DiscordChannel(BaseChannel):
19
+ """Discord bot channel with full media support.
20
+
21
+ Usage:
22
+ channel = DiscordChannel(agent=agent, token="BOT_TOKEN")
23
+ await channel.start()
24
+ """
25
+
26
+ def __init__(self, agent: Agent, token: str):
27
+ super().__init__(agent)
28
+ self.token = token
29
+ self._client = None
30
+
31
+ async def start(self) -> None:
32
+ try:
33
+ import discord
34
+ except ImportError:
35
+ raise ImportError(
36
+ "discord.py is required for Discord channel. "
37
+ "Install it with: pip install openagent[discord]"
38
+ )
39
+
40
+ intents = discord.Intents.default()
41
+ intents.message_content = True
42
+ client = discord.Client(intents=intents)
43
+ self._client = client
44
+
45
+ @client.event
46
+ async def on_ready():
47
+ logger.info(f"Discord bot '{self.agent.name}' connected as {client.user}")
48
+
49
+ @client.event
50
+ async def on_message(message: discord.Message):
51
+ if message.author == client.user:
52
+ return
53
+
54
+ is_dm = isinstance(message.channel, discord.DMChannel)
55
+ is_mentioned = client.user in message.mentions if client.user else False
56
+ if not is_dm and not is_mentioned:
57
+ return
58
+
59
+ content = message.content
60
+ if is_mentioned and client.user:
61
+ content = content.replace(f"<@{client.user.id}>", "").strip()
62
+
63
+ user_id = str(message.author.id)
64
+ session_id = self._user_session_id("discord", user_id)
65
+ attachments: list[dict] = []
66
+
67
+ # Download attachments
68
+ if message.attachments:
69
+ tmp_dir = tempfile.mkdtemp(prefix="openagent_dc_")
70
+ for att in message.attachments:
71
+ try:
72
+ path = str(Path(tmp_dir) / att.filename)
73
+ await att.save(path)
74
+ ct = att.content_type or ""
75
+ if ct.startswith("image/"):
76
+ att_type = "image"
77
+ elif ct.startswith("audio/") or att.filename.endswith((".ogg", ".mp3", ".wav")):
78
+ att_type = "voice"
79
+ elif ct.startswith("video/"):
80
+ att_type = "video"
81
+ else:
82
+ att_type = "file"
83
+ attachments.append({"type": att_type, "path": path, "filename": att.filename})
84
+ except Exception as e:
85
+ logger.error(f"Failed to download Discord attachment: {e}")
86
+
87
+ if not content and not attachments:
88
+ return
89
+
90
+ try:
91
+ async with message.channel.typing():
92
+ response = await self.agent.run(
93
+ message=content,
94
+ user_id=user_id,
95
+ session_id=session_id,
96
+ attachments=attachments if attachments else None,
97
+ )
98
+
99
+ await self._send_response(message.channel, response)
100
+ except Exception as e:
101
+ logger.error(f"Discord handler error: {e}")
102
+ await message.channel.send("Sorry, something went wrong.")
103
+
104
+ logger.info(f"Starting Discord bot for agent '{self.agent.name}'")
105
+ await client.start(self.token)
106
+
107
+ async def _send_response(self, channel, response: str) -> None:
108
+ """Send agent response, handling file markers."""
109
+ import discord
110
+
111
+ clean_text, attachments = parse_response_markers(response)
112
+
113
+ # Send attachments
114
+ files = []
115
+ for att in attachments:
116
+ path = Path(att.path)
117
+ if path.exists():
118
+ files.append(discord.File(str(path), filename=att.filename))
119
+
120
+ if files:
121
+ # Discord allows up to 10 files per message
122
+ for i in range(0, len(files), 10):
123
+ batch = files[i:i + 10]
124
+ text_chunk = clean_text[:2000] if i == 0 and clean_text else None
125
+ await channel.send(content=text_chunk, files=batch)
126
+ if i == 0 and clean_text:
127
+ clean_text = clean_text[2000:]
128
+
129
+ # Send remaining text
130
+ if clean_text:
131
+ for i in range(0, len(clean_text), 2000):
132
+ await channel.send(clean_text[i:i + 2000])
133
+
134
+ async def stop(self) -> None:
135
+ if self._client:
136
+ await self._client.close()
@@ -0,0 +1,161 @@
1
+ """Telegram channel using python-telegram-bot. Supports text, images, files, voice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from openagent.channels.base import BaseChannel, Attachment, parse_response_markers
12
+
13
+ if TYPE_CHECKING:
14
+ from openagent.agent import Agent
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TelegramChannel(BaseChannel):
20
+ """Telegram bot channel with full media support.
21
+
22
+ Usage:
23
+ channel = TelegramChannel(agent=agent, token="BOT_TOKEN")
24
+ await channel.start()
25
+ """
26
+
27
+ def __init__(self, agent: Agent, token: str):
28
+ super().__init__(agent)
29
+ self.token = token
30
+ self._app = None
31
+
32
+ async def _handle_message(self, update, context) -> None:
33
+ """Handle any incoming message (text, photo, voice, file, video)."""
34
+ if not update.message:
35
+ return
36
+
37
+ msg = update.message
38
+ user_id = str(msg.from_user.id)
39
+ session_id = self._user_session_id("telegram", user_id)
40
+ text = msg.caption or msg.text or ""
41
+ attachments: list[dict] = []
42
+
43
+ # Download media to temp dir
44
+ tmp_dir = tempfile.mkdtemp(prefix="openagent_tg_")
45
+
46
+ try:
47
+ if msg.photo:
48
+ photo = msg.photo[-1] # highest resolution
49
+ file = await photo.get_file()
50
+ path = str(Path(tmp_dir) / f"photo_{photo.file_unique_id}.jpg")
51
+ await file.download_to_drive(path)
52
+ attachments.append({"type": "image", "path": path, "filename": Path(path).name})
53
+
54
+ if msg.voice:
55
+ file = await msg.voice.get_file()
56
+ path = str(Path(tmp_dir) / f"voice_{msg.voice.file_unique_id}.ogg")
57
+ await file.download_to_drive(path)
58
+ attachments.append({"type": "voice", "path": path, "filename": Path(path).name})
59
+
60
+ if msg.audio:
61
+ file = await msg.audio.get_file()
62
+ fname = msg.audio.file_name or f"audio_{msg.audio.file_unique_id}"
63
+ path = str(Path(tmp_dir) / fname)
64
+ await file.download_to_drive(path)
65
+ attachments.append({"type": "file", "path": path, "filename": fname})
66
+
67
+ if msg.document:
68
+ file = await msg.document.get_file()
69
+ fname = msg.document.file_name or f"doc_{msg.document.file_unique_id}"
70
+ path = str(Path(tmp_dir) / fname)
71
+ await file.download_to_drive(path)
72
+ attachments.append({"type": "file", "path": path, "filename": fname})
73
+
74
+ if msg.video:
75
+ file = await msg.video.get_file()
76
+ fname = msg.video.file_name or f"video_{msg.video.file_unique_id}.mp4"
77
+ path = str(Path(tmp_dir) / fname)
78
+ await file.download_to_drive(path)
79
+ attachments.append({"type": "video", "path": path, "filename": fname})
80
+
81
+ if not text and not attachments:
82
+ return
83
+
84
+ response = await self.agent.run(
85
+ message=text,
86
+ user_id=user_id,
87
+ session_id=session_id,
88
+ attachments=attachments if attachments else None,
89
+ )
90
+
91
+ await self._send_response(msg, response)
92
+
93
+ except Exception as e:
94
+ logger.error(f"Telegram handler error: {e}")
95
+ await msg.reply_text("Sorry, something went wrong.")
96
+
97
+ async def _send_response(self, msg, response: str) -> None:
98
+ """Send agent response, handling file markers."""
99
+ clean_text, attachments = parse_response_markers(response)
100
+
101
+ # Send attachments
102
+ for att in attachments:
103
+ try:
104
+ path = Path(att.path)
105
+ if not path.exists():
106
+ continue
107
+ if att.type == "image":
108
+ await msg.reply_photo(photo=open(path, "rb"), caption=att.caption)
109
+ elif att.type == "voice":
110
+ await msg.reply_voice(voice=open(path, "rb"))
111
+ elif att.type == "video":
112
+ await msg.reply_video(video=open(path, "rb"), caption=att.caption)
113
+ else:
114
+ await msg.reply_document(document=open(path, "rb"), filename=att.filename)
115
+ except Exception as e:
116
+ logger.error(f"Failed to send attachment {att.filename}: {e}")
117
+
118
+ # Send text (split on 4096 char limit)
119
+ if clean_text:
120
+ for i in range(0, len(clean_text), 4096):
121
+ await msg.reply_text(clean_text[i:i + 4096])
122
+
123
+ async def _handle_start(self, update, context) -> None:
124
+ await update.message.reply_text(
125
+ f"Hello! I'm {self.agent.name}. Send me a message, photo, voice, or file."
126
+ )
127
+
128
+ async def start(self) -> None:
129
+ try:
130
+ from telegram.ext import ApplicationBuilder, MessageHandler, CommandHandler, filters
131
+ except ImportError:
132
+ raise ImportError(
133
+ "python-telegram-bot is required for Telegram channel. "
134
+ "Install it with: pip install openagent[telegram]"
135
+ )
136
+
137
+ self._app = ApplicationBuilder().token(self.token).build()
138
+ self._app.add_handler(CommandHandler("start", self._handle_start))
139
+ # Handle all content types
140
+ self._app.add_handler(MessageHandler(
141
+ filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO |
142
+ filters.Document.ALL | filters.VIDEO,
143
+ self._handle_message,
144
+ ))
145
+
146
+ logger.info(f"Starting Telegram bot for agent '{self.agent.name}'")
147
+ await self._app.initialize()
148
+ await self._app.start()
149
+ await self._app.updater.start_polling()
150
+
151
+ # Keep alive until stopped
152
+ self._stop_event = asyncio.Event()
153
+ await self._stop_event.wait()
154
+
155
+ async def stop(self) -> None:
156
+ if hasattr(self, '_stop_event'):
157
+ self._stop_event.set()
158
+ if self._app:
159
+ await self._app.updater.stop()
160
+ await self._app.stop()
161
+ await self._app.shutdown()