ltcai 0.1.3 → 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/README.md +121 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +802 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +2 -0
- package/server.py +818 -39
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +74 -2
- package/static/admin.html +225 -6
- package/static/chat.html +886 -147
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
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
|
-
|
|
30
|
-
|
|
31
|
-
CHAT_URL
|
|
32
|
-
AGENT_URL
|
|
33
|
-
MCP_TOOLS_URL
|
|
34
|
-
HISTORY_URL
|
|
35
|
-
|
|
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
|
|
38
|
-
INVITE_CODE
|
|
39
|
-
PUBLIC_WEB_URL
|
|
40
|
-
DATA_DIR
|
|
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(
|
|
59
|
+
return {int(cid) for cid in data.get("chat_ids", [])}
|
|
52
60
|
except Exception as e:
|
|
53
|
-
logger.error(
|
|
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(
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
196
|
+
except Exception:
|
|
98
197
|
return None
|
|
99
198
|
|
|
100
|
-
|
|
101
|
-
|
|
199
|
+
# ── File download ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async def download_telegram_file(client, file_id) -> bytes | None:
|
|
102
202
|
try:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
210
|
+
logger.error("파일 다운로드 실패: %s", e)
|
|
211
|
+
return None
|
|
108
212
|
|
|
109
|
-
async def
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
349
|
+
await send_message(client, chat_id, f"언로드 오류: {e}")
|
|
115
350
|
|
|
116
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
412
|
+
# ── History ───────────────────────────────────────────────────────────────────
|
|
135
413
|
|
|
136
|
-
def
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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에 있어야 바로 열립니다.
|
|
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": [[
|
|
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
|
-
|
|
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(
|
|
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
|
|
164
|
-
res = await
|
|
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, "사용 가능한
|
|
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 도구
|
|
487
|
+
await send_message(client, chat_id, f"MCP 도구 조회 실패: {e}")
|
|
173
488
|
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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")
|
|
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 = ["미리보기
|
|
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
|
-
|
|
285
|
-
await send_document(client, chat_id,
|
|
611
|
+
path, fpath = generated_files[0]
|
|
612
|
+
await send_document(client, chat_id, fpath, caption=f"생성 파일: {path}")
|
|
286
613
|
return
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
294
|
-
zf.write(
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
pass
|
|
626
|
+
zip_path.unlink(missing_ok=True)
|
|
627
|
+
|
|
628
|
+
# ── AI request task ───────────────────────────────────────────────────────────
|
|
311
629
|
|
|
312
|
-
async def
|
|
313
|
-
"""텔레그램 서버에서 파일을 다운로드하여 Base64로 변환합니다."""
|
|
630
|
+
async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
314
631
|
try:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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(
|
|
329
|
-
|
|
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(
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
logger.error(f"업데이트 처리 중 예외: {e}")
|
|
813
|
+
continue
|
|
398
814
|
|
|
399
|
-
|
|
815
|
+
if not text:
|
|
816
|
+
continue
|
|
400
817
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
831
|
+
except Exception as e:
|
|
832
|
+
logger.error("업데이트 처리 중 예외: %s", e)
|
|
413
833
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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:
|