linkgravity 1.3.0 → 1.4.0

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,202 @@
1
+ """Telegram bot setup. Runs in the same process as Discord (see main.py),
2
+ which starts both concurrently when both platforms are enabled."""
3
+
4
+ import asyncio
5
+ import os
6
+ import uuid
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
11
+ from telegram.ext import Application, ApplicationBuilder, CallbackQueryHandler, CommandHandler, MessageHandler, filters
12
+
13
+ from config import TELEGRAM_TOKEN, allowed, bot_settings, logger, save_bot_settings, session_manager
14
+ from core.atomic_io import atomic_write_json, safe_load_json
15
+ from handlers.message_router import handle_message
16
+ from messengers.registry import register_adapter
17
+ from messengers.telegram_adapter import TelegramAdapter, markdown_to_telegram_html
18
+ from utils.utils import get_default_cwd
19
+
20
+
21
+ def _start_session(chat_id: int, user_id: int) -> dict:
22
+ session = {
23
+ "status": "pending",
24
+ "platform": "telegram",
25
+ "user_id": user_id,
26
+ "cwd": get_default_cwd(),
27
+ "model": bot_settings.get("default_model") or None,
28
+ "conversation_id": None,
29
+ "created_at": datetime.now().isoformat(),
30
+ }
31
+ session_manager.set_session(str(chat_id), session)
32
+ return session
33
+
34
+
35
+ async def cmd_new(update: Update, context) -> None:
36
+ user = update.effective_user
37
+ chat_id = update.effective_chat.id
38
+ adapter: TelegramAdapter = context.bot_data["adapter"]
39
+
40
+ if not allowed(user.id, "telegram"):
41
+ await update.message.reply_text("❌ Permission denied.")
42
+ return
43
+
44
+ if session_manager.get_session(str(chat_id)):
45
+ prompt_id = uuid.uuid4().hex[:12]
46
+ confirm_key, cancel_key = f"{prompt_id}:confirm", f"{prompt_id}:cancel"
47
+ keyboard = InlineKeyboardMarkup(
48
+ [
49
+ [
50
+ InlineKeyboardButton("✅ Yes, start new", callback_data=confirm_key),
51
+ InlineKeyboardButton("Cancel", callback_data=cancel_key),
52
+ ]
53
+ ]
54
+ )
55
+ await update.message.reply_text(
56
+ "⚠️ A session is already active in this chat. Starting a new one will lose its context. Continue?",
57
+ reply_markup=keyboard,
58
+ )
59
+
60
+ async def confirm(query):
61
+ await query.answer()
62
+ await query.edit_message_text("🆕 Starting a new session...", reply_markup=None)
63
+ _start_session(chat_id, user.id)
64
+ await adapter.send_message(chat_id, "✅ **Ready for new session!** Send a message to begin.")
65
+
66
+ async def cancel(query):
67
+ await query.answer()
68
+ await query.edit_message_text("Cancelled.", reply_markup=None)
69
+
70
+ adapter.register_callback(confirm_key, confirm)
71
+ adapter.register_callback(cancel_key, cancel)
72
+ return
73
+
74
+ _start_session(chat_id, user.id)
75
+ await update.message.reply_text(
76
+ markdown_to_telegram_html("✅ **Ready for new session!** Send a message to begin."), parse_mode="HTML"
77
+ )
78
+
79
+
80
+ async def cmd_model(update: Update, context) -> None:
81
+ user = update.effective_user
82
+ chat_id = update.effective_chat.id
83
+ adapter: TelegramAdapter = context.bot_data["adapter"]
84
+
85
+ if not allowed(user.id, "telegram"):
86
+ await update.message.reply_text("❌ Permission Denied")
87
+ return
88
+
89
+ session = session_manager.get_session(str(chat_id))
90
+ if not session:
91
+ await update.message.reply_text("⚠️ No active session here. Start one with /new first.")
92
+ return
93
+
94
+ if not context.args:
95
+ await update.message.reply_text("Usage: /model <name>\nExample: /model gemini-3.6-flash-high")
96
+ return
97
+
98
+ requested = " ".join(context.args)
99
+ from cogs.general_cog import load_cached_models
100
+
101
+ cached_models = load_cached_models()
102
+ exact = next((m for m in cached_models if m.lower() == requested.lower()), None)
103
+ partial = next((m for m in cached_models if requested.lower() in m.lower()), None)
104
+ final_model = exact or partial or requested
105
+
106
+ session_manager.update_session(str(chat_id), "model", final_model)
107
+ bot_settings["default_model"] = final_model
108
+ save_bot_settings(bot_settings)
109
+
110
+ await adapter.send_message(
111
+ chat_id, f"🤖 Model changed: **{final_model}**\n💾 Also set as the default for new sessions."
112
+ )
113
+
114
+
115
+ async def cmd_credit(update: Update, context) -> None:
116
+ user = update.effective_user
117
+ if not allowed(user.id, "telegram"):
118
+ await update.message.reply_text("❌ Denied")
119
+ return
120
+
121
+ if not context.args or context.args[0].lower() not in ("on", "off"):
122
+ await update.message.reply_text("Usage: /credit <on|off>")
123
+ return
124
+
125
+ use_credits = context.args[0].lower() == "on"
126
+ settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
127
+ try:
128
+ data = safe_load_json(settings_path, {}, logger=logger)
129
+ data["useG1Credits"] = use_credits
130
+ atomic_write_json(settings_path, data)
131
+
132
+ status_text = "🟢 **ON** (Using AI Credits)" if use_credits else "🔴 **OFF** (Using default/free model)"
133
+ await update.message.reply_text(
134
+ markdown_to_telegram_html(f"✅ AI Credit setting updated: {status_text}"), parse_mode="HTML"
135
+ )
136
+ except Exception as e:
137
+ await update.message.reply_text(f"⚠️ Failed to update settings: {e}")
138
+
139
+
140
+ async def on_message(update: Update, context) -> None:
141
+ await handle_message(None, update, context.bot_data["adapter"])
142
+
143
+
144
+ async def on_error(update: object, context) -> None:
145
+ logger.exception(f"Unhandled exception in Telegram update: {context.error}")
146
+ if isinstance(update, Update) and update.effective_chat:
147
+ try:
148
+ adapter = context.bot_data["adapter"]
149
+ await adapter.send_message(
150
+ update.effective_chat.id,
151
+ "⚠️ **An internal bot error has occurred.** Please contact the developer or check the server logs.",
152
+ )
153
+ except Exception:
154
+ pass
155
+
156
+
157
+ async def on_ready(app: Application) -> None:
158
+ await app.bot.set_my_commands(
159
+ [
160
+ BotCommand("new", "Start a new session (or /start)"),
161
+ BotCommand("model", "Change the AI model for this session"),
162
+ BotCommand("credit", "Turn AI Credits on/off"),
163
+ ]
164
+ )
165
+ logger.info(f"✅ Bot is fully online and ready! Logged in as @{app.bot.username}")
166
+
167
+
168
+ def build_application() -> Application:
169
+ app = ApplicationBuilder().token(TELEGRAM_TOKEN).post_init(on_ready).concurrent_updates(True).build()
170
+ adapter = TelegramAdapter(app.bot)
171
+ app.bot_data["adapter"] = adapter
172
+ register_adapter("telegram", adapter)
173
+
174
+ app.add_handler(CommandHandler(["new", "start"], cmd_new))
175
+ app.add_handler(CommandHandler("model", cmd_model))
176
+ app.add_handler(CommandHandler("credit", cmd_credit))
177
+ app.add_handler(CallbackQueryHandler(adapter.handle_callback_query))
178
+ app.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, on_message))
179
+ app.add_error_handler(on_error)
180
+ return app
181
+
182
+
183
+ async def run_telegram(stop_event: asyncio.Event) -> None:
184
+ """Runs the Telegram bot manually (not via Application.run_polling(),
185
+ which blocks and manages its own event loop) so it can run alongside
186
+ Discord in the same process. See PTB docs on combining Application
187
+ with other asyncio frameworks."""
188
+ if not TELEGRAM_TOKEN:
189
+ logger.critical("Missing TELEGRAM_TOKEN - set telegram_token in lgy.json first.")
190
+ return
191
+
192
+ app = build_application()
193
+ logger.info("✅ Telegram bot starting (polling mode)...")
194
+ await app.initialize()
195
+ await app.start()
196
+ await app.updater.start_polling(allowed_updates=Update.ALL_TYPES)
197
+ try:
198
+ await stop_event.wait()
199
+ finally:
200
+ await app.updater.stop()
201
+ await app.stop()
202
+ await app.shutdown()
@@ -9,9 +9,35 @@ this interface; see VoiceCapable.
9
9
  """
10
10
 
11
11
  from abc import ABC, abstractmethod
12
+ from collections.abc import Awaitable, Callable
12
13
  from contextlib import asynccontextmanager
13
- from dataclasses import dataclass
14
- from typing import Any, Protocol, runtime_checkable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class IncomingAttachment:
20
+ """A file on an inbound message; reader defers fetching bytes until needed."""
21
+
22
+ filename: str
23
+ content_type: str | None
24
+ reader: Callable[[], Awaitable[bytes]]
25
+
26
+ async def read(self) -> bytes:
27
+ return await self.reader()
28
+
29
+
30
+ @dataclass
31
+ class IncomingMessage:
32
+ """Platform-agnostic inbound message - adapters build this, handlers never see raw platform types."""
33
+
34
+ author_id: Any
35
+ platform: str
36
+ content: str
37
+ conversation_id: str
38
+ conversation_ref: Any
39
+ attachments: list[IncomingAttachment] = field(default_factory=list)
40
+ add_reaction: Callable[[str], Awaitable[None]] | None = None
15
41
 
16
42
 
17
43
  @dataclass
@@ -42,6 +68,12 @@ class PromptHandle(ABC):
42
68
 
43
69
  class MessengerAdapter(ABC):
44
70
  platform_name: str = "unknown"
71
+ supports_renaming: bool = True # False skips the AI title-generation call - its result would go nowhere anyway
72
+
73
+ @abstractmethod
74
+ def to_incoming_message(self, raw_event: Any) -> IncomingMessage | None:
75
+ """Converts a native platform event to IncomingMessage, or None to ignore it (bot/system messages)."""
76
+ raise NotImplementedError
45
77
 
46
78
  @abstractmethod
47
79
  async def send_message(self, conversation_ref: Any, text: str) -> Any:
@@ -98,8 +130,15 @@ class MessengerAdapter(ABC):
98
130
  return isinstance(self, VoiceCapable)
99
131
 
100
132
 
101
- @runtime_checkable
102
- class VoiceCapable(Protocol):
103
- async def join_voice(self, guild_ref: Any, channel_ref: Any) -> None: ...
104
- async def leave_voice(self, guild_ref: Any) -> None: ...
105
- async def play_tts(self, guild_ref: Any, audio_bytes: bytes) -> None: ...
133
+ class VoiceCapable(ABC):
134
+ @abstractmethod
135
+ async def join_voice(self, guild_ref: Any, channel_ref: Any) -> None:
136
+ raise NotImplementedError
137
+
138
+ @abstractmethod
139
+ async def leave_voice(self, guild_ref: Any) -> None:
140
+ raise NotImplementedError
141
+
142
+ @abstractmethod
143
+ async def play_tts(self, guild_ref: Any, audio_bytes: bytes) -> None:
144
+ raise NotImplementedError
@@ -9,6 +9,8 @@ import discord
9
9
 
10
10
  from config import logger
11
11
  from messengers.base import (
12
+ IncomingAttachment,
13
+ IncomingMessage,
12
14
  MessengerAdapter,
13
15
  PromptHandle,
14
16
  ScopeOption,
@@ -17,6 +19,19 @@ from messengers.base import (
17
19
  )
18
20
 
19
21
 
22
+ class _ErrorLoggingView(discord.ui.View):
23
+ async def on_error(self, interaction: discord.Interaction, error: Exception, item) -> None:
24
+ logger.exception(f"Discord button interaction error: {error}")
25
+ error_msg = "⚠️ **An error occurred while processing this button.** Please try again later or check the logs."
26
+ try:
27
+ if interaction.response.is_done():
28
+ await interaction.followup.send(error_msg, ephemeral=True)
29
+ else:
30
+ await interaction.response.send_message(error_msg, ephemeral=True)
31
+ except Exception:
32
+ pass
33
+
34
+
20
35
  class _DiscordPromptHandle(PromptHandle):
21
36
  def __init__(self, embed: discord.Embed, view: discord.ui.View):
22
37
  self.embed = embed
@@ -25,7 +40,13 @@ class _DiscordPromptHandle(PromptHandle):
25
40
  self.outcome: ToolApprovalOutcome | None = None
26
41
 
27
42
  async def send(self, conversation_ref: discord.abc.Messageable) -> discord.Message:
28
- self.message = await conversation_ref.send(embed=self.embed, view=self.view)
43
+ if self.embed.description and len(self.embed.description) > 4000:
44
+ self.embed.description = self.embed.description[:3997] + "..."
45
+ try:
46
+ self.message = await conversation_ref.send(embed=self.embed, view=self.view)
47
+ except discord.HTTPException as e:
48
+ logger.error(f"Failed to send Discord prompt message: {e}")
49
+ raise
29
50
  return self.message
30
51
 
31
52
  async def finalize(self) -> None:
@@ -47,10 +68,41 @@ class DiscordAdapter(MessengerAdapter):
47
68
  def __init__(self, bot: discord.Client):
48
69
  self.bot = bot
49
70
 
71
+ # -- inbound -------------------------------------------------------------
72
+
73
+ def to_incoming_message(self, raw_event: discord.Message) -> IncomingMessage | None:
74
+ message = raw_event
75
+ if message.type not in (discord.MessageType.default, discord.MessageType.reply):
76
+ return None
77
+ if message.author.bot:
78
+ return None
79
+
80
+ attachments = [
81
+ IncomingAttachment(filename=att.filename, content_type=att.content_type, reader=att.read)
82
+ for att in message.attachments
83
+ ]
84
+
85
+ async def add_reaction(emoji: str) -> None:
86
+ await message.add_reaction(emoji)
87
+
88
+ return IncomingMessage(
89
+ author_id=message.author.id,
90
+ platform=self.platform_name,
91
+ content=message.content,
92
+ conversation_id=str(message.channel.id),
93
+ conversation_ref=message.channel,
94
+ attachments=attachments,
95
+ add_reaction=add_reaction,
96
+ )
97
+
50
98
  # -- plain messaging ---------------------------------------------------
51
99
 
52
100
  async def send_message(self, conversation_ref: discord.abc.Messageable, text: str) -> discord.Message:
53
- return await conversation_ref.send(text)
101
+ try:
102
+ return await conversation_ref.send(text)
103
+ except discord.HTTPException as e:
104
+ logger.error(f"Failed to send Discord message: {e}")
105
+ raise
54
106
 
55
107
  async def edit_message(self, message_ref: discord.Message, text: str) -> bool:
56
108
  try:
@@ -69,7 +121,11 @@ class DiscordAdapter(MessengerAdapter):
69
121
  async def send_files(self, conversation_ref: discord.abc.Messageable, file_paths: list[str]) -> None:
70
122
  if not file_paths:
71
123
  return
72
- await conversation_ref.send(files=[discord.File(p) for p in file_paths])
124
+ try:
125
+ await conversation_ref.send(files=[discord.File(p) for p in file_paths])
126
+ except discord.HTTPException as e:
127
+ logger.error(f"Failed to send Discord files: {e}")
128
+ raise
73
129
 
74
130
  def resolve_conversation(self, conversation_id: str) -> Any:
75
131
  try:
@@ -98,7 +154,7 @@ class DiscordAdapter(MessengerAdapter):
98
154
  scope_options: list[ScopeOption],
99
155
  ) -> PromptHandle:
100
156
  embed = discord.Embed(title=title, description=body, color=discord.Color.orange())
101
- view = discord.ui.View(timeout=None)
157
+ view = _ErrorLoggingView(timeout=None)
102
158
  handle = _DiscordPromptHandle(embed, view)
103
159
 
104
160
  def make_callback(decision: str, scope: ScopeOption | None):
@@ -154,7 +210,7 @@ class DiscordAdapter(MessengerAdapter):
154
210
  description=f"**{question}**\n\nPlease select an answer below.",
155
211
  color=discord.Color.blue(),
156
212
  )
157
- view = discord.ui.View(timeout=None)
213
+ view = _ErrorLoggingView(timeout=None)
158
214
  handle = _DiscordPromptHandle(embed, view)
159
215
 
160
216
  async def _resolve(interaction: discord.Interaction, text: str, note: str):
@@ -214,6 +270,17 @@ class DiscordAdapter(MessengerAdapter):
214
270
  async def on_submit(modal_self, interaction: discord.Interaction):
215
271
  await _resolve(interaction, modal_self.answer.value, "Selected (Write in)")
216
272
 
273
+ async def on_error(modal_self, interaction: discord.Interaction, error: Exception) -> None:
274
+ logger.exception(f"Discord modal submission error: {error}")
275
+ error_msg = "⚠️ **An error occurred while processing your response.** Please try again later or check the logs."
276
+ try:
277
+ if interaction.response.is_done():
278
+ await interaction.followup.send(error_msg, ephemeral=True)
279
+ else:
280
+ await interaction.response.send_message(error_msg, ephemeral=True)
281
+ except Exception:
282
+ pass
283
+
217
284
  async def write_in_callback(interaction: discord.Interaction):
218
285
  await interaction.response.send_modal(WriteInModal())
219
286
 
@@ -1,19 +1,35 @@
1
- """Process-wide messenger adapter instance, set once at startup by
2
- main.py. Matches the codebase's existing singleton pattern (see
3
- config.session_manager) so deep call sites don't need the adapter
4
- threaded through every signature."""
1
+ """Per-platform messenger adapter registry, populated once at startup by
2
+ main.py. Both bots now share one process, so callers must say which
3
+ platform they mean - either directly (voice/Discord-only code) or by
4
+ looking up which platform a given thread_id/conversation belongs to."""
5
5
 
6
6
  from messengers.base import MessengerAdapter
7
7
 
8
- _adapter: MessengerAdapter | None = None
8
+ _adapters: dict[str, MessengerAdapter] = {}
9
9
 
10
10
 
11
- def set_adapter(adapter: MessengerAdapter) -> None:
12
- global _adapter
13
- _adapter = adapter
11
+ def register_adapter(platform: str, adapter: MessengerAdapter) -> None:
12
+ _adapters[platform] = adapter
14
13
 
15
14
 
16
- def get_adapter() -> MessengerAdapter:
17
- if _adapter is None:
18
- raise RuntimeError("Messenger adapter not initialized - main.py must call set_adapter() at startup.")
19
- return _adapter
15
+ def get_adapter_for_platform(platform: str) -> MessengerAdapter:
16
+ if platform not in _adapters:
17
+ raise RuntimeError(f"No adapter registered for platform {platform!r} - is it enabled in lgy.json?")
18
+ return _adapters[platform]
19
+
20
+
21
+ def get_adapter_for_thread(thread_id: str) -> MessengerAdapter:
22
+ from config import session_manager
23
+
24
+ session = session_manager.get_session(thread_id) or {}
25
+ platform = session.get("platform", "discord") # pre-multi-platform sessions have no tag - assume discord
26
+ return get_adapter_for_platform(platform)
27
+
28
+
29
+ def get_adapter_for_conv_id(conv_id: str) -> MessengerAdapter | None:
30
+ from config import session_manager
31
+
32
+ for _thread_id, sess in session_manager.get_all_sessions().items():
33
+ if sess.get("conversation_id") == conv_id:
34
+ return get_adapter_for_platform(sess.get("platform", "discord"))
35
+ return None