soleclaw 0.1.0__tar.gz
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.
- soleclaw-0.1.0/PKG-INFO +27 -0
- soleclaw-0.1.0/README.md +86 -0
- soleclaw-0.1.0/pyproject.toml +38 -0
- soleclaw-0.1.0/setup.cfg +4 -0
- soleclaw-0.1.0/src/soleclaw/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/bus/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/bus/events.py +31 -0
- soleclaw-0.1.0/src/soleclaw/bus/queue.py +21 -0
- soleclaw-0.1.0/src/soleclaw/channels/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/channels/base.py +30 -0
- soleclaw-0.1.0/src/soleclaw/channels/cli.py +21 -0
- soleclaw-0.1.0/src/soleclaw/channels/manager.py +66 -0
- soleclaw-0.1.0/src/soleclaw/channels/telegram.py +158 -0
- soleclaw-0.1.0/src/soleclaw/cli/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/cli/commands.py +394 -0
- soleclaw-0.1.0/src/soleclaw/cli/configure.py +107 -0
- soleclaw-0.1.0/src/soleclaw/config/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/config/schema.py +59 -0
- soleclaw-0.1.0/src/soleclaw/core/__init__.py +1 -0
- soleclaw-0.1.0/src/soleclaw/core/bootstrap.py +421 -0
- soleclaw-0.1.0/src/soleclaw/core/bridge.py +206 -0
- soleclaw-0.1.0/src/soleclaw/core/context.py +111 -0
- soleclaw-0.1.0/src/soleclaw/core/pidfile.py +38 -0
- soleclaw-0.1.0/src/soleclaw/cron/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/cron/service.py +254 -0
- soleclaw-0.1.0/src/soleclaw/cron/store.py +109 -0
- soleclaw-0.1.0/src/soleclaw/cron/types.py +63 -0
- soleclaw-0.1.0/src/soleclaw/forge/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/forge/engine.py +91 -0
- soleclaw-0.1.0/src/soleclaw/forge/lifecycle.py +30 -0
- soleclaw-0.1.0/src/soleclaw/forge/validator.py +37 -0
- soleclaw-0.1.0/src/soleclaw/memory/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/memory/base.py +29 -0
- soleclaw-0.1.0/src/soleclaw/memory/local.py +51 -0
- soleclaw-0.1.0/src/soleclaw/memory/viking.py +137 -0
- soleclaw-0.1.0/src/soleclaw/skills/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/skills/loader.py +71 -0
- soleclaw-0.1.0/src/soleclaw/tools/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/tools/library/__init__.py +0 -0
- soleclaw-0.1.0/src/soleclaw/tools/library/registry.py +77 -0
- soleclaw-0.1.0/src/soleclaw/tools/library/runner.py +22 -0
- soleclaw-0.1.0/src/soleclaw/tools/library/schema.py +14 -0
- soleclaw-0.1.0/src/soleclaw/tools/sdk_tools.py +258 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/PKG-INFO +27 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/SOURCES.txt +67 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/dependency_links.txt +1 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/entry_points.txt +2 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/requires.txt +28 -0
- soleclaw-0.1.0/src/soleclaw.egg-info/top_level.txt +1 -0
- soleclaw-0.1.0/tests/test_bootstrap.py +42 -0
- soleclaw-0.1.0/tests/test_bridge.py +60 -0
- soleclaw-0.1.0/tests/test_bus.py +21 -0
- soleclaw-0.1.0/tests/test_channel_manager.py +103 -0
- soleclaw-0.1.0/tests/test_cli_channel.py +7 -0
- soleclaw-0.1.0/tests/test_config.py +34 -0
- soleclaw-0.1.0/tests/test_configure.py +41 -0
- soleclaw-0.1.0/tests/test_context.py +49 -0
- soleclaw-0.1.0/tests/test_cron.py +256 -0
- soleclaw-0.1.0/tests/test_e2e_bootstrap.py +47 -0
- soleclaw-0.1.0/tests/test_e2e_cron.py +101 -0
- soleclaw-0.1.0/tests/test_e2e_forge.py +52 -0
- soleclaw-0.1.0/tests/test_forge.py +47 -0
- soleclaw-0.1.0/tests/test_memory.py +55 -0
- soleclaw-0.1.0/tests/test_pidfile.py +34 -0
- soleclaw-0.1.0/tests/test_sdk_tools.py +91 -0
- soleclaw-0.1.0/tests/test_skills.py +31 -0
- soleclaw-0.1.0/tests/test_status.py +61 -0
- soleclaw-0.1.0/tests/test_telegram.py +59 -0
- soleclaw-0.1.0/tests/test_tool_library.py +40 -0
soleclaw-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: soleclaw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-evolving personal AI assistant
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: pydantic>=2.0
|
|
7
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: claude-agent-sdk>=0.1
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
12
|
+
Requires-Dist: loguru>=0.7
|
|
13
|
+
Requires-Dist: rich>=13.0
|
|
14
|
+
Requires-Dist: python-telegram-bot<22.0,>=21.0
|
|
15
|
+
Provides-Extra: telegram
|
|
16
|
+
Requires-Dist: python-telegram-bot>=21.0; extra == "telegram"
|
|
17
|
+
Provides-Extra: viking
|
|
18
|
+
Requires-Dist: openviking>=0.1; extra == "viking"
|
|
19
|
+
Provides-Extra: forge
|
|
20
|
+
Requires-Dist: aiosqlite>=0.20; extra == "forge"
|
|
21
|
+
Provides-Extra: cron
|
|
22
|
+
Requires-Dist: croniter>=2.0; extra == "cron"
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: soleclaw[cron,forge,telegram,viking]; extra == "all"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
soleclaw-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Soleclaw
|
|
2
|
+
|
|
3
|
+
A self-evolving personal AI assistant. Instead of shipping fixed tools, soleclaw **forges its own** — the agent identifies what it needs, generates the code, and integrates it into its toolkit permanently.
|
|
4
|
+
|
|
5
|
+
Inspired by [nanobot](https://github.com/HKUDS/nanobot) and [openclaw](https://github.com/anthropics/openclaw). Built on [claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk).
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Most agent frameworks extend capabilities through markdown skills that teach the agent how to chain built-in tools. Every invocation requires LLM reasoning through the same chain. Skills are knowledge, not capability.
|
|
10
|
+
|
|
11
|
+
Soleclaw's forge generates **real executable code** registered as first-class tools. The LLM's role shifts from multi-step orchestrator to single-step dispatcher — pick the right tool, pass the right arguments, done. All logic lives in the generated tool's code, not in the LLM's reasoning chain.
|
|
12
|
+
|
|
13
|
+
The tool library is model-agnostic. Swap Claude for another model and every tool, every piece of data continues to work.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
User (Telegram / CLI)
|
|
19
|
+
→ Channel Layer
|
|
20
|
+
→ SoleclawBridge
|
|
21
|
+
├── ContextBuilder → system prompt (identity, memory, skills, tools)
|
|
22
|
+
├── @tool functions → in-process MCP server
|
|
23
|
+
└── ClaudeSDKClient → LLM calls + tool execution loop
|
|
24
|
+
→ Bus (OutboundMessage)
|
|
25
|
+
→ Channel → User
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
soleclaw/
|
|
30
|
+
├── core/ Bridge, context builder, bootstrap
|
|
31
|
+
├── tools/ MCP tool definitions + user tool library
|
|
32
|
+
├── forge/ Tool generation engine
|
|
33
|
+
├── memory/ Local backend + OpenViking (optional)
|
|
34
|
+
├── cron/ Scheduled tasks (cron/every/at)
|
|
35
|
+
├── skills/ SKILL.md loader
|
|
36
|
+
├── channels/ Telegram, CLI
|
|
37
|
+
├── bus/ Async message routing
|
|
38
|
+
├── config/ Pydantic config schema
|
|
39
|
+
└── cli/ CLI commands (typer)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
~2900 lines of core code, 39 source files.
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone <repo-url> && cd soleclaw
|
|
50
|
+
uv sync --all-extras
|
|
51
|
+
|
|
52
|
+
# Configure (interactive wizard)
|
|
53
|
+
soleclaw configure
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Run as gateway with telegram, configure can also run gateway automatically
|
|
60
|
+
soleclaw gateway start
|
|
61
|
+
|
|
62
|
+
# Interactive CLI chat
|
|
63
|
+
soleclaw agent
|
|
64
|
+
soleclaw agent "hello"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Forge — Tool Generation
|
|
68
|
+
|
|
69
|
+
When the agent identifies a missing capability, it invokes the forge:
|
|
70
|
+
|
|
71
|
+
1. Agent proposes a tool to the user
|
|
72
|
+
2. User confirms
|
|
73
|
+
3. `forge_tool` spawns a ClaudeSDKClient sub-session to generate code
|
|
74
|
+
4. Generated tool lands in `~/.soleclaw/tool-library/<name>/`
|
|
75
|
+
5. Tool is immediately available via `run_user_tool`
|
|
76
|
+
|
|
77
|
+
Tool library structure:
|
|
78
|
+
```
|
|
79
|
+
tool-library/
|
|
80
|
+
└── <tool-name>/
|
|
81
|
+
├── manifest.json # name, description, parameters
|
|
82
|
+
└── tool.py # async def execute(args: dict) -> dict
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All tool data is stored in a shared SQLite database (`~/.soleclaw/data/store.db`), so tools can cross-reference each other's data.
|
|
86
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "soleclaw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Self-evolving personal AI assistant"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pydantic>=2.0",
|
|
8
|
+
"pydantic-settings>=2.0",
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"claude-agent-sdk>=0.1",
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
"prompt-toolkit>=3.0",
|
|
13
|
+
"loguru>=0.7",
|
|
14
|
+
"rich>=13.0",
|
|
15
|
+
"python-telegram-bot>=21.0,<22.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
telegram = ["python-telegram-bot>=21.0"]
|
|
20
|
+
viking = ["openviking>=0.1"]
|
|
21
|
+
forge = ["aiosqlite>=0.20"]
|
|
22
|
+
cron = ["croniter>=2.0"]
|
|
23
|
+
all = ["soleclaw[telegram,viking,forge,cron]"]
|
|
24
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
soleclaw = "soleclaw.cli.commands:app"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=61.0"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
testpaths = ["tests"]
|
soleclaw-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class InboundMessage:
|
|
9
|
+
channel: str
|
|
10
|
+
sender_id: str
|
|
11
|
+
chat_id: str
|
|
12
|
+
content: str
|
|
13
|
+
thread_id: str = ""
|
|
14
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
15
|
+
media: list[str] = field(default_factory=list)
|
|
16
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def session_key(self) -> str:
|
|
20
|
+
return f"{self.channel}:{self.chat_id}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class OutboundMessage:
|
|
25
|
+
channel: str
|
|
26
|
+
chat_id: str
|
|
27
|
+
content: str
|
|
28
|
+
thread_id: str = ""
|
|
29
|
+
reply_to: str | None = None
|
|
30
|
+
media: list[str] = field(default_factory=list)
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
from .events import InboundMessage, OutboundMessage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MessageBus:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
|
9
|
+
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
|
10
|
+
|
|
11
|
+
async def publish_inbound(self, msg: InboundMessage) -> None:
|
|
12
|
+
await self.inbound.put(msg)
|
|
13
|
+
|
|
14
|
+
async def consume_inbound(self) -> InboundMessage:
|
|
15
|
+
return await self.inbound.get()
|
|
16
|
+
|
|
17
|
+
async def publish_outbound(self, msg: OutboundMessage) -> None:
|
|
18
|
+
await self.outbound.put(msg)
|
|
19
|
+
|
|
20
|
+
async def consume_outbound(self) -> OutboundMessage:
|
|
21
|
+
return await self.outbound.get()
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..bus.events import InboundMessage, OutboundMessage
|
|
6
|
+
from ..bus.queue import MessageBus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseChannel(ABC):
|
|
10
|
+
name: str = "base"
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Any, bus: MessageBus):
|
|
13
|
+
self.config = config
|
|
14
|
+
self.bus = bus
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def start(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def stop(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def send(self, msg: OutboundMessage) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def send_typing(self, chat_id: str, thread_id: str = "") -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
async def _handle_message(self, sender_id: str, chat_id: str, content: str) -> None:
|
|
29
|
+
msg = InboundMessage(channel=self.name, sender_id=sender_id, chat_id=chat_id, content=content)
|
|
30
|
+
await self.bus.publish_inbound(msg)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..bus.events import OutboundMessage
|
|
4
|
+
from ..bus.queue import MessageBus
|
|
5
|
+
from .base import BaseChannel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CLIChannel(BaseChannel):
|
|
9
|
+
name = "cli"
|
|
10
|
+
|
|
11
|
+
def __init__(self, bus: MessageBus):
|
|
12
|
+
super().__init__(config=None, bus=bus)
|
|
13
|
+
|
|
14
|
+
async def start(self) -> None:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
async def stop(self) -> None:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
async def send(self, msg: OutboundMessage) -> None:
|
|
21
|
+
print(f"\n{msg.content}\n")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import BaseChannel
|
|
8
|
+
from ..bus.queue import MessageBus
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChannelManager:
|
|
14
|
+
def __init__(self, bus: MessageBus) -> None:
|
|
15
|
+
self.bus = bus
|
|
16
|
+
self._channels: dict[str, BaseChannel] = {}
|
|
17
|
+
self._running = False
|
|
18
|
+
|
|
19
|
+
def add(self, channel: BaseChannel) -> None:
|
|
20
|
+
self._channels[channel.name] = channel
|
|
21
|
+
|
|
22
|
+
async def start_all(self) -> None:
|
|
23
|
+
for name, ch in self._channels.items():
|
|
24
|
+
try:
|
|
25
|
+
await ch.start()
|
|
26
|
+
log.info("Channel %s started", name)
|
|
27
|
+
except Exception:
|
|
28
|
+
log.exception("Failed to start channel %s", name)
|
|
29
|
+
|
|
30
|
+
async def stop_all(self) -> None:
|
|
31
|
+
self._running = False
|
|
32
|
+
for name, ch in self._channels.items():
|
|
33
|
+
try:
|
|
34
|
+
await ch.stop()
|
|
35
|
+
log.info("Channel %s stopped", name)
|
|
36
|
+
except Exception:
|
|
37
|
+
log.exception("Failed to stop channel %s", name)
|
|
38
|
+
|
|
39
|
+
async def send_typing(self, channel: str, chat_id: str, thread_id: str = "") -> None:
|
|
40
|
+
ch = self._channels.get(channel)
|
|
41
|
+
if ch:
|
|
42
|
+
await ch.send_typing(chat_id, thread_id)
|
|
43
|
+
|
|
44
|
+
async def _dispatch_loop(self) -> None:
|
|
45
|
+
while self._running:
|
|
46
|
+
try:
|
|
47
|
+
msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0)
|
|
48
|
+
except asyncio.TimeoutError:
|
|
49
|
+
continue
|
|
50
|
+
ch = self._channels.get(msg.channel)
|
|
51
|
+
if not ch:
|
|
52
|
+
log.warning("No channel %r for outbound message to %s", msg.channel, msg.chat_id)
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
await ch.send(msg)
|
|
56
|
+
except Exception:
|
|
57
|
+
log.exception("Failed to send outbound on channel %s", msg.channel)
|
|
58
|
+
|
|
59
|
+
async def run(self) -> None:
|
|
60
|
+
self._running = True
|
|
61
|
+
await self.start_all()
|
|
62
|
+
log.info("ChannelManager running with %d channel(s)", len(self._channels))
|
|
63
|
+
try:
|
|
64
|
+
await self._dispatch_loop()
|
|
65
|
+
finally:
|
|
66
|
+
await self.stop_all()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from telegram import Update
|
|
6
|
+
from telegram.ext import Application, MessageHandler, filters, ContextTypes
|
|
7
|
+
|
|
8
|
+
from .base import BaseChannel
|
|
9
|
+
from ..bus.queue import MessageBus
|
|
10
|
+
from ..bus.events import InboundMessage, OutboundMessage
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
MAX_MESSAGE_LENGTH = 4096
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _markdown_to_html(text: str) -> str:
|
|
18
|
+
if not text:
|
|
19
|
+
return ""
|
|
20
|
+
|
|
21
|
+
code_blocks: list[str] = []
|
|
22
|
+
def _save_block(m: re.Match) -> str:
|
|
23
|
+
code_blocks.append(m.group(1))
|
|
24
|
+
return f"\x00CB{len(code_blocks) - 1}\x00"
|
|
25
|
+
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', _save_block, text)
|
|
26
|
+
|
|
27
|
+
inline_codes: list[str] = []
|
|
28
|
+
def _save_inline(m: re.Match) -> str:
|
|
29
|
+
inline_codes.append(m.group(1))
|
|
30
|
+
return f"\x00IC{len(inline_codes) - 1}\x00"
|
|
31
|
+
text = re.sub(r'`([^`]+)`', _save_inline, text)
|
|
32
|
+
|
|
33
|
+
text = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', text, flags=re.MULTILINE)
|
|
34
|
+
text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE)
|
|
35
|
+
|
|
36
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
37
|
+
|
|
38
|
+
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
|
39
|
+
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|
40
|
+
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
|
41
|
+
text = re.sub(r'(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])', r'<i>\1</i>', text)
|
|
42
|
+
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
|
|
43
|
+
text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
def _esc(s: str) -> str:
|
|
46
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
47
|
+
|
|
48
|
+
for i, code in enumerate(inline_codes):
|
|
49
|
+
text = text.replace(f"\x00IC{i}\x00", f"<code>{_esc(code)}</code>")
|
|
50
|
+
for i, code in enumerate(code_blocks):
|
|
51
|
+
text = text.replace(f"\x00CB{i}\x00", f"<pre><code>{_esc(code)}</code></pre>")
|
|
52
|
+
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TelegramChannel(BaseChannel):
|
|
57
|
+
name = "telegram"
|
|
58
|
+
|
|
59
|
+
def __init__(self, bus: MessageBus, token: str, allowed_users: list[str]):
|
|
60
|
+
super().__init__(config=None, bus=bus)
|
|
61
|
+
self._token = token
|
|
62
|
+
self._allowed_users = set(allowed_users) if allowed_users else set()
|
|
63
|
+
self._app: Application | None = None
|
|
64
|
+
|
|
65
|
+
def _is_allowed(self, username: str) -> bool:
|
|
66
|
+
if not self._allowed_users:
|
|
67
|
+
return True
|
|
68
|
+
return username in self._allowed_users
|
|
69
|
+
|
|
70
|
+
async def _handle_tg_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
71
|
+
user = update.effective_user
|
|
72
|
+
if not user or not self._is_allowed(user.username or ""):
|
|
73
|
+
logger.debug("Ignored message from %s", user)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if not update.message or not update.message.text:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
text = update.message.text
|
|
80
|
+
reply = update.message.reply_to_message
|
|
81
|
+
if reply and reply.text:
|
|
82
|
+
sender_name = reply.from_user.first_name if reply.from_user else "Unknown"
|
|
83
|
+
text = f"[Replying to {sender_name}: {reply.text}]\n\n{text}"
|
|
84
|
+
|
|
85
|
+
chat_id = str(update.effective_chat.id)
|
|
86
|
+
thread_id = str(update.message.message_thread_id) if update.message.message_thread_id else ""
|
|
87
|
+
sender = user.username or ""
|
|
88
|
+
logger.debug("Telegram inbound: chat=%s thread=%s user=%s text=%s", chat_id, thread_id, sender, text[:100])
|
|
89
|
+
|
|
90
|
+
msg = InboundMessage(channel=self.name, sender_id=sender, chat_id=chat_id, content=text, thread_id=thread_id)
|
|
91
|
+
await self.bus.publish_inbound(msg)
|
|
92
|
+
|
|
93
|
+
async def send_typing(self, chat_id: str, thread_id: str = "") -> None:
|
|
94
|
+
if not self._app:
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
kwargs: dict = {"chat_id": int(chat_id), "action": "typing"}
|
|
98
|
+
if thread_id:
|
|
99
|
+
kwargs["message_thread_id"] = int(thread_id)
|
|
100
|
+
await self._app.bot.send_chat_action(**kwargs)
|
|
101
|
+
except Exception:
|
|
102
|
+
logger.debug("Failed to send typing to %s", chat_id)
|
|
103
|
+
|
|
104
|
+
async def send(self, msg: OutboundMessage) -> None:
|
|
105
|
+
if not self._app:
|
|
106
|
+
return
|
|
107
|
+
thread_kwargs: dict = {}
|
|
108
|
+
if msg.thread_id:
|
|
109
|
+
thread_kwargs["message_thread_id"] = int(msg.thread_id)
|
|
110
|
+
html = _markdown_to_html(msg.content)
|
|
111
|
+
chunks = self._split_message(html)
|
|
112
|
+
for chunk in chunks:
|
|
113
|
+
try:
|
|
114
|
+
await self._app.bot.send_message(
|
|
115
|
+
chat_id=int(msg.chat_id), text=chunk, parse_mode="HTML",
|
|
116
|
+
**thread_kwargs,
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
logger.debug("HTML send failed, falling back to plain text")
|
|
120
|
+
plain_chunks = self._split_message(msg.content)
|
|
121
|
+
for pc in plain_chunks:
|
|
122
|
+
await self._app.bot.send_message(
|
|
123
|
+
chat_id=int(msg.chat_id), text=pc, **thread_kwargs,
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
async def start(self) -> None:
|
|
128
|
+
self._app = Application.builder().token(self._token).build()
|
|
129
|
+
self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_tg_message))
|
|
130
|
+
await self._app.initialize()
|
|
131
|
+
await self._app.start()
|
|
132
|
+
await self._app.updater.start_polling(
|
|
133
|
+
allowed_updates=["message"],
|
|
134
|
+
drop_pending_updates=True,
|
|
135
|
+
)
|
|
136
|
+
logger.info("Telegram channel started")
|
|
137
|
+
|
|
138
|
+
async def stop(self) -> None:
|
|
139
|
+
if self._app:
|
|
140
|
+
await self._app.updater.stop()
|
|
141
|
+
await self._app.stop()
|
|
142
|
+
await self._app.shutdown()
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _split_message(text: str) -> list[str]:
|
|
146
|
+
if len(text) <= MAX_MESSAGE_LENGTH:
|
|
147
|
+
return [text]
|
|
148
|
+
chunks = []
|
|
149
|
+
while text:
|
|
150
|
+
if len(text) <= MAX_MESSAGE_LENGTH:
|
|
151
|
+
chunks.append(text)
|
|
152
|
+
break
|
|
153
|
+
split_at = text.rfind("\n", 0, MAX_MESSAGE_LENGTH)
|
|
154
|
+
if split_at == -1:
|
|
155
|
+
split_at = MAX_MESSAGE_LENGTH
|
|
156
|
+
chunks.append(text[:split_at])
|
|
157
|
+
text = text[split_at:].lstrip("\n")
|
|
158
|
+
return chunks
|
|
File without changes
|