flowly-code 1.0.0__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.
- flowly_code/__init__.py +30 -0
- flowly_code/__main__.py +8 -0
- flowly_code/activity/__init__.py +1 -0
- flowly_code/activity/bus.py +91 -0
- flowly_code/activity/events.py +40 -0
- flowly_code/agent/__init__.py +8 -0
- flowly_code/agent/context.py +485 -0
- flowly_code/agent/loop.py +1349 -0
- flowly_code/agent/memory.py +109 -0
- flowly_code/agent/skills.py +259 -0
- flowly_code/agent/subagent.py +249 -0
- flowly_code/agent/tools/__init__.py +6 -0
- flowly_code/agent/tools/base.py +55 -0
- flowly_code/agent/tools/delegate.py +194 -0
- flowly_code/agent/tools/dispatch.py +840 -0
- flowly_code/agent/tools/docker.py +609 -0
- flowly_code/agent/tools/filesystem.py +280 -0
- flowly_code/agent/tools/mcp.py +85 -0
- flowly_code/agent/tools/message.py +235 -0
- flowly_code/agent/tools/registry.py +257 -0
- flowly_code/agent/tools/screenshot.py +444 -0
- flowly_code/agent/tools/shell.py +166 -0
- flowly_code/agent/tools/spawn.py +65 -0
- flowly_code/agent/tools/system.py +917 -0
- flowly_code/agent/tools/trello.py +420 -0
- flowly_code/agent/tools/web.py +139 -0
- flowly_code/agent/tools/x.py +399 -0
- flowly_code/bus/__init__.py +6 -0
- flowly_code/bus/events.py +37 -0
- flowly_code/bus/queue.py +81 -0
- flowly_code/channels/__init__.py +6 -0
- flowly_code/channels/base.py +121 -0
- flowly_code/channels/manager.py +135 -0
- flowly_code/channels/telegram.py +1132 -0
- flowly_code/cli/__init__.py +1 -0
- flowly_code/cli/commands.py +1831 -0
- flowly_code/cli/setup.py +1356 -0
- flowly_code/compaction/__init__.py +39 -0
- flowly_code/compaction/estimator.py +88 -0
- flowly_code/compaction/pruning.py +223 -0
- flowly_code/compaction/service.py +297 -0
- flowly_code/compaction/summarizer.py +384 -0
- flowly_code/compaction/types.py +71 -0
- flowly_code/config/__init__.py +6 -0
- flowly_code/config/loader.py +102 -0
- flowly_code/config/schema.py +324 -0
- flowly_code/exec/__init__.py +39 -0
- flowly_code/exec/approvals.py +288 -0
- flowly_code/exec/executor.py +184 -0
- flowly_code/exec/safety.py +247 -0
- flowly_code/exec/types.py +88 -0
- flowly_code/gateway/__init__.py +5 -0
- flowly_code/gateway/server.py +103 -0
- flowly_code/heartbeat/__init__.py +5 -0
- flowly_code/heartbeat/service.py +130 -0
- flowly_code/multiagent/README.md +248 -0
- flowly_code/multiagent/__init__.py +1 -0
- flowly_code/multiagent/invoke.py +210 -0
- flowly_code/multiagent/orchestrator.py +156 -0
- flowly_code/multiagent/router.py +156 -0
- flowly_code/multiagent/setup.py +171 -0
- flowly_code/pairing/__init__.py +21 -0
- flowly_code/pairing/store.py +343 -0
- flowly_code/providers/__init__.py +6 -0
- flowly_code/providers/base.py +69 -0
- flowly_code/providers/litellm_provider.py +178 -0
- flowly_code/providers/transcription.py +64 -0
- flowly_code/session/__init__.py +5 -0
- flowly_code/session/manager.py +249 -0
- flowly_code/skills/README.md +24 -0
- flowly_code/skills/compact/SKILL.md +27 -0
- flowly_code/skills/github/SKILL.md +48 -0
- flowly_code/skills/skill-creator/SKILL.md +371 -0
- flowly_code/skills/summarize/SKILL.md +67 -0
- flowly_code/skills/tmux/SKILL.md +121 -0
- flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
- flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
- flowly_code/skills/weather/SKILL.md +49 -0
- flowly_code/utils/__init__.py +5 -0
- flowly_code/utils/helpers.py +91 -0
- flowly_code-1.0.0.dist-info/METADATA +724 -0
- flowly_code-1.0.0.dist-info/RECORD +86 -0
- flowly_code-1.0.0.dist-info/WHEEL +4 -0
- flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
- flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
- flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""File system tools: read, write, edit."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from flowly_code.agent.tools.base import Tool
|
|
7
|
+
|
|
8
|
+
# Allowed paths outside workspace (home-relative config/data dirs)
|
|
9
|
+
_ALLOWED_PREFIXES = (
|
|
10
|
+
Path.home() / ".flowly",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_path_allowed(
|
|
15
|
+
resolved_path: Path,
|
|
16
|
+
workspace: Path | None,
|
|
17
|
+
access_mode: str = "full",
|
|
18
|
+
allowed_paths: list[Path] | None = None,
|
|
19
|
+
) -> bool:
|
|
20
|
+
"""Check if a resolved path is allowed based on access mode.
|
|
21
|
+
|
|
22
|
+
Modes:
|
|
23
|
+
full — all paths allowed
|
|
24
|
+
projects — only Dispatch project dirs + ~/.flowly
|
|
25
|
+
workspace — only ~/.flowly/workspace + ~/.flowly
|
|
26
|
+
"""
|
|
27
|
+
if access_mode == "full":
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
# ~/.flowly is always allowed
|
|
31
|
+
for prefix in _ALLOWED_PREFIXES:
|
|
32
|
+
try:
|
|
33
|
+
resolved_path.relative_to(prefix.resolve())
|
|
34
|
+
return True
|
|
35
|
+
except ValueError:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# Project directories (when mode=projects)
|
|
39
|
+
if access_mode == "projects" and allowed_paths:
|
|
40
|
+
for p in allowed_paths:
|
|
41
|
+
try:
|
|
42
|
+
resolved_path.relative_to(p.resolve())
|
|
43
|
+
return True
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Workspace directory
|
|
48
|
+
if workspace:
|
|
49
|
+
try:
|
|
50
|
+
resolved_path.relative_to(workspace.resolve())
|
|
51
|
+
return True
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ReadFileTool(Tool):
|
|
59
|
+
"""Tool to read file contents."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, workspace: Path | None = None,
|
|
62
|
+
access_mode: str = "full", allowed_paths: list[Path] | None = None):
|
|
63
|
+
self.workspace = workspace
|
|
64
|
+
self.access_mode = access_mode
|
|
65
|
+
self.allowed_paths = allowed_paths
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def name(self) -> str:
|
|
69
|
+
return "read_file"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def description(self) -> str:
|
|
73
|
+
return "Read the contents of a file at the given path."
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def parameters(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"path": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "The file path to read"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": ["path"]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
89
|
+
try:
|
|
90
|
+
file_path = Path(path).expanduser().resolve()
|
|
91
|
+
|
|
92
|
+
if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
|
|
93
|
+
return f"Error: Access denied — path outside allowed directories: {path}"
|
|
94
|
+
|
|
95
|
+
if not file_path.exists():
|
|
96
|
+
return f"Error: File not found: {path}"
|
|
97
|
+
if not file_path.is_file():
|
|
98
|
+
return f"Error: Not a file: {path}"
|
|
99
|
+
|
|
100
|
+
content = file_path.read_text(encoding="utf-8")
|
|
101
|
+
return content
|
|
102
|
+
except PermissionError:
|
|
103
|
+
return f"Error: Permission denied: {path}"
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return f"Error reading file: {str(e)}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class WriteFileTool(Tool):
|
|
109
|
+
"""Tool to write content to a file."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, workspace: Path | None = None,
|
|
112
|
+
access_mode: str = "full", allowed_paths: list[Path] | None = None):
|
|
113
|
+
self.workspace = workspace
|
|
114
|
+
self.access_mode = access_mode
|
|
115
|
+
self.allowed_paths = allowed_paths
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def name(self) -> str:
|
|
119
|
+
return "write_file"
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def description(self) -> str:
|
|
123
|
+
return "Write content to a file at the given path. Creates parent directories if needed."
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def parameters(self) -> dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"path": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "The file path to write to"
|
|
133
|
+
},
|
|
134
|
+
"content": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"description": "The content to write"
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"required": ["path", "content"]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
|
|
143
|
+
try:
|
|
144
|
+
file_path = Path(path).expanduser().resolve()
|
|
145
|
+
|
|
146
|
+
if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
|
|
147
|
+
return f"Error: Access denied — path outside allowed directories: {path}"
|
|
148
|
+
|
|
149
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
file_path.write_text(content, encoding="utf-8")
|
|
151
|
+
return f"Successfully wrote {len(content)} bytes to {path}"
|
|
152
|
+
except PermissionError:
|
|
153
|
+
return f"Error: Permission denied: {path}"
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return f"Error writing file: {str(e)}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class EditFileTool(Tool):
|
|
159
|
+
"""Tool to edit a file by replacing text."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, workspace: Path | None = None,
|
|
162
|
+
access_mode: str = "full", allowed_paths: list[Path] | None = None):
|
|
163
|
+
self.workspace = workspace
|
|
164
|
+
self.access_mode = access_mode
|
|
165
|
+
self.allowed_paths = allowed_paths
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def name(self) -> str:
|
|
169
|
+
return "edit_file"
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def description(self) -> str:
|
|
173
|
+
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def parameters(self) -> dict[str, Any]:
|
|
177
|
+
return {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"path": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": "The file path to edit"
|
|
183
|
+
},
|
|
184
|
+
"old_text": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"description": "The exact text to find and replace"
|
|
187
|
+
},
|
|
188
|
+
"new_text": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": "The text to replace with"
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"required": ["path", "old_text", "new_text"]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
|
|
197
|
+
try:
|
|
198
|
+
file_path = Path(path).expanduser().resolve()
|
|
199
|
+
|
|
200
|
+
if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
|
|
201
|
+
return f"Error: Access denied — path outside allowed directories: {path}"
|
|
202
|
+
|
|
203
|
+
if not file_path.exists():
|
|
204
|
+
return f"Error: File not found: {path}"
|
|
205
|
+
|
|
206
|
+
content = file_path.read_text(encoding="utf-8")
|
|
207
|
+
|
|
208
|
+
if old_text not in content:
|
|
209
|
+
return f"Error: old_text not found in file. Make sure it matches exactly."
|
|
210
|
+
|
|
211
|
+
# Count occurrences
|
|
212
|
+
count = content.count(old_text)
|
|
213
|
+
if count > 1:
|
|
214
|
+
return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
|
|
215
|
+
|
|
216
|
+
new_content = content.replace(old_text, new_text, 1)
|
|
217
|
+
file_path.write_text(new_content, encoding="utf-8")
|
|
218
|
+
|
|
219
|
+
return f"Successfully edited {path}"
|
|
220
|
+
except PermissionError:
|
|
221
|
+
return f"Error: Permission denied: {path}"
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return f"Error editing file: {str(e)}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ListDirTool(Tool):
|
|
227
|
+
"""Tool to list directory contents."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, workspace: Path | None = None,
|
|
230
|
+
access_mode: str = "full", allowed_paths: list[Path] | None = None):
|
|
231
|
+
self.workspace = workspace
|
|
232
|
+
self.access_mode = access_mode
|
|
233
|
+
self.allowed_paths = allowed_paths
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def name(self) -> str:
|
|
237
|
+
return "list_dir"
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def description(self) -> str:
|
|
241
|
+
return "List the contents of a directory."
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def parameters(self) -> dict[str, Any]:
|
|
245
|
+
return {
|
|
246
|
+
"type": "object",
|
|
247
|
+
"properties": {
|
|
248
|
+
"path": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "The directory path to list"
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
"required": ["path"]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async def execute(self, path: str, **kwargs: Any) -> str:
|
|
257
|
+
try:
|
|
258
|
+
dir_path = Path(path).expanduser().resolve()
|
|
259
|
+
|
|
260
|
+
if not _is_path_allowed(dir_path, self.workspace, self.access_mode, self.allowed_paths):
|
|
261
|
+
return f"Error: Access denied — path outside allowed directories: {path}"
|
|
262
|
+
|
|
263
|
+
if not dir_path.exists():
|
|
264
|
+
return f"Error: Directory not found: {path}"
|
|
265
|
+
if not dir_path.is_dir():
|
|
266
|
+
return f"Error: Not a directory: {path}"
|
|
267
|
+
|
|
268
|
+
items = []
|
|
269
|
+
for item in sorted(dir_path.iterdir()):
|
|
270
|
+
prefix = "d " if item.is_dir() else "f "
|
|
271
|
+
items.append(f"{prefix}{item.name}")
|
|
272
|
+
|
|
273
|
+
if not items:
|
|
274
|
+
return f"Directory {path} is empty"
|
|
275
|
+
|
|
276
|
+
return "\n".join(items)
|
|
277
|
+
except PermissionError:
|
|
278
|
+
return f"Error: Permission denied: {path}"
|
|
279
|
+
except Exception as e:
|
|
280
|
+
return f"Error listing directory: {str(e)}"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""MCP client: connects to MCP servers and wraps their tools as native flowly tools."""
|
|
2
|
+
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from flowly_code.agent.tools.base import Tool
|
|
9
|
+
from flowly_code.agent.tools.registry import ToolRegistry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MCPToolWrapper(Tool):
|
|
13
|
+
"""Wraps a single MCP server tool as a flowly Tool."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, session, server_name: str, tool_def):
|
|
16
|
+
self._session = session
|
|
17
|
+
self._original_name = tool_def.name
|
|
18
|
+
self._name = f"mcp_{server_name}_{tool_def.name}"
|
|
19
|
+
self._description = tool_def.description or tool_def.name
|
|
20
|
+
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def name(self) -> str:
|
|
24
|
+
return self._name
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def description(self) -> str:
|
|
28
|
+
return self._description
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def parameters(self) -> dict[str, Any]:
|
|
32
|
+
return self._parameters
|
|
33
|
+
|
|
34
|
+
async def execute(self, **kwargs: Any) -> str:
|
|
35
|
+
from mcp import types
|
|
36
|
+
|
|
37
|
+
result = await self._session.call_tool(self._original_name, arguments=kwargs)
|
|
38
|
+
parts = []
|
|
39
|
+
for block in result.content:
|
|
40
|
+
if isinstance(block, types.TextContent):
|
|
41
|
+
parts.append(block.text)
|
|
42
|
+
else:
|
|
43
|
+
parts.append(str(block))
|
|
44
|
+
return "\n".join(parts) or "(no output)"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def connect_mcp_servers(
|
|
48
|
+
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Connect to configured MCP servers and register their tools."""
|
|
51
|
+
from mcp import ClientSession, StdioServerParameters
|
|
52
|
+
from mcp.client.stdio import stdio_client
|
|
53
|
+
|
|
54
|
+
for name, cfg in mcp_servers.items():
|
|
55
|
+
try:
|
|
56
|
+
if cfg.command:
|
|
57
|
+
env = dict(cfg.env) if cfg.env else None
|
|
58
|
+
params = StdioServerParameters(
|
|
59
|
+
command=cfg.command, args=list(cfg.args), env=env
|
|
60
|
+
)
|
|
61
|
+
read, write = await stack.enter_async_context(stdio_client(params))
|
|
62
|
+
elif cfg.url:
|
|
63
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
64
|
+
|
|
65
|
+
read, write, _ = await stack.enter_async_context(
|
|
66
|
+
streamable_http_client(cfg.url)
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
73
|
+
await session.initialize()
|
|
74
|
+
|
|
75
|
+
tools = await session.list_tools()
|
|
76
|
+
for tool_def in tools.tools:
|
|
77
|
+
wrapper = MCPToolWrapper(session, name, tool_def)
|
|
78
|
+
registry.register(wrapper)
|
|
79
|
+
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
f"MCP server '{name}': connected, {len(tools.tools)} tools registered"
|
|
83
|
+
)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"MCP server '{name}': failed to connect: {e}")
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Message tool for sending messages and media to users."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Awaitable
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from flowly_code.agent.tools.base import Tool
|
|
10
|
+
from flowly_code.bus.events import OutboundMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Supported media MIME types
|
|
14
|
+
SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|
15
|
+
SUPPORTED_DOCUMENT_TYPES = {"application/pdf", "text/plain", "application/zip"}
|
|
16
|
+
SUPPORTED_MEDIA_TYPES = SUPPORTED_IMAGE_TYPES | SUPPORTED_DOCUMENT_TYPES
|
|
17
|
+
|
|
18
|
+
# Maximum media file size (10MB)
|
|
19
|
+
MAX_MEDIA_SIZE = 10 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageTool(Tool):
|
|
23
|
+
"""
|
|
24
|
+
Tool to send messages and media to users on chat channels.
|
|
25
|
+
|
|
26
|
+
Supports:
|
|
27
|
+
- Text messages
|
|
28
|
+
- Images (jpg, png, gif, webp)
|
|
29
|
+
- Documents (pdf, txt, zip)
|
|
30
|
+
|
|
31
|
+
Media files are validated for existence, type, and size before sending.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
|
37
|
+
default_channel: str = "",
|
|
38
|
+
default_chat_id: str = ""
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Initialize the message tool.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
send_callback: Async function to send OutboundMessage.
|
|
45
|
+
default_channel: Default channel for messages.
|
|
46
|
+
default_chat_id: Default chat ID for messages.
|
|
47
|
+
"""
|
|
48
|
+
self._send_callback = send_callback
|
|
49
|
+
self._default_channel = default_channel
|
|
50
|
+
self._default_chat_id = default_chat_id
|
|
51
|
+
|
|
52
|
+
def set_context(self, channel: str, chat_id: str) -> None:
|
|
53
|
+
"""Set the current message context (channel and chat_id)."""
|
|
54
|
+
self._default_channel = channel
|
|
55
|
+
self._default_chat_id = chat_id
|
|
56
|
+
|
|
57
|
+
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
|
58
|
+
"""Set the callback for sending messages."""
|
|
59
|
+
self._send_callback = callback
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
return "message"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def description(self) -> str:
|
|
67
|
+
return (
|
|
68
|
+
"Send a message to the user, optionally with media attachments (images, documents). "
|
|
69
|
+
"Use media_paths to attach files like screenshots. "
|
|
70
|
+
"The content will be sent as the caption for media messages."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def parameters(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"content": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "The message text to send. Used as caption when sending media."
|
|
81
|
+
},
|
|
82
|
+
"media_paths": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {"type": "string"},
|
|
85
|
+
"description": (
|
|
86
|
+
"Optional list of file paths to send as media attachments. "
|
|
87
|
+
"Supports images (jpg, png, gif) and documents (pdf, txt). "
|
|
88
|
+
"Example: [\"/path/to/screenshot.png\"]"
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
"channel": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": "Optional: target channel (telegram, whatsapp, etc.)"
|
|
94
|
+
},
|
|
95
|
+
"chat_id": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Optional: target chat/user ID"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"required": ["content"]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async def execute(
|
|
104
|
+
self,
|
|
105
|
+
content: str,
|
|
106
|
+
media_paths: list[str] | None = None,
|
|
107
|
+
channel: str | None = None,
|
|
108
|
+
chat_id: str | None = None,
|
|
109
|
+
**kwargs: Any
|
|
110
|
+
) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Send a message, optionally with media attachments.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
content: Message text content.
|
|
116
|
+
media_paths: Optional list of file paths to attach.
|
|
117
|
+
channel: Target channel (uses default if not specified).
|
|
118
|
+
chat_id: Target chat ID (uses default if not specified).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Success or error message.
|
|
122
|
+
"""
|
|
123
|
+
# Use defaults if not specified
|
|
124
|
+
channel = channel or self._default_channel
|
|
125
|
+
chat_id = chat_id or self._default_chat_id
|
|
126
|
+
|
|
127
|
+
# Validate required fields
|
|
128
|
+
if not channel or not chat_id:
|
|
129
|
+
return "Error: No target channel/chat specified. Cannot send message."
|
|
130
|
+
|
|
131
|
+
if not self._send_callback:
|
|
132
|
+
return "Error: Message sending not configured. Internal error."
|
|
133
|
+
|
|
134
|
+
# Validate and filter media paths
|
|
135
|
+
validated_media: list[str] = []
|
|
136
|
+
media_errors: list[str] = []
|
|
137
|
+
|
|
138
|
+
if media_paths:
|
|
139
|
+
for path_str in media_paths:
|
|
140
|
+
validation_result = self._validate_media_file(path_str)
|
|
141
|
+
if validation_result is None:
|
|
142
|
+
validated_media.append(path_str)
|
|
143
|
+
else:
|
|
144
|
+
media_errors.append(validation_result)
|
|
145
|
+
|
|
146
|
+
# Build outbound message
|
|
147
|
+
msg = OutboundMessage(
|
|
148
|
+
channel=channel,
|
|
149
|
+
chat_id=chat_id,
|
|
150
|
+
content=content,
|
|
151
|
+
media=validated_media,
|
|
152
|
+
metadata={
|
|
153
|
+
"has_media": len(validated_media) > 0,
|
|
154
|
+
"media_count": len(validated_media)
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Send the message
|
|
159
|
+
try:
|
|
160
|
+
await self._send_callback(msg)
|
|
161
|
+
|
|
162
|
+
# Build response
|
|
163
|
+
result_parts = [f"Message sent to {channel}:{chat_id}"]
|
|
164
|
+
|
|
165
|
+
if validated_media:
|
|
166
|
+
result_parts.append(f"Attached {len(validated_media)} media file(s)")
|
|
167
|
+
|
|
168
|
+
if media_errors:
|
|
169
|
+
result_parts.append(f"Skipped {len(media_errors)} invalid file(s):")
|
|
170
|
+
for error in media_errors:
|
|
171
|
+
result_parts.append(f" - {error}")
|
|
172
|
+
|
|
173
|
+
return "\n".join(result_parts)
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"Failed to send message: {e}")
|
|
177
|
+
return f"Error sending message: {str(e)}"
|
|
178
|
+
|
|
179
|
+
def _validate_media_file(self, path_str: str) -> str | None:
|
|
180
|
+
"""
|
|
181
|
+
Validate a media file for sending.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
path_str: Path to the file.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
None if valid, error message if invalid.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
path = Path(path_str).expanduser().resolve()
|
|
191
|
+
|
|
192
|
+
# Check existence
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return f"File not found: {path_str}"
|
|
195
|
+
|
|
196
|
+
if not path.is_file():
|
|
197
|
+
return f"Not a file: {path_str}"
|
|
198
|
+
|
|
199
|
+
# Check file size
|
|
200
|
+
file_size = path.stat().st_size
|
|
201
|
+
if file_size == 0:
|
|
202
|
+
return f"File is empty: {path_str}"
|
|
203
|
+
|
|
204
|
+
if file_size > MAX_MEDIA_SIZE:
|
|
205
|
+
size_mb = file_size / 1024 / 1024
|
|
206
|
+
return f"File too large ({size_mb:.1f}MB > 10MB): {path_str}"
|
|
207
|
+
|
|
208
|
+
# Check MIME type
|
|
209
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
210
|
+
if mime_type is None:
|
|
211
|
+
# Allow files with common image extensions even without MIME detection
|
|
212
|
+
ext = path.suffix.lower()
|
|
213
|
+
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
|
214
|
+
return None # Accept based on extension
|
|
215
|
+
return f"Unknown file type: {path_str}"
|
|
216
|
+
|
|
217
|
+
if mime_type not in SUPPORTED_MEDIA_TYPES:
|
|
218
|
+
return f"Unsupported file type ({mime_type}): {path_str}"
|
|
219
|
+
|
|
220
|
+
return None # Valid
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return f"Error validating file: {str(e)}"
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def is_image(path: str) -> bool:
|
|
227
|
+
"""Check if a file path points to an image."""
|
|
228
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
229
|
+
return mime_type in SUPPORTED_IMAGE_TYPES if mime_type else False
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def is_document(path: str) -> bool:
|
|
233
|
+
"""Check if a file path points to a document."""
|
|
234
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
235
|
+
return mime_type in SUPPORTED_DOCUMENT_TYPES if mime_type else False
|