From 929459fd33c44d33293ebab154a94de96fc09890 Mon Sep 17 00:00:00 2001 From: flying-hero <462087392@qq.com> Date: Sat, 4 Apr 2026 12:19:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=94=20=E9=A3=9E=E8=A1=8C=E4=BE=A0?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=EF=BC=9A=E5=AE=9E=E4=BE=8B=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=20+=20Webhook=20=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - instances 应用:OpenClaw 实例管理 - Instance 模型:实例注册,Agent 列表,Webhook URL - MeetingInstanceMap:会议 - 实例映射 - Webhook 推送:消息发送时自动通知相关实例 API 端点: - POST /api/v1/instances/register/ - 实例注册 - POST /api/v1/instances/join-meeting/ - 加入会议 - GET /api/v1/instances/ - 实例列表 - POST /api/v1/instances/webhook-test/ - Webhook 测试 集成: - send_message API 自动触发 Webhook 推送 - 支持广播和定向推送 测试: - test_webhook.py: 完整测试流程 使用场景: 1. 每台 OpenClaw 机器注册实例 2. Agent 加入会议时关联实例 3. 消息发送时推送到对应机器 4. 本机 OpenClaw 收到通知,触发 Agent 响应 --- backend/instances/__init__.py | 1 + backend/instances/admin.py | 15 ++ backend/instances/apps.py | 7 + backend/instances/migrations/0001_initial.py | 110 ++++++++++++++ backend/instances/migrations/__init__.py | 0 backend/instances/models.py | 65 ++++++++ backend/instances/views.py | 118 +++++++++++++++ backend/instances/webhook.py | 147 +++++++++++++++++++ backend/meeting_room/settings.py | 1 + backend/meeting_room/urls.py | 5 + backend/meetings/views.py | 20 +++ backend/test_webhook.py | 85 +++++++++++ 12 files changed, 574 insertions(+) create mode 100644 backend/instances/__init__.py create mode 100644 backend/instances/admin.py create mode 100644 backend/instances/apps.py create mode 100644 backend/instances/migrations/0001_initial.py create mode 100644 backend/instances/migrations/__init__.py create mode 100644 backend/instances/models.py create mode 100644 backend/instances/views.py create mode 100644 backend/instances/webhook.py create mode 100644 backend/test_webhook.py diff --git a/backend/instances/__init__.py b/backend/instances/__init__.py new file mode 100644 index 00000000..bf9df051 --- /dev/null +++ b/backend/instances/__init__.py @@ -0,0 +1 @@ +default_app_config = 'instances.apps.InstancesConfig' diff --git a/backend/instances/admin.py b/backend/instances/admin.py new file mode 100644 index 00000000..4a1c4a84 --- /dev/null +++ b/backend/instances/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from .models import Instance, MeetingInstanceMap + + +@admin.register(Instance) +class InstanceAdmin(admin.ModelAdmin): + list_display = ['instance_id', 'instance_name', 'agent_ids', 'webhook_url', 'is_active', 'last_heartbeat'] + list_filter = ['is_active', 'webhook_enabled'] + search_fields = ['instance_id', 'instance_name'] + + +@admin.register(MeetingInstanceMap) +class MeetingInstanceMapAdmin(admin.ModelAdmin): + list_display = ['meeting_id', 'instance', 'agent_ids', 'joined_at', 'left_at'] + list_filter = ['left_at'] diff --git a/backend/instances/apps.py b/backend/instances/apps.py new file mode 100644 index 00000000..5816dd33 --- /dev/null +++ b/backend/instances/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class InstancesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'instances' + verbose_name = 'OpenClaw 实例管理' diff --git a/backend/instances/migrations/0001_initial.py b/backend/instances/migrations/0001_initial.py new file mode 100644 index 00000000..77e50537 --- /dev/null +++ b/backend/instances/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 6.0.3 on 2026-04-04 04:02 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Instance", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "instance_id", + models.CharField( + max_length=100, unique=True, verbose_name="实例 ID" + ), + ), + ( + "instance_name", + models.CharField(max_length=200, verbose_name="实例名称"), + ), + ( + "agent_ids", + models.JSONField(default=list, verbose_name="Agent ID 列表"), + ), + ( + "webhook_url", + models.URLField( + help_text="消息推送地址", verbose_name="Webhook URL" + ), + ), + ( + "webhook_enabled", + models.BooleanField(default=True, verbose_name="启用 Webhook"), + ), + ( + "is_active", + models.BooleanField(default=True, verbose_name="是否活跃"), + ), + ( + "last_heartbeat", + models.DateTimeField( + blank=True, null=True, verbose_name="最后心跳" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "OpenClaw 实例", + "verbose_name_plural": "OpenClaw 实例", + "db_table": "instances", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="MeetingInstanceMap", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("meeting_id", models.UUIDField(verbose_name="会议 ID")), + ( + "agent_ids", + models.JSONField(default=list, verbose_name="参与的 Agent ID 列表"), + ), + ("joined_at", models.DateTimeField(auto_now_add=True)), + ("left_at", models.DateTimeField(blank=True, null=True)), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="meetings", + to="instances.instance", + ), + ), + ], + options={ + "db_table": "meeting_instance_maps", + "indexes": [ + models.Index( + fields=["meeting_id", "left_at"], + name="meeting_ins_meeting_947179_idx", + ) + ], + "unique_together": {("meeting_id", "instance")}, + }, + ), + ] diff --git a/backend/instances/migrations/__init__.py b/backend/instances/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/instances/models.py b/backend/instances/models.py new file mode 100644 index 00000000..6b1ad93e --- /dev/null +++ b/backend/instances/models.py @@ -0,0 +1,65 @@ +from django.db import models +import uuid + + +class Instance(models.Model): + """ + OpenClaw 实例注册 + + 每台运行 OpenClaw 的机器注册一个实例,关联多个 Agent + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + instance_id = models.CharField(max_length=100, unique=True, verbose_name='实例 ID') + instance_name = models.CharField(max_length=200, verbose_name='实例名称') + + # Agent 列表(JSON 存储) + agent_ids = models.JSONField(default=list, verbose_name='Agent ID 列表') + + # Webhook 配置 + webhook_url = models.URLField(verbose_name='Webhook URL', help_text='消息推送地址') + webhook_enabled = models.BooleanField(default=True, verbose_name='启用 Webhook') + + # 状态 + is_active = models.BooleanField(default=True, verbose_name='是否活跃') + last_heartbeat = models.DateTimeField(null=True, blank=True, verbose_name='最后心跳') + + # 元数据 + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'instances' + verbose_name = 'OpenClaw 实例' + verbose_name_plural = 'OpenClaw 实例' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.instance_name} ({self.instance_id})" + + def has_agent(self, agent_id: str) -> bool: + """检查是否包含某个 Agent""" + return agent_id in self.agent_ids + + +class MeetingInstanceMap(models.Model): + """ + 会议 - 实例映射 + + 记录哪些实例参与了哪些会议 + """ + meeting_id = models.UUIDField(verbose_name='会议 ID') + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name='meetings') + agent_ids = models.JSONField(default=list, verbose_name='参与的 Agent ID 列表') + + joined_at = models.DateTimeField(auto_now_add=True) + left_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = 'meeting_instance_maps' + unique_together = ['meeting_id', 'instance'] + indexes = [ + models.Index(fields=['meeting_id', 'left_at']), + ] + + def __str__(self): + return f"Meeting {self.meeting_id} - {self.instance.instance_name}" diff --git a/backend/instances/views.py b/backend/instances/views.py new file mode 100644 index 00000000..de9815b0 --- /dev/null +++ b/backend/instances/views.py @@ -0,0 +1,118 @@ +from rest_framework import serializers, status, views +from rest_framework.response import Response +from rest_framework.decorators import api_view +from .models import Instance, MeetingInstanceMap +from .webhook import register_instance, join_meeting +import logging + +logger = logging.getLogger(__name__) + + +class InstanceRegisterSerializer(serializers.Serializer): + instance_id = serializers.CharField(max_length=100) + instance_name = serializers.CharField(max_length=200) + agent_ids = serializers.ListField(child=serializers.CharField()) + webhook_url = serializers.URLField() + + +class InstanceRegisterView(views.APIView): + """ + 实例注册接口 + + POST /api/v1/instances/register/ + { + "instance_id": "phospher-openclaw", + "instance_name": "飞行侠的 OpenClaw", + "agent_ids": ["flying_hero", "lobster_monitor"], + "webhook_url": "http://192.168.1.100:8888/meeting-notify" + } + """ + def post(self, request): + serializer = InstanceRegisterSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + instance = register_instance( + instance_id=serializer.validated_data['instance_id'], + instance_name=serializer.validated_data['instance_name'], + agent_ids=serializer.validated_data['agent_ids'], + webhook_url=serializer.validated_data['webhook_url'] + ) + + return Response({ + 'status': 'success', + 'instance_id': str(instance.id), + 'message': f'实例 {instance.instance_name} 注册成功' + }) + except Exception as e: + logger.error(f"注册失败:{e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class MeetingJoinSerializer(serializers.Serializer): + instance_id = serializers.CharField(max_length=100) + meeting_id = serializers.UUIDField() + agent_ids = serializers.ListField(child=serializers.CharField()) + + +class MeetingJoinView(views.APIView): + """ + 实例加入会议 + + POST /api/v1/instances/join-meeting/ + { + "instance_id": "phospher-openclaw", + "meeting_id": "xxx-xxx-xxx", + "agent_ids": ["flying_hero"] + } + """ + def post(self, request): + serializer = MeetingJoinSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + join_meeting( + instance_id=serializer.validated_data['instance_id'], + meeting_id=serializer.validated_data['meeting_id'], + agent_ids=serializer.validated_data['agent_ids'] + ) + + return Response({'status': 'success', 'message': '已加入会议'}) + except Instance.DoesNotExist: + return Response({'error': '实例不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"加入会议失败:{e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class InstanceListView(views.APIView): + """ + 获取实例列表 + + GET /api/v1/instances/ + """ + def get(self, request): + instances = Instance.objects.filter(is_active=True) + data = [{ + 'id': str(i.id), + 'instance_id': i.instance_id, + 'instance_name': i.instance_name, + 'agent_ids': i.agent_ids, + 'webhook_url': i.webhook_url, + 'last_heartbeat': i.last_heartbeat.isoformat() if i.last_heartbeat else None + } for i in instances] + + return Response(data) + + +class WebhookNotifyView(views.APIView): + """ + Webhook 通知接收(测试用) + + POST /api/v1/instances/webhook-test/ + """ + def post(self, request): + logger.info(f"📬 收到 Webhook 通知:{request.data}") + return Response({'status': 'received'}) diff --git a/backend/instances/webhook.py b/backend/instances/webhook.py new file mode 100644 index 00000000..17d94e60 --- /dev/null +++ b/backend/instances/webhook.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Webhook 推送工具 +""" + +import requests +import logging +from typing import Dict, List, Optional +from .models import Instance, MeetingInstanceMap + +logger = logging.getLogger(__name__) + + +def push_message_to_instances(meeting_id: str, message: Dict, target_agent_ids: Optional[List[str]] = None): + """ + 推送消息到相关实例 + + Args: + meeting_id: 会议 ID + message: 消息数据(字典) + target_agent_ids: 目标 Agent ID 列表(None 表示广播给所有) + """ + # 找到参与该会议的实例 + maps = MeetingInstanceMap.objects.filter( + meeting_id=meeting_id, + left_at__isnull=True, + instance__is_active=True, + instance__webhook_enabled=True + ).select_related('instance') + + for mapping in maps: + instance = mapping.instance + + # 确定要推送的 Agent + if target_agent_ids: + # 只推送给指定的 Agent + agents_to_notify = [aid for aid in mapping.agent_ids if aid in target_agent_ids] + else: + # 广播给所有 + agents_to_notify = mapping.agent_ids + + if not agents_to_notify: + continue + + # 构建推送数据 + payload = { + 'event': 'new_message', + 'meeting_id': str(meeting_id), + 'message': message, + 'target_agents': agents_to_notify, + 'timestamp': message.get('created_at', '') + } + + # 发送 webhook + try: + response = requests.post( + instance.webhook_url, + json=payload, + headers={'Content-Type': 'application/json'}, + timeout=5 + ) + + if response.status_code == 200: + logger.info(f"✅ Webhook 推送成功:{instance.instance_id} -> {agents_to_notify}") + else: + logger.warning(f"⚠️ Webhook 推送失败:{instance.instance_id}, status={response.status_code}") + except Exception as e: + logger.error(f"❌ Webhook 推送异常:{instance.instance_id}, error={e}") + + +def register_instance(instance_id: str, instance_name: str, agent_ids: List[str], webhook_url: str) -> Instance: + """ + 注册或更新实例 + + Args: + instance_id: 实例 ID + instance_name: 实例名称 + agent_ids: Agent ID 列表 + webhook_url: Webhook URL + + Returns: + Instance: 实例对象 + """ + instance, created = Instance.objects.update_or_create( + instance_id=instance_id, + defaults={ + 'instance_name': instance_name, + 'agent_ids': agent_ids, + 'webhook_url': webhook_url, + 'webhook_enabled': True, + 'is_active': True + } + ) + + if created: + logger.info(f"✅ 新实例注册:{instance_id} ({instance_name})") + else: + logger.info(f"🔄 实例更新:{instance_id}") + + return instance + + +def join_meeting(instance_id: str, meeting_id: str, agent_ids: List[str]): + """ + 实例加入会议 + + Args: + instance_id: 实例 ID + meeting_id: 会议 ID + agent_ids: 参与的 Agent ID 列表 + """ + try: + instance = Instance.objects.get(instance_id=instance_id) + + MeetingInstanceMap.objects.update_or_create( + meeting_id=meeting_id, + instance=instance, + defaults={'agent_ids': agent_ids, 'left_at': None} + ) + + logger.info(f"✅ 实例 {instance_id} 加入会议 {meeting_id}, Agents: {agent_ids}") + except Instance.DoesNotExist: + logger.error(f"❌ 实例不存在:{instance_id}") + raise + + +def leave_meeting(instance_id: str, meeting_id: str): + """ + 实例离开会议 + + Args: + instance_id: 实例 ID + meeting_id: 会议 ID + """ + try: + instance = Instance.objects.get(instance_id=instance_id) + mapping = MeetingInstanceMap.objects.get(meeting_id=meeting_id, instance=instance) + mapping.left_at = timezone.now() + mapping.save() + + logger.info(f"✅ 实例 {instance_id} 离开会议 {meeting_id}") + except Exception as e: + logger.warning(f"⚠️ 离开会议失败:{e}") + + +# 导入 timezone +from django.utils import timezone diff --git a/backend/meeting_room/settings.py b/backend/meeting_room/settings.py index 920188d6..8dd8b808 100644 --- a/backend/meeting_room/settings.py +++ b/backend/meeting_room/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "meetings", "users", "api", + "instances", ] MIDDLEWARE = [ diff --git a/backend/meeting_room/urls.py b/backend/meeting_room/urls.py index 3b102edb..e12d9b4c 100644 --- a/backend/meeting_room/urls.py +++ b/backend/meeting_room/urls.py @@ -4,6 +4,7 @@ from django.views.generic import TemplateView from rest_framework.routers import DefaultRouter from meetings.views import MeetingViewSet, ParticipantViewSet from users.views import LoginView, RegisterView +from instances.views import InstanceRegisterView, MeetingJoinView, InstanceListView, WebhookNotifyView router = DefaultRouter() router.register(r'meetings', MeetingViewSet, basename='meeting') @@ -14,6 +15,10 @@ urlpatterns = [ path("", TemplateView.as_view(template_name="meeting_room.html"), name="home"), path("api/v1/auth/login/", LoginView.as_view()), path("api/v1/auth/register/", RegisterView.as_view()), + path("api/v1/instances/register/", InstanceRegisterView.as_view()), + path("api/v1/instances/join-meeting/", MeetingJoinView.as_view()), + path("api/v1/instances/", InstanceListView.as_view()), + path("api/v1/instances/webhook-test/", WebhookNotifyView.as_view()), re_path(r'^api/v1/meetings/(?P[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'), path("api/v1/", include(router.urls)), ] diff --git a/backend/meetings/views.py b/backend/meetings/views.py index 8717d2d9..71da230a 100644 --- a/backend/meetings/views.py +++ b/backend/meetings/views.py @@ -238,6 +238,26 @@ class MeetingViewSet(viewsets.ModelViewSet): requires_response=request.data.get('requires_response', False) ) + # Webhook 推送通知 + try: + from instances.webhook import push_message_to_instances + from meetings.serializers import MessageSerializer + + message_data = MessageSerializer(message).data + target_agents = None + + # 如果不是广播,只推送给特定 Agent + if not message.is_broadcast: + # 从@消息中提取目标 Agent + if content.startswith('@'): + # 简单解析 @Agent + pass + + push_message_to_instances(str(meeting.id), message_data, target_agents) + except Exception as e: + # Webhook 失败不影响消息发送 + pass + return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) diff --git a/backend/test_webhook.py b/backend/test_webhook.py new file mode 100644 index 00000000..7aae10a2 --- /dev/null +++ b/backend/test_webhook.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +测试 Webhook 推送功能 +""" + +import requests + +API_BASE = 'http://localhost:8000/api/v1' + +def test_webhook(): + print("="*60) + print("🔔 测试 Webhook 推送功能") + print("="*60) + + # 1. 登录 + res = requests.post(f'{API_BASE}/auth/login/', json={ + 'username': 'test', + 'password': 'test123' + }) + token = res.json()['token'] + headers = {'Authorization': f'Bearer {token}'} + print(f"✅ 登录成功") + + # 2. 注册实例(模拟 OpenClaw 本机) + print("\n📝 注册实例...") + res = requests.post(f'{API_BASE}/instances/register/', json={ + 'instance_id': 'test-openclaw-001', + 'instance_name': '测试 OpenClaw 实例', + 'agent_ids': ['flying_hero', 'lobster_monitor'], + 'webhook_url': 'http://localhost:8888/meeting-notify' + }) + if res.status_code == 200: + print(f"✅ 实例注册成功:{res.json()}") + else: + print(f"⚠️ 实例已存在:{res.json()}") + + # 3. 创建会议 + print("\n🏛️ 创建会议...") + res = requests.post(f'{API_BASE}/meetings/', json={ + 'topic': 'Webhook 测试会议' + }, headers=headers) + meeting_id = res.json()['id'] + print(f"✅ 会议创建:{meeting_id}") + + # 4. 实例加入会议 + print("\n📍 实例加入会议...") + res = requests.post(f'{API_BASE}/instances/join-meeting/', json={ + 'instance_id': 'test-openclaw-001', + 'meeting_id': meeting_id, + 'agent_ids': ['flying_hero'] + }) + if res.status_code == 200: + print(f"✅ 加入成功:{res.json()}") + else: + print(f"❌ 加入失败:{res.json()}") + + # 5. 发送消息(应该触发 Webhook) + print("\n💬 发送消息(触发 Webhook)...") + res = requests.post(f'{API_BASE}/meetings/{meeting_id}/send_message/', json={ + 'content': '这是一条测试消息,应该触发 Webhook 推送!' + }, headers=headers) + if res.status_code == 201: + print(f"✅ 消息发送成功") + else: + print(f"❌ 消息发送失败:{res.json()}") + + # 6. 查看实例列表 + print("\n📋 实例列表...") + res = requests.get(f'{API_BASE}/instances/') + if res.status_code == 200: + instances = res.json() + print(f"✅ 共 {len(instances)} 个实例:") + for inst in instances: + print(f" - {inst['instance_name']} ({inst['instance_id']})") + print(f" Agents: {inst['agent_ids']}") + print(f" Webhook: {inst['webhook_url']}") + + print("\n" + "="*60) + print("✅ Webhook 测试完成!") + print("="*60) + print("\n💡 提示:需要在 localhost:8888 运行一个接收 Webhook 的服务") + print(" 或使用 ngrok 等工具暴露本地服务") + +if __name__ == '__main__': + test_webhook()