1367 lines
43 KiB
Python
1367 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
InsightFlow Plugin Manager - Phase 7 Task 7
|
||
插件与集成系统:Chrome插件、飞书/钉钉机器人、Zapier/Make集成、WebDAV同步
|
||
"""
|
||
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import sqlite3
|
||
import time
|
||
import urllib.parse
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
# WebDAV 支持
|
||
try:
|
||
import webdav4.client as webdav_client
|
||
|
||
WEBDAV_AVAILABLE = True
|
||
except ImportError:
|
||
WEBDAV_AVAILABLE = False
|
||
|
||
|
||
class PluginType(Enum):
|
||
"""插件类型"""
|
||
|
||
CHROME_EXTENSION = "chrome_extension"
|
||
FEISHU_BOT = "feishu_bot"
|
||
DINGTALK_BOT = "dingtalk_bot"
|
||
ZAPIER = "zapier"
|
||
MAKE = "make"
|
||
WEBDAV = "webdav"
|
||
CUSTOM = "custom"
|
||
|
||
|
||
class PluginStatus(Enum):
|
||
"""插件状态"""
|
||
|
||
ACTIVE = "active"
|
||
INACTIVE = "inactive"
|
||
ERROR = "error"
|
||
PENDING = "pending"
|
||
|
||
|
||
@dataclass
|
||
class Plugin:
|
||
"""插件配置"""
|
||
|
||
id: str
|
||
name: str
|
||
plugin_type: str
|
||
project_id: str
|
||
status: str = "active"
|
||
config: dict = field(default_factory=dict)
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
last_used_at: str | None = None
|
||
use_count: int = 0
|
||
|
||
|
||
@dataclass
|
||
class PluginConfig:
|
||
"""插件详细配置"""
|
||
|
||
id: str
|
||
plugin_id: str
|
||
config_key: str
|
||
config_value: str
|
||
is_encrypted: bool = False
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
|
||
|
||
@dataclass
|
||
class BotSession:
|
||
"""机器人会话"""
|
||
|
||
id: str
|
||
bot_type: str # feishu, dingtalk
|
||
session_id: str # 群ID或会话ID
|
||
session_name: str
|
||
project_id: str | None = None
|
||
webhook_url: str = ""
|
||
secret: str = ""
|
||
is_active: bool = True
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
last_message_at: str | None = None
|
||
message_count: int = 0
|
||
|
||
|
||
@dataclass
|
||
class WebhookEndpoint:
|
||
"""Webhook 端点配置(Zapier/Make集成)"""
|
||
|
||
id: str
|
||
name: str
|
||
endpoint_type: str # zapier, make, custom
|
||
endpoint_url: str
|
||
project_id: str | None = None
|
||
auth_type: str = "none" # none, api_key, oauth, custom
|
||
auth_config: dict = field(default_factory=dict)
|
||
trigger_events: list[str] = field(default_factory=list)
|
||
is_active: bool = True
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
last_triggered_at: str | None = None
|
||
trigger_count: int = 0
|
||
|
||
|
||
@dataclass
|
||
class WebDAVSync:
|
||
"""WebDAV 同步配置"""
|
||
|
||
id: str
|
||
name: str
|
||
project_id: str
|
||
server_url: str
|
||
username: str
|
||
password: str = "" # 加密存储
|
||
remote_path: str = "/insightflow"
|
||
sync_mode: str = "bidirectional" # bidirectional, upload_only, download_only
|
||
sync_interval: int = 3600 # 秒
|
||
last_sync_at: str | None = None
|
||
last_sync_status: str = "pending" # pending, success, failed
|
||
last_sync_error: str = ""
|
||
is_active: bool = True
|
||
created_at: str = ""
|
||
updated_at: str = ""
|
||
sync_count: int = 0
|
||
|
||
|
||
@dataclass
|
||
class ChromeExtensionToken:
|
||
"""Chrome 扩展令牌"""
|
||
|
||
id: str
|
||
token: str
|
||
user_id: str | None = None
|
||
project_id: str | None = None
|
||
name: str = ""
|
||
permissions: list[str] = field(default_factory=lambda: ["read", "write"])
|
||
expires_at: str | None = None
|
||
created_at: str = ""
|
||
last_used_at: str | None = None
|
||
use_count: int = 0
|
||
is_revoked: bool = False
|
||
|
||
|
||
class PluginManager:
|
||
"""插件管理主类"""
|
||
|
||
def __init__(self, db_manager=None):
|
||
self.db = db_manager
|
||
self._handlers = {}
|
||
self._register_default_handlers()
|
||
|
||
def _register_default_handlers(self):
|
||
"""注册默认处理器"""
|
||
self._handlers[PluginType.CHROME_EXTENSION] = ChromeExtensionHandler(self)
|
||
self._handlers[PluginType.FEISHU_BOT] = BotHandler(self, "feishu")
|
||
self._handlers[PluginType.DINGTALK_BOT] = BotHandler(self, "dingtalk")
|
||
self._handlers[PluginType.ZAPIER] = WebhookIntegration(self, "zapier")
|
||
self._handlers[PluginType.MAKE] = WebhookIntegration(self, "make")
|
||
self._handlers[PluginType.WEBDAV] = WebDAVSyncManager(self)
|
||
|
||
def get_handler(self, plugin_type: PluginType) -> Any | None:
|
||
"""获取插件处理器"""
|
||
return self._handlers.get(plugin_type)
|
||
|
||
# ==================== Plugin CRUD ====================
|
||
|
||
def create_plugin(self, plugin: Plugin) -> Plugin:
|
||
"""创建插件"""
|
||
conn = self.db.get_conn()
|
||
now = datetime.now().isoformat()
|
||
|
||
conn.execute(
|
||
"""INSERT INTO plugins
|
||
(id, name, plugin_type, project_id, status, config, created_at, updated_at, use_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
plugin.id,
|
||
plugin.name,
|
||
plugin.plugin_type,
|
||
plugin.project_id,
|
||
plugin.status,
|
||
json.dumps(plugin.config),
|
||
now,
|
||
now,
|
||
0,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
plugin.created_at = now
|
||
plugin.updated_at = now
|
||
return plugin
|
||
|
||
def get_plugin(self, plugin_id: str) -> Plugin | None:
|
||
"""获取插件"""
|
||
conn = self.db.get_conn()
|
||
row = conn.execute("SELECT * FROM plugins WHERE id = ?", (plugin_id,)).fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
return self._row_to_plugin(row)
|
||
return None
|
||
|
||
def list_plugins(self, project_id: str = None, plugin_type: str = None, status: str = None) -> list[Plugin]:
|
||
"""列出插件"""
|
||
conn = self.db.get_conn()
|
||
|
||
conditions = []
|
||
params = []
|
||
|
||
if project_id:
|
||
conditions.append("project_id = ?")
|
||
params.append(project_id)
|
||
if plugin_type:
|
||
conditions.append("plugin_type = ?")
|
||
params.append(plugin_type)
|
||
if status:
|
||
conditions.append("status = ?")
|
||
params.append(status)
|
||
|
||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||
|
||
rows = conn.execute(f"SELECT * FROM plugins WHERE {where_clause} ORDER BY created_at DESC", params).fetchall()
|
||
conn.close()
|
||
|
||
return [self._row_to_plugin(row) for row in rows]
|
||
|
||
def update_plugin(self, plugin_id: str, **kwargs) -> Plugin | None:
|
||
"""更新插件"""
|
||
conn = self.db.get_conn()
|
||
|
||
allowed_fields = ["name", "status", "config"]
|
||
updates = []
|
||
values = []
|
||
|
||
for f in allowed_fields:
|
||
if f in kwargs:
|
||
updates.append(f"{f} = ?")
|
||
if f == "config":
|
||
values.append(json.dumps(kwargs[f]))
|
||
else:
|
||
values.append(kwargs[f])
|
||
|
||
if not updates:
|
||
conn.close()
|
||
return self.get_plugin(plugin_id)
|
||
|
||
updates.append("updated_at = ?")
|
||
values.append(datetime.now().isoformat())
|
||
values.append(plugin_id)
|
||
|
||
query = f"UPDATE plugins SET {', '.join(updates)} WHERE id = ?"
|
||
conn.execute(query, values)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return self.get_plugin(plugin_id)
|
||
|
||
def delete_plugin(self, plugin_id: str) -> bool:
|
||
"""删除插件"""
|
||
conn = self.db.get_conn()
|
||
|
||
# 删除关联的配置
|
||
conn.execute("DELETE FROM plugin_configs WHERE plugin_id = ?", (plugin_id,))
|
||
|
||
# 删除插件
|
||
cursor = conn.execute("DELETE FROM plugins WHERE id = ?", (plugin_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def _row_to_plugin(self, row: sqlite3.Row) -> Plugin:
|
||
"""将数据库行转换为 Plugin 对象"""
|
||
return Plugin(
|
||
id=row["id"],
|
||
name=row["name"],
|
||
plugin_type=row["plugin_type"],
|
||
project_id=row["project_id"],
|
||
status=row["status"],
|
||
config=json.loads(row["config"]) if row["config"] else {},
|
||
created_at=row["created_at"],
|
||
updated_at=row["updated_at"],
|
||
last_used_at=row["last_used_at"],
|
||
use_count=row["use_count"],
|
||
)
|
||
|
||
# ==================== Plugin Config ====================
|
||
|
||
def set_plugin_config(self, plugin_id: str, key: str, value: str, is_encrypted: bool = False) -> PluginConfig:
|
||
"""设置插件配置"""
|
||
conn = self.db.get_conn()
|
||
now = datetime.now().isoformat()
|
||
|
||
# 检查是否已存在
|
||
existing = conn.execute(
|
||
"SELECT id FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key)
|
||
).fetchone()
|
||
|
||
if existing:
|
||
conn.execute(
|
||
"""UPDATE plugin_configs
|
||
SET config_value = ?, is_encrypted = ?, updated_at = ?
|
||
WHERE id = ?""",
|
||
(value, is_encrypted, now, existing["id"]),
|
||
)
|
||
config_id = existing["id"]
|
||
else:
|
||
config_id = str(uuid.uuid4())[:8]
|
||
conn.execute(
|
||
"""INSERT INTO plugin_configs
|
||
(id, plugin_id, config_key, config_value, is_encrypted, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||
(config_id, plugin_id, key, value, is_encrypted, now, now),
|
||
)
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return PluginConfig(
|
||
id=config_id,
|
||
plugin_id=plugin_id,
|
||
config_key=key,
|
||
config_value=value,
|
||
is_encrypted=is_encrypted,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
def get_plugin_config(self, plugin_id: str, key: str) -> str | None:
|
||
"""获取插件配置"""
|
||
conn = self.db.get_conn()
|
||
row = conn.execute(
|
||
"SELECT config_value FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key)
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
return row["config_value"] if row else None
|
||
|
||
def get_all_plugin_configs(self, plugin_id: str) -> dict[str, str]:
|
||
"""获取插件所有配置"""
|
||
conn = self.db.get_conn()
|
||
rows = conn.execute(
|
||
"SELECT config_key, config_value FROM plugin_configs WHERE plugin_id = ?", (plugin_id,)
|
||
).fetchall()
|
||
conn.close()
|
||
|
||
return {row["config_key"]: row["config_value"] for row in rows}
|
||
|
||
def delete_plugin_config(self, plugin_id: str, key: str) -> bool:
|
||
"""删除插件配置"""
|
||
conn = self.db.get_conn()
|
||
cursor = conn.execute("DELETE FROM plugin_configs WHERE plugin_id = ? AND config_key = ?", (plugin_id, key))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def record_plugin_usage(self, plugin_id: str):
|
||
"""记录插件使用"""
|
||
conn = self.db.get_conn()
|
||
now = datetime.now().isoformat()
|
||
|
||
conn.execute(
|
||
"""UPDATE plugins
|
||
SET use_count = use_count + 1, last_used_at = ?
|
||
WHERE id = ?""",
|
||
(now, plugin_id),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
class ChromeExtensionHandler:
|
||
"""Chrome 扩展处理器"""
|
||
|
||
def __init__(self, plugin_manager: PluginManager):
|
||
self.pm = plugin_manager
|
||
|
||
def create_token(
|
||
self,
|
||
name: str,
|
||
user_id: str = None,
|
||
project_id: str = None,
|
||
permissions: list[str] = None,
|
||
expires_days: int = None,
|
||
) -> ChromeExtensionToken:
|
||
"""创建 Chrome 扩展令牌"""
|
||
token_id = str(uuid.uuid4())[:8]
|
||
|
||
# 生成随机令牌
|
||
raw_token = f"if_ext_{base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=')}"
|
||
|
||
# 哈希存储
|
||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||
|
||
now = datetime.now().isoformat()
|
||
expires_at = None
|
||
if expires_days:
|
||
from datetime import timedelta
|
||
|
||
expires_at = (datetime.now() + timedelta(days=expires_days)).isoformat()
|
||
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""INSERT INTO chrome_extension_tokens
|
||
(id, token_hash, user_id, project_id, name, permissions, expires_at,
|
||
created_at, is_revoked, use_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
token_id,
|
||
token_hash,
|
||
user_id,
|
||
project_id,
|
||
name,
|
||
json.dumps(permissions or ["read"]),
|
||
expires_at,
|
||
now,
|
||
False,
|
||
0,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return ChromeExtensionToken(
|
||
id=token_id,
|
||
token=raw_token, # 仅返回一次
|
||
user_id=user_id,
|
||
project_id=project_id,
|
||
name=name,
|
||
permissions=permissions or ["read"],
|
||
expires_at=expires_at,
|
||
created_at=now,
|
||
)
|
||
|
||
def validate_token(self, token: str) -> ChromeExtensionToken | None:
|
||
"""验证 Chrome 扩展令牌"""
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
|
||
conn = self.pm.db.get_conn()
|
||
row = conn.execute(
|
||
"""SELECT * FROM chrome_extension_tokens
|
||
WHERE token_hash = ? AND is_revoked = 0""",
|
||
(token_hash,),
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
# 检查是否过期
|
||
if row["expires_at"] and datetime.now().isoformat() > row["expires_at"]:
|
||
return None
|
||
|
||
# 更新使用记录
|
||
now = datetime.now().isoformat()
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""UPDATE chrome_extension_tokens
|
||
SET use_count = use_count + 1, last_used_at = ?
|
||
WHERE id = ?""",
|
||
(now, row["id"]),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return ChromeExtensionToken(
|
||
id=row["id"],
|
||
token="", # 不返回实际令牌
|
||
user_id=row["user_id"],
|
||
project_id=row["project_id"],
|
||
name=row["name"],
|
||
permissions=json.loads(row["permissions"]),
|
||
expires_at=row["expires_at"],
|
||
created_at=row["created_at"],
|
||
last_used_at=now,
|
||
use_count=row["use_count"] + 1,
|
||
)
|
||
|
||
def revoke_token(self, token_id: str) -> bool:
|
||
"""撤销令牌"""
|
||
conn = self.pm.db.get_conn()
|
||
cursor = conn.execute("UPDATE chrome_extension_tokens SET is_revoked = 1 WHERE id = ?", (token_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def list_tokens(self, user_id: str = None, project_id: str = None) -> list[ChromeExtensionToken]:
|
||
"""列出令牌"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
conditions = ["is_revoked = 0"]
|
||
params = []
|
||
|
||
if user_id:
|
||
conditions.append("user_id = ?")
|
||
params.append(user_id)
|
||
if project_id:
|
||
conditions.append("project_id = ?")
|
||
params.append(project_id)
|
||
|
||
where_clause = " AND ".join(conditions)
|
||
|
||
rows = conn.execute(
|
||
f"SELECT * FROM chrome_extension_tokens WHERE {where_clause} ORDER BY created_at DESC", params
|
||
).fetchall()
|
||
conn.close()
|
||
|
||
tokens = []
|
||
for row in rows:
|
||
tokens.append(
|
||
ChromeExtensionToken(
|
||
id=row["id"],
|
||
token="", # 不返回实际令牌
|
||
user_id=row["user_id"],
|
||
project_id=row["project_id"],
|
||
name=row["name"],
|
||
permissions=json.loads(row["permissions"]),
|
||
expires_at=row["expires_at"],
|
||
created_at=row["created_at"],
|
||
last_used_at=row["last_used_at"],
|
||
use_count=row["use_count"],
|
||
is_revoked=bool(row["is_revoked"]),
|
||
)
|
||
)
|
||
|
||
return tokens
|
||
|
||
async def import_webpage(
|
||
self, token: ChromeExtensionToken, url: str, title: str, content: str, html_content: str = None
|
||
) -> dict:
|
||
"""导入网页内容"""
|
||
if not token.project_id:
|
||
return {"success": False, "error": "Token not associated with any project"}
|
||
|
||
if "write" not in token.permissions:
|
||
return {"success": False, "error": "Insufficient permissions"}
|
||
|
||
# 创建转录记录(将网页作为文档处理)
|
||
transcript_id = str(uuid.uuid4())[:8]
|
||
now = datetime.now().isoformat()
|
||
|
||
# 构建完整文本
|
||
full_text = f"# {title}\n\nURL: {url}\n\n{content}"
|
||
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""INSERT INTO transcripts
|
||
(id, project_id, filename, full_text, type, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||
(transcript_id, token.project_id, f"web_{title[:50]}.md", full_text, "webpage", now),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return {
|
||
"success": True,
|
||
"transcript_id": transcript_id,
|
||
"project_id": token.project_id,
|
||
"url": url,
|
||
"title": title,
|
||
"content_length": len(content),
|
||
}
|
||
|
||
|
||
class BotHandler:
|
||
"""飞书/钉钉机器人处理器"""
|
||
|
||
def __init__(self, plugin_manager: PluginManager, bot_type: str):
|
||
self.pm = plugin_manager
|
||
self.bot_type = bot_type
|
||
|
||
def create_session(
|
||
self, session_id: str, session_name: str, project_id: str = None, webhook_url: str = "", secret: str = ""
|
||
) -> BotSession:
|
||
"""创建机器人会话"""
|
||
bot_id = str(uuid.uuid4())[:8]
|
||
now = datetime.now().isoformat()
|
||
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""INSERT INTO bot_sessions
|
||
(id, bot_type, session_id, session_name, project_id, webhook_url, secret,
|
||
is_active, created_at, updated_at, message_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(bot_id, self.bot_type, session_id, session_name, project_id, webhook_url, secret, True, now, now, 0),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return BotSession(
|
||
id=bot_id,
|
||
bot_type=self.bot_type,
|
||
session_id=session_id,
|
||
session_name=session_name,
|
||
project_id=project_id,
|
||
webhook_url=webhook_url,
|
||
secret=secret,
|
||
is_active=True,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
def get_session(self, session_id: str) -> BotSession | None:
|
||
"""获取会话"""
|
||
conn = self.pm.db.get_conn()
|
||
row = conn.execute(
|
||
"""SELECT * FROM bot_sessions
|
||
WHERE session_id = ? AND bot_type = ?""",
|
||
(session_id, self.bot_type),
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
return self._row_to_session(row)
|
||
return None
|
||
|
||
def list_sessions(self, project_id: str = None) -> list[BotSession]:
|
||
"""列出会话"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
if project_id:
|
||
rows = conn.execute(
|
||
"""SELECT * FROM bot_sessions
|
||
WHERE bot_type = ? AND project_id = ? ORDER BY created_at DESC""",
|
||
(self.bot_type, project_id),
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"""SELECT * FROM bot_sessions
|
||
WHERE bot_type = ? ORDER BY created_at DESC""",
|
||
(self.bot_type,),
|
||
).fetchall()
|
||
|
||
conn.close()
|
||
|
||
return [self._row_to_session(row) for row in rows]
|
||
|
||
def update_session(self, session_id: str, **kwargs) -> BotSession | None:
|
||
"""更新会话"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
allowed_fields = ["session_name", "project_id", "webhook_url", "secret", "is_active"]
|
||
updates = []
|
||
values = []
|
||
|
||
for f in allowed_fields:
|
||
if f in kwargs:
|
||
updates.append(f"{f} = ?")
|
||
values.append(kwargs[f])
|
||
|
||
if not updates:
|
||
conn.close()
|
||
return self.get_session(session_id)
|
||
|
||
updates.append("updated_at = ?")
|
||
values.append(datetime.now().isoformat())
|
||
values.append(session_id)
|
||
values.append(self.bot_type)
|
||
|
||
query = f"UPDATE bot_sessions SET {', '.join(updates)} WHERE session_id = ? AND bot_type = ?"
|
||
conn.execute(query, values)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return self.get_session(session_id)
|
||
|
||
def delete_session(self, session_id: str) -> bool:
|
||
"""删除会话"""
|
||
conn = self.pm.db.get_conn()
|
||
cursor = conn.execute(
|
||
"DELETE FROM bot_sessions WHERE session_id = ? AND bot_type = ?", (session_id, self.bot_type)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def _row_to_session(self, row: sqlite3.Row) -> BotSession:
|
||
"""将数据库行转换为 BotSession 对象"""
|
||
return BotSession(
|
||
id=row["id"],
|
||
bot_type=row["bot_type"],
|
||
session_id=row["session_id"],
|
||
session_name=row["session_name"],
|
||
project_id=row["project_id"],
|
||
webhook_url=row["webhook_url"],
|
||
secret=row["secret"],
|
||
is_active=bool(row["is_active"]),
|
||
created_at=row["created_at"],
|
||
updated_at=row["updated_at"],
|
||
last_message_at=row["last_message_at"],
|
||
message_count=row["message_count"],
|
||
)
|
||
|
||
async def handle_message(self, session: BotSession, message: dict) -> dict:
|
||
"""处理收到的消息"""
|
||
now = datetime.now().isoformat()
|
||
|
||
# 更新消息统计
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""UPDATE bot_sessions
|
||
SET message_count = message_count + 1, last_message_at = ?
|
||
WHERE id = ?""",
|
||
(now, session.id),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 处理消息
|
||
msg_type = message.get("msg_type", "text")
|
||
content = message.get("content", {})
|
||
|
||
if msg_type == "text":
|
||
text = content.get("text", "")
|
||
return await self._handle_text_message(session, text, message)
|
||
elif msg_type == "audio":
|
||
# 处理音频消息
|
||
return await self._handle_audio_message(session, message)
|
||
elif msg_type == "file":
|
||
# 处理文件消息
|
||
return await self._handle_file_message(session, message)
|
||
|
||
return {"success": False, "error": "Unsupported message type"}
|
||
|
||
async def _handle_text_message(self, session: BotSession, text: str, raw_message: dict) -> dict:
|
||
"""处理文本消息"""
|
||
# 简单命令处理
|
||
if text.startswith("/help"):
|
||
return {
|
||
"success": True,
|
||
"response": """🤖 InsightFlow 机器人命令:
|
||
/help - 显示帮助
|
||
/status - 查看项目状态
|
||
/analyze <URL> - 分析网页内容
|
||
/search <关键词> - 搜索知识库""",
|
||
}
|
||
|
||
if text.startswith("/status"):
|
||
if not session.project_id:
|
||
return {"success": True, "response": "⚠️ 当前会话未绑定项目"}
|
||
|
||
# 获取项目状态
|
||
summary = self.pm.db.get_project_summary(session.project_id)
|
||
stats = summary.get("statistics", {})
|
||
|
||
return {
|
||
"success": True,
|
||
"response": f"""📊 项目状态:
|
||
实体数量: {stats.get('entity_count', 0)}
|
||
关系数量: {stats.get('relation_count', 0)}
|
||
转录数量: {stats.get('transcript_count', 0)}""",
|
||
}
|
||
|
||
# 默认回复
|
||
return {"success": True, "response": f"收到消息:{text[:100]}...\n\n使用 /help 查看可用命令"}
|
||
|
||
async def _handle_audio_message(self, session: BotSession, message: dict) -> dict:
|
||
"""处理音频消息"""
|
||
if not session.project_id:
|
||
return {"success": False, "error": "Session not bound to any project"}
|
||
|
||
# 下载音频文件
|
||
audio_url = message.get("content", {}).get("download_url")
|
||
if not audio_url:
|
||
return {"success": False, "error": "No audio URL provided"}
|
||
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.get(audio_url)
|
||
audio_data = response.content
|
||
|
||
# 保存音频文件
|
||
filename = f"bot_audio_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp3"
|
||
|
||
# 这里应该调用 ASR 服务进行转录
|
||
# 简化处理,返回提示
|
||
return {
|
||
"success": True,
|
||
"response": "🎵 收到音频文件,正在处理中...\n分析完成后会通知您。",
|
||
"audio_size": len(audio_data),
|
||
"filename": filename,
|
||
}
|
||
|
||
except Exception as e:
|
||
return {"success": False, "error": f"Failed to process audio: {str(e)}"}
|
||
|
||
async def _handle_file_message(self, session: BotSession, message: dict) -> dict:
|
||
"""处理文件消息"""
|
||
return {"success": True, "response": "📎 收到文件,正在处理中..."}
|
||
|
||
async def send_message(self, session: BotSession, message: str, msg_type: str = "text") -> bool:
|
||
"""发送消息到群聊"""
|
||
if not session.webhook_url:
|
||
return False
|
||
|
||
try:
|
||
if self.bot_type == "feishu":
|
||
return await self._send_feishu_message(session, message, msg_type)
|
||
elif self.bot_type == "dingtalk":
|
||
return await self._send_dingtalk_message(session, message, msg_type)
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
print(f"Failed to send {self.bot_type} message: {e}")
|
||
return False
|
||
|
||
async def _send_feishu_message(self, session: BotSession, message: str, msg_type: str) -> bool:
|
||
"""发送飞书消息"""
|
||
import base64
|
||
import hashlib
|
||
|
||
timestamp = str(int(time.time()))
|
||
|
||
# 生成签名
|
||
if session.secret:
|
||
string_to_sign = f"{timestamp}\n{session.secret}"
|
||
hmac_code = hmac.new(
|
||
session.secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
|
||
).digest()
|
||
sign = base64.b64encode(hmac_code).decode("utf-8")
|
||
else:
|
||
sign = ""
|
||
|
||
payload = {"timestamp": timestamp, "sign": sign, "msg_type": "text", "content": {"text": message}}
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(
|
||
session.webhook_url, json=payload, headers={"Content-Type": "application/json"}
|
||
)
|
||
return response.status_code == 200
|
||
|
||
async def _send_dingtalk_message(self, session: BotSession, message: str, msg_type: str) -> bool:
|
||
"""发送钉钉消息"""
|
||
import base64
|
||
import hashlib
|
||
|
||
timestamp = str(round(time.time() * 1000))
|
||
|
||
# 生成签名
|
||
if session.secret:
|
||
string_to_sign = f"{timestamp}\n{session.secret}"
|
||
hmac_code = hmac.new(
|
||
session.secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
|
||
).digest()
|
||
sign = base64.b64encode(hmac_code).decode("utf-8")
|
||
sign = urllib.parse.quote(sign)
|
||
else:
|
||
sign = ""
|
||
|
||
payload = {"msgtype": "text", "text": {"content": message}}
|
||
|
||
url = session.webhook_url
|
||
if sign:
|
||
url = f"{url}×tamp={timestamp}&sign={sign}"
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
|
||
return response.status_code == 200
|
||
|
||
|
||
class WebhookIntegration:
|
||
"""Zapier/Make Webhook 集成"""
|
||
|
||
def __init__(self, plugin_manager: PluginManager, endpoint_type: str):
|
||
self.pm = plugin_manager
|
||
self.endpoint_type = endpoint_type
|
||
|
||
def create_endpoint(
|
||
self,
|
||
name: str,
|
||
endpoint_url: str,
|
||
project_id: str = None,
|
||
auth_type: str = "none",
|
||
auth_config: dict = None,
|
||
trigger_events: list[str] = None,
|
||
) -> WebhookEndpoint:
|
||
"""创建 Webhook 端点"""
|
||
endpoint_id = str(uuid.uuid4())[:8]
|
||
now = datetime.now().isoformat()
|
||
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""INSERT INTO webhook_endpoints
|
||
(id, name, endpoint_type, endpoint_url, project_id, auth_type, auth_config,
|
||
trigger_events, is_active, created_at, updated_at, trigger_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
endpoint_id,
|
||
name,
|
||
self.endpoint_type,
|
||
endpoint_url,
|
||
project_id,
|
||
auth_type,
|
||
json.dumps(auth_config or {}),
|
||
json.dumps(trigger_events or []),
|
||
True,
|
||
now,
|
||
now,
|
||
0,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return WebhookEndpoint(
|
||
id=endpoint_id,
|
||
name=name,
|
||
endpoint_type=self.endpoint_type,
|
||
endpoint_url=endpoint_url,
|
||
project_id=project_id,
|
||
auth_type=auth_type,
|
||
auth_config=auth_config or {},
|
||
trigger_events=trigger_events or [],
|
||
is_active=True,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
def get_endpoint(self, endpoint_id: str) -> WebhookEndpoint | None:
|
||
"""获取端点"""
|
||
conn = self.pm.db.get_conn()
|
||
row = conn.execute(
|
||
"SELECT * FROM webhook_endpoints WHERE id = ? AND endpoint_type = ?", (endpoint_id, self.endpoint_type)
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
return self._row_to_endpoint(row)
|
||
return None
|
||
|
||
def list_endpoints(self, project_id: str = None) -> list[WebhookEndpoint]:
|
||
"""列出端点"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
if project_id:
|
||
rows = conn.execute(
|
||
"""SELECT * FROM webhook_endpoints
|
||
WHERE endpoint_type = ? AND project_id = ? ORDER BY created_at DESC""",
|
||
(self.endpoint_type, project_id),
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"""SELECT * FROM webhook_endpoints
|
||
WHERE endpoint_type = ? ORDER BY created_at DESC""",
|
||
(self.endpoint_type,),
|
||
).fetchall()
|
||
|
||
conn.close()
|
||
|
||
return [self._row_to_endpoint(row) for row in rows]
|
||
|
||
def update_endpoint(self, endpoint_id: str, **kwargs) -> WebhookEndpoint | None:
|
||
"""更新端点"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
allowed_fields = [
|
||
"name",
|
||
"endpoint_url",
|
||
"project_id",
|
||
"auth_type",
|
||
"auth_config",
|
||
"trigger_events",
|
||
"is_active",
|
||
]
|
||
updates = []
|
||
values = []
|
||
|
||
for f in allowed_fields:
|
||
if f in kwargs:
|
||
updates.append(f"{f} = ?")
|
||
if f in ["auth_config", "trigger_events"]:
|
||
values.append(json.dumps(kwargs[f]))
|
||
else:
|
||
values.append(kwargs[f])
|
||
|
||
if not updates:
|
||
conn.close()
|
||
return self.get_endpoint(endpoint_id)
|
||
|
||
updates.append("updated_at = ?")
|
||
values.append(datetime.now().isoformat())
|
||
values.append(endpoint_id)
|
||
|
||
query = f"UPDATE webhook_endpoints SET {', '.join(updates)} WHERE id = ?"
|
||
conn.execute(query, values)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return self.get_endpoint(endpoint_id)
|
||
|
||
def delete_endpoint(self, endpoint_id: str) -> bool:
|
||
"""删除端点"""
|
||
conn = self.pm.db.get_conn()
|
||
cursor = conn.execute("DELETE FROM webhook_endpoints WHERE id = ?", (endpoint_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def _row_to_endpoint(self, row: sqlite3.Row) -> WebhookEndpoint:
|
||
"""将数据库行转换为 WebhookEndpoint 对象"""
|
||
return WebhookEndpoint(
|
||
id=row["id"],
|
||
name=row["name"],
|
||
endpoint_type=row["endpoint_type"],
|
||
endpoint_url=row["endpoint_url"],
|
||
project_id=row["project_id"],
|
||
auth_type=row["auth_type"],
|
||
auth_config=json.loads(row["auth_config"]) if row["auth_config"] else {},
|
||
trigger_events=json.loads(row["trigger_events"]) if row["trigger_events"] else [],
|
||
is_active=bool(row["is_active"]),
|
||
created_at=row["created_at"],
|
||
updated_at=row["updated_at"],
|
||
last_triggered_at=row["last_triggered_at"],
|
||
trigger_count=row["trigger_count"],
|
||
)
|
||
|
||
async def trigger(self, endpoint: WebhookEndpoint, event_type: str, data: dict) -> bool:
|
||
"""触发 Webhook"""
|
||
if not endpoint.is_active:
|
||
return False
|
||
|
||
if event_type not in endpoint.trigger_events:
|
||
return False
|
||
|
||
try:
|
||
headers = {"Content-Type": "application/json"}
|
||
|
||
# 添加认证头
|
||
if endpoint.auth_type == "api_key":
|
||
api_key = endpoint.auth_config.get("api_key", "")
|
||
header_name = endpoint.auth_config.get("header_name", "X-API-Key")
|
||
headers[header_name] = api_key
|
||
elif endpoint.auth_type == "bearer":
|
||
token = endpoint.auth_config.get("token", "")
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
|
||
payload = {"event": event_type, "timestamp": datetime.now().isoformat(), "data": data}
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(endpoint.endpoint_url, json=payload, headers=headers, timeout=30.0)
|
||
|
||
success = response.status_code in [200, 201, 202]
|
||
|
||
# 更新触发统计
|
||
now = datetime.now().isoformat()
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""UPDATE webhook_endpoints
|
||
SET trigger_count = trigger_count + 1, last_triggered_at = ?
|
||
WHERE id = ?""",
|
||
(now, endpoint.id),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
print(f"Failed to trigger webhook: {e}")
|
||
return False
|
||
|
||
async def test_endpoint(self, endpoint: WebhookEndpoint) -> dict:
|
||
"""测试端点"""
|
||
test_data = {
|
||
"message": "This is a test event from InsightFlow",
|
||
"test": True,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}
|
||
|
||
success = await self.trigger(endpoint, "test", test_data)
|
||
|
||
return {
|
||
"success": success,
|
||
"endpoint_id": endpoint.id,
|
||
"endpoint_url": endpoint.endpoint_url,
|
||
"message": "Test event sent successfully" if success else "Failed to send test event",
|
||
}
|
||
|
||
|
||
class WebDAVSyncManager:
|
||
"""WebDAV 同步管理"""
|
||
|
||
def __init__(self, plugin_manager: PluginManager):
|
||
self.pm = plugin_manager
|
||
|
||
def create_sync(
|
||
self,
|
||
name: str,
|
||
project_id: str,
|
||
server_url: str,
|
||
username: str,
|
||
password: str,
|
||
remote_path: str = "/insightflow",
|
||
sync_mode: str = "bidirectional",
|
||
sync_interval: int = 3600,
|
||
) -> WebDAVSync:
|
||
"""创建 WebDAV 同步配置"""
|
||
sync_id = str(uuid.uuid4())[:8]
|
||
now = datetime.now().isoformat()
|
||
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""INSERT INTO webdav_syncs
|
||
(id, name, project_id, server_url, username, password, remote_path,
|
||
sync_mode, sync_interval, last_sync_status, is_active, created_at, updated_at, sync_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
sync_id,
|
||
name,
|
||
project_id,
|
||
server_url,
|
||
username,
|
||
password,
|
||
remote_path,
|
||
sync_mode,
|
||
sync_interval,
|
||
"pending",
|
||
True,
|
||
now,
|
||
now,
|
||
0,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return WebDAVSync(
|
||
id=sync_id,
|
||
name=name,
|
||
project_id=project_id,
|
||
server_url=server_url,
|
||
username=username,
|
||
password=password,
|
||
remote_path=remote_path,
|
||
sync_mode=sync_mode,
|
||
sync_interval=sync_interval,
|
||
last_sync_status="pending",
|
||
is_active=True,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
def get_sync(self, sync_id: str) -> WebDAVSync | None:
|
||
"""获取同步配置"""
|
||
conn = self.pm.db.get_conn()
|
||
row = conn.execute("SELECT * FROM webdav_syncs WHERE id = ?", (sync_id,)).fetchone()
|
||
conn.close()
|
||
|
||
if row:
|
||
return self._row_to_sync(row)
|
||
return None
|
||
|
||
def list_syncs(self, project_id: str = None) -> list[WebDAVSync]:
|
||
"""列出同步配置"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
if project_id:
|
||
rows = conn.execute(
|
||
"SELECT * FROM webdav_syncs WHERE project_id = ? ORDER BY created_at DESC", (project_id,)
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute("SELECT * FROM webdav_syncs ORDER BY created_at DESC").fetchall()
|
||
|
||
conn.close()
|
||
|
||
return [self._row_to_sync(row) for row in rows]
|
||
|
||
def update_sync(self, sync_id: str, **kwargs) -> WebDAVSync | None:
|
||
"""更新同步配置"""
|
||
conn = self.pm.db.get_conn()
|
||
|
||
allowed_fields = [
|
||
"name",
|
||
"server_url",
|
||
"username",
|
||
"password",
|
||
"remote_path",
|
||
"sync_mode",
|
||
"sync_interval",
|
||
"is_active",
|
||
]
|
||
updates = []
|
||
values = []
|
||
|
||
for f in allowed_fields:
|
||
if f in kwargs:
|
||
updates.append(f"{f} = ?")
|
||
values.append(kwargs[f])
|
||
|
||
if not updates:
|
||
conn.close()
|
||
return self.get_sync(sync_id)
|
||
|
||
updates.append("updated_at = ?")
|
||
values.append(datetime.now().isoformat())
|
||
values.append(sync_id)
|
||
|
||
query = f"UPDATE webdav_syncs SET {', '.join(updates)} WHERE id = ?"
|
||
conn.execute(query, values)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return self.get_sync(sync_id)
|
||
|
||
def delete_sync(self, sync_id: str) -> bool:
|
||
"""删除同步配置"""
|
||
conn = self.pm.db.get_conn()
|
||
cursor = conn.execute("DELETE FROM webdav_syncs WHERE id = ?", (sync_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return cursor.rowcount > 0
|
||
|
||
def _row_to_sync(self, row: sqlite3.Row) -> WebDAVSync:
|
||
"""将数据库行转换为 WebDAVSync 对象"""
|
||
return WebDAVSync(
|
||
id=row["id"],
|
||
name=row["name"],
|
||
project_id=row["project_id"],
|
||
server_url=row["server_url"],
|
||
username=row["username"],
|
||
password=row["password"],
|
||
remote_path=row["remote_path"],
|
||
sync_mode=row["sync_mode"],
|
||
sync_interval=row["sync_interval"],
|
||
last_sync_at=row["last_sync_at"],
|
||
last_sync_status=row["last_sync_status"],
|
||
last_sync_error=row["last_sync_error"] or "",
|
||
is_active=bool(row["is_active"]),
|
||
created_at=row["created_at"],
|
||
updated_at=row["updated_at"],
|
||
sync_count=row["sync_count"],
|
||
)
|
||
|
||
async def test_connection(self, sync: WebDAVSync) -> dict:
|
||
"""测试 WebDAV 连接"""
|
||
if not WEBDAV_AVAILABLE:
|
||
return {"success": False, "error": "WebDAV library not available"}
|
||
|
||
try:
|
||
client = webdav_client.Client(sync.server_url, auth=(sync.username, sync.password))
|
||
|
||
# 尝试列出根目录
|
||
client.list("/")
|
||
|
||
return {"success": True, "message": "Connection successful"}
|
||
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def sync_project(self, sync: WebDAVSync) -> dict:
|
||
"""同步项目到 WebDAV"""
|
||
if not WEBDAV_AVAILABLE:
|
||
return {"success": False, "error": "WebDAV library not available"}
|
||
|
||
if not sync.is_active:
|
||
return {"success": False, "error": "Sync is not active"}
|
||
|
||
try:
|
||
client = webdav_client.Client(sync.server_url, auth=(sync.username, sync.password))
|
||
|
||
# 确保远程目录存在
|
||
remote_project_path = f"{sync.remote_path}/{sync.project_id}"
|
||
try:
|
||
client.mkdir(remote_project_path)
|
||
except BaseException:
|
||
pass # 目录可能已存在
|
||
|
||
# 获取项目数据
|
||
project = self.pm.db.get_project(sync.project_id)
|
||
if not project:
|
||
return {"success": False, "error": "Project not found"}
|
||
|
||
# 导出项目数据为 JSON
|
||
entities = self.pm.db.list_project_entities(sync.project_id)
|
||
relations = self.pm.db.list_project_relations(sync.project_id)
|
||
transcripts = self.pm.db.list_project_transcripts(sync.project_id)
|
||
|
||
export_data = {
|
||
"project": {"id": project.id, "name": project.name, "description": project.description},
|
||
"entities": [{"id": e.id, "name": e.name, "type": e.type} for e in entities],
|
||
"relations": relations,
|
||
"transcripts": [{"id": t["id"], "filename": t["filename"]} for t in transcripts],
|
||
"exported_at": datetime.now().isoformat(),
|
||
}
|
||
|
||
# 上传 JSON 文件
|
||
json_content = json.dumps(export_data, ensure_ascii=False, indent=2)
|
||
json_path = f"{remote_project_path}/project_export.json"
|
||
|
||
# 使用临时文件上传
|
||
import tempfile
|
||
|
||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||
f.write(json_content)
|
||
temp_path = f.name
|
||
|
||
client.upload_file(temp_path, json_path)
|
||
os.unlink(temp_path)
|
||
|
||
# 更新同步状态
|
||
now = datetime.now().isoformat()
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""UPDATE webdav_syncs
|
||
SET last_sync_at = ?, last_sync_status = ?, sync_count = sync_count + 1
|
||
WHERE id = ?""",
|
||
(now, "success", sync.id),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "Project synced successfully",
|
||
"entities_count": len(entities),
|
||
"relations_count": len(relations),
|
||
"remote_path": json_path,
|
||
}
|
||
|
||
except Exception as e:
|
||
# 更新失败状态
|
||
conn = self.pm.db.get_conn()
|
||
conn.execute(
|
||
"""UPDATE webdav_syncs
|
||
SET last_sync_status = ?, last_sync_error = ?
|
||
WHERE id = ?""",
|
||
("failed", str(e), sync.id),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
# Singleton instance
|
||
_plugin_manager = None
|
||
|
||
|
||
def get_plugin_manager(db_manager=None):
|
||
"""获取 PluginManager 单例"""
|
||
global _plugin_manager
|
||
if _plugin_manager is None:
|
||
_plugin_manager = PluginManager(db_manager)
|
||
return _plugin_manager
|