deltachat-claude-code 0.1.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.
- agentbot/__init__.py +0 -0
- agentbot/__main__.py +3 -0
- agentbot/avatar.py +61 -0
- agentbot/bot.py +268 -0
- agentbot/commands.py +456 -0
- agentbot/provision.py +88 -0
- agentbot/render.py +241 -0
- agentbot/session.py +292 -0
- agentbot/store.py +179 -0
- agentbot/transcribe.py +39 -0
- deltachat_claude_code-0.1.0.dist-info/METADATA +279 -0
- deltachat_claude_code-0.1.0.dist-info/RECORD +16 -0
- deltachat_claude_code-0.1.0.dist-info/WHEEL +5 -0
- deltachat_claude_code-0.1.0.dist-info/entry_points.txt +3 -0
- deltachat_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- deltachat_claude_code-0.1.0.dist-info/top_level.txt +1 -0
agentbot/__init__.py
ADDED
|
File without changes
|
agentbot/__main__.py
ADDED
agentbot/avatar.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import colorsys
|
|
2
|
+
import random
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
GRID = 24
|
|
8
|
+
SCALE = 8
|
|
9
|
+
FACE_CELLS = 4
|
|
10
|
+
CELL_SIZE = GRID // FACE_CELLS # 6px per cell
|
|
11
|
+
|
|
12
|
+
BLACK = (17, 17, 17, 255)
|
|
13
|
+
WHITE = (255, 255, 255, 255)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def random_color() -> tuple[int, int, int, int]:
|
|
17
|
+
h = random.random()
|
|
18
|
+
s = random.uniform(0.55, 0.85)
|
|
19
|
+
l = random.uniform(0.45, 0.65)
|
|
20
|
+
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
|
21
|
+
return (int(r * 255), int(g * 255), int(b * 255), 255)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def random_face() -> list[list[bool]]:
|
|
25
|
+
"""4x4 grid with left-right symmetry. Left 2 columns are random,
|
|
26
|
+
right 2 columns mirror them. 2*4 = 8 independent bits = 256 patterns.
|
|
27
|
+
Excludes all-off and all-on."""
|
|
28
|
+
while True:
|
|
29
|
+
left = [[random.choice([True, False]) for _ in range(2)] for _ in range(FACE_CELLS)]
|
|
30
|
+
grid = []
|
|
31
|
+
for row in left:
|
|
32
|
+
grid.append(row + list(reversed(row)))
|
|
33
|
+
on_count = sum(c for row in grid for c in row)
|
|
34
|
+
if 0 < on_count < FACE_CELLS * FACE_CELLS:
|
|
35
|
+
return grid
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_avatar(out_path: Path, color: tuple = None, face: list = None):
|
|
39
|
+
if color is None:
|
|
40
|
+
color = random_color()
|
|
41
|
+
if face is None:
|
|
42
|
+
face = random_face()
|
|
43
|
+
|
|
44
|
+
img = Image.new("RGBA", (GRID, GRID), color)
|
|
45
|
+
fg = WHITE if _is_dark(color) else BLACK
|
|
46
|
+
for row in range(FACE_CELLS):
|
|
47
|
+
for col in range(FACE_CELLS):
|
|
48
|
+
if face[row][col]:
|
|
49
|
+
x0 = col * CELL_SIZE
|
|
50
|
+
y0 = row * CELL_SIZE
|
|
51
|
+
for y in range(y0, y0 + CELL_SIZE):
|
|
52
|
+
for x in range(x0, x0 + CELL_SIZE):
|
|
53
|
+
img.putpixel((x, y), fg)
|
|
54
|
+
|
|
55
|
+
img.resize((GRID * SCALE, GRID * SCALE), Image.NEAREST).save(out_path)
|
|
56
|
+
return color, face
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_dark(color: tuple) -> bool:
|
|
60
|
+
r, g, b = color[:3]
|
|
61
|
+
return (r * 299 + g * 587 + b * 114) / 1000 < 128
|
agentbot/bot.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tomllib
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from deltachat_rpc_client import Client, DeltaChat, Rpc, events
|
|
9
|
+
|
|
10
|
+
from . import commands, store
|
|
11
|
+
from .render import ChatRenderer
|
|
12
|
+
from .session import Session, SessionManager
|
|
13
|
+
from .transcribe import transcribe, NOT_INSTALLED
|
|
14
|
+
|
|
15
|
+
BOT_DIR = Path.cwd()
|
|
16
|
+
ACCOUNTS_DIR = str(BOT_DIR / "accounts")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _find_rpc_server() -> str:
|
|
20
|
+
found = shutil.which("deltachat-rpc-server")
|
|
21
|
+
if found:
|
|
22
|
+
return found
|
|
23
|
+
import sys
|
|
24
|
+
venv_candidate = Path(sys.executable).parent / "deltachat-rpc-server"
|
|
25
|
+
if venv_candidate.is_file():
|
|
26
|
+
return str(venv_candidate)
|
|
27
|
+
return "deltachat-rpc-server"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RPC_SERVER_PATH = _find_rpc_server()
|
|
31
|
+
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=logging.INFO,
|
|
34
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
35
|
+
)
|
|
36
|
+
log = logging.getLogger("agentbot")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AgentBot:
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.bot_dir = BOT_DIR
|
|
42
|
+
self.config = self._load_config()
|
|
43
|
+
store.init_db()
|
|
44
|
+
self.session_manager = SessionManager(
|
|
45
|
+
max_live=self.config["max_live_sessions"],
|
|
46
|
+
idle_timeout_min=self.config["idle_timeout_min"],
|
|
47
|
+
)
|
|
48
|
+
self._renderers: dict[int, ChatRenderer] = {}
|
|
49
|
+
|
|
50
|
+
def _load_config(self) -> dict:
|
|
51
|
+
with open(BOT_DIR / "config.toml", "rb") as f:
|
|
52
|
+
cfg = tomllib.load(f)
|
|
53
|
+
# an empty default_model means "don't pass --model at all", letting the
|
|
54
|
+
# cwd's .claude/settings*.json decide; None is how that travels
|
|
55
|
+
cfg["default_model"] = cfg.get("default_model") or None
|
|
56
|
+
return cfg
|
|
57
|
+
|
|
58
|
+
def get_renderer(self, chat_id: int) -> ChatRenderer | None:
|
|
59
|
+
return self._renderers.get(chat_id)
|
|
60
|
+
|
|
61
|
+
def spawn_session(self, chat_id: int, session_id: str, cwd: str,
|
|
62
|
+
resume: bool = False) -> Session:
|
|
63
|
+
binding = store.get_binding(chat_id)
|
|
64
|
+
model = binding.get("model", self.config["default_model"]) if binding else self.config["default_model"]
|
|
65
|
+
perm = binding.get("permission_mode", self.config["default_permission_mode"]) if binding else self.config["default_permission_mode"]
|
|
66
|
+
effort = binding.get("effort") if binding else None
|
|
67
|
+
|
|
68
|
+
renderer = self._renderers.get(chat_id)
|
|
69
|
+
if not renderer:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def on_event(event):
|
|
73
|
+
etype = event.get("type")
|
|
74
|
+
if etype in ("assistant", "result", "user"):
|
|
75
|
+
renderer.handle_event(event)
|
|
76
|
+
if etype == "result":
|
|
77
|
+
self._record_result(chat_id, session_id, event)
|
|
78
|
+
|
|
79
|
+
session = Session(
|
|
80
|
+
session_id=session_id, cwd=cwd, model=model,
|
|
81
|
+
permission_mode=perm, effort=effort,
|
|
82
|
+
resume=resume, on_event=on_event,
|
|
83
|
+
)
|
|
84
|
+
self.session_manager.register(chat_id, session)
|
|
85
|
+
return session
|
|
86
|
+
|
|
87
|
+
def update_chat_description(self, chat, session_id: str, cwd: str):
|
|
88
|
+
desc = f"session: {session_id}\ncwd: {cwd}\nresume: claude --resume {session_id}"
|
|
89
|
+
try:
|
|
90
|
+
chat._rpc.set_chat_description(chat.account.id, chat.id, desc)
|
|
91
|
+
except Exception:
|
|
92
|
+
log.debug("could not set chat description (1:1 chat?)", exc_info=True)
|
|
93
|
+
|
|
94
|
+
def _record_result(self, chat_id: int, session_id: str, event: dict):
|
|
95
|
+
store.record_usage(
|
|
96
|
+
chat_id=chat_id,
|
|
97
|
+
session_id=session_id,
|
|
98
|
+
cost_usd=event.get("total_cost_usd"),
|
|
99
|
+
input_tokens=event.get("usage", {}).get("input_tokens"),
|
|
100
|
+
output_tokens=event.get("usage", {}).get("output_tokens"),
|
|
101
|
+
cache_read=event.get("usage", {}).get("cache_read_input_tokens"),
|
|
102
|
+
cache_creation=event.get("usage", {}).get("cache_creation_input_tokens"),
|
|
103
|
+
num_turns=event.get("num_turns"),
|
|
104
|
+
duration_ms=event.get("duration_ms"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _ensure_session(self, chat_id: int, chat) -> Session | None:
|
|
108
|
+
session = self.session_manager.get(chat_id)
|
|
109
|
+
if session:
|
|
110
|
+
store.touch_binding(chat_id)
|
|
111
|
+
return session
|
|
112
|
+
|
|
113
|
+
binding = store.get_binding(chat_id)
|
|
114
|
+
if binding:
|
|
115
|
+
session = self.spawn_session(
|
|
116
|
+
chat_id, binding["session_id"], binding["cwd"], resume=True,
|
|
117
|
+
)
|
|
118
|
+
if session and session.wait_ready(timeout=10.0):
|
|
119
|
+
return session
|
|
120
|
+
log.warning("resume failed for session %s, starting fresh", binding["session_id"][:8])
|
|
121
|
+
self.session_manager.remove(chat_id)
|
|
122
|
+
|
|
123
|
+
cwd = binding["cwd"] if binding else self.config["default_cwd"]
|
|
124
|
+
session_id = str(uuid.uuid4())
|
|
125
|
+
store.set_binding(chat_id, session_id, cwd,
|
|
126
|
+
self.config["default_model"],
|
|
127
|
+
self.config["default_permission_mode"])
|
|
128
|
+
self.update_chat_description(chat, session_id, cwd)
|
|
129
|
+
return self.spawn_session(chat_id, session_id, cwd)
|
|
130
|
+
|
|
131
|
+
def _ensure_renderer(self, chat_id: int, chat) -> ChatRenderer:
|
|
132
|
+
renderer = self._renderers.get(chat_id)
|
|
133
|
+
if not renderer:
|
|
134
|
+
binding = store.get_binding(chat_id)
|
|
135
|
+
verbose = bool(binding.get("verbose", self.config["verbose_tools"])) if binding else self.config["verbose_tools"]
|
|
136
|
+
renderer = ChatRenderer(chat, verbose=verbose)
|
|
137
|
+
self._renderers[chat_id] = renderer
|
|
138
|
+
return renderer
|
|
139
|
+
|
|
140
|
+
def handle_message(self, event):
|
|
141
|
+
snapshot = event.message_snapshot
|
|
142
|
+
sender = snapshot.sender.get_snapshot().address
|
|
143
|
+
text = (snapshot.text or "").strip()
|
|
144
|
+
chat = snapshot.chat
|
|
145
|
+
chat_id = chat.id
|
|
146
|
+
|
|
147
|
+
if sender not in self.config["admin_addresses"]:
|
|
148
|
+
log.warning("unauthorized message from %s", sender)
|
|
149
|
+
chat.send_text("not authorized")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if not text and not snapshot.file:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
log.info("message from %s in chat %d: %r", sender, chat_id, text[:100])
|
|
156
|
+
|
|
157
|
+
renderer = self._ensure_renderer(chat_id, chat)
|
|
158
|
+
renderer.set_inbound(snapshot.id)
|
|
159
|
+
renderer.react_receipt()
|
|
160
|
+
|
|
161
|
+
quote_obj = getattr(snapshot, "quote", None)
|
|
162
|
+
quoted = quote_obj.text.strip() if quote_obj and getattr(quote_obj, "text", None) else None
|
|
163
|
+
|
|
164
|
+
if snapshot.file:
|
|
165
|
+
text = self._handle_attachment(chat_id, snapshot, text)
|
|
166
|
+
if not text:
|
|
167
|
+
renderer.react_done()
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
parsed = commands.classify(text) if text else None
|
|
171
|
+
if parsed:
|
|
172
|
+
cmd, args = parsed
|
|
173
|
+
result = commands.handle(cmd, args, chat_id, chat, self)
|
|
174
|
+
if result is not None:
|
|
175
|
+
if result:
|
|
176
|
+
chat.send_text(result)
|
|
177
|
+
renderer.react_done()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if quoted and text:
|
|
181
|
+
text = f"[replying to: \"{quoted}\"]\n{text}"
|
|
182
|
+
|
|
183
|
+
session = self._ensure_session(chat_id, chat)
|
|
184
|
+
if not session:
|
|
185
|
+
chat.send_text("failed to start session — check logs")
|
|
186
|
+
renderer.react_done(error=True)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not session.alive:
|
|
190
|
+
binding = store.get_binding(chat_id)
|
|
191
|
+
if binding:
|
|
192
|
+
session = self.spawn_session(
|
|
193
|
+
chat_id, binding["session_id"], binding["cwd"], resume=True,
|
|
194
|
+
)
|
|
195
|
+
if not session or not session.alive:
|
|
196
|
+
chat.send_text("session died — try /new or /clear")
|
|
197
|
+
renderer.react_done(error=True)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if text:
|
|
201
|
+
if text.startswith("!"):
|
|
202
|
+
renderer._show_bash_output = True
|
|
203
|
+
session.send_user(text)
|
|
204
|
+
|
|
205
|
+
def _handle_attachment(self, chat_id: int, snapshot, text: str) -> str:
|
|
206
|
+
binding = store.get_binding(chat_id)
|
|
207
|
+
cwd = binding["cwd"] if binding else self.config["default_cwd"]
|
|
208
|
+
inbox = Path(cwd) / ".agentbot-inbox"
|
|
209
|
+
inbox.mkdir(exist_ok=True)
|
|
210
|
+
src = snapshot.file
|
|
211
|
+
dst = inbox / Path(src).name
|
|
212
|
+
counter = 1
|
|
213
|
+
while dst.exists():
|
|
214
|
+
dst = inbox / f"{Path(src).stem}_{counter}{Path(src).suffix}"
|
|
215
|
+
counter += 1
|
|
216
|
+
shutil.copy2(src, dst)
|
|
217
|
+
log.info("saved attachment to %s", dst)
|
|
218
|
+
|
|
219
|
+
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".m4a", ".flac", ".opus", ".webm"}
|
|
220
|
+
if dst.suffix.lower() in AUDIO_EXTS:
|
|
221
|
+
transcript = transcribe(dst)
|
|
222
|
+
if transcript and transcript != NOT_INSTALLED:
|
|
223
|
+
log.info("transcribed voice memo: %s", transcript[:100])
|
|
224
|
+
snapshot.chat.send_text(f"🎤 {transcript}")
|
|
225
|
+
prefix = f"[voice memo transcription]\n{transcript}"
|
|
226
|
+
return (prefix + "\n\n" + text) if text else prefix
|
|
227
|
+
if transcript == NOT_INSTALLED:
|
|
228
|
+
snapshot.chat.send_text(
|
|
229
|
+
"🎤 Voice memo received but I can't transcribe it — "
|
|
230
|
+
"faster-whisper is not installed.\n\n"
|
|
231
|
+
"To enable voice transcription, install it in the bot's venv:\n"
|
|
232
|
+
" pip install -r requirements-voice.txt\n\n"
|
|
233
|
+
"Then restart the bot. In the meantime, please type your message."
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
snapshot.chat.send_text(
|
|
237
|
+
"🎤 Voice memo received but transcription failed. "
|
|
238
|
+
"Please type your message instead."
|
|
239
|
+
)
|
|
240
|
+
return text or ""
|
|
241
|
+
|
|
242
|
+
suffix = f"\n[attached: {dst}]"
|
|
243
|
+
return (text + suffix) if text else f"[attached: {dst}]"
|
|
244
|
+
|
|
245
|
+
def run(self):
|
|
246
|
+
log.info("starting agentbot")
|
|
247
|
+
with Rpc(accounts_dir=ACCOUNTS_DIR, rpc_server_path=RPC_SERVER_PATH) as rpc:
|
|
248
|
+
dc = DeltaChat(rpc)
|
|
249
|
+
accounts = dc.get_all_accounts()
|
|
250
|
+
if not accounts:
|
|
251
|
+
log.error("no accounts — run provision.py first")
|
|
252
|
+
return
|
|
253
|
+
account = accounts[0]
|
|
254
|
+
log.info("running as %s", account.get_config("addr"))
|
|
255
|
+
client = Client(
|
|
256
|
+
account,
|
|
257
|
+
hooks=[(self.handle_message, events.NewMessage())],
|
|
258
|
+
)
|
|
259
|
+
client.run_forever()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main():
|
|
263
|
+
bot = AgentBot()
|
|
264
|
+
bot.run()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|