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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/ltcai.js +74 -0
- package/codex_telegram_bot.py +191 -0
- package/llm_router.py +537 -0
- package/ltcai_cli.py +74 -0
- package/p_reinforce.py +148 -0
- package/package.json +44 -0
- package/requirements.txt +11 -0
- package/server.py +3215 -0
- package/static/admin.html +1013 -0
- package/static/index.html +270 -0
- package/static/indexd.html +5664 -0
- package/telegram_bot.py +430 -0
- package/tools.py +1136 -0
package/telegram_bot.py
ADDED
|
@@ -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
|