🔔 飞行侠实现:实例注册 + 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