simplecontext-bot 1.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.
@@ -0,0 +1,4 @@
1
+ """SimpleContext-Bot — AI Telegram Bot powered by SimpleContext."""
2
+
3
+ __version__ = "1.0.0"
4
+ __author__ = "zacxyonly"
@@ -0,0 +1,465 @@
1
+ """
2
+ bot.py — Telegram Bot v1.2
3
+ Dynamic plugin system:
4
+ - Auto-scan semua .py di plugins/ folder (termasuk plugin komunitas)
5
+ - Plugin bisa declare BOT_COMMANDS untuk auto-register command ke Telegram
6
+ - /plugins selalu akurat: baca dari sc._plugins, bukan registry hardcoded
7
+ """
8
+
9
+ import sys
10
+ import logging
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from . import config as cfg
14
+ from . import llm
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _load_simplecontext():
20
+ install_dir = Path(cfg.get("install_dir"))
21
+ if str(install_dir) not in sys.path:
22
+ sys.path.insert(0, str(install_dir))
23
+ try:
24
+ from simplecontext import SimpleContext
25
+ return SimpleContext
26
+ except ImportError:
27
+ logger.error("SimpleContext not found. Run: simplecontext-bot setup")
28
+ return None
29
+
30
+
31
+ # ── Plugin Loader ─────────────────────────────────────────────────────────────
32
+
33
+ def _scan_plugin_files(plugins_dir: Path) -> list[Path]:
34
+ """
35
+ Scan semua file .py di plugins_dir.
36
+ Tidak tergantung OFFICIAL_PLUGINS — plugin komunitas yang di-drop manual
37
+ juga terdeteksi otomatis.
38
+ """
39
+ if not plugins_dir.exists():
40
+ return []
41
+ return [f for f in plugins_dir.glob("*.py") if not f.name.startswith("_")]
42
+
43
+
44
+ def _load_plugin_class(plugin_file: Path):
45
+ """
46
+ Load satu file plugin, return (plugin_class, module) atau (None, None).
47
+ Cari class pertama yang mewarisi BasePlugin dengan atribut name.
48
+ """
49
+ try:
50
+ from simplecontext.plugins.base import BasePlugin
51
+ except ImportError:
52
+ return None, None
53
+
54
+ try:
55
+ module_name = f"sc_plugin_{plugin_file.stem}"
56
+ spec = importlib.util.spec_from_file_location(module_name, plugin_file)
57
+ module = importlib.util.module_from_spec(spec)
58
+ spec.loader.exec_module(module)
59
+
60
+ for attr_name in dir(module):
61
+ attr = getattr(module, attr_name)
62
+ if (isinstance(attr, type)
63
+ and issubclass(attr, BasePlugin)
64
+ and attr is not BasePlugin
65
+ and getattr(attr, "name", "")):
66
+ return attr, module
67
+
68
+ logger.warning(f"Tidak ada BasePlugin class di {plugin_file.name}")
69
+ return None, None
70
+
71
+ except Exception as e:
72
+ logger.warning(f"Gagal load plugin '{plugin_file.name}': {e}")
73
+ return None, None
74
+
75
+
76
+ def _load_all_plugins(sc, install_dir: Path) -> dict:
77
+ """
78
+ Auto-scan dan load semua plugin dari install_dir/plugins/.
79
+ Tidak bergantung config.json installed list — semua .py ter-load otomatis.
80
+
81
+ Return: dict { plugin_name: plugin_instance }
82
+ """
83
+ plugins_dir = install_dir / "plugins"
84
+ plugin_files = _scan_plugin_files(plugins_dir)
85
+ plugin_configs = cfg.get("plugins.configs", {})
86
+
87
+ if not plugin_files:
88
+ return {}
89
+
90
+ if str(plugins_dir) not in sys.path:
91
+ sys.path.insert(0, str(plugins_dir))
92
+
93
+ loaded = {}
94
+ for plugin_file in sorted(plugin_files):
95
+ plugin_cls, _ = _load_plugin_class(plugin_file)
96
+ if not plugin_cls:
97
+ continue
98
+
99
+ plugin_name = plugin_cls.name
100
+
101
+ # Cari config: dari config.json (keyed by plugin_id atau plugin_name)
102
+ # Fallback ke empty dict — plugin tetap load dengan default config-nya
103
+ plugin_cfg = (
104
+ plugin_configs.get(plugin_name)
105
+ or plugin_configs.get(plugin_file.stem)
106
+ or {}
107
+ )
108
+
109
+ try:
110
+ sc.use(plugin_cls(config=plugin_cfg))
111
+ loaded[plugin_name] = sc._plugins.get(plugin_name)
112
+ logger.info(
113
+ f"✅ Plugin loaded: {plugin_name} v{plugin_cls.version}"
114
+ + (f" (BOT_COMMANDS: {list(plugin_cls.BOT_COMMANDS.keys())})"
115
+ if getattr(plugin_cls, "BOT_COMMANDS", None) else "")
116
+ )
117
+ except Exception as e:
118
+ logger.warning(f"Gagal register plugin '{plugin_name}': {e}")
119
+
120
+ return loaded
121
+
122
+
123
+ # ── Dynamic Command Registration ─────────────────────────────────────────────
124
+
125
+ def _collect_app_commands(sc) -> dict:
126
+ """
127
+ Kumpulkan semua app_commands dari plugin via loader.get_all_app_commands().
128
+ Menggunakan kontrak resmi BasePlugin v4 — bukan convention ad-hoc.
129
+ Return: { "command_name": {...cmd_info, "plugin": plugin_instance} }
130
+ """
131
+ commands = sc._plugins.get_all_app_commands()
132
+ for cmd_name, cmd_info in commands.items():
133
+ plugin_name = cmd_info["plugin"].name
134
+ logger.info(f" ↳ App command: /{cmd_name} (dari {plugin_name})")
135
+ return commands
136
+
137
+
138
+ def _make_dynamic_handler(sc, cmd_name: str):
139
+ """
140
+ Buat async Telegram handler untuk satu app_command.
141
+ Eksekusi via sc._plugins.fire_app_command() — routing ditangani core,
142
+ bukan bot. Handler plugin cukup terima AppCommandContext.
143
+ """
144
+ async def handler(update, ctx):
145
+ from simplecontext.plugins.base import AppCommandContext
146
+
147
+ tg_ctx = AppCommandContext.create(
148
+ command = cmd_name,
149
+ user_id = str(update.effective_user.id),
150
+ args = ctx.args or [],
151
+ platform = "telegram",
152
+ raw = update,
153
+ sc = sc,
154
+ )
155
+
156
+ await ctx.bot.send_chat_action(update.effective_chat.id, "typing")
157
+ try:
158
+ result = await sc._plugins.fire_app_command(tg_ctx)
159
+ if result:
160
+ try:
161
+ await update.message.reply_text(result, parse_mode="Markdown")
162
+ except Exception:
163
+ await update.message.reply_text(result)
164
+ else:
165
+ await update.message.reply_text(
166
+ f"⚠️ Command `/{cmd_name}` tidak menghasilkan response.",
167
+ parse_mode="Markdown"
168
+ )
169
+ except Exception as e:
170
+ logger.error(f"Error di app_command /{cmd_name}: {e}")
171
+ await update.message.reply_text(f"❌ Error: {e}")
172
+
173
+ handler.__name__ = f"plugin_cmd_{cmd_name}"
174
+ return handler
175
+
176
+
177
+ # ── Bot Runner ────────────────────────────────────────────────────────────────
178
+
179
+ def run():
180
+ token = cfg.get("telegram.token", "")
181
+ if not token:
182
+ print("❌ Telegram token not configured. Run: simplecontext-bot setup")
183
+ sys.exit(1)
184
+
185
+ try:
186
+ from telegram import Update, BotCommand
187
+ from telegram.ext import (
188
+ ApplicationBuilder, CommandHandler,
189
+ MessageHandler, filters,
190
+ )
191
+ except ImportError:
192
+ print("❌ python-telegram-bot not installed.")
193
+ print(" Run: pip install python-telegram-bot")
194
+ sys.exit(1)
195
+
196
+ SimpleContext = _load_simplecontext()
197
+ if not SimpleContext:
198
+ print("❌ SimpleContext engine not found. Run: simplecontext-bot setup")
199
+ sys.exit(1)
200
+
201
+ install_dir = Path(cfg.get("install_dir"))
202
+
203
+ sc = SimpleContext(
204
+ storage__backend = "sqlite",
205
+ storage__path = cfg.get("simplecontext.db_path"),
206
+ agents__folder = cfg.get("simplecontext.agents_dir"),
207
+ agents__hot_reload = cfg.get("simplecontext.hot_reload", True),
208
+ agents__default = cfg.get("simplecontext.default_agent", "general"),
209
+ plugins__enabled = False,
210
+ plugins__folder = str(install_dir / "plugins"),
211
+ memory__default_limit = cfg.get("bot.memory_limit", 20),
212
+ debug__retrieval = cfg.get("bot.debug", False),
213
+ )
214
+
215
+ # ── Load semua plugin (auto-scan, tidak tergantung registry) ──────────────
216
+ loaded_plugins = _load_all_plugins(sc, install_dir)
217
+ dynamic_commands = _collect_bot_commands(loaded_plugins)
218
+
219
+ # Inject app_info ke semua plugin — mereka bisa tahu platform & versi bot
220
+ sc._plugins.set_app_info({"platform": "telegram", "version": "1.2.0"})
221
+
222
+ agents = sc._registry.names()
223
+ logger.info(
224
+ f"✅ Bot ready — {len(agents)} agents, "
225
+ f"{len(loaded_plugins)} plugins, "
226
+ f"{len(dynamic_commands)} plugin commands"
227
+ )
228
+
229
+ # ── Helpers ───────────────────────────────────────────────────────────────
230
+
231
+ def _plugin_summary_lines() -> list[str]:
232
+ """Buat daftar ringkasan plugin yang ter-load untuk ditampilkan di bot."""
233
+ lines = []
234
+ all_plugins = sc._plugins.all()
235
+ if not all_plugins:
236
+ return ["_No plugins active._"]
237
+ for p in all_plugins:
238
+ cmds = p.get_app_commands()
239
+ cmd_str = ""
240
+ if cmds:
241
+ cmd_str = " · commands: " + ", ".join(f"`/{c}`" for c in cmds)
242
+ lines.append(f" ✅ *{p.name}* v{p.version} — {p.description}{cmd_str}")
243
+ return lines
244
+
245
+ # ── Static Handlers ───────────────────────────────────────────────────────
246
+
247
+ async def cmd_start(update: Update, ctx):
248
+ user = update.effective_user
249
+ mem = sc.memory(user.id)
250
+ mem.remember("name", user.first_name)
251
+ if user.username:
252
+ mem.remember("username", f"@{user.username}")
253
+
254
+ agents_list = "\n".join(f" • `{a}`" for a in agents)
255
+ plugin_lines = _plugin_summary_lines()
256
+ plugin_block = ""
257
+ if loaded_plugins:
258
+ plugin_block = "\n\n🔌 *Active plugins:*\n" + "\n".join(plugin_lines)
259
+
260
+ await update.message.reply_text(
261
+ f"👋 Hello *{user.first_name}*\\!\n\n"
262
+ f"I'm powered by *SimpleContext* — an AI brain with memory\\.\n\n"
263
+ f"🤖 *Available agents:*\n{agents_list}"
264
+ f"{plugin_block}\n\n"
265
+ f"Use /plugins to see plugin details\\.",
266
+ parse_mode="MarkdownV2"
267
+ )
268
+
269
+ async def cmd_help(update: Update, ctx):
270
+ static_cmds = (
271
+ "/start — Welcome message\n"
272
+ "/agents — List all agents\n"
273
+ "/agent \\<name\\> — Switch agent\n"
274
+ "/agent auto — Back to auto\\-routing\n"
275
+ "/clear — Clear conversation history\n"
276
+ "/status — Current status\n"
277
+ "/memory — Your saved profile\n"
278
+ "/plugins — Plugin details & available commands\n"
279
+ )
280
+ dynamic_cmd_lines = ""
281
+ if dynamic_commands:
282
+ dynamic_cmd_lines = "\n*Plugin Commands:*\n"
283
+ for cmd_name, info in dynamic_commands.items():
284
+ usage = info.get("usage", f"/{cmd_name}")
285
+ desc = info.get("description", "")
286
+ dynamic_cmd_lines += f"/{cmd_name} — {desc}\n"
287
+ if usage != f"/{cmd_name}":
288
+ dynamic_cmd_lines += f" Usage: `{usage}`\n"
289
+
290
+ await update.message.reply_text(
291
+ "📖 *Commands:*\n\n" + static_cmds + dynamic_cmd_lines,
292
+ parse_mode="MarkdownV2"
293
+ )
294
+
295
+ async def cmd_agents(update: Update, ctx):
296
+ lines = ["🤖 *Available Agents:*\n"]
297
+ for name in agents:
298
+ agent = sc._registry.get(name)
299
+ desc = agent.description if agent else ""
300
+ lines.append(f"• *{name}* — {desc}")
301
+ lines.append("\n_Use /agent \\<name\\> to select one_")
302
+ await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
303
+
304
+ async def cmd_agent(update: Update, ctx):
305
+ uid = update.effective_user.id
306
+ args = ctx.args
307
+ if not args:
308
+ current = sc.memory(uid).recall("preferred_agent", "auto")
309
+ await update.message.reply_text(
310
+ f"Current agent: `{current}`\nUse `/agent <n>` or `/agent auto`",
311
+ parse_mode="Markdown"
312
+ )
313
+ return
314
+ name = args[0].lower()
315
+ if name == "auto":
316
+ sc.router.clear_user_agent(uid)
317
+ await update.message.reply_text("✅ Back to *auto-routing*.", parse_mode="Markdown")
318
+ elif name in agents:
319
+ sc.router.set_user_agent(uid, name)
320
+ await update.message.reply_text(f"✅ Agent set to *{name}*.", parse_mode="Markdown")
321
+ else:
322
+ await update.message.reply_text(
323
+ f"❌ Agent `{name}` not found.\nAvailable: {', '.join(f'`{a}`' for a in agents)}",
324
+ parse_mode="Markdown"
325
+ )
326
+
327
+ async def cmd_clear(update: Update, ctx):
328
+ sc.memory(update.effective_user.id).clear()
329
+ await update.message.reply_text("🗑 Conversation cleared\\. Profile kept\\.", parse_mode="MarkdownV2")
330
+
331
+ async def cmd_status(update: Update, ctx):
332
+ uid = update.effective_user.id
333
+ mem = sc.memory(uid)
334
+ result = sc.router.route(uid, "")
335
+ all_p = sc._plugins.all()
336
+ plugin_str = ", ".join(p.name for p in all_p) if all_p else "None"
337
+ await update.message.reply_text(
338
+ f"📊 *Status*\n\n"
339
+ f"🤖 Agent: `{result.agent_id}`\n"
340
+ f"💬 Messages: `{mem.count()}`\n"
341
+ f"🧠 Agents: `{len(agents)}`\n"
342
+ f"🔌 Plugins: `{plugin_str}`",
343
+ parse_mode="Markdown"
344
+ )
345
+
346
+ async def cmd_memory(update: Update, ctx):
347
+ uid = update.effective_user.id
348
+ profile = sc.memory(uid).get_profile()
349
+ display = {k: v for k, v in profile.items()
350
+ if not k.startswith("_") and k != "preferred_agent"}
351
+ if not display:
352
+ await update.message.reply_text("📝 No profile data saved yet.")
353
+ return
354
+ lines = ["📝 *Your Profile:*\n"]
355
+ for k, v in display.items():
356
+ lines.append(f"• *{k}*: {v}")
357
+ await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
358
+
359
+ async def cmd_plugins(update: Update, ctx):
360
+ """
361
+ Tampilkan semua plugin aktif (baca dari sc._plugins, bukan registry hardcoded).
362
+ Plugin komunitas yang di-drop manual ke plugins/ juga muncul di sini.
363
+ """
364
+ lines = ["🔌 *Active Plugins*\n"]
365
+ all_p = sc._plugins.all()
366
+
367
+ if not all_p:
368
+ lines.append("_No plugins loaded._\n")
369
+ lines.append("Drop plugin `.py` ke folder `~/.simplecontext-bot/plugins/` dan restart bot.")
370
+ else:
371
+ for p in all_p:
372
+ cmds = p.get_app_commands()
373
+ lines.append(f"*{p.name}* v{p.version}")
374
+ lines.append(f" {p.description}")
375
+ if cmds:
376
+ for cmd_name, cmd_info in cmds.items():
377
+ lines.append(f" • `/{cmd_name}` — {cmd_info.get('description', '')}")
378
+ lines.append(f" Usage: `{cmd_info.get('usage', '/' + cmd_name)}`")
379
+ lines.append("")
380
+
381
+ lines.append("📖 More plugins: [SimpleContext\\-Plugin](https://github.com/zacxyonly/SimpleContext-Plugin)")
382
+ await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
383
+
384
+ async def handle_message(update: Update, ctx):
385
+ uid = update.effective_user.id
386
+ user = update.effective_user
387
+ text = update.message.text
388
+
389
+ mem = sc.memory(uid)
390
+ if not mem.recall("name"):
391
+ mem.remember("name", user.first_name)
392
+ if user.username:
393
+ mem.remember("username", f"@{user.username}")
394
+
395
+ await ctx.bot.send_chat_action(update.effective_chat.id, "typing")
396
+
397
+ result = sc.router.route(uid, text)
398
+ messages = sc.prepare_messages(uid, text, result)
399
+ logger.info(f"[{user.username or uid}] → agent={result.agent_id}")
400
+
401
+ reply = llm.call(messages, max_tokens=1024)
402
+
403
+ chain_rule = result.should_chain(text)
404
+ if chain_rule and not reply.startswith("❌"):
405
+ await ctx.bot.send_chat_action(update.effective_chat.id, "typing")
406
+ result2 = sc.router.chain(uid, text, reply, chain_rule,
407
+ from_agent_id=result.agent_id)
408
+ messages2 = sc.prepare_messages(uid, text, result2)
409
+ reply = llm.call(messages2, max_tokens=1024)
410
+ reply = sc.process_response(uid, text, reply, result2,
411
+ chain_from=result.agent_id)
412
+ else:
413
+ reply = sc.process_response(uid, text, reply, result)
414
+
415
+ try:
416
+ await update.message.reply_text(reply, parse_mode="Markdown")
417
+ except Exception:
418
+ await update.message.reply_text(reply)
419
+
420
+ # ── Build & Register Handlers ─────────────────────────────────────────────
421
+
422
+ app = ApplicationBuilder().token(token).build()
423
+
424
+ # Static commands
425
+ app.add_handler(CommandHandler("start", cmd_start))
426
+ app.add_handler(CommandHandler("help", cmd_help))
427
+ app.add_handler(CommandHandler("agents", cmd_agents))
428
+ app.add_handler(CommandHandler("agent", cmd_agent))
429
+ app.add_handler(CommandHandler("clear", cmd_clear))
430
+ app.add_handler(CommandHandler("status", cmd_status))
431
+ app.add_handler(CommandHandler("memory", cmd_memory))
432
+ app.add_handler(CommandHandler("plugins", cmd_plugins))
433
+
434
+ # Dynamic commands dari plugin (auto-register)
435
+ for cmd_name, cmd_info in dynamic_commands.items():
436
+ handler_fn = _make_dynamic_handler(sc, cmd_name)
437
+ app.add_handler(CommandHandler(cmd_name, handler_fn))
438
+ logger.info(f" ↳ Telegram handler registered: /{cmd_name}")
439
+
440
+ # Set bot commands list di Telegram (muncul di menu)
441
+ async def post_init(application):
442
+ static = [
443
+ BotCommand("start", "Welcome message"),
444
+ BotCommand("help", "Show all commands"),
445
+ BotCommand("agents", "List available agents"),
446
+ BotCommand("agent", "Switch agent"),
447
+ BotCommand("clear", "Clear conversation history"),
448
+ BotCommand("status", "Show current status"),
449
+ BotCommand("memory", "Show your saved profile"),
450
+ BotCommand("plugins", "List active plugins & their commands"),
451
+ ]
452
+ plugin_cmds = [
453
+ BotCommand(cmd_name, info.get("description", "")[:256])
454
+ for cmd_name, info in dynamic_commands.items()
455
+ ]
456
+ await application.bot.set_my_commands(static + plugin_cmds)
457
+
458
+ app.post_init = post_init
459
+
460
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
461
+
462
+ print(f"🚀 Bot is running — {len(loaded_plugins)} plugins, {len(dynamic_commands)} plugin commands")
463
+ if dynamic_commands:
464
+ print(f" Plugin commands: {', '.join('/' + c for c in dynamic_commands)}")
465
+ app.run_polling(drop_pending_updates=True)