🔔 飞行侠实现:实例注册 + Webhook 推送

新功能:
- 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 响应
This commit is contained in:
2026-04-04 12:19:43 +08:00
parent 09f2bb9b6c
commit 929459fd33
12 changed files with 574 additions and 0 deletions

View File

@@ -0,0 +1 @@
default_app_config = 'instances.apps.InstancesConfig'

View File

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

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class InstancesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'instances'
verbose_name = 'OpenClaw 实例管理'

View File

@@ -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")},
},
),
]

View File

View File

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

118
backend/instances/views.py Normal file
View File

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

View File

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

View File

@@ -46,6 +46,7 @@ INSTALLED_APPS = [
"meetings", "meetings",
"users", "users",
"api", "api",
"instances",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -4,6 +4,7 @@ from django.views.generic import TemplateView
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from meetings.views import MeetingViewSet, ParticipantViewSet from meetings.views import MeetingViewSet, ParticipantViewSet
from users.views import LoginView, RegisterView from users.views import LoginView, RegisterView
from instances.views import InstanceRegisterView, MeetingJoinView, InstanceListView, WebhookNotifyView
router = DefaultRouter() router = DefaultRouter()
router.register(r'meetings', MeetingViewSet, basename='meeting') router.register(r'meetings', MeetingViewSet, basename='meeting')
@@ -14,6 +15,10 @@ urlpatterns = [
path("", TemplateView.as_view(template_name="meeting_room.html"), name="home"), path("", TemplateView.as_view(template_name="meeting_room.html"), name="home"),
path("api/v1/auth/login/", LoginView.as_view()), path("api/v1/auth/login/", LoginView.as_view()),
path("api/v1/auth/register/", RegisterView.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<pk>[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'), re_path(r'^api/v1/meetings/(?P<pk>[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'),
path("api/v1/", include(router.urls)), path("api/v1/", include(router.urls)),
] ]

View File

@@ -238,6 +238,26 @@ class MeetingViewSet(viewsets.ModelViewSet):
requires_response=request.data.get('requires_response', False) 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) return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])

85
backend/test_webhook.py Normal file
View File

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