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 ADDED
File without changes
agentbot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from agentbot.bot import main
2
+
3
+ main()
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()