feat: 飞书群自动发现与可视化绑定
- FeishuService 新增 list_bot_chats() 拉取机器人所在的所有群(含群名、chat_id) - 新增 GET /api/tenants/feishu-groups 端点,返回群列表并标注每个群当前绑定的租户 - 租户编辑弹窗改为可视化群列表(checkbox 勾选绑定),替代手动填 chat_id - 已绑定其他租户的群显示为禁用状态,防止重复绑定 - 编辑租户时自动加载群列表 - 新建租户时可手动刷新群列表
This commit is contained in:
@@ -128,3 +128,49 @@ class FeishuService:
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"回复消息时发生网络错误: {e}")
|
||||
return False
|
||||
|
||||
def list_bot_chats(self) -> list:
|
||||
"""
|
||||
获取机器人所在的所有群列表。
|
||||
返回 [{"chat_id": "oc_xxx", "name": "群名", "chat_type": "group"}]
|
||||
"""
|
||||
token = self._get_tenant_access_token()
|
||||
if not token:
|
||||
return []
|
||||
|
||||
all_chats = []
|
||||
page_token = ""
|
||||
while True:
|
||||
url = f"{self.BASE_URL}/im/v1/chats?page_size=100"
|
||||
if page_token:
|
||||
url += f"&page_token={page_token}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"获取群列表失败: {data.get('msg')}")
|
||||
break
|
||||
|
||||
items = data.get("data", {}).get("items", [])
|
||||
for item in items:
|
||||
all_chats.append({
|
||||
"chat_id": item.get("chat_id", ""),
|
||||
"name": item.get("name", "未命名群"),
|
||||
"description": item.get("description", ""),
|
||||
"chat_type": item.get("chat_type", ""),
|
||||
"owner_id": item.get("owner_id", ""),
|
||||
})
|
||||
|
||||
if not data.get("data", {}).get("has_more", False):
|
||||
break
|
||||
page_token = data.get("data", {}).get("page_token", "")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"获取群列表网络错误: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"获取到 {len(all_chats)} 个机器人所在的群")
|
||||
return all_chats
|
||||
|
||||
@@ -43,6 +43,38 @@ def list_tenants():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@tenants_bp.route('/feishu-groups', methods=['GET'])
|
||||
def list_feishu_groups():
|
||||
"""获取机器人所在的所有飞书群,并标注每个群当前绑定的租户"""
|
||||
try:
|
||||
from src.integrations.feishu_service import FeishuService
|
||||
feishu = FeishuService()
|
||||
groups = feishu.list_bot_chats()
|
||||
|
||||
# 构建 chat_id → tenant_id 映射
|
||||
bound_map = {}
|
||||
with db_manager.get_session() as session:
|
||||
tenants = session.query(Tenant).filter(Tenant.is_active == True).all()
|
||||
for t in tenants:
|
||||
if not t.config:
|
||||
continue
|
||||
try:
|
||||
cfg = json.loads(t.config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for cid in cfg.get('feishu', {}).get('chat_groups', []):
|
||||
bound_map[cid] = t.tenant_id
|
||||
|
||||
# 给每个群标注绑定状态
|
||||
for g in groups:
|
||||
g['bound_tenant_id'] = bound_map.get(g['chat_id'], None)
|
||||
|
||||
return jsonify({"success": True, "groups": groups})
|
||||
except Exception as e:
|
||||
logger.error(f"获取飞书群列表失败: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@tenants_bp.route('', methods=['POST'])
|
||||
def create_tenant():
|
||||
"""创建新租户"""
|
||||
|
||||
@@ -4,16 +4,13 @@ Object.assign(TSPDashboard.prototype, {
|
||||
const container = document.getElementById('tenant-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tenants');
|
||||
const tenants = await response.json();
|
||||
|
||||
if (!Array.isArray(tenants) || tenants.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-4 text-muted">暂无租户,请点击"新建租户"创建</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tenants.map(t => {
|
||||
const chatGroups = t.config?.feishu?.chat_groups || [];
|
||||
return `
|
||||
@@ -23,21 +20,15 @@ Object.assign(TSPDashboard.prototype, {
|
||||
<strong>${t.name}</strong>
|
||||
<span class="text-muted ms-2">(${t.tenant_id})</span>
|
||||
${t.description ? `<br><small class="text-muted">${t.description}</small>` : ''}
|
||||
${!t.is_active ? '<span class="badge bg-secondary ms-2">已禁用</span>' : ''}
|
||||
${chatGroups.length > 0 ? `<span class="badge bg-info ms-2"><i class="fas fa-comments me-1"></i>${chatGroups.length} 个飞书群</span>` : '<span class="badge bg-light text-muted ms-2">未绑定飞书群</span>'}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="dashboard.showEditTenantModal('${t.tenant_id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
${t.tenant_id !== 'default' ? `
|
||||
<button class="btn btn-outline-danger" onclick="dashboard.deleteTenant('${t.tenant_id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>` : ''}
|
||||
<button class="btn btn-outline-primary" onclick="dashboard.showEditTenantModal('${t.tenant_id}')"><i class="fas fa-edit"></i></button>
|
||||
${t.tenant_id !== 'default' ? `<button class="btn btn-outline-danger" onclick="dashboard.deleteTenant('${t.tenant_id}')"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('加载租户列表失败:', error);
|
||||
container.innerHTML = '<div class="text-center py-4 text-danger">加载失败</div>';
|
||||
@@ -53,6 +44,7 @@ Object.assign(TSPDashboard.prototype, {
|
||||
document.getElementById('tenant-name-input').value = '';
|
||||
document.getElementById('tenant-desc-input').value = '';
|
||||
document.getElementById('tenant-feishu-chatgroups').value = '';
|
||||
document.getElementById('feishu-groups-list').innerHTML = '<small class="text-muted">点击"刷新群列表"从飞书拉取机器人所在的群</small>';
|
||||
new bootstrap.Modal(document.getElementById('tenantModal')).show();
|
||||
},
|
||||
|
||||
@@ -62,8 +54,6 @@ Object.assign(TSPDashboard.prototype, {
|
||||
document.getElementById('tenant-id-input').value = tenantId;
|
||||
document.getElementById('tenant-id-input').disabled = true;
|
||||
document.getElementById('tenant-id-group').style.display = 'none';
|
||||
|
||||
// 加载租户完整数据
|
||||
try {
|
||||
const resp = await fetch('/api/tenants');
|
||||
const tenants = await resp.json();
|
||||
@@ -71,14 +61,64 @@ Object.assign(TSPDashboard.prototype, {
|
||||
if (tenant) {
|
||||
document.getElementById('tenant-name-input').value = tenant.name || '';
|
||||
document.getElementById('tenant-desc-input').value = tenant.description || '';
|
||||
const chatGroups = tenant.config?.feishu?.chat_groups || [];
|
||||
document.getElementById('tenant-feishu-chatgroups').value = chatGroups.join('\n');
|
||||
document.getElementById('tenant-feishu-chatgroups').value = (tenant.config?.feishu?.chat_groups || []).join('\n');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载租户数据失败:', e);
|
||||
}
|
||||
|
||||
} catch (e) { console.warn('加载租户数据失败:', e); }
|
||||
// 自动加载飞书群列表
|
||||
document.getElementById('feishu-groups-list').innerHTML = '<small class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>加载群列表中...</small>';
|
||||
new bootstrap.Modal(document.getElementById('tenantModal')).show();
|
||||
this.loadFeishuGroups();
|
||||
},
|
||||
|
||||
async loadFeishuGroups() {
|
||||
const container = document.getElementById('feishu-groups-list');
|
||||
const currentTenantId = document.getElementById('tenant-edit-id').value || document.getElementById('tenant-id-input').value.trim();
|
||||
const boundRaw = document.getElementById('tenant-feishu-chatgroups').value.trim();
|
||||
const boundSet = new Set(boundRaw ? boundRaw.split('\n').map(s => s.trim()).filter(Boolean) : []);
|
||||
|
||||
container.innerHTML = '<small class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>正在从飞书拉取群列表...</small>';
|
||||
try {
|
||||
const resp = await fetch('/api/tenants/feishu-groups');
|
||||
const data = await resp.json();
|
||||
if (!data.success || !data.groups || data.groups.length === 0) {
|
||||
container.innerHTML = '<small class="text-muted">未找到机器人所在的群,请确认飞书配置正确</small>';
|
||||
return;
|
||||
}
|
||||
const groups = data.groups;
|
||||
container.innerHTML = `
|
||||
<div class="list-group" style="max-height: 250px; overflow-y: auto;">
|
||||
${groups.map(g => {
|
||||
const isBoundHere = boundSet.has(g.chat_id);
|
||||
const isBoundElsewhere = g.bound_tenant_id && g.bound_tenant_id !== currentTenantId;
|
||||
const checked = isBoundHere ? 'checked' : '';
|
||||
const disabled = isBoundElsewhere ? 'disabled' : '';
|
||||
const badge = isBoundElsewhere
|
||||
? `<span class="badge bg-warning text-dark ms-2">已绑定: ${g.bound_tenant_id}</span>`
|
||||
: isBoundHere
|
||||
? '<span class="badge bg-success ms-2">已绑定</span>'
|
||||
: '';
|
||||
return `
|
||||
<label class="list-group-item list-group-item-action d-flex align-items-center py-2 ${isBoundElsewhere ? 'text-muted' : ''}">
|
||||
<input type="checkbox" class="form-check-input me-2 feishu-group-cb" value="${g.chat_id}" ${checked} ${disabled}>
|
||||
<div class="flex-grow-1">
|
||||
<div><strong>${g.name}</strong>${badge}</div>
|
||||
<small class="text-muted">${g.chat_id}</small>
|
||||
${g.description ? `<br><small class="text-muted">${g.description}</small>` : ''}
|
||||
</div>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<small class="text-muted mt-1 d-block">勾选要绑定到此租户的群,已绑定其他租户的群不可重复绑定</small>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('加载飞书群列表失败:', error);
|
||||
container.innerHTML = '<small class="text-danger">加载飞书群列表失败: ' + error.message + '</small>';
|
||||
}
|
||||
},
|
||||
|
||||
_getSelectedChatGroups() {
|
||||
const checkboxes = document.querySelectorAll('.feishu-group-cb:checked:not(:disabled)');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
},
|
||||
|
||||
async saveTenant() {
|
||||
@@ -87,11 +127,14 @@ Object.assign(TSPDashboard.prototype, {
|
||||
const name = document.getElementById('tenant-name-input').value.trim();
|
||||
const description = document.getElementById('tenant-desc-input').value.trim();
|
||||
|
||||
// 飞书群绑定
|
||||
const chatGroupsText = document.getElementById('tenant-feishu-chatgroups').value.trim();
|
||||
const chatGroups = chatGroupsText ? chatGroupsText.split('\n').map(s => s.trim()).filter(Boolean) : [];
|
||||
// 从勾选的 checkbox 获取群列表,如果群列表 UI 没加载则回退到 hidden input
|
||||
let chatGroups = this._getSelectedChatGroups();
|
||||
if (chatGroups.length === 0) {
|
||||
const raw = document.getElementById('tenant-feishu-chatgroups').value.trim();
|
||||
chatGroups = raw ? raw.split('\n').map(s => s.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
// 构建 config,保留已有的非 feishu 配置
|
||||
// 保留已有配置
|
||||
let existingConfig = {};
|
||||
if (editId) {
|
||||
try {
|
||||
@@ -104,12 +147,9 @@ Object.assign(TSPDashboard.prototype, {
|
||||
const config = { ...existingConfig };
|
||||
if (chatGroups.length > 0) {
|
||||
config.feishu = { ...(config.feishu || {}), chat_groups: chatGroups };
|
||||
} else {
|
||||
// 清空飞书群绑定
|
||||
if (config.feishu) {
|
||||
delete config.feishu.chat_groups;
|
||||
if (Object.keys(config.feishu).length === 0) delete config.feishu;
|
||||
}
|
||||
} else if (config.feishu) {
|
||||
delete config.feishu.chat_groups;
|
||||
if (Object.keys(config.feishu).length === 0) delete config.feishu;
|
||||
}
|
||||
|
||||
if (!name) { this.showNotification('租户名称不能为空', 'error'); return; }
|
||||
@@ -118,28 +158,23 @@ Object.assign(TSPDashboard.prototype, {
|
||||
let response;
|
||||
if (editId) {
|
||||
response = await fetch(`/api/tenants/${encodeURIComponent(editId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, config })
|
||||
});
|
||||
} else {
|
||||
if (!tenantId) { this.showNotification('租户标识不能为空', 'error'); return; }
|
||||
response = await fetch('/api/tenants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenantId, name, description, config })
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('tenantModal')).hide();
|
||||
this.showNotification(editId ? '租户已更新' : '租户已创建', 'success');
|
||||
this.loadTenantList();
|
||||
this.populateTenantSelectors();
|
||||
} else {
|
||||
this.showNotification(data.error || '操作失败', 'error');
|
||||
}
|
||||
} else { this.showNotification(data.error || '操作失败', 'error'); }
|
||||
} catch (error) {
|
||||
console.error('保存租户失败:', error);
|
||||
this.showNotification('保存租户失败: ' + error.message, 'error');
|
||||
@@ -147,7 +182,7 @@ Object.assign(TSPDashboard.prototype, {
|
||||
},
|
||||
|
||||
async deleteTenant(tenantId) {
|
||||
if (!confirm(`确定要删除租户 "${tenantId}" 吗?该操作不会删除关联数据。`)) return;
|
||||
if (!confirm(`确定要删除租户 "${tenantId}" 吗?`)) return;
|
||||
try {
|
||||
const response = await fetch(`/api/tenants/${encodeURIComponent(tenantId)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
@@ -155,9 +190,7 @@ Object.assign(TSPDashboard.prototype, {
|
||||
this.showNotification('租户已删除', 'success');
|
||||
this.loadTenantList();
|
||||
this.populateTenantSelectors();
|
||||
} else {
|
||||
this.showNotification(data.error || '删除失败', 'error');
|
||||
}
|
||||
} else { this.showNotification(data.error || '删除失败', 'error'); }
|
||||
} catch (error) {
|
||||
console.error('删除租户失败:', error);
|
||||
this.showNotification('删除租户失败: ' + error.message, 'error');
|
||||
|
||||
@@ -2266,11 +2266,12 @@
|
||||
<textarea class="form-control" id="tenant-desc-input" rows="2" placeholder="可选"></textarea>
|
||||
</div>
|
||||
<hr>
|
||||
<h6 class="text-muted">飞书群绑定</h6>
|
||||
<h6 class="text-muted">飞书群绑定 <button class="btn btn-outline-primary btn-sm ms-2" onclick="dashboard.loadFeishuGroups()"><i class="fas fa-sync-alt me-1"></i>刷新群列表</button></h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">绑定的飞书群 Chat ID(每行一个)</label>
|
||||
<textarea class="form-control" id="tenant-feishu-chatgroups" rows="3" placeholder="oc_xxxxxxxx oc_yyyyyyyy"></textarea>
|
||||
<div class="form-text">将飞书群绑定到此租户,机器人在该群收到消息时自动使用此租户的知识库。Chat ID 可从机器人日志中获取。</div>
|
||||
<div id="feishu-groups-list">
|
||||
<small class="text-muted">点击"刷新群列表"从飞书拉取机器人所在的群</small>
|
||||
</div>
|
||||
<input type="hidden" id="tenant-feishu-chatgroups">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
Reference in New Issue
Block a user