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/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 + built .rpw reference (see EnrollmentManager).
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
- return not ALLOWED_IDS or user_id in ALLOWED_IDS
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
@@ -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["AGY_DISCORD_BOT"] = "1"
121
+ env["LGY_APPROVAL_HOOK"] = "1"
122
122
  env["PYTHONUNBUFFERED"] = "1"
123
123
  if thread_id:
124
- env["DISCORD_THREAD_ID"] = thread_id
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 Discord approval (up to 3600s).
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, message: discord.Message):
8
- if message.type not in (discord.MessageType.default, discord.MessageType.reply):
9
- return
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(message.author.id):
10
+ if not allowed(incoming.author_id, incoming.platform):
13
11
  return
14
12
 
15
- if isinstance(message.channel, discord.Thread):
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.registry import get_adapter
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
- message: discord.Message, thread: discord.Thread, session: dict, content: str, pa
25
- ) -> bool:
26
- adapter = get_adapter()
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(str(thread.id))
45
+ old_sess = session_manager.remove_session(incoming.conversation_id)
42
46
  if old_sess:
43
- session_manager.set_session(str(thread.id), old_sess)
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, thread: discord.Thread, session: dict, agy_content: str, content: str, image_paths: list
53
+ bot, incoming: IncomingMessage, session: dict, agy_content: str, content: str, image_paths: list
50
54
  ):
51
- adapter = get_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(str(thread.id), queue)
58
- stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
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=str(thread.id), cwd=cwd
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
- new_title = await generate_thread_title(content, response_text)
71
- await adapter.rename_conversation(thread, new_title)
72
- await update_agy_conversation_title(new_conv_id, new_title)
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, thread: discord.Thread, session: dict, conv_id: str, agy_content: str, image_paths: list
95
+ bot, incoming: IncomingMessage, session: dict, conv_id: str, agy_content: str, image_paths: list
88
96
  ):
89
- adapter = get_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(str(thread.id), queue)
96
- stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
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=str(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, message: discord.Message):
116
- thread = message.channel
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 = get_adapter()
122
- content = message.content.strip()
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 message.attachments:
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
- await message.add_reaction("🎤")
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(message)
153
+ image_paths = await handle_image_attachments(incoming.attachments)
142
154
  if image_paths:
143
- await message.add_reaction("📎")
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(message, thread, session, content, pa)
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, thread, session, agy_content, content, image_paths)
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, thread, session, conv_id, agy_content, image_paths)
196
+ await handle_existing_session(bot, incoming, session, conv_id, agy_content, image_paths)