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/README.md +54 -19
- package/bin/cli.js +27 -3
- package/bin/platforms.js +18 -1
- package/bin/setup.js +43 -0
- package/package.json +4 -2
- package/requirements.txt +1 -0
- package/src/cogs/general_cog.py +0 -21
- package/src/cogs/voice_cog.py +81 -7
- package/src/config.py +15 -2
- package/src/core/logger.py +6 -0
- package/src/core/platform_health.py +28 -0
- package/src/handlers/thread_reply.py +1 -1
- package/src/main.py +33 -7
- package/src/main_discord.py +3 -0
- package/src/main_slack.py +252 -0
- package/src/main_telegram.py +3 -0
- package/src/messengers/base.py +5 -0
- package/src/messengers/discord_adapter.py +19 -1
- package/src/messengers/slack_adapter.py +492 -0
- package/src/services/audio_service.py +6 -1
- package/src/services/streaming.py +6 -5
- package/voice-service/index.js +14 -1001
package/src/main.py
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
"""Top-level entrypoint
|
|
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
|
|
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
|
|
package/src/main_discord.py
CHANGED
|
@@ -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()
|
package/src/main_telegram.py
CHANGED
|
@@ -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:
|
package/src/messengers/base.py
CHANGED
|
@@ -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
|
-
|
|
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
|