ltcai 0.1.4 → 0.1.8

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/telegram_bot.py CHANGED
@@ -4,6 +4,7 @@ import logging
4
4
  import base64
5
5
  import os
6
6
  import socket
7
+ import subprocess
7
8
  import tempfile
8
9
  import zipfile
9
10
  import json
@@ -25,32 +26,39 @@ load_env_file()
25
26
  def env_value(primary: str, default: str = "") -> str:
26
27
  return os.getenv(primary) or default
27
28
 
28
- # 설정
29
- TOKEN = env_value("LATTICEAI_TELEGRAM_BOT_TOKEN")
30
- API_URL = f"https://api.telegram.org/bot{TOKEN}"
31
- CHAT_URL = "http://127.0.0.1:4825/chat"
32
- AGENT_URL = "http://127.0.0.1:4825/agent"
33
- MCP_TOOLS_URL = "http://127.0.0.1:4825/mcp/tools"
34
- HISTORY_URL = "http://127.0.0.1:4825/history"
35
- AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
29
+ TOKEN = env_value("LATTICEAI_TELEGRAM_BOT_TOKEN")
30
+ API_URL = f"https://api.telegram.org/bot{TOKEN}"
31
+ BASE_URL = "http://127.0.0.1:4825"
32
+ CHAT_URL = f"{BASE_URL}/chat"
33
+ AGENT_URL = f"{BASE_URL}/agent"
34
+ MCP_TOOLS_URL = f"{BASE_URL}/mcp/tools"
35
+ HISTORY_URL = f"{BASE_URL}/history"
36
+ STATUS_URL = f"{BASE_URL}/status"
37
+ MODELS_URL = f"{BASE_URL}/models"
38
+ GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
39
+ UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
40
+
41
+ AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
36
42
  MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
37
- SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
38
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
39
- PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
40
- DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
43
+ SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
44
+ INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
45
+ PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
46
+ DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
41
47
  DATA_DIR.mkdir(parents=True, exist_ok=True)
42
48
  CHAT_IDS_FILE = Path(env_value("LATTICEAI_TELEGRAM_CHATS_FILE", str(DATA_DIR / "telegram_chats.json")))
43
49
 
44
50
  logging.basicConfig(level=logging.INFO)
45
51
  logger = logging.getLogger(__name__)
46
52
 
53
+ # ── Chat ID registry ─────────────────────────────────────────────────────────
54
+
47
55
  def load_chat_ids():
48
56
  try:
49
57
  if CHAT_IDS_FILE.exists():
50
58
  data = json.loads(CHAT_IDS_FILE.read_text(encoding="utf-8"))
51
- return {int(chat_id) for chat_id in data.get("chat_ids", [])}
59
+ return {int(cid) for cid in data.get("chat_ids", [])}
52
60
  except Exception as e:
53
- logger.error(f"텔레그램 채팅 목록 로드 실패: {e}")
61
+ logger.error("텔레그램 채팅 목록 로드 실패: %s", e)
54
62
  return set()
55
63
 
56
64
  def save_chat_ids(chat_ids):
@@ -60,33 +68,124 @@ def save_chat_ids(chat_ids):
60
68
  encoding="utf-8",
61
69
  )
62
70
  except Exception as e:
63
- logger.error(f"텔레그램 채팅 목록 저장 실패: {e}")
71
+ logger.error("텔레그램 채팅 목록 저장 실패: %s", e)
64
72
 
65
73
  def register_chat_id(chat_id):
66
74
  chat_ids = load_chat_ids()
67
- if chat_id in chat_ids:
68
- return
69
- chat_ids.add(chat_id)
70
- save_chat_ids(chat_ids)
71
- logger.info(f"텔레그램 웹 미러링 대상 등록: {chat_id}")
75
+ if chat_id not in chat_ids:
76
+ chat_ids.add(chat_id)
77
+ save_chat_ids(chat_ids)
78
+ logger.info("텔레그램 웹 미러링 대상 등록: %s", chat_id)
79
+
80
+ # ── Telegram API helpers ──────────────────────────────────────────────────────
81
+
82
+ async def send_message(client, chat_id, text, reply_markup=None):
83
+ url = f"{API_URL}/sendMessage"
84
+ try:
85
+ chunks = [text[i:i+3900] for i in range(0, len(text), 3900)] or [""]
86
+ for i, chunk in enumerate(chunks):
87
+ payload = {"chat_id": chat_id, "text": chunk}
88
+ if reply_markup and i == len(chunks) - 1:
89
+ payload["reply_markup"] = reply_markup
90
+ await client.post(url, json=payload)
91
+ except Exception as e:
92
+ logger.error("메시지 전송 실패: %s", e)
93
+
94
+ async def send_photo(client, chat_id, file_path: Path, caption: str = ""):
95
+ url = f"{API_URL}/sendPhoto"
96
+ try:
97
+ with open(file_path, "rb") as f:
98
+ res = await client.post(url, data={"chat_id": str(chat_id), "caption": caption[:1024]},
99
+ files={"photo": (file_path.name, f)}, timeout=60.0)
100
+ if res.status_code != 200:
101
+ await send_message(client, chat_id, f"사진 전송 실패 ({res.status_code})")
102
+ except Exception as e:
103
+ logger.error("사진 전송 실패: %s", e)
104
+ await send_message(client, chat_id, f"사진 전송 오류: {e}")
105
+
106
+ async def send_document(client, chat_id, file_path, caption=None, filename=None):
107
+ url = f"{API_URL}/sendDocument"
108
+ try:
109
+ with open(file_path, "rb") as f:
110
+ res = await client.post(
111
+ url,
112
+ data={"chat_id": str(chat_id), **({"caption": caption[:1024]} if caption else {})},
113
+ files={"document": (filename or Path(file_path).name, f)},
114
+ timeout=300.0,
115
+ )
116
+ if res.status_code != 200:
117
+ logger.error("파일 전송 실패 (%s): %s", res.status_code, res.text)
118
+ except Exception as e:
119
+ logger.error("파일 전송 실패: %s", e)
120
+
121
+ async def send_chat_action(client, chat_id, action="typing"):
122
+ try:
123
+ await client.post(f"{API_URL}/sendChatAction", json={"chat_id": chat_id, "action": action})
124
+ except Exception:
125
+ pass
126
+
127
+ async def answer_callback(client, callback_query_id, text=""):
128
+ try:
129
+ await client.post(f"{API_URL}/answerCallbackQuery",
130
+ json={"callback_query_id": callback_query_id, "text": text})
131
+ except Exception:
132
+ pass
133
+
134
+ async def edit_message(client, chat_id, message_id, text, reply_markup=None):
135
+ try:
136
+ payload = {"chat_id": chat_id, "message_id": message_id, "text": text}
137
+ if reply_markup:
138
+ payload["reply_markup"] = reply_markup
139
+ await client.post(f"{API_URL}/editMessageText", json=payload)
140
+ except Exception:
141
+ pass
142
+
143
+ # ── Network helpers ───────────────────────────────────────────────────────────
144
+
145
+ def get_lan_ip():
146
+ try:
147
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
148
+ s.connect(("8.8.8.8", 80))
149
+ ip = s.getsockname()[0]
150
+ if not ip.startswith("127."):
151
+ return ip
152
+ except OSError:
153
+ pass
154
+ try:
155
+ hostname = socket.gethostname()
156
+ for ip in socket.gethostbyname_ex(hostname)[2]:
157
+ if not ip.startswith("127."):
158
+ return ip
159
+ except OSError:
160
+ pass
161
+ return "127.0.0.1"
162
+
163
+ def get_web_url():
164
+ if PUBLIC_WEB_URL:
165
+ return PUBLIC_WEB_URL.rstrip("/")
166
+ return f"http://{get_lan_ip()}:{SERVER_PORT}/?code={INVITE_CODE}"
167
+
168
+ def get_graph_url():
169
+ if PUBLIC_WEB_URL:
170
+ return f"{PUBLIC_WEB_URL.rstrip('/')}/graph"
171
+ return f"http://{get_lan_ip()}:{SERVER_PORT}/graph"
172
+
173
+ # ── Broadcast (web → telegram mirror) ────────────────────────────────────────
72
174
 
73
175
  async def broadcast_web_chat(role, text):
74
176
  if not TOKEN:
75
- logger.info("LATTICEAI_TELEGRAM_BOT_TOKEN이 없어 웹 대화 텔레그램 미러링을 건너뜁니다.")
76
177
  return
77
-
78
178
  chat_ids = load_chat_ids()
79
179
  if not chat_ids:
80
- logger.info("웹 대화 미러링 대상 텔레그램 채팅이 없습니다. 봇에 /start 또는 /web을 먼저 보내세요.")
81
180
  return
82
-
83
181
  label = "사용자" if role == "user" else "Lattice AI"
84
182
  message = f"[Web] {label}\n{text}"
85
-
86
183
  async with httpx.AsyncClient() as client:
87
184
  for chat_id in chat_ids:
88
185
  await send_message(client, chat_id, message)
89
186
 
187
+ # ── Polling ───────────────────────────────────────────────────────────────────
188
+
90
189
  async def get_updates(client, offset=None):
91
190
  url = f"{API_URL}/getUpdates?timeout=30"
92
191
  if offset:
@@ -94,94 +193,348 @@ async def get_updates(client, offset=None):
94
193
  try:
95
194
  res = await client.get(url, timeout=35)
96
195
  return res.json()
97
- except Exception as e:
196
+ except Exception:
98
197
  return None
99
198
 
100
- async def send_message(client, chat_id, text):
101
- url = f"{API_URL}/sendMessage"
199
+ # ── File download ─────────────────────────────────────────────────────────────
200
+
201
+ async def download_telegram_file(client, file_id) -> bytes | None:
102
202
  try:
103
- chunks = [text[i:i + 3900] for i in range(0, len(text), 3900)] or [""]
104
- for chunk in chunks:
105
- await client.post(url, json={"chat_id": chat_id, "text": chunk})
203
+ res = await client.get(f"{API_URL}/getFile?file_id={file_id}")
204
+ file_path = res.json().get("result", {}).get("file_path")
205
+ if not file_path:
206
+ return None
207
+ dl = await client.get(f"https://api.telegram.org/file/bot{TOKEN}/{file_path}")
208
+ return dl.content if dl.status_code == 200 else None
106
209
  except Exception as e:
107
- logger.error(f"메시지 전송 실패: {e}")
210
+ logger.error("파일 다운로드 실패: %s", e)
211
+ return None
108
212
 
109
- async def send_chat_action(client, chat_id, action="typing"):
110
- url = f"{API_URL}/sendChatAction"
213
+ async def download_as_base64(client, file_id) -> str | None:
214
+ data = await download_telegram_file(client, file_id)
215
+ return base64.b64encode(data).decode() if data else None
216
+
217
+ # ── Main menu ─────────────────────────────────────────────────────────────────
218
+
219
+ MAIN_MENU = {
220
+ "inline_keyboard": [
221
+ [
222
+ {"text": "📊 서버 상태", "callback_data": "cmd:status"},
223
+ {"text": "🧠 현재 모델", "callback_data": "cmd:model"},
224
+ ],
225
+ [
226
+ {"text": "🕸 Knowledge Graph", "callback_data": "cmd:graph"},
227
+ {"text": "📸 스크린샷", "callback_data": "cmd:screenshot"},
228
+ ],
229
+ [
230
+ {"text": "📜 최근 대화 5건", "callback_data": "cmd:history"},
231
+ {"text": "🗑 기록 정리", "callback_data": "cmd:clear"},
232
+ ],
233
+ [
234
+ {"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
235
+ {"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
236
+ ],
237
+ ]
238
+ }
239
+
240
+ async def show_menu(client, chat_id):
241
+ await send_message(client, chat_id, "📱 Lattice AI 원격 제어 메뉴입니다.", reply_markup=MAIN_MENU)
242
+
243
+ # ── Server status ─────────────────────────────────────────────────────────────
244
+
245
+ async def _mac_ram_used_gb() -> str:
111
246
  try:
112
- await client.post(url, json={"chat_id": chat_id, "action": action})
247
+ vm_proc = await asyncio.create_subprocess_exec(
248
+ "vm_stat", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
249
+ )
250
+ vm_out, _ = await vm_proc.communicate()
251
+ lines = vm_out.decode().splitlines()
252
+
253
+ # Parse page size from header line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
254
+ page_size = 4096
255
+ if lines:
256
+ import re
257
+ m = re.search(r"page size of (\d+) bytes", lines[0])
258
+ if m:
259
+ page_size = int(m.group(1))
260
+
261
+ stats = {}
262
+ for line in lines[1:]:
263
+ if ":" in line:
264
+ k, _, v = line.partition(":")
265
+ try:
266
+ stats[k.strip()] = int(v.strip().rstrip(".")) * page_size
267
+ except ValueError:
268
+ pass
269
+
270
+ used = stats.get("Pages active", 0) + stats.get("Pages wired down", 0)
271
+
272
+ mem_proc = await asyncio.create_subprocess_exec(
273
+ "sysctl", "-n", "hw.memsize", stdout=asyncio.subprocess.PIPE
274
+ )
275
+ mem_out, _ = await mem_proc.communicate()
276
+ total = int(mem_out.strip())
277
+ return f"{used/1e9:.1f} GB / {total/1e9:.0f} GB"
278
+ except Exception:
279
+ return "N/A"
280
+
281
+ async def show_status(client, chat_id):
282
+ await send_chat_action(client, chat_id, "typing")
283
+ try:
284
+ async with httpx.AsyncClient() as lc:
285
+ res = await lc.get(STATUS_URL, timeout=5.0)
286
+ data = res.json() if res.status_code == 200 else {}
287
+ except Exception:
288
+ data = {}
289
+
290
+ ram = await _mac_ram_used_gb()
291
+ model = data.get("loaded_model") or "없음"
292
+ mode = data.get("mode") or "unknown"
293
+ state = "🟢 온라인" if data.get("status") == "online" else "🔴 오프라인"
294
+
295
+ text = (
296
+ f"📊 Lattice AI 서버 상태\n"
297
+ f"상태: {state}\n"
298
+ f"모드: {mode}\n"
299
+ f"모델: {model}\n"
300
+ f"RAM: {ram}"
301
+ )
302
+ await send_message(client, chat_id, text)
303
+
304
+ # ── Model info & unload ───────────────────────────────────────────────────────
305
+
306
+ async def show_model_info(client, chat_id):
307
+ await send_chat_action(client, chat_id, "typing")
308
+ try:
309
+ async with httpx.AsyncClient() as lc:
310
+ res = await lc.get(MODELS_URL, timeout=5.0)
311
+ data = res.json() if res.status_code == 200 else {}
312
+ except Exception:
313
+ data = {}
314
+
315
+ current = data.get("current") or "없음"
316
+ loaded = data.get("loaded") or []
317
+ loaded_str = "\n".join(f" - {m}" for m in loaded) if loaded else " 없음"
318
+ text = f"🧠 현재 모델: {current}\n\n로드된 모델:\n{loaded_str}"
319
+
320
+ markup = None
321
+ if loaded:
322
+ markup = {
323
+ "inline_keyboard": [
324
+ [{"text": f"🗑 {m} 언로드", "callback_data": f"model:unload:{m}"}]
325
+ for m in loaded
326
+ ] + [[{"text": "↩ 메뉴로", "callback_data": "cmd:menu"}]]
327
+ }
328
+ await send_message(client, chat_id, text, reply_markup=markup)
329
+
330
+ async def do_unload_model(client, chat_id, model_id: str = ""):
331
+ await send_chat_action(client, chat_id, "typing")
332
+ try:
333
+ async with httpx.AsyncClient() as lc:
334
+ if model_id:
335
+ res = await lc.delete(f"{BASE_URL}/models/unload/{model_id}", timeout=15.0)
336
+ else:
337
+ # Unload all
338
+ res = await lc.get(MODELS_URL, timeout=5.0)
339
+ mdata = res.json() if res.status_code == 200 else {}
340
+ for mid in mdata.get("loaded") or []:
341
+ await lc.delete(f"{BASE_URL}/models/unload/{mid}", timeout=15.0)
342
+ res = type("R", (), {"status_code": 200})()
343
+ if res.status_code == 200:
344
+ label = model_id or "모든 모델"
345
+ await send_message(client, chat_id, f"✅ {label} 언로드 완료. RAM이 해제되었습니다.")
346
+ else:
347
+ await send_message(client, chat_id, f"언로드 실패 ({res.status_code})")
113
348
  except Exception as e:
114
- logger.error(f"채팅 액션 전송 실패: {e}")
349
+ await send_message(client, chat_id, f"언로드 오류: {e}")
115
350
 
116
- def get_lan_ip():
351
+ # ── Knowledge Graph stats ─────────────────────────────────────────────────────
352
+
353
+ async def show_graph_stats(client, chat_id):
354
+ await send_chat_action(client, chat_id, "typing")
117
355
  try:
118
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
119
- sock.connect(("8.8.8.8", 80))
120
- ip = sock.getsockname()[0]
121
- if not ip.startswith("127."):
122
- return ip
123
- except OSError:
124
- pass
356
+ async with httpx.AsyncClient() as lc:
357
+ res = await lc.get(GRAPH_STATS_URL, timeout=5.0)
358
+ data = res.json() if res.status_code == 200 else {}
359
+ except Exception:
360
+ data = {}
361
+
362
+ nodes = data.get("nodes") or {}
363
+ edges = data.get("edges") or {}
364
+ total_nodes = sum(nodes.values())
365
+ total_edges = sum(edges.values())
366
+
367
+ node_lines = "\n".join(f" {t}: {c}" for t, c in sorted(nodes.items(), key=lambda x: -x[1])) or " 없음"
368
+ edge_lines = "\n".join(f" {t}: {c}" for t, c in sorted(edges.items(), key=lambda x: -x[1])[:8]) or " 없음"
125
369
 
370
+ text = (
371
+ f"🕸 Knowledge Graph 통계\n\n"
372
+ f"노드 총 {total_nodes}개:\n{node_lines}\n\n"
373
+ f"엣지 총 {total_edges}개:\n{edge_lines}\n\n"
374
+ f"그래프 보기: {get_graph_url()}"
375
+ )
376
+ markup = {
377
+ "inline_keyboard": [[
378
+ {"text": "🔗 그래프 열기", "url": get_graph_url()},
379
+ {"text": "↩ 메뉴로", "callback_data": "cmd:menu"},
380
+ ]]
381
+ }
382
+ await send_message(client, chat_id, text, reply_markup=markup)
383
+
384
+ # ── Screenshot ────────────────────────────────────────────────────────────────
385
+
386
+ async def take_screenshot(client, chat_id):
387
+ await send_chat_action(client, chat_id, "upload_photo")
388
+ tmp = Path(tempfile.mktemp(suffix=".jpg"))
126
389
  try:
127
- hostname = socket.gethostname()
128
- for ip in socket.gethostbyname_ex(hostname)[2]:
129
- if not ip.startswith("127."):
130
- return ip
131
- except OSError:
132
- pass
390
+ proc = await asyncio.create_subprocess_exec(
391
+ "screencapture", "-x", str(tmp),
392
+ stdout=asyncio.subprocess.DEVNULL,
393
+ stderr=asyncio.subprocess.DEVNULL,
394
+ )
395
+ await asyncio.wait_for(proc.communicate(), timeout=10.0)
396
+ if tmp.exists() and tmp.stat().st_size > 0:
397
+ await send_photo(client, chat_id, tmp, caption="현재 화면입니다.")
398
+ else:
399
+ await send_message(client, chat_id, "스크린샷 파일이 생성되지 않았습니다. screencapture가 설치되어 있는지 확인하세요.")
400
+ except asyncio.TimeoutError:
401
+ await send_message(client, chat_id, "스크린샷 시간 초과")
402
+ except FileNotFoundError:
403
+ await send_message(client, chat_id, "screencapture 명령이 없습니다. macOS에서만 동작합니다.")
404
+ except Exception as e:
405
+ await send_message(client, chat_id, f"스크린샷 오류: {e}")
406
+ finally:
407
+ try:
408
+ tmp.unlink(missing_ok=True)
409
+ except Exception:
410
+ pass
133
411
 
134
- return "127.0.0.1"
412
+ # ── History ───────────────────────────────────────────────────────────────────
135
413
 
136
- def get_web_url():
137
- if PUBLIC_WEB_URL:
138
- return PUBLIC_WEB_URL.rstrip("/")
139
- return f"http://{get_lan_ip()}:{SERVER_PORT}/?code={INVITE_CODE}"
414
+ async def show_history_summary(client, chat_id, n: int = 5):
415
+ await send_chat_action(client, chat_id, "typing")
416
+ try:
417
+ async with httpx.AsyncClient() as lc:
418
+ res = await lc.get(HISTORY_URL, timeout=10.0)
419
+ items = res.json() if res.status_code == 200 else []
420
+ except Exception:
421
+ items = []
422
+
423
+ if not items:
424
+ await send_message(client, chat_id, "저장된 대화 기록이 없습니다.")
425
+ return
426
+
427
+ recent = [i for i in items if i.get("role") == "user"][-n:]
428
+ lines = [f"📜 최근 사용자 메시지 {len(recent)}건\n"]
429
+ for item in recent:
430
+ ts = str(item.get("timestamp", ""))[:16]
431
+ src = item.get("source", "web")
432
+ content = str(item.get("content", ""))[:120].replace("\n", " ")
433
+ lines.append(f"[{ts}] ({src}) {content}")
434
+ await send_message(client, chat_id, "\n".join(lines))
435
+
436
+ async def clear_server_history(client, chat_id, keep_last=0):
437
+ try:
438
+ async with httpx.AsyncClient() as lc:
439
+ res = await lc.delete(HISTORY_URL, params={"keep_last": keep_last}, timeout=10.0)
440
+ data = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
441
+ if res.status_code == 200:
442
+ await send_message(client, chat_id, f"대화 기록을 정리했습니다. 삭제 {data.get('removed', 0)}개, 유지 {data.get('kept', 0)}개.")
443
+ else:
444
+ await send_message(client, chat_id, f"대화 기록 정리 실패: {res.status_code}")
445
+ except Exception as e:
446
+ await send_message(client, chat_id, f"대화 기록 정리 오류: {e}")
447
+
448
+ # ── Web UI link ───────────────────────────────────────────────────────────────
140
449
 
141
450
  async def send_web_link(client, chat_id):
142
- url = f"{API_URL}/sendMessage"
143
451
  web_url = get_web_url()
144
452
  text = (
145
453
  "웹 UI 링크입니다.\n"
146
454
  f"{web_url}\n\n"
147
- "핸드폰이 Mac과 같은 Wi-Fi에 있어야 바로 열립니다. 외부망에서 쓰려면 LATTICEAI_PUBLIC_URL에 터널 주소를 설정하세요."
455
+ "핸드폰이 Mac과 같은 Wi-Fi에 있어야 바로 열립니다. "
456
+ "외부망에서 쓰려면 LATTICEAI_PUBLIC_URL에 터널 주소를 설정하세요."
148
457
  )
149
458
  payload = {
150
459
  "chat_id": chat_id,
151
460
  "text": text,
152
461
  "reply_markup": {
153
- "inline_keyboard": [[{"text": "Lattice AI Web 열기", "url": web_url}]]
462
+ "inline_keyboard": [[
463
+ {"text": "Lattice AI Web 열기", "url": web_url},
464
+ {"text": "Knowledge Graph", "url": get_graph_url()},
465
+ ]]
154
466
  },
155
467
  }
156
468
  try:
157
- await client.post(url, json=payload)
469
+ async with httpx.AsyncClient() as lc:
470
+ await lc.post(f"{API_URL}/sendMessage", json=payload)
158
471
  except Exception as e:
159
- logger.error(f"웹 링크 전송 실패: {e}")
472
+ logger.error("웹 링크 전송 실패: %s", e)
473
+
474
+ # ── MCP tools ─────────────────────────────────────────────────────────────────
160
475
 
161
476
  async def send_mcp_tools(client, chat_id):
162
477
  try:
163
- async with httpx.AsyncClient() as local_client:
164
- res = await local_client.get(MCP_TOOLS_URL, timeout=10.0)
478
+ async with httpx.AsyncClient() as lc:
479
+ res = await lc.get(MCP_TOOLS_URL, timeout=10.0)
165
480
  if res.status_code != 200:
166
481
  await send_message(client, chat_id, f"MCP 도구 목록을 가져오지 못했습니다: {res.status_code}")
167
482
  return
168
483
  data = res.json()
169
484
  names = [tool["name"] for tool in data.get("tools", [])]
170
- await send_message(client, chat_id, "사용 가능한 로컬 MCP 도구:\n" + "\n".join(f"- {name}" for name in names))
485
+ await send_message(client, chat_id, "사용 가능한 MCP 도구:\n" + ("\n".join(f"- {n}" for n in names) or "없음"))
171
486
  except Exception as e:
172
- await send_message(client, chat_id, f"MCP 도구 목록 조회 실패: {e}")
487
+ await send_message(client, chat_id, f"MCP 도구 조회 실패: {e}")
173
488
 
174
- async def clear_server_history(client, chat_id, keep_last=0):
489
+ # ── Document upload → knowledge graph ────────────────────────────────────────
490
+
491
+ async def process_document_file(client, chat_id, file_id: str, filename: str, caption: str = ""):
492
+ await send_chat_action(client, chat_id, "upload_document")
493
+ raw = await download_telegram_file(client, file_id)
494
+ if not raw:
495
+ await send_message(client, chat_id, "파일 다운로드 실패")
496
+ return
497
+
498
+ suffix = Path(filename).suffix.lower()
499
+ allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
500
+ if suffix not in allowed:
501
+ await send_message(client, chat_id,
502
+ f"지원하지 않는 파일 형식입니다({suffix}). "
503
+ f"지원 형식: {', '.join(sorted(allowed))}")
504
+ return
505
+
506
+ tmp = Path(tempfile.mktemp(suffix=suffix))
175
507
  try:
176
- async with httpx.AsyncClient() as local_client:
177
- res = await local_client.delete(HISTORY_URL, params={"keep_last": keep_last}, timeout=10.0)
178
- data = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
508
+ tmp.write_bytes(raw)
509
+ async with httpx.AsyncClient() as lc:
510
+ with open(tmp, "rb") as f:
511
+ res = await lc.post(
512
+ UPLOAD_DOC_URL,
513
+ files={"file": (filename, f)},
514
+ timeout=60.0,
515
+ )
179
516
  if res.status_code == 200:
180
- await send_message(client, chat_id, f"대화 기록을 정리했습니다. 삭제 {data.get('removed', 0)}개, 유지 {data.get('kept', 0)}개.")
517
+ data = res.json()
518
+ chars = data.get("chars") or len(raw)
519
+ preview = str(data.get("preview") or "")[:300]
520
+ kg = data.get("knowledge_graph") or {}
521
+ node_id = kg.get("node_id", "")
522
+ text = (
523
+ f"✅ {filename} 수집 완료\n"
524
+ f"크기: {len(raw) // 1024} KB | 문자: {chars}\n"
525
+ f"노드: {node_id}\n"
526
+ f"\n미리보기:\n{preview}"
527
+ )
528
+ await send_message(client, chat_id, text)
181
529
  else:
182
- await send_message(client, chat_id, f"대화 기록 정리에 실패했습니다: {res.status_code}")
530
+ err = res.json().get("detail") if res.headers.get("content-type", "").startswith("application/json") else res.text
531
+ await send_message(client, chat_id, f"업로드 실패 ({res.status_code}): {err}")
183
532
  except Exception as e:
184
- await send_message(client, chat_id, f"대화 기록 정리 실패: {e}")
533
+ await send_message(client, chat_id, f"문서 처리 오류: {e}")
534
+ finally:
535
+ tmp.unlink(missing_ok=True)
536
+
537
+ # ── AI chat ───────────────────────────────────────────────────────────────────
185
538
 
186
539
  async def ask_ai(client, message, image_data=None, agent_mode=True):
187
540
  try:
@@ -190,13 +543,16 @@ async def ask_ai(client, message, image_data=None, agent_mode=True):
190
543
  if image_data:
191
544
  payload["stream"] = False
192
545
  payload["image_data"] = image_data
193
-
194
546
  res = await client.post(url, json=payload, timeout=300.0)
195
547
  if res.status_code == 200:
196
- data = res.json()
197
- return data
198
- else:
199
- return {"response": f"❌ 서버 에러 ({res.status_code}): {res.text}"}
548
+ return res.json()
549
+ try:
550
+ detail = res.json().get("detail", "")
551
+ except Exception:
552
+ detail = ""
553
+ if res.status_code == 400 and "model" in detail.lower():
554
+ return {"response": "⚠️ 로드된 모델이 없습니다. 먼저 /model 명령으로 모델을 선택해주세요."}
555
+ return {"response": f"❌ 서버 에러 ({res.status_code}){': ' + detail if detail else ''}"}
200
556
  except Exception as e:
201
557
  return {"response": f"❌ 서버 연결 실패: {e}"}
202
558
 
@@ -211,10 +567,9 @@ def resolve_workspace_file(relative_path):
211
567
  return target
212
568
 
213
569
  def collect_generated_files(agent_data):
214
- files = []
215
- seen = set()
570
+ files, seen = [], set()
216
571
  for step in agent_data.get("steps", []):
217
- if step.get("action") != "write_file":
572
+ if step.get("action") not in {"write_file", "create_docx", "create_xlsx", "create_pptx", "create_pdf"}:
218
573
  continue
219
574
  path = (step.get("result") or {}).get("path") or (step.get("args") or {}).get("path")
220
575
  if not path or path in seen:
@@ -226,107 +581,157 @@ def collect_generated_files(agent_data):
226
581
  return files
227
582
 
228
583
  def collect_preview_urls(agent_data):
229
- urls = []
230
- seen = set()
584
+ urls, seen = [], set()
231
585
  for step in agent_data.get("steps", []):
232
586
  if step.get("action") != "preview_url":
233
587
  continue
234
588
  result = step.get("result") or {}
235
589
  local_url = result.get("local_url")
236
- path = result.get("path")
237
590
  if not local_url or local_url in seen:
238
591
  continue
239
592
  phone_url = local_url.replace("http://127.0.0.1:4825", f"http://{get_lan_ip()}:{SERVER_PORT}")
240
593
  seen.add(local_url)
241
- urls.append((path or "preview", phone_url))
594
+ urls.append((result.get("path") or "preview", phone_url))
242
595
  return urls
243
596
 
244
597
  async def send_preview_links(client, chat_id, preview_urls):
245
598
  if not preview_urls:
246
599
  return
247
- lines = ["미리보기 링크입니다. 핸드폰이 Mac과 같은 Wi-Fi 있어야 열립니다."]
600
+ lines = ["미리보기 링크 (Mac과 같은 Wi-Fi 필요):"]
248
601
  keyboard = []
249
602
  for label, url in preview_urls:
250
603
  lines.append(f"- {label}: {url}")
251
604
  keyboard.append([{"text": f"{label} 열기"[:64], "url": url}])
252
-
253
- try:
254
- await client.post(
255
- f"{API_URL}/sendMessage",
256
- json={
257
- "chat_id": chat_id,
258
- "text": "\n".join(lines),
259
- "reply_markup": {"inline_keyboard": keyboard[:8]},
260
- },
261
- )
262
- except Exception as e:
263
- logger.error(f"미리보기 링크 전송 실패: {e}")
264
-
265
- async def send_document(client, chat_id, file_path, caption=None, filename=None):
266
- url = f"{API_URL}/sendDocument"
267
- try:
268
- with open(file_path, "rb") as f:
269
- files = {"document": (filename or Path(file_path).name, f)}
270
- data = {"chat_id": str(chat_id)}
271
- if caption:
272
- data["caption"] = caption[:1024]
273
- res = await client.post(url, data=data, files=files, timeout=300.0)
274
- if res.status_code != 200:
275
- logger.error(f"파일 전송 실패 ({res.status_code}): {res.text}")
276
- except Exception as e:
277
- logger.error(f"파일 전송 실패: {e}")
605
+ await send_message(client, chat_id, "\n".join(lines), reply_markup={"inline_keyboard": keyboard[:8]})
278
606
 
279
607
  async def send_generated_files(client, chat_id, generated_files):
280
608
  if not generated_files:
281
609
  return
282
-
283
610
  if len(generated_files) == 1:
284
- relative_path, file_path = generated_files[0]
285
- await send_document(client, chat_id, file_path, caption=f"생성 파일: {relative_path}")
611
+ path, fpath = generated_files[0]
612
+ await send_document(client, chat_id, fpath, caption=f"생성 파일: {path}")
286
613
  return
287
-
288
- with tempfile.NamedTemporaryFile(prefix="ltcai-", suffix=".zip", delete=False) as temp:
289
- zip_path = Path(temp.name)
290
-
614
+ with tempfile.NamedTemporaryFile(prefix="ltcai-", suffix=".zip", delete=False) as tmp:
615
+ zip_path = Path(tmp.name)
291
616
  try:
292
617
  with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
293
- for relative_path, file_path in generated_files:
294
- zf.write(file_path, arcname=relative_path)
295
-
618
+ for rel, fpath in generated_files:
619
+ zf.write(fpath, arcname=rel)
296
620
  if zip_path.stat().st_size <= MAX_TELEGRAM_FILE_BYTES:
297
- await send_document(
298
- client,
299
- chat_id,
300
- zip_path,
301
- caption=f"생성 파일 {len(generated_files)}개를 zip으로 묶었습니다.",
302
- filename="ltcai-generated-files.zip",
303
- )
621
+ await send_document(client, chat_id, zip_path,
622
+ caption=f"생성 파일 {len(generated_files)}개", filename="ltcai-files.zip")
304
623
  else:
305
- await send_message(client, chat_id, "생성 파일 묶음이 너무 커서 텔레그램으로 전송하지 못했습니다.")
624
+ await send_message(client, chat_id, "생성 파일이 너무 커서 전송할 없습니다.")
306
625
  finally:
307
- try:
308
- zip_path.unlink()
309
- except OSError:
310
- pass
626
+ zip_path.unlink(missing_ok=True)
627
+
628
+ # ── AI request task ───────────────────────────────────────────────────────────
311
629
 
312
- async def download_telegram_file(client, file_id):
313
- """텔레그램 서버에서 파일을 다운로드하여 Base64로 변환합니다."""
630
+ async def process_ai_request(client, chat_id, user_text, image_data=None):
314
631
  try:
315
- # 1. 파일 경로 가져오기
316
- res = await client.get(f"{API_URL}/getFile?file_id={file_id}")
317
- file_info = res.json()
318
- file_path = file_info.get("result", {}).get("file_path")
319
- if not file_path:
320
- return None
321
-
322
- # 2. 실제 파일 다운로드
323
- file_url = f"https://api.telegram.org/file/bot{TOKEN}/{file_path}"
324
- file_res = await client.get(file_url)
325
- if file_res.status_code == 200:
326
- return base64.b64encode(file_res.content).decode("utf-8")
632
+ await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
633
+ data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
634
+ ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
635
+ if not ans or not str(ans).strip():
636
+ ans = "⚠️ AI가 답변을 생성하지 못했습니다."
637
+ await send_message(client, chat_id, str(ans))
638
+ if not image_data and isinstance(data, dict):
639
+ await send_generated_files(client, chat_id, collect_generated_files(data))
640
+ await send_preview_links(client, chat_id, collect_preview_urls(data))
327
641
  except Exception as e:
328
- logger.error(f"Failed to download file: {e}")
329
- return None
642
+ logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, e)
643
+ try:
644
+ await send_message(client, chat_id, "⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
645
+ except Exception:
646
+ pass
647
+
648
+ # ── Command dispatch ──────────────────────────────────────────────────────────
649
+
650
+ HELP_TEXT = """\
651
+ 🧠 Lattice AI 원격 제어 명령어
652
+
653
+ /menu — 메인 메뉴 (인라인 키보드)
654
+ /status — 서버 상태 및 메모리
655
+ /model — 현재 모델 + 언로드 버튼
656
+ /unload — 모든 모델 언로드 (RAM 해제)
657
+ /graph — Knowledge Graph 통계
658
+ /ss 또는 /screenshot — 현재 화면 캡처
659
+ /history [n] — 최근 대화 n건 (기본 5)
660
+ /clear [n] — 기록 정리 (마지막 n건 유지)
661
+ /web — 웹 UI 링크
662
+ /mcp — MCP 도구 목록
663
+ /help — 이 도움말
664
+
665
+ 일반 텍스트 → AI에게 질문
666
+ 사진 전송 → AI 이미지 분석
667
+ 문서 전송(PDF, DOCX, XLSX, PPTX, TXT, CSV) → Knowledge Graph 수집
668
+ """
669
+
670
+ async def handle_command(client, chat_id, command: str, args: str):
671
+ cmd = command.lower().lstrip("/").split("@")[0]
672
+
673
+ if cmd == "start":
674
+ await send_message(client, chat_id, "🧠 Lattice AI 원격 제어 준비 완료!")
675
+ await show_menu(client, chat_id)
676
+ elif cmd == "menu":
677
+ await show_menu(client, chat_id)
678
+ elif cmd == "status":
679
+ await show_status(client, chat_id)
680
+ elif cmd == "model":
681
+ await show_model_info(client, chat_id)
682
+ elif cmd == "unload":
683
+ await do_unload_model(client, chat_id)
684
+ elif cmd == "graph":
685
+ await show_graph_stats(client, chat_id)
686
+ elif cmd in {"ss", "screenshot"}:
687
+ await take_screenshot(client, chat_id)
688
+ elif cmd == "history":
689
+ n = int(args.strip()) if args.strip().isdigit() else 5
690
+ await show_history_summary(client, chat_id, n)
691
+ elif cmd in {"clear", "clear_history", "forget"}:
692
+ keep = int(args.strip()) if args.strip().isdigit() else 0
693
+ await clear_server_history(client, chat_id, keep)
694
+ elif cmd == "web":
695
+ await send_web_link(client, chat_id)
696
+ elif cmd == "mcp":
697
+ await send_mcp_tools(client, chat_id)
698
+ elif cmd in {"help", "h"}:
699
+ await send_message(client, chat_id, HELP_TEXT)
700
+ else:
701
+ await send_message(client, chat_id, f"알 수 없는 명령어: /{cmd}\n/help 로 명령어 목록을 확인하세요.")
702
+
703
+ # ── Callback query handler ────────────────────────────────────────────────────
704
+
705
+ async def handle_callback_query(client, callback_query):
706
+ cq_id = callback_query["id"]
707
+ chat_id = callback_query["message"]["chat"]["id"]
708
+ data = callback_query.get("data", "")
709
+
710
+ await answer_callback(client, cq_id)
711
+
712
+ if data == "cmd:status":
713
+ await show_status(client, chat_id)
714
+ elif data == "cmd:model":
715
+ await show_model_info(client, chat_id)
716
+ elif data == "cmd:graph":
717
+ await show_graph_stats(client, chat_id)
718
+ elif data == "cmd:screenshot":
719
+ await take_screenshot(client, chat_id)
720
+ elif data == "cmd:history":
721
+ await show_history_summary(client, chat_id, 5)
722
+ elif data == "cmd:clear":
723
+ await clear_server_history(client, chat_id, 0)
724
+ elif data == "cmd:web":
725
+ await send_web_link(client, chat_id)
726
+ elif data == "cmd:mcp":
727
+ await send_mcp_tools(client, chat_id)
728
+ elif data == "cmd:menu":
729
+ await show_menu(client, chat_id)
730
+ elif data.startswith("model:unload:"):
731
+ model_id = data[len("model:unload:"):]
732
+ await do_unload_model(client, chat_id, model_id)
733
+
734
+ # ── Main loop ─────────────────────────────────────────────────────────────────
330
735
 
331
736
  async def run_bot():
332
737
  if not TOKEN:
@@ -343,85 +748,94 @@ async def run_bot():
343
748
  updates = await get_updates(client, last_update_id)
344
749
  retry_delay = 1
345
750
  except Exception as e:
346
- logger.error(f"get_updates 실패: {e}")
751
+ logger.error("get_updates 실패: %s", e)
347
752
  await asyncio.sleep(min(retry_delay, 30))
348
753
  retry_delay = min(retry_delay * 2, 30)
349
754
  continue
350
755
 
351
- if updates and updates.get("ok"):
352
- for update in updates.get("result", []):
353
- try:
354
- last_update_id = update.get("update_id") + 1
355
-
356
- if "message" not in update:
357
- continue
358
-
359
- msg = update["message"]
360
- chat_id = msg["chat"]["id"]
361
- register_chat_id(chat_id)
362
- text = msg.get("text", "")
363
- caption = msg.get("caption", "")
364
-
365
- image_data = None
366
- final_prompt = text or caption or "이 이미지를 분석해줘."
367
-
368
- if "photo" in msg:
369
- file_id = msg["photo"][-1]["file_id"]
370
- await send_message(client, chat_id, "📸 사진을 받았습니다. 분석을 시작합니다...")
371
- image_data = await download_telegram_file(client, file_id)
372
- elif "document" in msg and msg["document"].get("mime_type", "").startswith("image/"):
373
- file_id = msg["document"]["file_id"]
374
- image_data = await download_telegram_file(client, file_id)
375
-
376
- if not (text or image_data):
377
- continue
378
-
379
- if final_prompt == "/start":
380
- await send_message(client, chat_id, "🧠 Lattice AI 준비 완료! 텍스트로 지시하면 agent_workspace 안에서 파일 작업을 하고, 사진을 보내면 분석합니다. /web 은 웹 UI 링크, /mcp 는 로컬 도구 목록입니다.")
381
- continue
382
- if final_prompt == "/web":
383
- await send_web_link(client, chat_id)
384
- continue
385
- if final_prompt == "/mcp":
386
- await send_mcp_tools(client, chat_id)
387
- continue
388
- if final_prompt in {"/clear", "/clear_history", "/forget"}:
389
- await clear_server_history(client, chat_id)
390
- continue
391
-
392
- task = asyncio.create_task(process_ai_request(client, chat_id, final_prompt, image_data))
393
- task.add_done_callback(
394
- lambda t: logger.error(f"process_ai_request 예외: {t.exception()}") if not t.cancelled() and t.exception() else None
756
+ if not (updates and updates.get("ok")):
757
+ await asyncio.sleep(0.5)
758
+ continue
759
+
760
+ for update in updates.get("result", []):
761
+ try:
762
+ last_update_id = update.get("update_id") + 1
763
+
764
+ # ── Callback query (inline button press) ──────────────────
765
+ if "callback_query" in update:
766
+ task = asyncio.create_task(handle_callback_query(client, update["callback_query"]))
767
+ task.add_done_callback(_log_task_exception)
768
+ continue
769
+
770
+ if "message" not in update:
771
+ continue
772
+
773
+ msg = update["message"]
774
+ chat_id = msg["chat"]["id"]
775
+ register_chat_id(chat_id)
776
+ text = msg.get("text", "")
777
+ caption = msg.get("caption", "")
778
+
779
+ # ── Photo vision AI ─────────────────────────────────────
780
+ if "photo" in msg:
781
+ file_id = msg["photo"][-1]["file_id"]
782
+ await send_message(client, chat_id, "📸 사진을 받았습니다. 분석을 시작합니다...")
783
+ image_data = await download_as_base64(client, file_id)
784
+ prompt = caption or text or "이 이미지를 분석해줘."
785
+ task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
786
+ task.add_done_callback(_log_task_exception)
787
+ continue
788
+
789
+ # ── Document ──────────────────────────────────────────────
790
+ if "document" in msg:
791
+ doc = msg["document"]
792
+ mime = doc.get("mime_type", "")
793
+ filename = doc.get("file_name", "file")
794
+ if mime.startswith("image/"):
795
+ image_data = await download_as_base64(client, doc["file_id"])
796
+ prompt = caption or text or "이 이미지를 분석해줘."
797
+ task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
798
+ else:
799
+ await send_message(client, chat_id, f"📄 {filename} Knowledge Graph에 수집합니다...")
800
+ task = asyncio.create_task(
801
+ process_document_file(client, chat_id, doc["file_id"], filename, caption)
802
+ )
803
+ task.add_done_callback(_log_task_exception)
804
+ continue
805
+
806
+ # ── Voice / audio ─────────────────────────────────────────
807
+ if "voice" in msg or "audio" in msg:
808
+ await send_message(
809
+ client, chat_id,
810
+ "🎤 음성 메시지를 받았습니다. 현재 음성 인식(Whisper)이 설정되어 있지 않습니다.\n"
811
+ "텍스트로 질문을 보내주세요."
395
812
  )
396
- except Exception as e:
397
- logger.error(f"업데이트 처리 중 예외: {e}")
813
+ continue
398
814
 
399
- await asyncio.sleep(0.5)
815
+ if not text:
816
+ continue
400
817
 
401
- async def process_ai_request(client, chat_id, user_text, image_data=None):
402
- """별도의 태스크로 AI 답변을 처리합니다."""
403
- try:
404
- await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
405
- data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
406
- logger.info("🤖 AI 답변 생성 완료")
818
+ # ── Commands ──────────────────────────────────────────────
819
+ if text.startswith("/"):
820
+ parts = text.split(None, 1)
821
+ command = parts[0]
822
+ args = parts[1] if len(parts) > 1 else ""
823
+ task = asyncio.create_task(handle_command(client, chat_id, command, args))
824
+ task.add_done_callback(_log_task_exception)
825
+ continue
407
826
 
408
- ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
409
- if not ans or not str(ans).strip():
410
- ans = "⚠️ AI가 답변을 생성하지 못했습니다."
827
+ # ── Plain text AI ───────────────────────────────────────
828
+ task = asyncio.create_task(process_ai_request(client, chat_id, text))
829
+ task.add_done_callback(_log_task_exception)
411
830
 
412
- await send_message(client, chat_id, str(ans))
831
+ except Exception as e:
832
+ logger.error("업데이트 처리 중 예외: %s", e)
413
833
 
414
- if not image_data and isinstance(data, dict):
415
- generated_files = collect_generated_files(data)
416
- await send_generated_files(client, chat_id, generated_files)
417
- preview_urls = collect_preview_urls(data)
418
- await send_preview_links(client, chat_id, preview_urls)
419
- except Exception as e:
420
- logger.error(f"process_ai_request 실패 (chat_id={chat_id}): {e}")
421
- try:
422
- await send_message(client, chat_id, f"⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
423
- except Exception:
424
- pass
834
+ await asyncio.sleep(0.5)
835
+
836
+ def _log_task_exception(task):
837
+ if not task.cancelled() and task.exception():
838
+ logger.error("백그라운드 태스크 예외: %s", task.exception())
425
839
 
426
840
  if __name__ == "__main__":
427
841
  try: