linkgravity 1.4.1 → 1.5.1

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.
package/src/main.py CHANGED
@@ -1,11 +1,29 @@
1
- """Top-level entrypoint. Starts whichever platforms are enabled
2
- (main_discord.run_discord / main_telegram.run_telegram) concurrently in
3
- one process, sharing a single webhook server and exception handler."""
1
+ """Top-level entrypoint - starts whichever platforms are enabled, concurrently in one process."""
4
2
 
5
3
  import asyncio
6
4
 
7
5
  from api import server
8
- from config import DISCORD_TOKEN, SESSION_SCOPES, bot_settings, logger, session_manager
6
+ from config import (
7
+ DISCORD_TOKEN,
8
+ SESSION_SCOPES,
9
+ SLACK_APP_TOKEN,
10
+ SLACK_BOT_TOKEN,
11
+ bot_settings,
12
+ logger,
13
+ session_manager,
14
+ )
15
+ from core import platform_health
16
+
17
+
18
+ async def _run_platform_isolated(platform: str, coro) -> None:
19
+ """Catches exceptions so one platform crashing can't take the others down via asyncio.gather."""
20
+ platform_health.set_status(platform, "connecting")
21
+ try:
22
+ await coro
23
+ platform_health.set_status(platform, "stopped")
24
+ except Exception as e:
25
+ logger.opt(exception=e).error(f"{platform} platform crashed - other platforms will keep running")
26
+ platform_health.set_status(platform, "error", detail=str(e))
9
27
 
10
28
 
11
29
  async def main():
@@ -15,14 +33,18 @@ async def main():
15
33
 
16
34
  discord_enabled = bot_settings.get("discord_enabled", bool(DISCORD_TOKEN))
17
35
  telegram_enabled = bot_settings.get("telegram_enabled", False)
36
+ slack_enabled = bot_settings.get("slack_enabled", False)
18
37
 
19
38
  if discord_enabled and not SESSION_SCOPES:
20
39
  logger.warning("Discord enabled but no server/channel configured (session_scopes) - run `lgy setup`")
21
40
  if discord_enabled and not DISCORD_TOKEN:
22
41
  logger.critical("Discord enabled but missing DISCORD_TOKEN - run `lgy setup`")
23
42
  discord_enabled = False
43
+ if slack_enabled and not (SLACK_BOT_TOKEN and SLACK_APP_TOKEN):
44
+ logger.critical("Slack enabled but missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN - run `lgy setup`")
45
+ slack_enabled = False
24
46
 
25
- if not discord_enabled and not telegram_enabled:
47
+ if not discord_enabled and not telegram_enabled and not slack_enabled:
26
48
  logger.critical("No messenger platform is enabled - run `lgy setup`")
27
49
  return
28
50
 
@@ -59,11 +81,15 @@ async def main():
59
81
  if discord_enabled:
60
82
  from main_discord import run_discord
61
83
 
62
- tasks.append(asyncio.create_task(run_discord(stop_event)))
84
+ tasks.append(asyncio.create_task(_run_platform_isolated("discord", run_discord(stop_event))))
63
85
  if telegram_enabled:
64
86
  from main_telegram import run_telegram
65
87
 
66
- tasks.append(asyncio.create_task(run_telegram(stop_event)))
88
+ tasks.append(asyncio.create_task(_run_platform_isolated("telegram", run_telegram(stop_event))))
89
+ if slack_enabled:
90
+ from main_slack import run_slack
91
+
92
+ tasks.append(asyncio.create_task(_run_platform_isolated("slack", run_slack(stop_event))))
67
93
 
68
94
  await asyncio.gather(*tasks)
69
95
 
@@ -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()
@@ -240,6 +240,9 @@ async def run_telegram(stop_event: asyncio.Event) -> None:
240
240
  await app.post_init(app)
241
241
  await app.start()
242
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")
243
246
  try:
244
247
  await stop_event.wait()
245
248
  finally:
@@ -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