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.
Files changed (69) hide show
  1. soleclaw-0.1.0/PKG-INFO +27 -0
  2. soleclaw-0.1.0/README.md +86 -0
  3. soleclaw-0.1.0/pyproject.toml +38 -0
  4. soleclaw-0.1.0/setup.cfg +4 -0
  5. soleclaw-0.1.0/src/soleclaw/__init__.py +0 -0
  6. soleclaw-0.1.0/src/soleclaw/bus/__init__.py +0 -0
  7. soleclaw-0.1.0/src/soleclaw/bus/events.py +31 -0
  8. soleclaw-0.1.0/src/soleclaw/bus/queue.py +21 -0
  9. soleclaw-0.1.0/src/soleclaw/channels/__init__.py +0 -0
  10. soleclaw-0.1.0/src/soleclaw/channels/base.py +30 -0
  11. soleclaw-0.1.0/src/soleclaw/channels/cli.py +21 -0
  12. soleclaw-0.1.0/src/soleclaw/channels/manager.py +66 -0
  13. soleclaw-0.1.0/src/soleclaw/channels/telegram.py +158 -0
  14. soleclaw-0.1.0/src/soleclaw/cli/__init__.py +0 -0
  15. soleclaw-0.1.0/src/soleclaw/cli/commands.py +394 -0
  16. soleclaw-0.1.0/src/soleclaw/cli/configure.py +107 -0
  17. soleclaw-0.1.0/src/soleclaw/config/__init__.py +0 -0
  18. soleclaw-0.1.0/src/soleclaw/config/schema.py +59 -0
  19. soleclaw-0.1.0/src/soleclaw/core/__init__.py +1 -0
  20. soleclaw-0.1.0/src/soleclaw/core/bootstrap.py +421 -0
  21. soleclaw-0.1.0/src/soleclaw/core/bridge.py +206 -0
  22. soleclaw-0.1.0/src/soleclaw/core/context.py +111 -0
  23. soleclaw-0.1.0/src/soleclaw/core/pidfile.py +38 -0
  24. soleclaw-0.1.0/src/soleclaw/cron/__init__.py +0 -0
  25. soleclaw-0.1.0/src/soleclaw/cron/service.py +254 -0
  26. soleclaw-0.1.0/src/soleclaw/cron/store.py +109 -0
  27. soleclaw-0.1.0/src/soleclaw/cron/types.py +63 -0
  28. soleclaw-0.1.0/src/soleclaw/forge/__init__.py +0 -0
  29. soleclaw-0.1.0/src/soleclaw/forge/engine.py +91 -0
  30. soleclaw-0.1.0/src/soleclaw/forge/lifecycle.py +30 -0
  31. soleclaw-0.1.0/src/soleclaw/forge/validator.py +37 -0
  32. soleclaw-0.1.0/src/soleclaw/memory/__init__.py +0 -0
  33. soleclaw-0.1.0/src/soleclaw/memory/base.py +29 -0
  34. soleclaw-0.1.0/src/soleclaw/memory/local.py +51 -0
  35. soleclaw-0.1.0/src/soleclaw/memory/viking.py +137 -0
  36. soleclaw-0.1.0/src/soleclaw/skills/__init__.py +0 -0
  37. soleclaw-0.1.0/src/soleclaw/skills/loader.py +71 -0
  38. soleclaw-0.1.0/src/soleclaw/tools/__init__.py +0 -0
  39. soleclaw-0.1.0/src/soleclaw/tools/library/__init__.py +0 -0
  40. soleclaw-0.1.0/src/soleclaw/tools/library/registry.py +77 -0
  41. soleclaw-0.1.0/src/soleclaw/tools/library/runner.py +22 -0
  42. soleclaw-0.1.0/src/soleclaw/tools/library/schema.py +14 -0
  43. soleclaw-0.1.0/src/soleclaw/tools/sdk_tools.py +258 -0
  44. soleclaw-0.1.0/src/soleclaw.egg-info/PKG-INFO +27 -0
  45. soleclaw-0.1.0/src/soleclaw.egg-info/SOURCES.txt +67 -0
  46. soleclaw-0.1.0/src/soleclaw.egg-info/dependency_links.txt +1 -0
  47. soleclaw-0.1.0/src/soleclaw.egg-info/entry_points.txt +2 -0
  48. soleclaw-0.1.0/src/soleclaw.egg-info/requires.txt +28 -0
  49. soleclaw-0.1.0/src/soleclaw.egg-info/top_level.txt +1 -0
  50. soleclaw-0.1.0/tests/test_bootstrap.py +42 -0
  51. soleclaw-0.1.0/tests/test_bridge.py +60 -0
  52. soleclaw-0.1.0/tests/test_bus.py +21 -0
  53. soleclaw-0.1.0/tests/test_channel_manager.py +103 -0
  54. soleclaw-0.1.0/tests/test_cli_channel.py +7 -0
  55. soleclaw-0.1.0/tests/test_config.py +34 -0
  56. soleclaw-0.1.0/tests/test_configure.py +41 -0
  57. soleclaw-0.1.0/tests/test_context.py +49 -0
  58. soleclaw-0.1.0/tests/test_cron.py +256 -0
  59. soleclaw-0.1.0/tests/test_e2e_bootstrap.py +47 -0
  60. soleclaw-0.1.0/tests/test_e2e_cron.py +101 -0
  61. soleclaw-0.1.0/tests/test_e2e_forge.py +52 -0
  62. soleclaw-0.1.0/tests/test_forge.py +47 -0
  63. soleclaw-0.1.0/tests/test_memory.py +55 -0
  64. soleclaw-0.1.0/tests/test_pidfile.py +34 -0
  65. soleclaw-0.1.0/tests/test_sdk_tools.py +91 -0
  66. soleclaw-0.1.0/tests/test_skills.py +31 -0
  67. soleclaw-0.1.0/tests/test_status.py +61 -0
  68. soleclaw-0.1.0/tests/test_telegram.py +59 -0
  69. soleclaw-0.1.0/tests/test_tool_library.py +40 -0
@@ -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"
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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