feat: 飞书群自动发现与可视化绑定

- FeishuService 新增 list_bot_chats() 拉取机器人所在的所有群(含群名、chat_id)
- 新增 GET /api/tenants/feishu-groups 端点,返回群列表并标注每个群当前绑定的租户
- 租户编辑弹窗改为可视化群列表(checkbox 勾选绑定),替代手动填 chat_id
- 已绑定其他租户的群显示为禁用状态,防止重复绑定
- 编辑租户时自动加载群列表
- 新建租户时可手动刷新群列表
This commit is contained in:
2026-04-02 15:30:02 +08:00
parent 28e90d2182
commit 88a79d1936
4 changed files with 159 additions and 47 deletions

View File

@@ -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

View File

@@ -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():
"""创建新租户"""

View File

@@ -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');

View File

@@ -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&#10;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">