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.
- package/bin/cli.js +160 -80
- package/bin/platforms.js +63 -0
- package/bin/setup.js +172 -76
- package/hooks/hook.py +3 -4
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/api/server.py +2 -4
- package/src/api/ui_routes.py +46 -16
- package/src/api/voice_routes.py +5 -8
- package/src/cogs/general_cog.py +4 -1
- package/src/cogs/voice_cog.py +4 -7
- package/src/config.py +9 -4
- package/src/core/agy_runner.py +3 -3
- package/src/core/session_manager.py +25 -0
- package/src/handlers/message_router.py +6 -9
- package/src/handlers/thread_reply.py +62 -34
- package/src/main.py +35 -305
- package/src/main_discord.py +311 -0
- package/src/main_telegram.py +202 -0
- package/src/messengers/base.py +46 -7
- package/src/messengers/discord_adapter.py +72 -5
- package/src/messengers/registry.py +28 -12
- package/src/messengers/telegram_adapter.py +372 -0
- package/src/services/audio_service.py +2 -2
- package/src/services/discord_helpers.py +3 -4
- package/src/services/response.py +2 -2
- package/src/services/streaming.py +12 -11
- package/src/services/discord_mcp.py +0 -50
package/src/config.py
CHANGED
|
@@ -7,8 +7,7 @@ WORKSPACE_DIR = Path.home() / ".gemini" / "linkgravity"
|
|
|
7
7
|
WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
|
|
8
8
|
DATA_DIR = WORKSPACE_DIR / "data"
|
|
9
9
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
10
|
-
# Per-user wake-word recordings
|
|
11
|
-
# Defined early so load_bot_settings' migration below can read it.
|
|
10
|
+
# Per-user wake-word recordings (see EnrollmentManager); defined early so load_bot_settings' migration below can read it.
|
|
12
11
|
WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
|
|
13
12
|
WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
13
|
|
|
@@ -16,6 +15,8 @@ LGY_CONFIG_FILE = WORKSPACE_DIR / "lgy.json"
|
|
|
16
15
|
|
|
17
16
|
DEFAULT_LGY_CONFIG = {
|
|
18
17
|
"discord_token": "",
|
|
18
|
+
"telegram_token": "",
|
|
19
|
+
"telegram_allowed_user_ids": "",
|
|
19
20
|
"session_scopes": [],
|
|
20
21
|
"allowed_user_ids": "",
|
|
21
22
|
# user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
|
|
@@ -74,6 +75,7 @@ logger = init_logger(WORKSPACE_DIR)
|
|
|
74
75
|
|
|
75
76
|
|
|
76
77
|
DISCORD_TOKEN = bot_settings.get("discord_token", "")
|
|
78
|
+
TELEGRAM_TOKEN = bot_settings.get("telegram_token", "")
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
def _parse_session_scopes(raw_scopes) -> dict:
|
|
@@ -97,6 +99,7 @@ def _parse_session_scopes(raw_scopes) -> dict:
|
|
|
97
99
|
|
|
98
100
|
SESSION_SCOPES = _parse_session_scopes(bot_settings.get("session_scopes"))
|
|
99
101
|
ALLOWED_IDS = set(int(x) for x in bot_settings.get("allowed_user_ids", "").split(",") if x.strip())
|
|
102
|
+
TELEGRAM_ALLOWED_IDS = set(int(x) for x in bot_settings.get("telegram_allowed_user_ids", "").split(",") if x.strip())
|
|
100
103
|
TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
|
|
101
104
|
|
|
102
105
|
|
|
@@ -120,6 +123,7 @@ TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
120
123
|
|
|
121
124
|
MAX_EMBED_LEN = 1900
|
|
122
125
|
STREAM_RATE_LIMIT_SEC = 0.5
|
|
126
|
+
APPROVAL_TIMEOUT_SEC = 1800
|
|
123
127
|
PERSISTENT_FILE = DATA_DIR / "persistent_tools.json"
|
|
124
128
|
SESSION_FILE = DATA_DIR / "sessions.json"
|
|
125
129
|
|
|
@@ -136,5 +140,6 @@ AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
|
|
|
136
140
|
session_manager = SessionManager(DATA_DIR)
|
|
137
141
|
|
|
138
142
|
|
|
139
|
-
def allowed(user_id: int) -> bool:
|
|
140
|
-
|
|
143
|
+
def allowed(user_id: int, platform: str = "discord") -> bool:
|
|
144
|
+
ids = TELEGRAM_ALLOWED_IDS if platform == "telegram" else ALLOWED_IDS
|
|
145
|
+
return not ids or user_id in ids
|
package/src/core/agy_runner.py
CHANGED
|
@@ -118,10 +118,10 @@ async def run_agy(
|
|
|
118
118
|
for attempt in range(max_retries):
|
|
119
119
|
try:
|
|
120
120
|
env = os.environ.copy()
|
|
121
|
-
env["
|
|
121
|
+
env["LGY_APPROVAL_HOOK"] = "1"
|
|
122
122
|
env["PYTHONUNBUFFERED"] = "1"
|
|
123
123
|
if thread_id:
|
|
124
|
-
env["
|
|
124
|
+
env["LGY_THREAD_ID"] = thread_id
|
|
125
125
|
|
|
126
126
|
libstdbuf_path = _find_libstdbuf() # works around agy's output-truncation bug
|
|
127
127
|
if libstdbuf_path:
|
|
@@ -206,7 +206,7 @@ async def run_agy(
|
|
|
206
206
|
gather_task = asyncio.create_task(_gather_pipes())
|
|
207
207
|
wait_task = asyncio.create_task(proc.wait())
|
|
208
208
|
|
|
209
|
-
# Slices let the timeout pause during a pending
|
|
209
|
+
# Slices let the timeout pause during a pending tool approval (up to 3600s).
|
|
210
210
|
from config import session_manager as _sm
|
|
211
211
|
|
|
212
212
|
poll_slice = 5.0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
@@ -79,6 +80,30 @@ class SessionManager:
|
|
|
79
80
|
def get_active_queue_keys(self) -> list:
|
|
80
81
|
return list(self.active_queues.keys())
|
|
81
82
|
|
|
83
|
+
def cleanup_stale_sessions(self, pending_max_age_days: int = 7) -> int:
|
|
84
|
+
"""Only ever removes 'pending' sessions (started with /new but never actually used - no real conversation attached) older than pending_max_age_days, or with no created_at at all (pre-dates that field, safe to treat as stale). 'active' sessions are NEVER removed by age: Discord threads must stay resumable indefinitely, and Telegram's 1-chat-1-session model already overwrites its one entry on each /new, so there's nothing to accumulate there either. Returns how many were removed."""
|
|
85
|
+
now = datetime.now()
|
|
86
|
+
to_remove = []
|
|
87
|
+
for thread_id, session in self.sessions.items():
|
|
88
|
+
if session.get("status") != "pending":
|
|
89
|
+
continue
|
|
90
|
+
created_at_str = session.get("created_at")
|
|
91
|
+
if not created_at_str:
|
|
92
|
+
to_remove.append(thread_id)
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
age_days = (now - datetime.fromisoformat(created_at_str)).days
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
if age_days > pending_max_age_days:
|
|
99
|
+
to_remove.append(thread_id)
|
|
100
|
+
|
|
101
|
+
for thread_id in to_remove:
|
|
102
|
+
self.sessions.pop(thread_id, None)
|
|
103
|
+
if to_remove:
|
|
104
|
+
self.save_sessions()
|
|
105
|
+
return len(to_remove)
|
|
106
|
+
|
|
82
107
|
def get_tts_task(self, thread_id: str) -> asyncio.Task | None:
|
|
83
108
|
return self.active_tts_tasks.get(str(thread_id))
|
|
84
109
|
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import discord
|
|
2
|
-
|
|
3
1
|
from config import allowed
|
|
4
2
|
from handlers.thread_reply import handle_thread_reply
|
|
3
|
+
from messengers.base import MessengerAdapter
|
|
5
4
|
|
|
6
5
|
|
|
7
|
-
async def handle_message(bot,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if message.author.bot:
|
|
6
|
+
async def handle_message(bot, raw_event, adapter: MessengerAdapter):
|
|
7
|
+
incoming = adapter.to_incoming_message(raw_event)
|
|
8
|
+
if incoming is None:
|
|
11
9
|
return
|
|
12
|
-
if not allowed(
|
|
10
|
+
if not allowed(incoming.author_id, incoming.platform):
|
|
13
11
|
return
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
await handle_thread_reply(bot, message)
|
|
13
|
+
await handle_thread_reply(bot, incoming)
|
|
@@ -2,10 +2,9 @@ import asyncio
|
|
|
2
2
|
import time
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
5
|
-
import discord
|
|
6
|
-
|
|
7
5
|
from config import session_manager
|
|
8
|
-
from messengers.
|
|
6
|
+
from messengers.base import IncomingMessage
|
|
7
|
+
from messengers.registry import get_adapter_for_platform
|
|
9
8
|
from services.response import render_thought_process, send_agy_response
|
|
10
9
|
from services.streaming import stream_thinking_latest
|
|
11
10
|
from utils.utils import (
|
|
@@ -20,10 +19,15 @@ from utils.utils import (
|
|
|
20
19
|
)
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
async def handle_approval_reply(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
async def handle_approval_reply(incoming: IncomingMessage, session: dict, content: str, pa) -> bool:
|
|
23
|
+
adapter = get_adapter_for_platform(incoming.platform)
|
|
24
|
+
thread = incoming.conversation_ref
|
|
25
|
+
|
|
26
|
+
if session_manager.get_pending_approval_type_by_conv(incoming.conversation_id) == "ask_question":
|
|
27
|
+
pa.set_result(content)
|
|
28
|
+
await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
|
|
29
|
+
return True
|
|
30
|
+
|
|
27
31
|
if content.lower() in ("yes", "y", "allow", "승인"):
|
|
28
32
|
pa.set_result("allow")
|
|
29
33
|
await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
|
|
@@ -38,38 +42,42 @@ async def handle_approval_reply(
|
|
|
38
42
|
return True
|
|
39
43
|
elif content.lower() in ("clear", "reset"):
|
|
40
44
|
await adapter.send_message(thread, "🧹 Conversation context cleared.")
|
|
41
|
-
old_sess = session_manager.remove_session(
|
|
45
|
+
old_sess = session_manager.remove_session(incoming.conversation_id)
|
|
42
46
|
if old_sess:
|
|
43
|
-
session_manager.set_session(
|
|
47
|
+
session_manager.set_session(incoming.conversation_id, old_sess)
|
|
44
48
|
return True
|
|
45
49
|
return False
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
async def handle_pending_session(
|
|
49
|
-
bot,
|
|
53
|
+
bot, incoming: IncomingMessage, session: dict, agy_content: str, content: str, image_paths: list
|
|
50
54
|
):
|
|
51
|
-
adapter =
|
|
55
|
+
adapter = get_adapter_for_platform(incoming.platform)
|
|
56
|
+
thread = incoming.conversation_ref
|
|
52
57
|
try:
|
|
53
58
|
async with adapter.typing(thread):
|
|
54
59
|
ctx = {"status_msg": None}
|
|
55
60
|
start_time = time.time()
|
|
56
61
|
queue = asyncio.Queue()
|
|
57
|
-
session_manager.register_queue(
|
|
58
|
-
stream_task = asyncio.create_task(
|
|
62
|
+
session_manager.register_queue(incoming.conversation_id, queue)
|
|
63
|
+
stream_task = asyncio.create_task(
|
|
64
|
+
stream_thinking_latest(bot, thread, incoming.conversation_id, context_dict=ctx, queue=queue)
|
|
65
|
+
)
|
|
59
66
|
|
|
60
67
|
cwd = session.get("cwd")
|
|
61
68
|
model = session.get("model")
|
|
62
69
|
result_text, new_conv_id = await agy_new_conversation(
|
|
63
|
-
agy_content, model=model, stream_queue=queue, thread_id=
|
|
70
|
+
agy_content, model=model, stream_queue=queue, thread_id=incoming.conversation_id, cwd=cwd
|
|
64
71
|
)
|
|
65
72
|
|
|
66
73
|
await queue.put(("__END__", True))
|
|
67
74
|
await stream_task
|
|
68
75
|
|
|
69
76
|
response_text = result_text
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
if adapter.supports_renaming:
|
|
78
|
+
new_title = await generate_thread_title(content, response_text)
|
|
79
|
+
await adapter.rename_conversation(thread, new_title)
|
|
80
|
+
await update_agy_conversation_title(new_conv_id, new_title)
|
|
73
81
|
|
|
74
82
|
response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
|
|
75
83
|
|
|
@@ -84,22 +92,25 @@ async def handle_pending_session(
|
|
|
84
92
|
|
|
85
93
|
|
|
86
94
|
async def handle_existing_session(
|
|
87
|
-
bot,
|
|
95
|
+
bot, incoming: IncomingMessage, session: dict, conv_id: str, agy_content: str, image_paths: list
|
|
88
96
|
):
|
|
89
|
-
adapter =
|
|
97
|
+
adapter = get_adapter_for_platform(incoming.platform)
|
|
98
|
+
thread = incoming.conversation_ref
|
|
90
99
|
try:
|
|
91
100
|
async with adapter.typing(thread):
|
|
92
101
|
ctx = {"status_msg": None}
|
|
93
102
|
start_time = time.time()
|
|
94
103
|
queue = asyncio.Queue()
|
|
95
|
-
session_manager.register_queue(
|
|
96
|
-
stream_task = asyncio.create_task(
|
|
104
|
+
session_manager.register_queue(incoming.conversation_id, queue)
|
|
105
|
+
stream_task = asyncio.create_task(
|
|
106
|
+
stream_thinking_latest(bot, thread, incoming.conversation_id, context_dict=ctx, queue=queue)
|
|
107
|
+
)
|
|
97
108
|
result_text = await agy_send_message(
|
|
98
109
|
conv_id,
|
|
99
110
|
agy_content,
|
|
100
111
|
model=session.get("model"),
|
|
101
112
|
stream_queue=queue,
|
|
102
|
-
thread_id=
|
|
113
|
+
thread_id=incoming.conversation_id,
|
|
103
114
|
cwd=session.get("cwd"),
|
|
104
115
|
)
|
|
105
116
|
await queue.put(("__END__", True))
|
|
@@ -112,14 +123,14 @@ async def handle_existing_session(
|
|
|
112
123
|
cleanup_images(image_paths)
|
|
113
124
|
|
|
114
125
|
|
|
115
|
-
async def handle_thread_reply(bot,
|
|
116
|
-
|
|
117
|
-
session = session_manager.get_session(str(thread.id))
|
|
126
|
+
async def handle_thread_reply(bot, incoming: IncomingMessage):
|
|
127
|
+
session = session_manager.get_session(incoming.conversation_id)
|
|
118
128
|
if not session:
|
|
119
129
|
return
|
|
120
130
|
|
|
121
|
-
adapter =
|
|
122
|
-
|
|
131
|
+
adapter = get_adapter_for_platform(incoming.platform)
|
|
132
|
+
thread = incoming.conversation_ref
|
|
133
|
+
content = incoming.content.strip()
|
|
123
134
|
if content.startswith("/new"):
|
|
124
135
|
await adapter.send_message(
|
|
125
136
|
thread,
|
|
@@ -127,10 +138,11 @@ async def handle_thread_reply(bot, message: discord.Message):
|
|
|
127
138
|
)
|
|
128
139
|
return
|
|
129
140
|
|
|
130
|
-
for att in
|
|
141
|
+
for att in incoming.attachments:
|
|
131
142
|
ct = att.content_type or ""
|
|
132
143
|
if "audio" in ct or att.filename.endswith((".ogg", ".mp3", ".m4a", ".wav")):
|
|
133
|
-
|
|
144
|
+
if incoming.add_reaction:
|
|
145
|
+
await incoming.add_reaction("🎤")
|
|
134
146
|
audio_bytes = await att.read()
|
|
135
147
|
text = await stt(audio_bytes)
|
|
136
148
|
if text:
|
|
@@ -138,9 +150,10 @@ async def handle_thread_reply(bot, message: discord.Message):
|
|
|
138
150
|
await adapter.send_message(thread, f'🎤 *Speech Recognized: "{text}"*')
|
|
139
151
|
break
|
|
140
152
|
|
|
141
|
-
image_paths = await handle_image_attachments(
|
|
153
|
+
image_paths = await handle_image_attachments(incoming.attachments)
|
|
142
154
|
if image_paths:
|
|
143
|
-
|
|
155
|
+
if incoming.add_reaction:
|
|
156
|
+
await incoming.add_reaction("📎")
|
|
144
157
|
await adapter.send_message(thread, f"📎 *{len(image_paths)} file(s) attached*")
|
|
145
158
|
|
|
146
159
|
if not content and not image_paths:
|
|
@@ -153,16 +166,31 @@ async def handle_thread_reply(bot, message: discord.Message):
|
|
|
153
166
|
pa = session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
|
|
154
167
|
|
|
155
168
|
if conv_id and pa and not pa.done():
|
|
156
|
-
handled = await handle_approval_reply(
|
|
169
|
+
handled = await handle_approval_reply(incoming, session, content, pa)
|
|
157
170
|
if handled:
|
|
158
171
|
return
|
|
159
172
|
|
|
173
|
+
has_pending_approval = bool(conv_id and pa and not pa.done())
|
|
174
|
+
if session_manager.get_queue(incoming.conversation_id) is not None and not has_pending_approval:
|
|
175
|
+
from core.agy_runner import stop_active_process
|
|
176
|
+
|
|
177
|
+
stop_active_process(incoming.conversation_id)
|
|
178
|
+
session_manager.remove_queue(incoming.conversation_id)
|
|
179
|
+
|
|
180
|
+
prev_session = session_manager.get_session(incoming.conversation_id)
|
|
181
|
+
if prev_session:
|
|
182
|
+
session_manager.set_session(
|
|
183
|
+
incoming.conversation_id, {**prev_session, "status": "pending", "conversation_id": None}
|
|
184
|
+
)
|
|
185
|
+
session = session_manager.get_session(incoming.conversation_id)
|
|
186
|
+
conv_id = None
|
|
187
|
+
|
|
160
188
|
if not conv_id:
|
|
161
189
|
if session.get("status") == "pending":
|
|
162
|
-
await handle_pending_session(bot,
|
|
190
|
+
await handle_pending_session(bot, incoming, session, agy_content, content, image_paths)
|
|
163
191
|
return
|
|
164
192
|
else:
|
|
165
193
|
await adapter.send_message(thread, "⚠️ Session ID not found. Start a new session with `/new`.")
|
|
166
194
|
return
|
|
167
195
|
|
|
168
|
-
await handle_existing_session(bot,
|
|
196
|
+
await handle_existing_session(bot, incoming, session, conv_id, agy_content, image_paths)
|