import argparse, asyncio, importlib.util, json, os, queue as Q, re, sys, threading, time, uuid from pathlib import Path PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, PROJECT_ROOT) os.chdir(PROJECT_ROOT) import traceback import lark_oapi as lark from lark_oapi.api.im.v1 import * def _ensure_dir(path): path = Path(path) path.mkdir(parents=True, exist_ok=True) return path def _workspace_root_dir(): root = os.environ.get("GA_WORKSPACE_ROOT") if root: return _ensure_dir(Path(root).expanduser().resolve()) return _ensure_dir(Path(PROJECT_ROOT).resolve()) def _workspace_config_dir(root=None): base = Path(root).expanduser().resolve() if root else _workspace_root_dir() if base.name == "ga_config": return _ensure_dir(base) return _ensure_dir(base / "ga_config") def _load_dict_config(path): path = Path(path) if not path.exists(): return None try: if path.suffix == ".py": mod_name = f"_fs_mykey_{uuid.uuid4().hex}" spec = importlib.util.spec_from_file_location(mod_name, path) if not spec or not spec.loader: return None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) data = {k: v for k, v in vars(module).items() if not k.startswith("_")} else: with open(path, encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else None except Exception as e: print(f"[ERROR] load config failed {path}: {e}") return None def _resolve_mykey_path(): workspace_root = _workspace_root_dir() config_root = _workspace_config_dir(workspace_root) candidates = [ config_root / "mykey.json", config_root / "mykey.py", workspace_root / "mykey.json", workspace_root / "mykey.py", Path(PROJECT_ROOT) / "mykey.json", Path(PROJECT_ROOT) / "mykey.py", ] for candidate in candidates: if _load_dict_config(candidate): return candidate return candidates[0] def _ensure_runtime_paths(): workspace_root = _workspace_root_dir() config_root = _workspace_config_dir(workspace_root) os.environ.setdefault("GA_WORKSPACE_ROOT", str(workspace_root)) os.environ.setdefault("GA_USER_DATA_DIR", str(config_root)) return str(workspace_root), str(config_root) _ensure_runtime_paths() from agentmain import GeneraticAgent from frontends.chatapp_common import AgentChatMixin, FILE_HINT, split_text _TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", } _MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"} TEMP_DIR = os.path.join(PROJECT_ROOT, "temp") MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media") os.makedirs(MEDIA_DIR, exist_ok=True) _TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数 _DEDUP_TTL_SEC = 10 * 60 _DEDUP_MAX = 2000 _DEDUP_LOCK = threading.Lock() _SEEN_MESSAGES = {} def _claim_message_once(message_id): """Best-effort cross-platform dedup for Feishu reconnect redeliveries.""" if not message_id: return True now = time.time() with _DEDUP_LOCK: expired = [mid for mid, ts in _SEEN_MESSAGES.items() if now - ts > _DEDUP_TTL_SEC] for mid in expired: _SEEN_MESSAGES.pop(mid, None) if len(_SEEN_MESSAGES) > _DEDUP_MAX: for mid, _ in sorted(_SEEN_MESSAGES.items(), key=lambda item: item[1])[:len(_SEEN_MESSAGES) - _DEDUP_MAX]: _SEEN_MESSAGES.pop(mid, None) if message_id in _SEEN_MESSAGES: return False _SEEN_MESSAGES[message_id] = now return True def _clean(text): for pat in _TAG_PATS: text = re.sub(pat, "", text or "", flags=re.DOTALL) return re.sub(r"\n{3,}", "\n\n", text).strip() def _extract_files(text): return re.findall(r"\[FILE:([^\]]+)\]", text or "") def _strip_files(text): return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip() def _display_text(text): cleaned = _strip_files(_clean(text)) if cleaned: return cleaned tail = (text or "").strip()[-_TRUNC_TAIL:] return "⚠️ 模型输出被截断或为空" + (f"\n…{tail}" if tail else "") def _to_allowed_set(value): if value is None: return set() if isinstance(value, str): value = [value] return {str(x).strip() for x in value if str(x).strip()} def _parse_json(raw): if not raw: return {} try: return json.loads(raw) except Exception: return {} def _extract_share_card_content(content_json, msg_type): parts = [] if msg_type == "share_chat": parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") elif msg_type == "share_user": parts.append(f"[shared user: {content_json.get('user_id', '')}]") elif msg_type == "interactive": parts.extend(_extract_interactive_content(content_json)) elif msg_type == "share_calendar_event": parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") elif msg_type == "system": parts.append("[system message]") elif msg_type == "merge_forward": parts.append("[merged forward messages]") return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]" def _extract_interactive_content(content): parts = [] if isinstance(content, str): try: content = json.loads(content) except Exception: return [content] if content.strip() else [] if not isinstance(content, dict): return parts title = content.get("title") if isinstance(title, dict): title_text = title.get("content", "") or title.get("text", "") if title_text: parts.append(f"title: {title_text}") elif isinstance(title, str) and title: parts.append(f"title: {title}") elements = content.get("elements", []) if isinstance(elements, list): for row in elements: if isinstance(row, dict): parts.extend(_extract_element_content(row)) elif isinstance(row, list): for el in row: parts.extend(_extract_element_content(el)) card = content.get("card", {}) if card: parts.extend(_extract_interactive_content(card)) header = content.get("header", {}) if isinstance(header, dict): header_title = header.get("title", {}) if isinstance(header_title, dict): header_text = header_title.get("content", "") or header_title.get("text", "") if header_text: parts.append(f"title: {header_text}") return [p for p in parts if p] def _extract_element_content(element): parts = [] if not isinstance(element, dict): return parts tag = element.get("tag", "") if tag in ("markdown", "lark_md"): content = element.get("content", "") if content: parts.append(content) elif tag == "div": text = element.get("text", {}) if isinstance(text, dict): text_content = text.get("content", "") or text.get("text", "") if text_content: parts.append(text_content) elif isinstance(text, str) and text: parts.append(text) for field in element.get("fields", []) or []: if isinstance(field, dict): field_text = field.get("text", {}) if isinstance(field_text, dict): content = field_text.get("content", "") or field_text.get("text", "") if content: parts.append(content) elif tag == "a": href = element.get("href", "") text = element.get("text", "") if href: parts.append(f"link: {href}") if text: parts.append(text) elif tag == "button": text = element.get("text", {}) if isinstance(text, dict): content = text.get("content", "") or text.get("text", "") if content: parts.append(content) url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "") if url: parts.append(f"link: {url}") elif tag == "img": alt = element.get("alt", {}) if isinstance(alt, dict): parts.append(alt.get("content", "[image]") or "[image]") else: parts.append("[image]") for child in element.get("elements", []) or []: parts.extend(_extract_element_content(child)) for col in element.get("columns", []) or []: for child in (col.get("elements", []) if isinstance(col, dict) else []): parts.extend(_extract_element_content(child)) return parts def _extract_post_content(content_json): def _parse_block(block): if not isinstance(block, dict) or not isinstance(block.get("content"), list): return None, [] texts, images = [], [] if block.get("title"): texts.append(block.get("title")) for row in block["content"]: if not isinstance(row, list): continue for el in row: if not isinstance(el, dict): continue tag = el.get("tag") if tag in ("text", "a"): texts.append(el.get("text", "")) elif tag == "at": texts.append(f"@{el.get('user_name', 'user')}") elif tag == "img" and el.get("image_key"): images.append(el["image_key"]) text = " ".join([t for t in texts if t]).strip() return text or None, images root = content_json if isinstance(root, dict) and isinstance(root.get("post"), dict): root = root["post"] if not isinstance(root, dict): return "", [] if "content" in root: text, imgs = _parse_block(root) if text or imgs: return text or "", imgs for key in ("zh_cn", "en_us", "ja_jp"): if key in root: text, imgs = _parse_block(root[key]) if text or imgs: return text or "", imgs for val in root.values(): if isinstance(val, dict): text, imgs = _parse_block(val) if text or imgs: return text or "", imgs return "", [] AGENT_TIMEOUT_SEC = 900 agent = None agent_error = None agent_thread = None client, user_tasks, app = None, {}, None agent_lock = threading.Lock() def _load_config(): path = _resolve_mykey_path() if not path or not path.exists(): return {}, str(path or "") try: data = _load_dict_config(path) return data if isinstance(data, dict) else {}, str(path) except Exception as e: print(f"[ERROR] load mykey failed {path}: {e}") return {}, str(path) def _feishu_config(): cfg, path = _load_config() app_id = str(cfg.get("fs_app_id", "") or "").strip() app_secret = str(cfg.get("fs_app_secret", "") or "").strip() allowed = _to_allowed_set(cfg.get("fs_allowed_users", [])) return app_id, app_secret, allowed, (not allowed or "*" in allowed), path APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config() def get_agent(): global agent, agent_error, agent_thread with agent_lock: if agent is not None: return agent if agent_error: raise RuntimeError(agent_error) try: agent = GeneraticAgent() agent_thread = threading.Thread(target=agent.run, daemon=True) agent_thread.start() return agent except Exception as e: agent_error = str(e) raise def create_client(): return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build() def _mask_secret(value): value = str(value or "") if len(value) <= 8: return "*" * len(value) return value[:4] + "*" * (len(value) - 8) + value[-4:] def check_config(init_agent=False): app_id, app_secret, allowed, public_access, path = _feishu_config() result = { "config_path": path, "app_id": app_id, "app_secret": _mask_secret(app_secret), "app_secret_present": bool(app_secret), "public_access": public_access, "allowed_users": sorted(allowed), "ready": bool(app_id and app_secret), } if init_agent: try: ga = get_agent() result["agent_ready"] = True result["llm_count"] = len(ga.list_llms()) if hasattr(ga, "list_llms") else 0 result["current_llm"] = ga.get_llm_name() if getattr(ga, "llmclient", None) else "" except Exception as e: result["agent_ready"] = False result["agent_error"] = str(e) return result def _card_raw(elements): return json.dumps({ "schema": "2.0", "config": {"streaming_mode": False, "width_mode": "fill"}, "body": {"elements": elements}, }, ensure_ascii=False) def _card(text): return _card_raw([{"tag": "markdown", "content": text}]) def _send_raw(receive_id, payload, msg_type, rtype): try: body = CreateMessageRequest.builder().receive_id_type(rtype).request_body( CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build() ).build() r = client.im.v1.message.create(body) if r.success(): return r.data.message_id if r.data else None print(f"发送失败: {r.code}, {r.msg}") except Exception as e: print(f"[ERROR] send_message failed: {e}") traceback.print_exc() return None def _patch_card(message_id, card_json): try: body = PatchMessageRequest.builder().message_id(message_id).request_body( PatchMessageRequestBody.builder().content(card_json).build() ).build() r = client.im.v1.message.patch(body) if not r.success(): print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") return r.success() except Exception as e: print(f"[ERROR] patch_card exception: {e}") traceback.print_exc() return False def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"): if use_card: return _send_raw(receive_id, _card(content), "interactive", receive_id_type) if msg_type == "text": return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type) return _send_raw(receive_id, content, msg_type, receive_id_type) def update_message(message_id, content): return _patch_card(message_id, _card(content)) def _upload_image_sync(file_path): try: with open(file_path, "rb") as f: request = CreateImageRequest.builder().request_body( CreateImageRequestBody.builder().image_type("message").image(f).build() ).build() response = client.im.v1.image.create(request) if response.success(): return response.data.image_key print(f"[ERROR] upload image failed: {response.code}, {response.msg}") except Exception as e: print(f"[ERROR] upload image failed {file_path}: {e}") return None def _upload_file_sync(file_path): ext = os.path.splitext(file_path)[1].lower() file_type = _FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) try: with open(file_path, "rb") as f: request = CreateFileRequest.builder().request_body( CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build() ).build() response = client.im.v1.file.create(request) if response.success(): return response.data.file_key print(f"[ERROR] upload file failed: {response.code}, {response.msg}") except Exception as e: print(f"[ERROR] upload file failed {file_path}: {e}") return None def _download_image_sync(message_id, image_key): try: request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build() response = client.im.v1.message_resource.get(request) if response.success(): data = response.file.read() if hasattr(response.file, "read") else response.file return data, response.file_name print(f"[ERROR] download image failed: {response.code}, {response.msg}") except Exception as e: print(f"[ERROR] download image failed {image_key}: {e}") return None, None def _download_file_sync(message_id, file_key, resource_type="file"): if resource_type == "audio": resource_type = "file" try: request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build() response = client.im.v1.message_resource.get(request) if response.success(): data = response.file.read() if hasattr(response.file, "read") else response.file return data, response.file_name print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}") except Exception as e: print(f"[ERROR] download {resource_type} failed {file_key}: {e}") return None, None def _download_and_save_media(msg_type, content_json, message_id): data, filename = None, None if msg_type == "image": image_key = content_json.get("image_key") if image_key and message_id: data, filename = _download_image_sync(message_id, image_key) if not filename: filename = f"{image_key[:16]}.jpg" elif msg_type in ("audio", "file", "media"): file_key = content_json.get("file_key") if file_key and message_id: data, filename = _download_file_sync(message_id, file_key, msg_type) if not filename: filename = file_key[:16] if msg_type == "audio" and filename and not filename.endswith(".opus"): filename = f"{filename}.opus" if data and filename: file_path = os.path.join(MEDIA_DIR, os.path.basename(filename)) with open(file_path, "wb") as f: f.write(data) return file_path, filename return None, None def _describe_media(msg_type, file_path, filename): if msg_type == "image": return f"[image: {filename}]\n[Image: source: {file_path}]" if msg_type == "audio": return f"[audio: {filename}]\n[File: source: {file_path}]" if msg_type in ("file", "media"): return f"[{msg_type}: {filename}]\n[File: source: {file_path}]" return f"[{msg_type}]\n[File: source: {file_path}]" def _send_local_file(receive_id, file_path, receive_id_type="open_id"): if not os.path.isfile(file_path): send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type) return False ext = os.path.splitext(file_path)[1].lower() if ext in _IMAGE_EXTS: image_key = _upload_image_sync(file_path) if image_key: send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type) return True else: file_key = _upload_file_sync(file_path) if file_key: msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file" send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type) return True send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type) return False def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"): for file_path in _extract_files(raw_text): _send_local_file(receive_id, file_path, receive_id_type) def _build_user_message(message): msg_type = message.message_type message_id = message.message_id content_json = _parse_json(message.content) parts, image_paths = [], [] if msg_type == "text": text = str(content_json.get("text", "") or "").strip() if text: parts.append(text) elif msg_type == "post": text, image_keys = _extract_post_content(content_json) if text: parts.append(text) for image_key in image_keys: file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id) if file_path and filename: parts.append(_describe_media("image", file_path, filename)) image_paths.append(file_path) else: parts.append("[image: download failed]") elif msg_type in ("image", "audio", "file", "media"): file_path, filename = _download_and_save_media(msg_type, content_json, message_id) if file_path and filename: parts.append(_describe_media(msg_type, file_path, filename)) if msg_type == "image": image_paths.append(file_path) else: parts.append(f"[{msg_type}: download failed]") elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): parts.append(_extract_share_card_content(content_json, msg_type)) else: parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) return "\n".join([p for p in parts if p]).strip(), image_paths def _fmt_tool_call(tc): name = tc.get('tool_name', '?') args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')} return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})" def _build_step_detail(resp, tool_calls): """从 LLM response + tool_calls 组装单步展开详情(纯函数)。""" parts = [] thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else '' if thinking: parts.append(f"### 💭 Thinking\n{thinking}") if tool_calls: parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls)) content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else '' if content and content != '...': parts.append(f"### 📝 Output\n{content}") return "\n\n".join(parts) class _TaskCard: """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。""" _DETAIL_LIMIT = 8000 def __init__(self, receive_id, rid_type): self.rid, self.rtype = receive_id, rid_type self.steps = [] # [(summary, detail), ...] self.status = "🤔 思考中..." self.final = None self.msg_id = None self.start_fallback_sent = False self.final_fallback_sent = False def _step_panel(self, idx, summary, detail): detail = detail or "_(无输出)_" if len(detail) > self._DETAIL_LIMIT: detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)" return { "tag": "collapsible_panel", "expanded": False, "header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}}, "elements": [{"tag": "markdown", "content": detail}], } def _build(self): els = [{"tag": "markdown", "content": f"**{self.status}**"}] for i, (s, d) in enumerate(self.steps, 1): els.append(self._step_panel(i, s, d)) if self.final: els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}] return _card_raw(els) def _push(self): card = self._build() if self.msg_id: ok = _patch_card(self.msg_id, card) else: self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype) ok = bool(self.msg_id) return ok def _fallback_text(self, text, *, final=False): attr = "final_fallback_sent" if final else "start_fallback_sent" if getattr(self, attr): return setattr(self, attr, True) send_message(self.rid, text, receive_id_type=self.rtype) # ── 公开接口 ── def start(self): if not self._push(): self._fallback_text("🤔 思考中...") def step(self, summary, detail=""): self.steps.append((summary, detail)) self.status = f"⏳ 工作中 · Turn {len(self.steps)}" self._push() def done(self, text): self.status = "✅ 已完成" self.final = text or "_(无文本输出)_" if not self._push(): self._fallback_text(_display_text(text), final=True) def fail(self, msg): self.status = f"❌ {msg}" if not self._push(): self._fallback_text(f"❌ {msg}", final=True) def _make_task_hook(card, task_id, on_final): """飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。""" def hook(ctx): try: parent = getattr(ctx.get("self"), "parent", None) if getattr(parent, "_fs_active_task_id", None) != task_id: return if ctx.get('exit_reason'): resp = ctx.get('response') raw = resp.content if hasattr(resp, 'content') else str(resp) on_final(raw) elif ctx.get('summary'): detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or []) card.step(ctx['summary'], detail) except Exception as e: print(f"[fs hook] error: {e}") return hook class FeishuApp(AgentChatMixin): label, source, split_limit = "Feishu", "feishu", 4000 async def send_text(self, chat_id, content, *, receive_id=None, receive_id_type="open_id", **_): rid = receive_id or chat_id for part in split_text(content, self.split_limit): await asyncio.to_thread(send_message, rid, part, "text", False, receive_id_type) async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type="open_id", **_): rid = receive_id or chat_id text = _display_text(raw_text) await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type) await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type) async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_): if self.user_tasks: await self.send_text(chat_id, "当前会话已有任务在运行,请等待完成或发送 /stop 后再试。", receive_id=receive_id, receive_id_type=receive_id_type) return state = {"running": True} self.user_tasks[chat_id] = state rid = receive_id or chat_id task_id = f"{chat_id}_{uuid.uuid4().hex}" hook_key = f"fs_{task_id}" card = _TaskCard(rid, receive_id_type) result = {"raw": None, "sent": False} finish_lock = threading.Lock() def _finish(raw): with finish_lock: if result["sent"]: return result["raw"] = raw result["sent"] = True card.done(_display_text(raw)) _send_generated_files(rid, raw, receive_id_type=receive_id_type) try: await asyncio.to_thread(card.start) if not hasattr(self.agent, '_turn_end_hooks'): self.agent._turn_end_hooks = {} self.agent._turn_end_hooks[hook_key] = _make_task_hook(card, task_id, _finish) self.agent._fs_active_task_id = task_id dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source, images=images or None) start = time.time() while state["running"] and not result["sent"]: try: item = await asyncio.to_thread(dq.get, True, 1) except Q.Empty: item = None if item and "done" in item: await asyncio.to_thread(_finish, item.get("done", "")) break if time.time() - start > AGENT_TIMEOUT_SEC: self.agent.abort() await asyncio.to_thread(card.fail, "任务超时") break if not state["running"] and not result["sent"]: self.agent.abort() await asyncio.to_thread(card.fail, "已停止") except Exception as e: traceback.print_exc() await asyncio.to_thread(card.fail, f"错误: {e}") finally: if getattr(self.agent, "_fs_active_task_id", None) == task_id: try: delattr(self.agent, "_fs_active_task_id") except AttributeError: pass if hasattr(self.agent, '_turn_end_hooks'): self.agent._turn_end_hooks.pop(hook_key, None) self.user_tasks.pop(chat_id, None) def get_app(): global app if app is None: app = FeishuApp(get_agent(), user_tasks) return app def _run_async(coro): try: asyncio.run(coro) except Exception: traceback.print_exc() def handle_message(data): event, message, sender = data.event, data.event.message, data.event.sender message_id = getattr(message, "message_id", "") or "" if not _claim_message_once(message_id): print(f"忽略重复飞书消息: {message_id}") return open_id = sender.sender_id.open_id chat_id = message.chat_id if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS: print(f"未授权用户: {open_id}") return user_input, image_paths = _build_user_message(message) if not user_input: if chat_id: send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id") else: send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}") return print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}") receive_id = chat_id or open_id receive_id_type = "chat_id" if chat_id else "open_id" chat_key = receive_id if message.message_type == "text" and user_input.startswith("/"): threading.Thread( target=_run_async, args=(get_app().handle_command(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type),), daemon=True, ).start() return threading.Thread( target=_run_async, args=(get_app().run_agent(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type, images=image_paths),), daemon=True, ).start() def main(): global client, APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config() if not APP_ID or not APP_SECRET: print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True) sys.exit(1) handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build() retry_delay = 5 while True: try: client = create_client() cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO) print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n配置: {CONFIG_PATH}\n等待消息...\n" + "=" * 50, flush=True) cli.start() retry_delay = 5 except KeyboardInterrupt: raise except Exception as e: print(f"[WARN] 飞书长连接断开或启动失败: {e}", flush=True) traceback.print_exc() print(f"[INFO] {retry_delay}s 后重连飞书长连接...", flush=True) time.sleep(retry_delay) retry_delay = min(retry_delay * 2, 120) if __name__ == "__main__": parser = argparse.ArgumentParser(description="A3Agent Feishu frontend") parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接") parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM") args = parser.parse_args() if args.check or args.check_agent: print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True) else: main()