deepagents-talon 0.0.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.
- deepagents_talon/__init__.py +68 -0
- deepagents_talon/__main__.py +218 -0
- deepagents_talon/_version.py +3 -0
- deepagents_talon/channels/__init__.py +22 -0
- deepagents_talon/channels/base.py +192 -0
- deepagents_talon/channels/whatsapp.py +745 -0
- deepagents_talon/channels/whatsapp_bridge/bridge.js +591 -0
- deepagents_talon/channels/whatsapp_bridge/package-lock.json +2117 -0
- deepagents_talon/channels/whatsapp_bridge/package.json +9 -0
- deepagents_talon/config.py +157 -0
- deepagents_talon/cron/__init__.py +26 -0
- deepagents_talon/cron/jobs.py +737 -0
- deepagents_talon/cron/scheduler.py +170 -0
- deepagents_talon/cron/tools.py +261 -0
- deepagents_talon/data_lifecycle.py +140 -0
- deepagents_talon/fleet.py +417 -0
- deepagents_talon/host.py +584 -0
- deepagents_talon/interfaces.py +198 -0
- deepagents_talon/mcp.py +133 -0
- deepagents_talon/media.py +383 -0
- deepagents_talon/observability.py +185 -0
- deepagents_talon/py.typed +1 -0
- deepagents_talon/runtime.py +1046 -0
- deepagents_talon/speech.py +305 -0
- deepagents_talon-0.0.1.dist-info/METADATA +197 -0
- deepagents_talon-0.0.1.dist-info/RECORD +28 -0
- deepagents_talon-0.0.1.dist-info/WHEEL +4 -0
- deepagents_talon-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Local runtime host for long-running Deep Agents.
|
|
2
|
+
|
|
3
|
+
Talon is an experimental runtime and is subject to change or removal at any time.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from deepagents_talon._version import __version__
|
|
7
|
+
from deepagents_talon.config import TalonConfig
|
|
8
|
+
from deepagents_talon.cron import (
|
|
9
|
+
CronJob,
|
|
10
|
+
CronJobError,
|
|
11
|
+
CronJobStore,
|
|
12
|
+
CronOrigin,
|
|
13
|
+
CronSchedule,
|
|
14
|
+
CronTools,
|
|
15
|
+
PersistentCronScheduler,
|
|
16
|
+
)
|
|
17
|
+
from deepagents_talon.host import TalonHost
|
|
18
|
+
from deepagents_talon.interfaces import (
|
|
19
|
+
AgentRequest,
|
|
20
|
+
AgentResult,
|
|
21
|
+
AgentRuntime,
|
|
22
|
+
ChannelAdapter,
|
|
23
|
+
ChannelMedia,
|
|
24
|
+
ChannelMessage,
|
|
25
|
+
ChannelStatus,
|
|
26
|
+
CronScheduler,
|
|
27
|
+
ToolApprovalDecision,
|
|
28
|
+
ToolApprovalHandler,
|
|
29
|
+
ToolApprovalRequest,
|
|
30
|
+
)
|
|
31
|
+
from deepagents_talon.runtime import DeepAgentRuntime, EchoAgentRuntime, RuntimeAgentComponents
|
|
32
|
+
from deepagents_talon.speech import (
|
|
33
|
+
DEFAULT_LOCAL_VOICE_TRANSCRIPTION_MODEL,
|
|
34
|
+
LocalParakeetVoiceTranscriber,
|
|
35
|
+
OpenAIVoiceTranscriber,
|
|
36
|
+
VoiceTranscriber,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_LOCAL_VOICE_TRANSCRIPTION_MODEL",
|
|
41
|
+
"AgentRequest",
|
|
42
|
+
"AgentResult",
|
|
43
|
+
"AgentRuntime",
|
|
44
|
+
"ChannelAdapter",
|
|
45
|
+
"ChannelMedia",
|
|
46
|
+
"ChannelMessage",
|
|
47
|
+
"ChannelStatus",
|
|
48
|
+
"CronJob",
|
|
49
|
+
"CronJobError",
|
|
50
|
+
"CronJobStore",
|
|
51
|
+
"CronOrigin",
|
|
52
|
+
"CronSchedule",
|
|
53
|
+
"CronScheduler",
|
|
54
|
+
"CronTools",
|
|
55
|
+
"DeepAgentRuntime",
|
|
56
|
+
"EchoAgentRuntime",
|
|
57
|
+
"LocalParakeetVoiceTranscriber",
|
|
58
|
+
"OpenAIVoiceTranscriber",
|
|
59
|
+
"PersistentCronScheduler",
|
|
60
|
+
"RuntimeAgentComponents",
|
|
61
|
+
"TalonConfig",
|
|
62
|
+
"TalonHost",
|
|
63
|
+
"ToolApprovalDecision",
|
|
64
|
+
"ToolApprovalHandler",
|
|
65
|
+
"ToolApprovalRequest",
|
|
66
|
+
"VoiceTranscriber",
|
|
67
|
+
"__version__",
|
|
68
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Command line entry point for the Talon runtime host.
|
|
2
|
+
|
|
3
|
+
Talon is an experimental runtime and is subject to change or removal at any time.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from deepagents_talon.channels.whatsapp import WhatsAppChannel, WhatsAppChannelConfig
|
|
17
|
+
from deepagents_talon.config import TalonConfig
|
|
18
|
+
from deepagents_talon.cron import CronJobStore, PersistentCronScheduler
|
|
19
|
+
from deepagents_talon.data_lifecycle import cleanup_sensitive_state
|
|
20
|
+
from deepagents_talon.fleet import FleetAgentComponents, load_fleet_agent_components
|
|
21
|
+
from deepagents_talon.host import TalonHost
|
|
22
|
+
from deepagents_talon.mcp import load_mcp_tools, print_mcp_config_paths
|
|
23
|
+
from deepagents_talon.runtime import (
|
|
24
|
+
DeepAgentRuntime,
|
|
25
|
+
EchoAgentRuntime,
|
|
26
|
+
RuntimeAgentComponents,
|
|
27
|
+
)
|
|
28
|
+
from deepagents_talon.speech import build_voice_transcriber
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Sequence
|
|
32
|
+
|
|
33
|
+
from deepagents_talon.cron import CronJob
|
|
34
|
+
from deepagents_talon.interfaces import ChannelAdapter
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
"""Run the Talon host with the placeholder runtime."""
|
|
41
|
+
parser = argparse.ArgumentParser(description="Run the Deep Agents Talon host.")
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--once",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Start and stop immediately after bootstrapping the host.",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--whatsapp",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Attach the WhatsApp channel adapter.",
|
|
51
|
+
)
|
|
52
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
53
|
+
_add_mcp_parsers(subparsers)
|
|
54
|
+
args = parser.parse_args()
|
|
55
|
+
|
|
56
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
|
|
57
|
+
|
|
58
|
+
config = TalonConfig.from_env()
|
|
59
|
+
if args.command == "mcp":
|
|
60
|
+
sys.exit(asyncio.run(_run_mcp_command(args, config)))
|
|
61
|
+
|
|
62
|
+
cron_factory = CronJobStore
|
|
63
|
+
cron_store = cron_factory(assistant_id=config.assistant_id, cron_dir=config.cron_dir)
|
|
64
|
+
config.ensure_home()
|
|
65
|
+
cleanup_sensitive_state(config=config, cron_store=cron_store)
|
|
66
|
+
|
|
67
|
+
channels = _channels(config, enabled=args.whatsapp)
|
|
68
|
+
host = TalonHost(
|
|
69
|
+
config=config,
|
|
70
|
+
agent=asyncio.run(_agent_runtime(config, cron_store)),
|
|
71
|
+
channels=channels,
|
|
72
|
+
voice_transcriber=build_voice_transcriber(config),
|
|
73
|
+
)
|
|
74
|
+
if channels:
|
|
75
|
+
host.scheduler = PersistentCronScheduler(
|
|
76
|
+
store=cron_store,
|
|
77
|
+
run_job=host.run_scheduled_job,
|
|
78
|
+
deliver_result=lambda job, text: _deliver_cron_result(host, channels, job, text),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if args.once:
|
|
82
|
+
asyncio.run(_run_once(host))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
asyncio.run(host.run_until_stopped())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _add_mcp_parsers(
|
|
89
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
90
|
+
) -> None:
|
|
91
|
+
mcp = subparsers.add_parser("mcp", help="Manage MCP servers")
|
|
92
|
+
mcp_sub = mcp.add_subparsers(dest="mcp_command")
|
|
93
|
+
|
|
94
|
+
mcp_sub.add_parser("config", help="Show MCP config discovery paths")
|
|
95
|
+
|
|
96
|
+
login = mcp_sub.add_parser("login", help="Run OAuth login for an MCP server")
|
|
97
|
+
login.add_argument("server", help="Server name from mcpServers")
|
|
98
|
+
login.add_argument("--mcp-config", dest="config_path", default=None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _agent_runtime(
|
|
102
|
+
config: TalonConfig,
|
|
103
|
+
cron_store: CronJobStore,
|
|
104
|
+
) -> EchoAgentRuntime | DeepAgentRuntime:
|
|
105
|
+
env = _runtime_env(config)
|
|
106
|
+
if config.fleet_dir is not None:
|
|
107
|
+
fleet_dir = config.fleet_dir
|
|
108
|
+
components = await load_fleet_agent_components(fleet_dir, env=env)
|
|
109
|
+
runtime_components = _runtime_components_from_fleet(config, components)
|
|
110
|
+
|
|
111
|
+
async def reload_fleet_components() -> RuntimeAgentComponents:
|
|
112
|
+
refreshed = await load_fleet_agent_components(fleet_dir, env=env)
|
|
113
|
+
return _runtime_components_from_fleet(config, refreshed)
|
|
114
|
+
|
|
115
|
+
return DeepAgentRuntime(
|
|
116
|
+
model=runtime_components.model,
|
|
117
|
+
tools=runtime_components.tools,
|
|
118
|
+
system_prompt=runtime_components.system_prompt,
|
|
119
|
+
subagents=runtime_components.subagents,
|
|
120
|
+
skills=runtime_components.skills,
|
|
121
|
+
middleware=runtime_components.middleware,
|
|
122
|
+
interrupt_on=runtime_components.interrupt_on,
|
|
123
|
+
cron_store=cron_store,
|
|
124
|
+
env=env,
|
|
125
|
+
reload_agent_components=reload_fleet_components,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if config.model is None:
|
|
129
|
+
return EchoAgentRuntime()
|
|
130
|
+
|
|
131
|
+
mcp = await load_mcp_tools(config)
|
|
132
|
+
for server in mcp.servers:
|
|
133
|
+
if server.error is not None:
|
|
134
|
+
logger.warning("MCP server %s failed: %s", server.name, server.error)
|
|
135
|
+
else:
|
|
136
|
+
logger.info("MCP server %s loaded %d tool(s)", server.name, len(server.tools))
|
|
137
|
+
return DeepAgentRuntime(
|
|
138
|
+
model=config.model,
|
|
139
|
+
tools=mcp.tools,
|
|
140
|
+
assistant_dir=config.manifest_dir,
|
|
141
|
+
cron_store=cron_store,
|
|
142
|
+
env=env,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _runtime_components_from_fleet(
|
|
147
|
+
config: TalonConfig,
|
|
148
|
+
components: FleetAgentComponents,
|
|
149
|
+
) -> RuntimeAgentComponents:
|
|
150
|
+
return RuntimeAgentComponents(
|
|
151
|
+
model=config.model or components.model,
|
|
152
|
+
tools=components.tools,
|
|
153
|
+
system_prompt=components.system_prompt,
|
|
154
|
+
subagents=components.subagents,
|
|
155
|
+
skills=components.skills,
|
|
156
|
+
middleware=components.middleware,
|
|
157
|
+
interrupt_on=components.interrupt_on,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _run_mcp_command(args: argparse.Namespace, config: TalonConfig) -> int:
|
|
162
|
+
if args.mcp_command == "config":
|
|
163
|
+
print_mcp_config_paths(config)
|
|
164
|
+
return 0
|
|
165
|
+
if args.mcp_command == "login":
|
|
166
|
+
return await _run_mcp_login(args)
|
|
167
|
+
print("Specify an MCP command: config or login", file=sys.stderr) # noqa: T201
|
|
168
|
+
return 2
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _run_mcp_login(args: argparse.Namespace) -> int:
|
|
172
|
+
try:
|
|
173
|
+
module = importlib.import_module("deepagents_code.mcp_commands")
|
|
174
|
+
except ImportError:
|
|
175
|
+
print( # noqa: T201
|
|
176
|
+
"MCP login requires deepagents-code to be installed in this environment.",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return 1
|
|
180
|
+
run_mcp_login = module.run_mcp_login
|
|
181
|
+
return await run_mcp_login(server=args.server, config_path=args.config_path)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _run_once(host: TalonHost) -> None:
|
|
185
|
+
await host.start()
|
|
186
|
+
await host.stop()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _channels(config: TalonConfig, *, enabled: bool) -> tuple[ChannelAdapter, ...]:
|
|
190
|
+
if not enabled and config.env.get("DEEPAGENTS_TALON_WHATSAPP_ENABLED", "").lower() not in {
|
|
191
|
+
"1",
|
|
192
|
+
"true",
|
|
193
|
+
"yes",
|
|
194
|
+
}:
|
|
195
|
+
return ()
|
|
196
|
+
return (WhatsAppChannel(WhatsAppChannelConfig.from_talon_config(config)),)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _runtime_env(config: TalonConfig) -> dict[str, str]:
|
|
200
|
+
values = dict(os.environ)
|
|
201
|
+
values.update(config.env)
|
|
202
|
+
return values
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _deliver_cron_result(
|
|
206
|
+
host: TalonHost,
|
|
207
|
+
channels: Sequence[ChannelAdapter],
|
|
208
|
+
job: CronJob,
|
|
209
|
+
text: str,
|
|
210
|
+
) -> None:
|
|
211
|
+
for channel in channels:
|
|
212
|
+
if job.origin.channel is None or (await channel.status()).provider == job.origin.channel:
|
|
213
|
+
await host.deliver_scheduled_result(channel, job, text)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Channel integrations for Talon.
|
|
2
|
+
|
|
3
|
+
Talon is an experimental runtime and is subject to change or removal at any time.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from deepagents_talon.channels.base import (
|
|
7
|
+
ChannelExposure,
|
|
8
|
+
ChannelMediaError,
|
|
9
|
+
ExposureMode,
|
|
10
|
+
chunk_text,
|
|
11
|
+
format_markdown_for_channel,
|
|
12
|
+
validate_media,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ChannelExposure",
|
|
17
|
+
"ChannelMediaError",
|
|
18
|
+
"ExposureMode",
|
|
19
|
+
"chunk_text",
|
|
20
|
+
"format_markdown_for_channel",
|
|
21
|
+
"validate_media",
|
|
22
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Reusable channel policy and formatting helpers.
|
|
2
|
+
|
|
3
|
+
Talon is an experimental runtime and is subject to change or removal at any time.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import mimetypes
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from deepagents_talon.interfaces import ChannelMedia, ChannelMessage
|
|
16
|
+
from deepagents_talon.media import resolve_bounded_media_path
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
MAX_TEXT_CHARS = 4096
|
|
22
|
+
MAX_IMAGE_BYTES = 16 * 1024 * 1024
|
|
23
|
+
MAX_VIDEO_BYTES = 64 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
_LINK_PATTERN = re.compile(r"\[([^\]]+)]\(([^)]+)\)")
|
|
26
|
+
_HEADING_PATTERN = re.compile(r"^#{1,6}\s+", flags=re.MULTILINE)
|
|
27
|
+
_BOLD_PATTERN = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
|
|
28
|
+
_ITALIC_PATTERN = re.compile(r"(?<!\*)\*([^*\n]+)\*(?!\*)|_([^_\n]+)_")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExposureMode(StrEnum):
|
|
32
|
+
"""Who may trigger a channel-backed agent."""
|
|
33
|
+
|
|
34
|
+
SELF = "self"
|
|
35
|
+
ALLOWLIST = "allowlist"
|
|
36
|
+
OPEN = "open"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChannelMediaError(ValueError):
|
|
40
|
+
"""Raised when outbound media cannot be sent safely."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ChannelExposure:
|
|
45
|
+
"""Inbound exposure policy shared by channel adapters.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
mode: Trigger policy for inbound messages.
|
|
49
|
+
operator_id: Channel-specific id for the operator's own account.
|
|
50
|
+
conversations: Conversation ids allowed in allowlist mode.
|
|
51
|
+
mention_patterns: Glob-style patterns that may allow a message by text.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
mode: ExposureMode = ExposureMode.SELF
|
|
55
|
+
operator_id: str | None = None
|
|
56
|
+
conversations: frozenset[str] = field(default_factory=frozenset)
|
|
57
|
+
mention_patterns: tuple[str, ...] = ()
|
|
58
|
+
|
|
59
|
+
def allows(self, message: ChannelMessage) -> bool:
|
|
60
|
+
"""Return whether an inbound message may trigger the agent.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
message: Inbound message from a channel adapter.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
`True` when the message passes this exposure policy.
|
|
67
|
+
"""
|
|
68
|
+
if self.mode == ExposureMode.OPEN:
|
|
69
|
+
return True
|
|
70
|
+
if self.mode == ExposureMode.SELF:
|
|
71
|
+
return _is_self_message(message, self.operator_id)
|
|
72
|
+
return message.conversation_id in self.conversations or _matches_text(
|
|
73
|
+
message.text,
|
|
74
|
+
self.mention_patterns,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_markdown_for_channel(text: str) -> str:
|
|
79
|
+
"""Convert common Markdown into conservative WhatsApp-compatible text.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
text: Markdown text returned by the agent.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Text with common Markdown constructs mapped to WhatsApp formatting.
|
|
86
|
+
"""
|
|
87
|
+
value = _HEADING_PATTERN.sub("", text)
|
|
88
|
+
value = _LINK_PATTERN.sub(r"\1 (\2)", value)
|
|
89
|
+
value = _ITALIC_PATTERN.sub(lambda match: f"_{match.group(1) or match.group(2)}_", value)
|
|
90
|
+
return _BOLD_PATTERN.sub(lambda match: f"*{match.group(1) or match.group(2)}*", value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def chunk_text(text: str, *, limit: int = MAX_TEXT_CHARS) -> list[str]:
|
|
94
|
+
"""Split outbound text into channel-sized chunks.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
text: Text to split.
|
|
98
|
+
limit: Maximum characters per returned chunk.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Non-empty chunks no longer than `limit`.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If `limit` is not positive.
|
|
105
|
+
"""
|
|
106
|
+
if limit < 1:
|
|
107
|
+
msg = "chunk limit must be positive"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
|
|
110
|
+
chunks: list[str] = []
|
|
111
|
+
remaining = text
|
|
112
|
+
while len(remaining) > limit:
|
|
113
|
+
split = _split_index(remaining, limit)
|
|
114
|
+
chunk = remaining[:split].rstrip()
|
|
115
|
+
chunks.append(chunk or remaining[:limit])
|
|
116
|
+
remaining = remaining[split:].lstrip()
|
|
117
|
+
if remaining:
|
|
118
|
+
chunks.append(remaining)
|
|
119
|
+
return chunks
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_media(media: ChannelMedia, *, root: Path | None = None) -> ChannelMedia:
|
|
123
|
+
"""Validate outbound media path, type, and size.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
media: Media payload to validate.
|
|
127
|
+
root: Optional directory that must contain the media after symlink
|
|
128
|
+
resolution.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The validated media payload.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ChannelMediaError: If the file is missing, unsupported, or too large.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
path = (
|
|
138
|
+
resolve_bounded_media_path(media.path, root, require_relative=False)
|
|
139
|
+
if root is not None
|
|
140
|
+
else media.path.expanduser()
|
|
141
|
+
)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
msg = str(exc)
|
|
144
|
+
raise ChannelMediaError(msg) from exc
|
|
145
|
+
if not path.is_file():
|
|
146
|
+
msg = f"media file does not exist: {path}"
|
|
147
|
+
raise ChannelMediaError(msg)
|
|
148
|
+
|
|
149
|
+
detected = _media_type(path)
|
|
150
|
+
if detected != media.media_type:
|
|
151
|
+
msg = f"media file type {detected!r} does not match requested type {media.media_type!r}"
|
|
152
|
+
raise ChannelMediaError(msg)
|
|
153
|
+
|
|
154
|
+
limit = MAX_IMAGE_BYTES if media.media_type == "image" else MAX_VIDEO_BYTES
|
|
155
|
+
size = path.stat().st_size
|
|
156
|
+
if size > limit:
|
|
157
|
+
msg = f"{media.media_type} media is too large: {size} bytes exceeds {limit}"
|
|
158
|
+
raise ChannelMediaError(msg)
|
|
159
|
+
|
|
160
|
+
return ChannelMedia(path=path, media_type=media.media_type, caption=media.caption)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_self_message(message: ChannelMessage, operator_id: str | None) -> bool:
|
|
164
|
+
if message.metadata.get("from_self") is True:
|
|
165
|
+
return True
|
|
166
|
+
return operator_id is not None and message.sender_id == operator_id
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _matches_text(text: str, patterns: tuple[str, ...]) -> bool:
|
|
170
|
+
return any(fnmatch.fnmatchcase(text, pattern) for pattern in patterns)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _split_index(text: str, limit: int) -> int:
|
|
174
|
+
window = text[:limit]
|
|
175
|
+
for delimiter in ("\n\n", "\n", " "):
|
|
176
|
+
index = window.rfind(delimiter)
|
|
177
|
+
if index > 0:
|
|
178
|
+
return index + len(delimiter)
|
|
179
|
+
return limit
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _media_type(path: Path) -> str:
|
|
183
|
+
mime, _ = mimetypes.guess_type(path)
|
|
184
|
+
if mime is None:
|
|
185
|
+
msg = f"unsupported media file type: {path}"
|
|
186
|
+
raise ChannelMediaError(msg)
|
|
187
|
+
if mime.startswith("image/"):
|
|
188
|
+
return "image"
|
|
189
|
+
if mime.startswith("video/"):
|
|
190
|
+
return "video"
|
|
191
|
+
msg = f"unsupported media mime type: {mime}"
|
|
192
|
+
raise ChannelMediaError(msg)
|