pythonclaw 0.2.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.
- pythonclaw/__init__.py +17 -0
- pythonclaw/__main__.py +6 -0
- pythonclaw/channels/discord_bot.py +231 -0
- pythonclaw/channels/telegram_bot.py +236 -0
- pythonclaw/config.py +190 -0
- pythonclaw/core/__init__.py +25 -0
- pythonclaw/core/agent.py +773 -0
- pythonclaw/core/compaction.py +220 -0
- pythonclaw/core/knowledge/rag.py +93 -0
- pythonclaw/core/llm/anthropic_client.py +107 -0
- pythonclaw/core/llm/base.py +26 -0
- pythonclaw/core/llm/gemini_client.py +139 -0
- pythonclaw/core/llm/openai_compatible.py +39 -0
- pythonclaw/core/llm/response.py +57 -0
- pythonclaw/core/memory/manager.py +120 -0
- pythonclaw/core/memory/storage.py +164 -0
- pythonclaw/core/persistent_agent.py +103 -0
- pythonclaw/core/retrieval/__init__.py +6 -0
- pythonclaw/core/retrieval/chunker.py +78 -0
- pythonclaw/core/retrieval/dense.py +152 -0
- pythonclaw/core/retrieval/fusion.py +51 -0
- pythonclaw/core/retrieval/reranker.py +112 -0
- pythonclaw/core/retrieval/retriever.py +166 -0
- pythonclaw/core/retrieval/sparse.py +69 -0
- pythonclaw/core/session_store.py +269 -0
- pythonclaw/core/skill_loader.py +322 -0
- pythonclaw/core/skillhub.py +290 -0
- pythonclaw/core/tools.py +622 -0
- pythonclaw/core/utils.py +64 -0
- pythonclaw/daemon.py +221 -0
- pythonclaw/init.py +61 -0
- pythonclaw/main.py +489 -0
- pythonclaw/onboard.py +290 -0
- pythonclaw/scheduler/cron.py +310 -0
- pythonclaw/scheduler/heartbeat.py +178 -0
- pythonclaw/server.py +145 -0
- pythonclaw/session_manager.py +104 -0
- pythonclaw/templates/persona/demo_persona.md +2 -0
- pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
- pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
- pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/communication/email/send_email.py +88 -0
- pythonclaw/templates/skills/data/CATEGORY.md +4 -0
- pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
- pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
- pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
- pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
- pythonclaw/templates/skills/data/news/SKILL.md +39 -0
- pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/news/search_news.py +57 -0
- pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
- pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
- pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
- pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
- pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
- pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/weather/weather.py +142 -0
- pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
- pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
- pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
- pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
- pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
- pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
- pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/github/gh.py +165 -0
- pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
- pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/http_request/request.py +90 -0
- pythonclaw/templates/skills/google/CATEGORY.md +4 -0
- pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
- pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
- pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
- pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
- pythonclaw/templates/skills/system/CATEGORY.md +4 -0
- pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
- pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
- pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
- pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
- pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
- pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
- pythonclaw/templates/skills/system/random/SKILL.md +33 -0
- pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/random/random_util.py +45 -0
- pythonclaw/templates/skills/system/time/SKILL.md +33 -0
- pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/time/time_util.py +81 -0
- pythonclaw/templates/skills/text/CATEGORY.md +4 -0
- pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
- pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/text/translator/translate.py +66 -0
- pythonclaw/templates/skills/web/CATEGORY.md +4 -0
- pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
- pythonclaw/templates/soul/SOUL.md +54 -0
- pythonclaw/web/__init__.py +1 -0
- pythonclaw/web/app.py +585 -0
- pythonclaw/web/static/favicon.png +0 -0
- pythonclaw/web/static/index.html +1318 -0
- pythonclaw/web/static/logo.png +0 -0
- pythonclaw-0.2.0.dist-info/METADATA +410 -0
- pythonclaw-0.2.0.dist-info/RECORD +112 -0
- pythonclaw-0.2.0.dist-info/WHEEL +5 -0
- pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
- pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
- pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
pythonclaw/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PythonClaw — Open-source autonomous AI agent framework."""
|
|
2
|
+
|
|
3
|
+
from . import config
|
|
4
|
+
from .core.agent import Agent
|
|
5
|
+
from .core.llm.base import LLMProvider
|
|
6
|
+
from .core.llm.openai_compatible import OpenAICompatibleProvider
|
|
7
|
+
from .init import init
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Agent",
|
|
12
|
+
"LLMProvider",
|
|
13
|
+
"OpenAICompatibleProvider",
|
|
14
|
+
"config",
|
|
15
|
+
"init",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
pythonclaw/__main__.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discord channel for PythonClaw.
|
|
3
|
+
|
|
4
|
+
Session IDs: "discord:{user_id}" (DMs) or "discord:{channel_id}" (guilds)
|
|
5
|
+
|
|
6
|
+
Commands
|
|
7
|
+
--------
|
|
8
|
+
!reset — discard and recreate the current session
|
|
9
|
+
!status — show session info
|
|
10
|
+
!compact [hint] — compact conversation history
|
|
11
|
+
<text> — forwarded to Agent.chat(), reply sent back
|
|
12
|
+
|
|
13
|
+
The bot responds to:
|
|
14
|
+
- Direct messages (always)
|
|
15
|
+
- Channel mentions (@bot message) in guilds
|
|
16
|
+
- Optionally all messages in whitelisted channels
|
|
17
|
+
|
|
18
|
+
Access control
|
|
19
|
+
--------------
|
|
20
|
+
Set DISCORD_ALLOWED_USERS to a comma-separated list of Discord user IDs.
|
|
21
|
+
Set DISCORD_ALLOWED_CHANNELS to restrict which guild channels the bot listens in.
|
|
22
|
+
Leave empty to allow all.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
import discord
|
|
31
|
+
|
|
32
|
+
from .. import config
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..session_manager import SessionManager
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
MAX_MSG_LEN = 2000 # Discord message character limit
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DiscordBot:
|
|
43
|
+
"""
|
|
44
|
+
Discord channel — pure I/O layer.
|
|
45
|
+
|
|
46
|
+
Routes messages to the appropriate Agent via the shared SessionManager.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
session_manager: "SessionManager",
|
|
52
|
+
token: str,
|
|
53
|
+
allowed_users: list[int] | None = None,
|
|
54
|
+
allowed_channels: list[int] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._sm = session_manager
|
|
57
|
+
self._token = token
|
|
58
|
+
self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
|
|
59
|
+
self._allowed_channels: set[int] = set(allowed_channels) if allowed_channels else set()
|
|
60
|
+
|
|
61
|
+
intents = discord.Intents.default()
|
|
62
|
+
intents.message_content = True
|
|
63
|
+
self._client = discord.Client(intents=intents)
|
|
64
|
+
self._setup_handlers()
|
|
65
|
+
|
|
66
|
+
# ── Session ID convention ─────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _session_id(source_id: int, is_dm: bool = False) -> str:
|
|
70
|
+
prefix = "discord:dm" if is_dm else "discord"
|
|
71
|
+
return f"{prefix}:{source_id}"
|
|
72
|
+
|
|
73
|
+
# ── Access control ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def _is_allowed_user(self, user_id: int) -> bool:
|
|
76
|
+
if not self._allowed_users:
|
|
77
|
+
return True
|
|
78
|
+
return user_id in self._allowed_users
|
|
79
|
+
|
|
80
|
+
def _is_allowed_channel(self, channel_id: int) -> bool:
|
|
81
|
+
if not self._allowed_channels:
|
|
82
|
+
return True
|
|
83
|
+
return channel_id in self._allowed_channels
|
|
84
|
+
|
|
85
|
+
# ── Message splitting ─────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _split_message(text: str, limit: int = MAX_MSG_LEN) -> list[str]:
|
|
89
|
+
if len(text) <= limit:
|
|
90
|
+
return [text]
|
|
91
|
+
chunks = []
|
|
92
|
+
while text:
|
|
93
|
+
chunks.append(text[:limit])
|
|
94
|
+
text = text[limit:]
|
|
95
|
+
return chunks
|
|
96
|
+
|
|
97
|
+
# ── Handlers ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def _setup_handlers(self) -> None:
|
|
100
|
+
client = self._client
|
|
101
|
+
|
|
102
|
+
@client.event
|
|
103
|
+
async def on_ready():
|
|
104
|
+
logger.info("[Discord] Logged in as %s (id=%s)", client.user.name, client.user.id)
|
|
105
|
+
|
|
106
|
+
@client.event
|
|
107
|
+
async def on_message(message: discord.Message):
|
|
108
|
+
if message.author == client.user:
|
|
109
|
+
return
|
|
110
|
+
if message.author.bot:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
is_dm = isinstance(message.channel, discord.DMChannel)
|
|
114
|
+
is_mentioned = client.user in message.mentions if not is_dm else False
|
|
115
|
+
|
|
116
|
+
# In guilds, only respond to mentions or whitelisted channels
|
|
117
|
+
if not is_dm and not is_mentioned and not self._is_allowed_channel(message.channel.id):
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if not self._is_allowed_user(message.author.id):
|
|
121
|
+
await message.reply("Sorry, you are not authorised to use this bot.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
content = message.content.strip()
|
|
125
|
+
# Remove bot mention from the beginning
|
|
126
|
+
if is_mentioned and client.user:
|
|
127
|
+
content = content.replace(f"<@{client.user.id}>", "").strip()
|
|
128
|
+
|
|
129
|
+
if not content:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Command dispatch
|
|
133
|
+
if content.startswith("!reset"):
|
|
134
|
+
await self._cmd_reset(message, is_dm)
|
|
135
|
+
return
|
|
136
|
+
if content.startswith("!status"):
|
|
137
|
+
await self._cmd_status(message, is_dm)
|
|
138
|
+
return
|
|
139
|
+
if content.startswith("!compact"):
|
|
140
|
+
hint = content[len("!compact"):].strip() or None
|
|
141
|
+
await self._cmd_compact(message, is_dm, hint)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
await self._handle_chat(message, content, is_dm)
|
|
145
|
+
|
|
146
|
+
# ── Command implementations ───────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async def _cmd_reset(self, message: discord.Message, is_dm: bool) -> None:
|
|
149
|
+
sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
|
|
150
|
+
self._sm.reset(sid)
|
|
151
|
+
await message.reply("Session reset. Starting fresh!")
|
|
152
|
+
|
|
153
|
+
async def _cmd_status(self, message: discord.Message, is_dm: bool) -> None:
|
|
154
|
+
sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
|
|
155
|
+
agent = self._sm.get_or_create(sid)
|
|
156
|
+
from ..core.compaction import estimate_tokens
|
|
157
|
+
status = (
|
|
158
|
+
f"**Session Status**\n"
|
|
159
|
+
f"```\n"
|
|
160
|
+
f"Session ID : {sid}\n"
|
|
161
|
+
f"Provider : {type(agent.provider).__name__}\n"
|
|
162
|
+
f"Skills : {len(agent.loaded_skill_names)} loaded\n"
|
|
163
|
+
f"Memories : {len(agent.memory.list_all())} entries\n"
|
|
164
|
+
f"History : {len(agent.messages)} messages\n"
|
|
165
|
+
f"Est. tokens : ~{estimate_tokens(agent.messages)}\n"
|
|
166
|
+
f"Compactions : {agent.compaction_count}\n"
|
|
167
|
+
f"```"
|
|
168
|
+
)
|
|
169
|
+
await message.reply(status)
|
|
170
|
+
|
|
171
|
+
async def _cmd_compact(self, message: discord.Message, is_dm: bool, hint: str | None) -> None:
|
|
172
|
+
sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
|
|
173
|
+
agent = self._sm.get_or_create(sid)
|
|
174
|
+
await message.reply("Compacting conversation history...")
|
|
175
|
+
try:
|
|
176
|
+
result = agent.compact(instruction=hint)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logger.exception("[Discord] compact() raised an exception")
|
|
179
|
+
result = f"Compaction failed: {exc}"
|
|
180
|
+
for chunk in self._split_message(result or "(no result)"):
|
|
181
|
+
await message.reply(chunk)
|
|
182
|
+
|
|
183
|
+
async def _handle_chat(self, message: discord.Message, content: str, is_dm: bool) -> None:
|
|
184
|
+
sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
|
|
185
|
+
agent = self._sm.get_or_create(sid)
|
|
186
|
+
async with message.channel.typing():
|
|
187
|
+
try:
|
|
188
|
+
response = agent.chat(content)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.exception("[Discord] Agent.chat() raised an exception")
|
|
191
|
+
response = f"Sorry, something went wrong: {exc}"
|
|
192
|
+
for chunk in self._split_message(response or "(no response)"):
|
|
193
|
+
await message.reply(chunk)
|
|
194
|
+
|
|
195
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
async def start_async(self) -> None:
|
|
198
|
+
"""Non-blocking start — for use inside an existing asyncio event loop."""
|
|
199
|
+
logger.info("[Discord] Starting bot (async mode)...")
|
|
200
|
+
await self._client.start(self._token)
|
|
201
|
+
|
|
202
|
+
async def stop_async(self) -> None:
|
|
203
|
+
logger.info("[Discord] Stopping bot...")
|
|
204
|
+
await self._client.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Utility ───────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def create_bot(session_manager: "SessionManager") -> "DiscordBot":
|
|
210
|
+
"""Create a DiscordBot from pythonclaw.json / env vars."""
|
|
211
|
+
token = config.get_str(
|
|
212
|
+
"channels", "discord", "token", env="DISCORD_BOT_TOKEN",
|
|
213
|
+
)
|
|
214
|
+
if not token:
|
|
215
|
+
raise ValueError("Discord token not set (env DISCORD_BOT_TOKEN or channels.discord.token)")
|
|
216
|
+
allowed_users = config.get_int_list(
|
|
217
|
+
"channels", "discord", "allowedUsers", env="DISCORD_ALLOWED_USERS",
|
|
218
|
+
)
|
|
219
|
+
allowed_channels = config.get_int_list(
|
|
220
|
+
"channels", "discord", "allowedChannels", env="DISCORD_ALLOWED_CHANNELS",
|
|
221
|
+
)
|
|
222
|
+
return DiscordBot(
|
|
223
|
+
session_manager=session_manager,
|
|
224
|
+
token=token,
|
|
225
|
+
allowed_users=allowed_users or None,
|
|
226
|
+
allowed_channels=allowed_channels or None,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Backward-compatible alias
|
|
231
|
+
create_bot_from_env = create_bot
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram channel for pythonclaw.
|
|
3
|
+
|
|
4
|
+
Telegram is purely a *channel* — it handles sending and receiving messages.
|
|
5
|
+
Session lifecycle (which Agent handles which chat) is delegated to the
|
|
6
|
+
SessionManager, which is shared across all channels and the cron scheduler.
|
|
7
|
+
|
|
8
|
+
Session IDs used by this channel: "telegram:{chat_id}"
|
|
9
|
+
|
|
10
|
+
Commands
|
|
11
|
+
--------
|
|
12
|
+
/start — greeting + usage hint
|
|
13
|
+
/reset — discard and recreate the current session
|
|
14
|
+
/status — show session info (provider, skills, memory, tokens, compactions)
|
|
15
|
+
/compact [hint] — compact conversation history
|
|
16
|
+
<text> — forwarded to Agent.chat(), reply sent back
|
|
17
|
+
|
|
18
|
+
Access control
|
|
19
|
+
--------------
|
|
20
|
+
Set TELEGRAM_ALLOWED_USERS to a comma-separated list of integer Telegram user
|
|
21
|
+
IDs to restrict access. Leave empty (or unset) to allow all users.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from telegram import Update
|
|
30
|
+
from telegram.ext import (
|
|
31
|
+
Application,
|
|
32
|
+
CommandHandler,
|
|
33
|
+
ContextTypes,
|
|
34
|
+
MessageHandler,
|
|
35
|
+
filters,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .. import config
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ..session_manager import SessionManager
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TelegramBot:
|
|
47
|
+
"""
|
|
48
|
+
Telegram channel — pure I/O layer.
|
|
49
|
+
|
|
50
|
+
Receives messages from Telegram and routes them to the appropriate Agent
|
|
51
|
+
via the shared SessionManager. Does not own or manage Agent instances.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
session_manager: "SessionManager",
|
|
57
|
+
token: str,
|
|
58
|
+
allowed_users: list[int] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._sm = session_manager
|
|
61
|
+
self._token = token
|
|
62
|
+
self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
|
|
63
|
+
self._app: Application | None = None
|
|
64
|
+
|
|
65
|
+
# ── Session ID convention ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _session_id(chat_id: int) -> str:
|
|
69
|
+
return f"telegram:{chat_id}"
|
|
70
|
+
|
|
71
|
+
# ── Push message (called by cron / heartbeat) ─────────────────────────────
|
|
72
|
+
|
|
73
|
+
async def send_message(self, chat_id: int, text: str) -> None:
|
|
74
|
+
"""Send a message to a specific chat (used by cron/heartbeat)."""
|
|
75
|
+
if self._app is None:
|
|
76
|
+
logger.warning("[Telegram] send_message called before bot is running")
|
|
77
|
+
return
|
|
78
|
+
await self._app.bot.send_message(chat_id=chat_id, text=text)
|
|
79
|
+
|
|
80
|
+
# ── Access control ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def _is_allowed(self, user_id: int) -> bool:
|
|
83
|
+
if not self._allowed_users:
|
|
84
|
+
return True
|
|
85
|
+
return user_id in self._allowed_users
|
|
86
|
+
|
|
87
|
+
async def _check_access(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
|
|
88
|
+
user = update.effective_user
|
|
89
|
+
if user is None or not self._is_allowed(user.id):
|
|
90
|
+
logger.warning("[Telegram] Rejected user_id=%s", user.id if user else "unknown")
|
|
91
|
+
await update.message.reply_text("Sorry, you are not authorised to use this bot.")
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
# ── Command handlers ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async def _cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
98
|
+
if not await self._check_access(update, context):
|
|
99
|
+
return
|
|
100
|
+
sid = self._session_id(update.effective_chat.id)
|
|
101
|
+
self._sm.get_or_create(sid)
|
|
102
|
+
await update.message.reply_text(
|
|
103
|
+
"👋 Hi! I'm your PythonClaw agent.\n\n"
|
|
104
|
+
"Just send me a message and I'll do my best to help.\n\n"
|
|
105
|
+
"Commands:\n"
|
|
106
|
+
" /start — show this message\n"
|
|
107
|
+
" /reset — start a fresh session\n"
|
|
108
|
+
" /status — show session info\n"
|
|
109
|
+
" /compact [hint] — compact conversation history"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def _cmd_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
113
|
+
if not await self._check_access(update, context):
|
|
114
|
+
return
|
|
115
|
+
sid = self._session_id(update.effective_chat.id)
|
|
116
|
+
self._sm.reset(sid)
|
|
117
|
+
await update.message.reply_text("Session reset. Starting fresh! Send me a message.")
|
|
118
|
+
|
|
119
|
+
async def _cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
120
|
+
if not await self._check_access(update, context):
|
|
121
|
+
return
|
|
122
|
+
sid = self._session_id(update.effective_chat.id)
|
|
123
|
+
agent = self._sm.get_or_create(sid)
|
|
124
|
+
from ..core.compaction import estimate_tokens
|
|
125
|
+
await update.message.reply_text(
|
|
126
|
+
f"📊 Session Status\n"
|
|
127
|
+
f" Session ID : {sid}\n"
|
|
128
|
+
f" Provider : {type(agent.provider).__name__}\n"
|
|
129
|
+
f" Skills : {len(agent.loaded_skill_names)} loaded\n"
|
|
130
|
+
f" Memories : {len(agent.memory.list_all())} entries\n"
|
|
131
|
+
f" History : {len(agent.messages)} messages\n"
|
|
132
|
+
f" Est. tokens : ~{estimate_tokens(agent.messages):,}\n"
|
|
133
|
+
f" Compactions : {agent.compaction_count}\n"
|
|
134
|
+
f" Total sessions: {len(self._sm)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def _cmd_compact(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
138
|
+
if not await self._check_access(update, context):
|
|
139
|
+
return
|
|
140
|
+
sid = self._session_id(update.effective_chat.id)
|
|
141
|
+
agent = self._sm.get_or_create(sid)
|
|
142
|
+
hint: str | None = " ".join(context.args).strip() or None if context.args else None
|
|
143
|
+
await update.message.reply_text("⏳ Compacting conversation history...")
|
|
144
|
+
try:
|
|
145
|
+
result = agent.compact(instruction=hint)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
result = f"Compaction failed: {exc}"
|
|
148
|
+
for chunk in _split_message(result):
|
|
149
|
+
await update.message.reply_text(chunk)
|
|
150
|
+
|
|
151
|
+
# ── Message handler ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
154
|
+
if not await self._check_access(update, context):
|
|
155
|
+
return
|
|
156
|
+
user_text = (update.message.text or "").strip()
|
|
157
|
+
if not user_text:
|
|
158
|
+
return
|
|
159
|
+
sid = self._session_id(update.effective_chat.id)
|
|
160
|
+
agent = self._sm.get_or_create(sid)
|
|
161
|
+
await update.message.chat.send_action("typing")
|
|
162
|
+
try:
|
|
163
|
+
response = agent.chat(user_text)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
logger.exception("[Telegram] Agent.chat() raised an exception")
|
|
166
|
+
response = f"Sorry, something went wrong: {exc}"
|
|
167
|
+
for chunk in _split_message(response or "(no response)"):
|
|
168
|
+
await update.message.reply_text(chunk)
|
|
169
|
+
|
|
170
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def build_application(self) -> Application:
|
|
173
|
+
app = Application.builder().token(self._token).build()
|
|
174
|
+
app.add_handler(CommandHandler("start", self._cmd_start))
|
|
175
|
+
app.add_handler(CommandHandler("reset", self._cmd_reset))
|
|
176
|
+
app.add_handler(CommandHandler("status", self._cmd_status))
|
|
177
|
+
app.add_handler(CommandHandler("compact", self._cmd_compact))
|
|
178
|
+
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
|
|
179
|
+
self._app = app
|
|
180
|
+
return app
|
|
181
|
+
|
|
182
|
+
def run_polling(self) -> None:
|
|
183
|
+
"""Blocking call — starts the bot using long polling (for standalone use)."""
|
|
184
|
+
app = self.build_application()
|
|
185
|
+
logger.info("[Telegram] Starting bot (polling mode)...")
|
|
186
|
+
app.run_polling(drop_pending_updates=True)
|
|
187
|
+
|
|
188
|
+
async def start_async(self) -> None:
|
|
189
|
+
"""Non-blocking start — for use inside an existing asyncio event loop."""
|
|
190
|
+
app = self.build_application()
|
|
191
|
+
logger.info("[Telegram] Initialising bot (async mode)...")
|
|
192
|
+
await app.initialize()
|
|
193
|
+
await app.start()
|
|
194
|
+
await app.updater.start_polling(drop_pending_updates=True)
|
|
195
|
+
|
|
196
|
+
async def stop_async(self) -> None:
|
|
197
|
+
if self._app is None:
|
|
198
|
+
return
|
|
199
|
+
logger.info("[Telegram] Stopping bot...")
|
|
200
|
+
await self._app.updater.stop()
|
|
201
|
+
await self._app.stop()
|
|
202
|
+
await self._app.shutdown()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── Utility ───────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def _split_message(text: str, limit: int = 4096) -> list[str]:
|
|
208
|
+
"""Split a long string into chunks that fit within Telegram's message limit."""
|
|
209
|
+
if len(text) <= limit:
|
|
210
|
+
return [text]
|
|
211
|
+
chunks = []
|
|
212
|
+
while text:
|
|
213
|
+
chunks.append(text[:limit])
|
|
214
|
+
text = text[limit:]
|
|
215
|
+
return chunks
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_bot(session_manager: "SessionManager") -> TelegramBot:
|
|
219
|
+
"""Create a TelegramBot from pythonclaw.json / env vars."""
|
|
220
|
+
token = config.get_str(
|
|
221
|
+
"channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN",
|
|
222
|
+
)
|
|
223
|
+
if not token:
|
|
224
|
+
raise ValueError("Telegram token not set (env TELEGRAM_BOT_TOKEN or channels.telegram.token)")
|
|
225
|
+
allowed_users = config.get_int_list(
|
|
226
|
+
"channels", "telegram", "allowedUsers", env="TELEGRAM_ALLOWED_USERS",
|
|
227
|
+
)
|
|
228
|
+
return TelegramBot(
|
|
229
|
+
session_manager=session_manager,
|
|
230
|
+
token=token,
|
|
231
|
+
allowed_users=allowed_users or None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Backward-compatible alias
|
|
236
|
+
create_bot_from_env = create_bot
|
pythonclaw/config.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralised configuration for PythonClaw.
|
|
3
|
+
|
|
4
|
+
Load order (later sources override earlier ones):
|
|
5
|
+
1. pythonclaw.json in current working directory
|
|
6
|
+
2. ~/.pythonclaw/pythonclaw.json
|
|
7
|
+
3. Environment variables (highest priority)
|
|
8
|
+
|
|
9
|
+
The JSON file supports // line comments and trailing commas for convenience
|
|
10
|
+
(a subset of JSON5 that covers the most common needs).
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
-----
|
|
14
|
+
from pythonclaw import config
|
|
15
|
+
|
|
16
|
+
config.load() # call once at startup
|
|
17
|
+
provider = config.get("llm", "provider", env="LLM_PROVIDER", default="deepseek")
|
|
18
|
+
token = config.get("channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN")
|
|
19
|
+
users = config.get_int_list("channels", "telegram", "allowedUsers",
|
|
20
|
+
env="TELEGRAM_ALLOWED_USERS")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
_TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")
|
|
32
|
+
|
|
33
|
+
_config: dict | None = None
|
|
34
|
+
_config_path: Path | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strip_json5(text: str) -> str:
|
|
38
|
+
"""Strip // comments and trailing commas so standard json.loads works.
|
|
39
|
+
|
|
40
|
+
Handles // inside quoted strings correctly (they are preserved).
|
|
41
|
+
"""
|
|
42
|
+
result: list[str] = []
|
|
43
|
+
i = 0
|
|
44
|
+
n = len(text)
|
|
45
|
+
while i < n:
|
|
46
|
+
ch = text[i]
|
|
47
|
+
if ch == '"':
|
|
48
|
+
# Consume the entire string literal (including escaped chars)
|
|
49
|
+
j = i + 1
|
|
50
|
+
while j < n:
|
|
51
|
+
if text[j] == '\\':
|
|
52
|
+
j += 2
|
|
53
|
+
elif text[j] == '"':
|
|
54
|
+
j += 1
|
|
55
|
+
break
|
|
56
|
+
else:
|
|
57
|
+
j += 1
|
|
58
|
+
result.append(text[i:j])
|
|
59
|
+
i = j
|
|
60
|
+
elif ch == '/' and i + 1 < n and text[i + 1] == '/':
|
|
61
|
+
# Line comment — skip until end of line
|
|
62
|
+
while i < n and text[i] != '\n':
|
|
63
|
+
i += 1
|
|
64
|
+
else:
|
|
65
|
+
result.append(ch)
|
|
66
|
+
i += 1
|
|
67
|
+
text = "".join(result)
|
|
68
|
+
text = _TRAILING_COMMA_RE.sub(r"\1", text)
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_config_file() -> Path | None:
|
|
73
|
+
candidates = [
|
|
74
|
+
Path.cwd() / "pythonclaw.json",
|
|
75
|
+
Path.home() / ".pythonclaw" / "pythonclaw.json",
|
|
76
|
+
]
|
|
77
|
+
for p in candidates:
|
|
78
|
+
if p.is_file():
|
|
79
|
+
return p
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _deep_get(data: dict, *keys: str, default: Any = None) -> Any:
|
|
84
|
+
current = data
|
|
85
|
+
for key in keys:
|
|
86
|
+
if not isinstance(current, dict):
|
|
87
|
+
return default
|
|
88
|
+
current = current.get(key)
|
|
89
|
+
if current is None:
|
|
90
|
+
return default
|
|
91
|
+
return current
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load(path: str | Path | None = None, *, force: bool = False) -> dict:
|
|
95
|
+
"""Load and cache configuration. Safe to call multiple times.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
path : explicit path to a JSON config file (overrides auto-discovery)
|
|
100
|
+
force : if True, reload even if already cached
|
|
101
|
+
"""
|
|
102
|
+
global _config, _config_path
|
|
103
|
+
|
|
104
|
+
if _config is not None and not force:
|
|
105
|
+
return _config
|
|
106
|
+
|
|
107
|
+
config_path = Path(path) if path else _find_config_file()
|
|
108
|
+
_config_path = config_path
|
|
109
|
+
raw: dict = {}
|
|
110
|
+
|
|
111
|
+
if config_path and config_path.is_file():
|
|
112
|
+
text = config_path.read_text(encoding="utf-8")
|
|
113
|
+
text = _strip_json5(text)
|
|
114
|
+
raw = json.loads(text)
|
|
115
|
+
|
|
116
|
+
_config = raw
|
|
117
|
+
return _config
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get(*keys: str, env: str | None = None, default: Any = None) -> Any:
|
|
121
|
+
"""Get a config value. Env var takes priority over JSON.
|
|
122
|
+
|
|
123
|
+
Examples
|
|
124
|
+
--------
|
|
125
|
+
config.get("llm", "provider", env="LLM_PROVIDER", default="deepseek")
|
|
126
|
+
config.get("channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN")
|
|
127
|
+
"""
|
|
128
|
+
if _config is None:
|
|
129
|
+
load()
|
|
130
|
+
|
|
131
|
+
if env:
|
|
132
|
+
env_val = os.environ.get(env)
|
|
133
|
+
if env_val is not None:
|
|
134
|
+
return env_val
|
|
135
|
+
|
|
136
|
+
val = _deep_get(_config, *keys, default=default)
|
|
137
|
+
return val
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_int(*keys: str, env: str | None = None, default: int = 0) -> int:
|
|
141
|
+
"""Get an integer config value."""
|
|
142
|
+
val = get(*keys, env=env, default=default)
|
|
143
|
+
return int(val) if val is not None else default
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_str(*keys: str, env: str | None = None, default: str = "") -> str:
|
|
147
|
+
"""Get a string config value."""
|
|
148
|
+
val = get(*keys, env=env, default=default)
|
|
149
|
+
return str(val) if val is not None else default
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_list(*keys: str, env: str | None = None, default: list | None = None) -> list:
|
|
153
|
+
"""Get a list value. Env var is parsed as comma-separated."""
|
|
154
|
+
if _config is None:
|
|
155
|
+
load()
|
|
156
|
+
|
|
157
|
+
if env:
|
|
158
|
+
env_val = os.environ.get(env)
|
|
159
|
+
if env_val is not None and env_val.strip():
|
|
160
|
+
return [v.strip() for v in env_val.split(",") if v.strip()]
|
|
161
|
+
|
|
162
|
+
val = _deep_get(_config, *keys)
|
|
163
|
+
if isinstance(val, list):
|
|
164
|
+
return val
|
|
165
|
+
return default or []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_int_list(*keys: str, env: str | None = None) -> list[int]:
|
|
169
|
+
"""Get a list of integers. Env var is parsed as comma-separated ints."""
|
|
170
|
+
raw = get_list(*keys, env=env)
|
|
171
|
+
return [int(v) for v in raw] if raw else []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def config_path() -> Path | None:
|
|
175
|
+
"""Return the path to the loaded config file, or None."""
|
|
176
|
+
return _config_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def as_dict() -> dict:
|
|
180
|
+
"""Return a copy of the full loaded config dict."""
|
|
181
|
+
if _config is None:
|
|
182
|
+
load()
|
|
183
|
+
return dict(_config)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def reset() -> None:
|
|
187
|
+
"""Clear the cached config (mainly for testing)."""
|
|
188
|
+
global _config, _config_path
|
|
189
|
+
_config = None
|
|
190
|
+
_config_path = None
|