ltcai 0.1.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.
@@ -0,0 +1,430 @@
1
+ import asyncio
2
+ import httpx
3
+ import logging
4
+ import base64
5
+ import os
6
+ import socket
7
+ import tempfile
8
+ import zipfile
9
+ import json
10
+ from pathlib import Path
11
+
12
+ def load_env_file(path=".env"):
13
+ env_path = Path(path)
14
+ if not env_path.exists():
15
+ return
16
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
17
+ line = raw_line.strip()
18
+ if not line or line.startswith("#") or "=" not in line:
19
+ continue
20
+ key, value = line.split("=", 1)
21
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
22
+
23
+ load_env_file()
24
+
25
+ def env_value(primary: str, default: str = "") -> str:
26
+ return os.getenv(primary) or default
27
+
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()
36
+ 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")))
41
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
42
+ CHAT_IDS_FILE = Path(env_value("LATTICEAI_TELEGRAM_CHATS_FILE", str(DATA_DIR / "telegram_chats.json")))
43
+
44
+ logging.basicConfig(level=logging.INFO)
45
+ logger = logging.getLogger(__name__)
46
+
47
+ def load_chat_ids():
48
+ try:
49
+ if CHAT_IDS_FILE.exists():
50
+ data = json.loads(CHAT_IDS_FILE.read_text(encoding="utf-8"))
51
+ return {int(chat_id) for chat_id in data.get("chat_ids", [])}
52
+ except Exception as e:
53
+ logger.error(f"텔레그램 채팅 목록 로드 실패: {e}")
54
+ return set()
55
+
56
+ def save_chat_ids(chat_ids):
57
+ try:
58
+ CHAT_IDS_FILE.write_text(
59
+ json.dumps({"chat_ids": sorted(chat_ids)}, ensure_ascii=False, indent=2),
60
+ encoding="utf-8",
61
+ )
62
+ except Exception as e:
63
+ logger.error(f"텔레그램 채팅 목록 저장 실패: {e}")
64
+
65
+ def register_chat_id(chat_id):
66
+ 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}")
72
+
73
+ async def broadcast_web_chat(role, text):
74
+ if not TOKEN:
75
+ logger.info("LATTICEAI_TELEGRAM_BOT_TOKEN이 없어 웹 대화 텔레그램 미러링을 건너뜁니다.")
76
+ return
77
+
78
+ chat_ids = load_chat_ids()
79
+ if not chat_ids:
80
+ logger.info("웹 대화 미러링 대상 텔레그램 채팅이 없습니다. 봇에 /start 또는 /web을 먼저 보내세요.")
81
+ return
82
+
83
+ label = "사용자" if role == "user" else "Lattice AI"
84
+ message = f"[Web] {label}\n{text}"
85
+
86
+ async with httpx.AsyncClient() as client:
87
+ for chat_id in chat_ids:
88
+ await send_message(client, chat_id, message)
89
+
90
+ async def get_updates(client, offset=None):
91
+ url = f"{API_URL}/getUpdates?timeout=30"
92
+ if offset:
93
+ url += f"&offset={offset}"
94
+ try:
95
+ res = await client.get(url, timeout=35)
96
+ return res.json()
97
+ except Exception as e:
98
+ return None
99
+
100
+ async def send_message(client, chat_id, text):
101
+ url = f"{API_URL}/sendMessage"
102
+ 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})
106
+ except Exception as e:
107
+ logger.error(f"메시지 전송 실패: {e}")
108
+
109
+ async def send_chat_action(client, chat_id, action="typing"):
110
+ url = f"{API_URL}/sendChatAction"
111
+ try:
112
+ await client.post(url, json={"chat_id": chat_id, "action": action})
113
+ except Exception as e:
114
+ logger.error(f"채팅 액션 전송 실패: {e}")
115
+
116
+ def get_lan_ip():
117
+ 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
125
+
126
+ 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
133
+
134
+ return "127.0.0.1"
135
+
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}"
140
+
141
+ async def send_web_link(client, chat_id):
142
+ url = f"{API_URL}/sendMessage"
143
+ web_url = get_web_url()
144
+ text = (
145
+ "웹 UI 링크입니다.\n"
146
+ f"{web_url}\n\n"
147
+ "핸드폰이 Mac과 같은 Wi-Fi에 있어야 바로 열립니다. 외부망에서 쓰려면 LATTICEAI_PUBLIC_URL에 터널 주소를 설정하세요."
148
+ )
149
+ payload = {
150
+ "chat_id": chat_id,
151
+ "text": text,
152
+ "reply_markup": {
153
+ "inline_keyboard": [[{"text": "Lattice AI Web 열기", "url": web_url}]]
154
+ },
155
+ }
156
+ try:
157
+ await client.post(url, json=payload)
158
+ except Exception as e:
159
+ logger.error(f"웹 링크 전송 실패: {e}")
160
+
161
+ async def send_mcp_tools(client, chat_id):
162
+ try:
163
+ async with httpx.AsyncClient() as local_client:
164
+ res = await local_client.get(MCP_TOOLS_URL, timeout=10.0)
165
+ if res.status_code != 200:
166
+ await send_message(client, chat_id, f"MCP 도구 목록을 가져오지 못했습니다: {res.status_code}")
167
+ return
168
+ data = res.json()
169
+ 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))
171
+ except Exception as e:
172
+ await send_message(client, chat_id, f"MCP 도구 목록 조회 실패: {e}")
173
+
174
+ async def clear_server_history(client, chat_id, keep_last=0):
175
+ 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 {}
179
+ if res.status_code == 200:
180
+ await send_message(client, chat_id, f"대화 기록을 정리했습니다. 삭제 {data.get('removed', 0)}개, 유지 {data.get('kept', 0)}개.")
181
+ else:
182
+ await send_message(client, chat_id, f"대화 기록 정리에 실패했습니다: {res.status_code}")
183
+ except Exception as e:
184
+ await send_message(client, chat_id, f"대화 기록 정리 실패: {e}")
185
+
186
+ async def ask_ai(client, message, image_data=None, agent_mode=True):
187
+ try:
188
+ url = CHAT_URL if image_data or not agent_mode else AGENT_URL
189
+ payload = {"message": message, "source": "telegram"}
190
+ if image_data:
191
+ payload["stream"] = False
192
+ payload["image_data"] = image_data
193
+
194
+ res = await client.post(url, json=payload, timeout=300.0)
195
+ if res.status_code == 200:
196
+ data = res.json()
197
+ return data
198
+ else:
199
+ return {"response": f"❌ 서버 에러 ({res.status_code}): {res.text}"}
200
+ except Exception as e:
201
+ return {"response": f"❌ 서버 연결 실패: {e}"}
202
+
203
+ def resolve_workspace_file(relative_path):
204
+ target = (AGENT_WORKSPACE / relative_path).resolve()
205
+ if target != AGENT_WORKSPACE and AGENT_WORKSPACE not in target.parents:
206
+ return None
207
+ if not target.exists() or not target.is_file():
208
+ return None
209
+ if target.stat().st_size > MAX_TELEGRAM_FILE_BYTES:
210
+ return None
211
+ return target
212
+
213
+ def collect_generated_files(agent_data):
214
+ files = []
215
+ seen = set()
216
+ for step in agent_data.get("steps", []):
217
+ if step.get("action") != "write_file":
218
+ continue
219
+ path = (step.get("result") or {}).get("path") or (step.get("args") or {}).get("path")
220
+ if not path or path in seen:
221
+ continue
222
+ target = resolve_workspace_file(path)
223
+ if target:
224
+ seen.add(path)
225
+ files.append((path, target))
226
+ return files
227
+
228
+ def collect_preview_urls(agent_data):
229
+ urls = []
230
+ seen = set()
231
+ for step in agent_data.get("steps", []):
232
+ if step.get("action") != "preview_url":
233
+ continue
234
+ result = step.get("result") or {}
235
+ local_url = result.get("local_url")
236
+ path = result.get("path")
237
+ if not local_url or local_url in seen:
238
+ continue
239
+ phone_url = local_url.replace("http://127.0.0.1:4825", f"http://{get_lan_ip()}:{SERVER_PORT}")
240
+ seen.add(local_url)
241
+ urls.append((path or "preview", phone_url))
242
+ return urls
243
+
244
+ async def send_preview_links(client, chat_id, preview_urls):
245
+ if not preview_urls:
246
+ return
247
+ lines = ["미리보기 링크입니다. 핸드폰이 Mac과 같은 Wi-Fi에 있어야 열립니다."]
248
+ keyboard = []
249
+ for label, url in preview_urls:
250
+ lines.append(f"- {label}: {url}")
251
+ 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}")
278
+
279
+ async def send_generated_files(client, chat_id, generated_files):
280
+ if not generated_files:
281
+ return
282
+
283
+ 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}")
286
+ return
287
+
288
+ with tempfile.NamedTemporaryFile(prefix="ltcai-", suffix=".zip", delete=False) as temp:
289
+ zip_path = Path(temp.name)
290
+
291
+ try:
292
+ 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
+
296
+ 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
+ )
304
+ else:
305
+ await send_message(client, chat_id, "생성 파일 묶음이 너무 커서 텔레그램으로 전송하지 못했습니다.")
306
+ finally:
307
+ try:
308
+ zip_path.unlink()
309
+ except OSError:
310
+ pass
311
+
312
+ async def download_telegram_file(client, file_id):
313
+ """텔레그램 서버에서 파일을 다운로드하여 Base64로 변환합니다."""
314
+ 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")
327
+ except Exception as e:
328
+ logger.error(f"Failed to download file: {e}")
329
+ return None
330
+
331
+ async def run_bot():
332
+ if not TOKEN:
333
+ logger.warning("LATTICEAI_TELEGRAM_BOT_TOKEN이 설정되지 않아 텔레그램 봇을 시작하지 않습니다.")
334
+ return
335
+
336
+ logger.info("🚀 비동기 텔레그램 봇 모드 시작!")
337
+ last_update_id = None
338
+ retry_delay = 1
339
+
340
+ async with httpx.AsyncClient() as client:
341
+ while True:
342
+ try:
343
+ updates = await get_updates(client, last_update_id)
344
+ retry_delay = 1
345
+ except Exception as e:
346
+ logger.error(f"get_updates 실패: {e}")
347
+ await asyncio.sleep(min(retry_delay, 30))
348
+ retry_delay = min(retry_delay * 2, 30)
349
+ continue
350
+
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
395
+ )
396
+ except Exception as e:
397
+ logger.error(f"업데이트 처리 중 예외: {e}")
398
+
399
+ await asyncio.sleep(0.5)
400
+
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 답변 생성 완료")
407
+
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가 답변을 생성하지 못했습니다."
411
+
412
+ await send_message(client, chat_id, str(ans))
413
+
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
425
+
426
+ if __name__ == "__main__":
427
+ try:
428
+ asyncio.run(run_bot())
429
+ except KeyboardInterrupt:
430
+ pass