linkgravity 1.4.0 → 1.5.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.
@@ -190,6 +190,9 @@ async def _supervise_voice_process(voice_dir: str):
190
190
  @bot.event
191
191
  async def on_ready():
192
192
  logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
193
+ from core import platform_health
194
+
195
+ platform_health.set_status("discord", "running")
193
196
 
194
197
 
195
198
  def _terminate_voice_process():
@@ -0,0 +1,252 @@
1
+ """Slack bot setup. Runs in the same process as Discord/Telegram (see
2
+ main.py), which starts all enabled platforms concurrently."""
3
+
4
+ import asyncio
5
+ import re
6
+ import uuid
7
+ from datetime import datetime
8
+
9
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
10
+ from slack_bolt.app.async_app import AsyncApp
11
+ from slack_sdk.errors import SlackApiError
12
+
13
+ from config import SLACK_APP_TOKEN, SLACK_BOT_TOKEN, allowed, bot_settings, logger, session_manager
14
+ from core import platform_health
15
+ from handlers.message_router import handle_message
16
+ from messengers.registry import register_adapter
17
+ from messengers.slack_adapter import SlackAdapter, encode_conversation_id, latest_channel_session
18
+ from utils.utils import get_default_cwd
19
+
20
+
21
+ def _start_session(conversation_id: str, user_id: str) -> dict:
22
+ session = {
23
+ "status": "pending",
24
+ "platform": "slack",
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(conversation_id, session)
32
+ return session
33
+
34
+
35
+ async def cmd_new(ack, body, respond, context) -> None:
36
+ await ack()
37
+ user_id = body["user_id"]
38
+ channel = body["channel_id"]
39
+
40
+ if not allowed(user_id, "slack"):
41
+ await respond("❌ Permission denied.")
42
+ return
43
+
44
+ if body.get("channel_name") == "directmessage":
45
+ # DMs are 1:1 like Telegram - no threading, so the channel itself is the session key.
46
+ conversation_id = encode_conversation_id(channel, channel)
47
+ _start_session(conversation_id, user_id)
48
+ await respond("✅ *Ready for new session!* Send a message to begin.")
49
+ return
50
+
51
+ client = context["client"]
52
+ # A slash command has no message ts to thread under, and (unlike Discord) Slack has no
53
+ # explicit "create thread" call - so post the announcement first and thread off its ts.
54
+ try:
55
+ resp = await client.chat_postMessage(
56
+ channel=channel, text="✅ *New session started!* Reply in this thread to begin."
57
+ )
58
+ except SlackApiError as e:
59
+ if e.response.get("error") == "not_in_channel":
60
+ await respond("❌ I'm not in this channel yet - run `/invite @<this bot>` here first, then try /new again.")
61
+ else:
62
+ await respond(f"❌ Failed to start a session: {e.response.get('error')}")
63
+ return
64
+ conversation_id = encode_conversation_id(channel, resp["ts"])
65
+ _start_session(conversation_id, user_id)
66
+
67
+
68
+ async def cmd_model(ack, body, respond, context) -> None:
69
+ await ack()
70
+ adapter: SlackAdapter = context["adapter"]
71
+ user_id = body["user_id"]
72
+ channel = body["channel_id"]
73
+
74
+ if not allowed(user_id, "slack"):
75
+ await respond("❌ Permission Denied")
76
+ return
77
+
78
+ found = latest_channel_session(channel)
79
+ if not found:
80
+ await respond("⚠️ No active session here. Start one with /new first.")
81
+ return
82
+ conversation_id, session = found
83
+
84
+ from cogs.general_cog import load_cached_models
85
+
86
+ cached_models = load_cached_models()
87
+ current_model = session.get("model") or bot_settings.get("default_model")
88
+
89
+ def _apply_model(final_model: str) -> str:
90
+ session_manager.update_session(conversation_id, "model", final_model)
91
+ bot_settings["default_model"] = final_model
92
+ from config import save_bot_settings
93
+
94
+ save_bot_settings(bot_settings)
95
+ return final_model
96
+
97
+ requested = (body.get("text") or "").strip()
98
+ if not requested:
99
+ prompt_id = uuid.uuid4().hex[:12]
100
+ elements = []
101
+ for m in cached_models:
102
+ key = f"{prompt_id}:{m}"
103
+ label = ("✅ " if m == current_model else "") + m
104
+
105
+ async def pick(action_body, client, m=m):
106
+ final_model = _apply_model(m)
107
+ await client.chat_postMessage(channel=channel, text=f"🤖 Model changed: *{final_model}*")
108
+
109
+ adapter.register_callback(key, pick)
110
+ elements.append({"type": "button", "text": {"type": "plain_text", "text": label[:75]}, "action_id": key})
111
+
112
+ await respond(
113
+ {
114
+ "text": "Pick a model (or send /model <name> to type one):",
115
+ "blocks": [
116
+ {
117
+ "type": "section",
118
+ "text": {"type": "mrkdwn", "text": "Pick a model (or send `/model <name>` to type one):"},
119
+ },
120
+ {"type": "actions", "elements": elements[:25]},
121
+ ],
122
+ }
123
+ )
124
+ return
125
+
126
+ exact = next((m for m in cached_models if m.lower() == requested.lower()), None)
127
+ partial = next((m for m in cached_models if requested.lower() in m.lower()), None)
128
+ final_model = _apply_model(exact or partial or requested)
129
+ await respond(f"🤖 Model changed: *{final_model}*\n💾 Also set as the default for new sessions.")
130
+
131
+
132
+ async def cmd_credit(ack, body, respond, context) -> None:
133
+ await ack()
134
+ adapter: SlackAdapter = context["adapter"]
135
+ user_id = body["user_id"]
136
+ channel = body["channel_id"]
137
+
138
+ if not allowed(user_id, "slack"):
139
+ await respond("❌ Denied")
140
+ return
141
+
142
+ import os
143
+ from pathlib import Path
144
+
145
+ from core.atomic_io import atomic_write_json, safe_load_json
146
+
147
+ settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
148
+ current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
149
+
150
+ async def set_credit(use_credits: bool, action_body, client) -> None:
151
+ try:
152
+ data = safe_load_json(settings_path, {}, logger=logger)
153
+ data["useG1Credits"] = use_credits
154
+ atomic_write_json(settings_path, data)
155
+ except Exception as e:
156
+ await client.chat_postMessage(channel=channel, text=f"⚠️ Failed to update settings: {e}")
157
+ return
158
+ status_text = "🟢 *ON* (Using AI Credits)" if use_credits else "🔴 *OFF* (Using default/free model)"
159
+ await client.chat_postMessage(channel=channel, text=f"✅ AI Credit setting updated: {status_text}")
160
+
161
+ prompt_id = uuid.uuid4().hex[:12]
162
+ on_key, off_key = f"{prompt_id}:on", f"{prompt_id}:off"
163
+ adapter.register_callback(on_key, lambda b, c: set_credit(True, b, c))
164
+ adapter.register_callback(off_key, lambda b, c: set_credit(False, b, c))
165
+
166
+ await respond(
167
+ {
168
+ "text": "AI Credits:",
169
+ "blocks": [
170
+ {
171
+ "type": "actions",
172
+ "elements": [
173
+ {
174
+ "type": "button",
175
+ "text": {"type": "plain_text", "text": ("✅ " if current else "") + "🟢 ON"},
176
+ "action_id": on_key,
177
+ },
178
+ {
179
+ "type": "button",
180
+ "text": {"type": "plain_text", "text": ("✅ " if not current else "") + "🔴 OFF"},
181
+ "action_id": off_key,
182
+ },
183
+ ],
184
+ }
185
+ ],
186
+ }
187
+ )
188
+
189
+
190
+ async def on_message(event, context) -> None:
191
+ await handle_message(None, event, context["adapter"])
192
+
193
+
194
+ async def on_action(ack, body, context) -> None:
195
+ await ack()
196
+ await context["adapter"].handle_block_action(body)
197
+
198
+
199
+ async def on_view_submission(ack, body, context) -> None:
200
+ await ack()
201
+ await context["adapter"].handle_view_submission(body)
202
+
203
+
204
+ def build_app() -> tuple[AsyncApp, SlackAdapter]:
205
+ app = AsyncApp(token=SLACK_BOT_TOKEN)
206
+ adapter = SlackAdapter(app)
207
+
208
+ @app.middleware
209
+ async def inject_adapter(context, next):
210
+ context["adapter"] = adapter
211
+ await next()
212
+
213
+ app.command("/new")(cmd_new)
214
+ app.command("/model")(cmd_model)
215
+ app.command("/credit")(cmd_credit)
216
+ app.event("message")(on_message)
217
+ app.action(re.compile(".*"))(on_action)
218
+ app.view(re.compile(".*"))(on_view_submission)
219
+
220
+ register_adapter("slack", adapter)
221
+ return app, adapter
222
+
223
+
224
+ async def run_slack(stop_event: asyncio.Event) -> None:
225
+ """Uses connect_async()/close_async() directly - start_async() sleeps forever internally and never returns."""
226
+ if not SLACK_BOT_TOKEN or not SLACK_APP_TOKEN:
227
+ logger.critical(
228
+ "Missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN - set slack_bot_token/slack_app_token in lgy.json first."
229
+ )
230
+ return
231
+ if not SLACK_BOT_TOKEN.startswith("xoxb-"):
232
+ logger.critical(
233
+ "slack_bot_token doesn't start with xoxb- - looks like the User OAuth Token was used "
234
+ "instead of the Bot User OAuth Token (OAuth & Permissions page has both)."
235
+ )
236
+ return
237
+
238
+ app, adapter = build_app()
239
+ try:
240
+ await adapter.resolve_bot_user_id()
241
+ except SlackApiError as e:
242
+ logger.critical(f"Slack auth_test failed - check slack_bot_token: {e}")
243
+ return
244
+
245
+ handler = AsyncSocketModeHandler(app, SLACK_APP_TOKEN)
246
+ logger.info("✅ Slack bot starting (Socket Mode)...")
247
+ await handler.connect_async()
248
+ platform_health.set_status("slack", "running")
249
+ try:
250
+ await stop_event.wait()
251
+ finally:
252
+ await handler.close_async()
@@ -14,7 +14,7 @@ from config import TELEGRAM_TOKEN, allowed, bot_settings, logger, save_bot_setti
14
14
  from core.atomic_io import atomic_write_json, safe_load_json
15
15
  from handlers.message_router import handle_message
16
16
  from messengers.registry import register_adapter
17
- from messengers.telegram_adapter import TelegramAdapter, markdown_to_telegram_html
17
+ from messengers.telegram_adapter import TelegramAdapter, markdown_to_telegram_html, safe_query_edit
18
18
  from utils.utils import get_default_cwd
19
19
 
20
20
 
@@ -91,21 +91,45 @@ async def cmd_model(update: Update, context) -> None:
91
91
  await update.message.reply_text("⚠️ No active session here. Start one with /new first.")
92
92
  return
93
93
 
94
+ from cogs.general_cog import load_cached_models
95
+
96
+ cached_models = load_cached_models()
97
+ current_model = session.get("model") or bot_settings.get("default_model")
98
+
99
+ def _apply_model(final_model: str) -> str:
100
+ session_manager.update_session(str(chat_id), "model", final_model)
101
+ bot_settings["default_model"] = final_model
102
+ save_bot_settings(bot_settings)
103
+ return final_model
104
+
94
105
  if not context.args:
95
- await update.message.reply_text("Usage: /model <name>\nExample: /model gemini-3.6-flash-high")
106
+ prompt_id = uuid.uuid4().hex[:12]
107
+ keyboard = []
108
+ for m in cached_models:
109
+ key = f"{prompt_id}:{m}"
110
+ label = ("✅ " if m == current_model else "") + m
111
+
112
+ async def pick(query, m=m):
113
+ final_model = _apply_model(m)
114
+ await query.answer()
115
+ await safe_query_edit(
116
+ query,
117
+ text=markdown_to_telegram_html(f"🤖 Model changed: **{final_model}**"),
118
+ parse_mode="HTML",
119
+ )
120
+
121
+ adapter.register_callback(key, pick)
122
+ keyboard.append([InlineKeyboardButton(label, callback_data=key)])
123
+
124
+ await update.message.reply_text(
125
+ "Pick a model (or send /model <name> to type one):", reply_markup=InlineKeyboardMarkup(keyboard)
126
+ )
96
127
  return
97
128
 
98
129
  requested = " ".join(context.args)
99
- from cogs.general_cog import load_cached_models
100
-
101
- cached_models = load_cached_models()
102
130
  exact = next((m for m in cached_models if m.lower() == requested.lower()), None)
103
131
  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)
132
+ final_model = _apply_model(exact or partial or requested)
109
133
 
110
134
  await adapter.send_message(
111
135
  chat_id, f"🤖 Model changed: **{final_model}**\n💾 Also set as the default for new sessions."
@@ -114,27 +138,47 @@ async def cmd_model(update: Update, context) -> None:
114
138
 
115
139
  async def cmd_credit(update: Update, context) -> None:
116
140
  user = update.effective_user
141
+ adapter: TelegramAdapter = context.bot_data["adapter"]
142
+
117
143
  if not allowed(user.id, "telegram"):
118
144
  await update.message.reply_text("❌ Denied")
119
145
  return
120
146
 
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
147
  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)
148
+ current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
149
+
150
+ async def set_credit(use_credits: bool, query) -> None:
151
+ try:
152
+ data = safe_load_json(settings_path, {}, logger=logger)
153
+ data["useG1Credits"] = use_credits
154
+ atomic_write_json(settings_path, data)
155
+ except Exception as e:
156
+ await query.answer()
157
+ await safe_query_edit(query, text=f"⚠️ Failed to update settings: {e}")
158
+ return
131
159
 
132
160
  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"
161
+ await query.answer()
162
+ await safe_query_edit(
163
+ query,
164
+ text=markdown_to_telegram_html(f"✅ AI Credit setting updated: {status_text}"),
165
+ parse_mode="HTML",
135
166
  )
136
- except Exception as e:
137
- await update.message.reply_text(f"⚠️ Failed to update settings: {e}")
167
+
168
+ prompt_id = uuid.uuid4().hex[:12]
169
+ on_key, off_key = f"{prompt_id}:on", f"{prompt_id}:off"
170
+ adapter.register_callback(on_key, lambda query: set_credit(True, query))
171
+ adapter.register_callback(off_key, lambda query: set_credit(False, query))
172
+
173
+ keyboard = InlineKeyboardMarkup(
174
+ [
175
+ [
176
+ InlineKeyboardButton(("✅ " if current else "") + "🟢 ON", callback_data=on_key),
177
+ InlineKeyboardButton(("✅ " if not current else "") + "🔴 OFF", callback_data=off_key),
178
+ ]
179
+ ]
180
+ )
181
+ await update.message.reply_text("AI Credits:", reply_markup=keyboard)
138
182
 
139
183
 
140
184
  async def on_message(update: Update, context) -> None:
@@ -192,11 +236,18 @@ async def run_telegram(stop_event: asyncio.Event) -> None:
192
236
  app = build_application()
193
237
  logger.info("✅ Telegram bot starting (polling mode)...")
194
238
  await app.initialize()
239
+ if app.post_init: # not auto-called outside run_polling()/run_webhook()
240
+ await app.post_init(app)
195
241
  await app.start()
196
242
  await app.updater.start_polling(allowed_updates=Update.ALL_TYPES)
243
+ from core import platform_health
244
+
245
+ platform_health.set_status("telegram", "running")
197
246
  try:
198
247
  await stop_event.wait()
199
248
  finally:
200
249
  await app.updater.stop()
201
250
  await app.stop()
202
251
  await app.shutdown()
252
+ if app.post_shutdown: # same gotcha as post_init: not auto-called here
253
+ await app.post_shutdown(app)
@@ -96,6 +96,11 @@ class MessengerAdapter(ABC):
96
96
  async def start_conversation(self, origin_ref: Any, title: str) -> Any:
97
97
  raise NotImplementedError
98
98
 
99
+ def can_rename(self, conversation_ref: Any) -> bool:
100
+ """Per-conversation version of supports_renaming - lets a single adapter answer
101
+ differently depending on the target (e.g. Discord threads vs. Discord DMs)."""
102
+ return self.supports_renaming
103
+
99
104
  @abstractmethod
100
105
  async def rename_conversation(self, conversation_ref: Any, title: str) -> None:
101
106
  raise NotImplementedError
@@ -129,14 +129,32 @@ class DiscordAdapter(MessengerAdapter):
129
129
 
130
130
  def resolve_conversation(self, conversation_id: str) -> Any:
131
131
  try:
132
- return self.bot.get_channel(int(conversation_id))
132
+ channel_id = int(conversation_id)
133
133
  except (TypeError, ValueError):
134
134
  return None
135
+ channel = self.bot.get_channel(channel_id)
136
+ if channel is not None:
137
+ return channel
138
+ # discord.py doesn't cache DM channels the way it caches guild channels/threads -
139
+ # DMChannel objects built from incoming messages (DMChannel._from_message) are never
140
+ # added to the private-channel cache, so get_channel() reliably misses for DMs. This
141
+ # is exactly what the approval webhook hits when it resolves a conversation purely by
142
+ # its stored ID (rather than from a live message/interaction object): the lookup came
143
+ # back None, .send() on None blew up, and the broad except in handle_approve_request
144
+ # swallowed it into a silent "allow" - no prompt, no tool-call display, nothing.
145
+ # PartialMessageable is the same fallback discord.py itself uses internally for this
146
+ # exact situation - it still supports send()/typing() from just the ID.
147
+ return discord.PartialMessageable(state=self.bot._connection, id=channel_id)
135
148
 
136
149
  async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
137
150
  return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
138
151
 
152
+ def can_rename(self, conversation_ref: Any) -> bool:
153
+ return isinstance(conversation_ref, discord.Thread)
154
+
139
155
  async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
156
+ if not isinstance(conversation_ref, discord.Thread):
157
+ return # DMs (and any other non-thread channel) have no per-session title surface to rename
140
158
  await conversation_ref.edit(name=title[:100])
141
159
 
142
160
  @asynccontextmanager