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.
- openagent/__init__.py +5 -0
- openagent/agent.py +262 -0
- openagent/channels/__init__.py +3 -0
- openagent/channels/base.py +91 -0
- openagent/channels/discord.py +136 -0
- openagent/channels/telegram.py +161 -0
- openagent/channels/whatsapp.py +196 -0
- openagent/cli.py +430 -0
- openagent/config.py +74 -0
- openagent/mcp/__init__.py +3 -0
- openagent/mcp/client.py +419 -0
- openagent/mcps/computer-use/.gitignore +2 -0
- openagent/mcps/computer-use/package.json +27 -0
- openagent/mcps/computer-use/src/index.ts +14 -0
- openagent/mcps/computer-use/src/main.ts +73 -0
- openagent/mcps/computer-use/src/tools/computer.ts +444 -0
- openagent/mcps/computer-use/src/tools/index.ts +6 -0
- openagent/mcps/computer-use/src/utils/response.ts +8 -0
- openagent/mcps/computer-use/src/xdotoolStringToKeys.ts +230 -0
- openagent/mcps/computer-use/tsconfig.json +16 -0
- openagent/mcps/editor/.gitignore +2 -0
- openagent/mcps/editor/package.json +23 -0
- openagent/mcps/editor/src/index.ts +267 -0
- openagent/mcps/editor/tsconfig.json +14 -0
- openagent/mcps/messaging/requirements.txt +4 -0
- openagent/mcps/messaging/server.py +277 -0
- openagent/mcps/shell/.gitignore +2 -0
- openagent/mcps/shell/package.json +22 -0
- openagent/mcps/shell/src/index.ts +149 -0
- openagent/mcps/shell/tsconfig.json +14 -0
- openagent/mcps/web-search/.gitignore +2 -0
- openagent/mcps/web-search/package.json +28 -0
- openagent/mcps/web-search/src/browser-pool.ts +131 -0
- openagent/mcps/web-search/src/content-extractor.ts +260 -0
- openagent/mcps/web-search/src/enhanced-content-extractor.ts +590 -0
- openagent/mcps/web-search/src/index.ts +538 -0
- openagent/mcps/web-search/src/rate-limiter.ts +45 -0
- openagent/mcps/web-search/src/search-engine.ts +1158 -0
- openagent/mcps/web-search/src/types.ts +80 -0
- openagent/mcps/web-search/src/utils.ts +61 -0
- openagent/mcps/web-search/tsconfig.json +16 -0
- openagent/memory/__init__.py +5 -0
- openagent/memory/db.py +341 -0
- openagent/memory/knowledge.py +435 -0
- openagent/memory/manager.py +233 -0
- openagent/models/__init__.py +6 -0
- openagent/models/base.py +60 -0
- openagent/models/claude_api.py +127 -0
- openagent/models/claude_cli.py +169 -0
- openagent/models/zhipu.py +143 -0
- openagent/scheduler.py +133 -0
- openagent/scripts/restart.sh +10 -0
- openagent/scripts/setup.sh +65 -0
- openagent/scripts/start.sh +71 -0
- openagent/scripts/status.sh +35 -0
- openagent/scripts/stop.sh +25 -0
- openagent/service.py +224 -0
- openagent_framework-0.1.1.dist-info/METADATA +571 -0
- openagent_framework-0.1.1.dist-info/RECORD +62 -0
- openagent_framework-0.1.1.dist-info/WHEEL +5 -0
- openagent_framework-0.1.1.dist-info/entry_points.txt +2 -0
- openagent_framework-0.1.1.dist-info/top_level.txt +1 -0
openagent/__init__.py
ADDED
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,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()
|