django-cfg 1.5.14__py3-none-any.whl → 1.5.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (53) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  5. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  6. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  7. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  8. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  9. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  10. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  11. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  12. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  13. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  14. django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
  15. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  16. django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
  17. django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
  18. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
  19. django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
  20. django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
  21. django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
  22. django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
  23. django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
  24. django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
  25. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  26. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
  27. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
  28. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  29. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  30. django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
  31. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  32. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
  33. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  34. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  35. django_cfg/apps/system/frontend/views.py +87 -6
  36. django_cfg/core/builders/security_builder.py +1 -0
  37. django_cfg/core/generation/integration_generators/api.py +2 -0
  38. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  39. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  40. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  41. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  42. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  43. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  44. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  45. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  46. django_cfg/modules/django_client/core/parser/base.py +12 -0
  47. django_cfg/pyproject.toml +1 -1
  48. django_cfg/static/frontend/admin.zip +0 -0
  49. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
  50. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
  51. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
  52. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
  53. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,101 @@
1
+ """
2
+ Centrifugo Token API
3
+
4
+ Provides endpoint for generating Centrifugo JWT tokens with user permissions.
5
+ """
6
+
7
+ import logging
8
+
9
+ from drf_spectacular.utils import extend_schema
10
+ from pydantic import BaseModel, Field
11
+ from rest_framework import status, viewsets
12
+ from rest_framework.decorators import action
13
+ from rest_framework.response import Response
14
+ from rest_framework.permissions import IsAuthenticated
15
+
16
+ from ..services import generate_centrifugo_token
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ========================================================================
22
+ # Response Models
23
+ # ========================================================================
24
+
25
+
26
+ class ConnectionTokenResponse(BaseModel):
27
+ """Response model for Centrifugo connection token."""
28
+
29
+ token: str = Field(..., description="JWT token for Centrifugo connection")
30
+ centrifugo_url: str = Field(..., description="Centrifugo WebSocket URL")
31
+ expires_at: str = Field(..., description="Token expiration time (ISO 8601)")
32
+ channels: list[str] = Field(..., description="List of allowed channels")
33
+
34
+
35
+ class CentrifugoTokenViewSet(viewsets.ViewSet):
36
+ """
37
+ Centrifugo Token API ViewSet.
38
+
39
+ Provides endpoint for authenticated users to get Centrifugo JWT token
40
+ with their allowed channels based on permissions.
41
+ """
42
+
43
+ permission_classes = [IsAuthenticated]
44
+
45
+ @extend_schema(
46
+ tags=["Centrifugo Auth"],
47
+ summary="Get Centrifugo connection token",
48
+ description=(
49
+ "Generate JWT token for WebSocket connection to Centrifugo. "
50
+ "Token includes user's allowed channels based on their permissions. "
51
+ "Requires authentication."
52
+ ),
53
+ responses={
54
+ 200: ConnectionTokenResponse,
55
+ 401: {"description": "Unauthorized - authentication required"},
56
+ 500: {"description": "Server error"},
57
+ },
58
+ )
59
+ @action(detail=False, methods=["get"], url_path="token")
60
+ def get_token(self, request):
61
+ """
62
+ Get Centrifugo connection token for authenticated user.
63
+
64
+ Returns JWT token with user's allowed channels that can be used
65
+ to connect to Centrifugo WebSocket.
66
+ """
67
+ try:
68
+ user = request.user
69
+
70
+ # Generate token with user's channels
71
+ token_data = generate_centrifugo_token(user)
72
+
73
+ logger.debug(
74
+ f"Generated Centrifugo token for user {user.email} (ID: {user.id}) "
75
+ f"with {len(token_data['channels'])} channels: {token_data['channels']}"
76
+ )
77
+
78
+ # Format expires_at as ISO 8601 string
79
+ return Response({
80
+ "token": token_data["token"],
81
+ "centrifugo_url": token_data["centrifugo_url"],
82
+ "expires_at": token_data["expires_at"].isoformat() + "Z",
83
+ "channels": token_data["channels"],
84
+ })
85
+
86
+ except ValueError as e:
87
+ # Centrifugo not configured or disabled
88
+ logger.warning(f"Centrifugo token generation failed: {e}")
89
+ return Response(
90
+ {"error": str(e)},
91
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
92
+ )
93
+ except Exception as e:
94
+ logger.error(f"Failed to generate Centrifugo token: {e}", exc_info=True)
95
+ return Response(
96
+ {"error": "Failed to generate token"},
97
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
98
+ )
99
+
100
+
101
+ __all__ = ["CentrifugoTokenViewSet"]
@@ -0,0 +1,257 @@
1
+ """
2
+ Centrifugo Wrapper API.
3
+
4
+ Provides /api/publish endpoint that acts as a proxy to Centrifugo
5
+ with ACK tracking and database logging.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ import uuid
11
+ from typing import Any, Dict
12
+
13
+ import httpx
14
+ from django.http import JsonResponse
15
+ from django.utils.decorators import method_decorator
16
+ from django.views import View
17
+ from django.views.decorators.csrf import csrf_exempt
18
+ from django_cfg.modules.django_logging import get_logger
19
+ from pydantic import BaseModel, Field
20
+
21
+ from ..services import get_centrifugo_config
22
+ from ..services.logging import CentrifugoLogger
23
+
24
+ logger = get_logger("centrifugo.wrapper")
25
+
26
+
27
+ # ========================================================================
28
+ # Request/Response Models
29
+ # ========================================================================
30
+
31
+
32
+ class PublishRequest(BaseModel):
33
+ """Request model for publish endpoint."""
34
+
35
+ channel: str = Field(..., description="Target channel name")
36
+ data: Dict[str, Any] = Field(..., description="Message data")
37
+ wait_for_ack: bool = Field(default=False, description="Wait for client ACK")
38
+ ack_timeout: int = Field(default=10, description="ACK timeout in seconds")
39
+ message_id: str | None = Field(default=None, description="Optional message ID")
40
+
41
+
42
+ class PublishResponse(BaseModel):
43
+ """Response model for publish endpoint."""
44
+
45
+ published: bool = Field(..., description="Whether message was published")
46
+ message_id: str = Field(..., description="Unique message ID")
47
+ channel: str = Field(..., description="Target channel")
48
+ delivered: bool = Field(default=False, description="Whether message was delivered")
49
+ acks_received: int = Field(default=0, description="Number of ACKs received")
50
+
51
+
52
+ # ========================================================================
53
+ # Wrapper View
54
+ # ========================================================================
55
+
56
+
57
+ @method_decorator(csrf_exempt, name='dispatch')
58
+ class PublishWrapperView(View):
59
+ """
60
+ Centrifugo publish wrapper endpoint.
61
+
62
+ Provides /api/publish endpoint that:
63
+ - Accepts publish requests from CentrifugoClient
64
+ - Logs to database (CentrifugoLog)
65
+ - Proxies to Centrifugo HTTP API
66
+ - Returns publish result with ACK tracking
67
+ """
68
+
69
+ def __init__(self, *args, **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self._http_client = None
72
+
73
+ @property
74
+ def http_client(self) -> httpx.AsyncClient:
75
+ """Get or create HTTP client for Centrifugo API calls."""
76
+ if self._http_client is None:
77
+ config = get_centrifugo_config()
78
+ if not config:
79
+ raise ValueError("Centrifugo not configured")
80
+
81
+ headers = {"Content-Type": "application/json"}
82
+
83
+ # Add Centrifugo API key for server-to-server auth
84
+ if config.centrifugo_api_key:
85
+ headers["Authorization"] = f"apikey {config.centrifugo_api_key}"
86
+
87
+ # Use Centrifugo API URL (not wrapper URL)
88
+ base_url = config.centrifugo_api_url.rstrip("/api").rstrip("/")
89
+
90
+ self._http_client = httpx.AsyncClient(
91
+ base_url=base_url,
92
+ headers=headers,
93
+ timeout=httpx.Timeout(config.http_timeout),
94
+ verify=config.verify_ssl,
95
+ )
96
+
97
+ return self._http_client
98
+
99
+ async def _publish_to_centrifugo(
100
+ self, channel: str, data: Dict[str, Any], wait_for_ack: bool, ack_timeout: int, message_id: str
101
+ ) -> Dict[str, Any]:
102
+ """
103
+ Publish message to Centrifugo API.
104
+
105
+ Args:
106
+ channel: Target channel
107
+ data: Message data
108
+ wait_for_ack: Whether to wait for ACK
109
+ ack_timeout: ACK timeout in seconds
110
+ message_id: Message ID
111
+
112
+ Returns:
113
+ Publish result dict
114
+ """
115
+ start_time = time.time()
116
+
117
+ # Create log entry
118
+ log_entry = await CentrifugoLogger.create_log_async(
119
+ message_id=message_id,
120
+ channel=channel,
121
+ data=data,
122
+ wait_for_ack=wait_for_ack,
123
+ ack_timeout=ack_timeout if wait_for_ack else None,
124
+ is_notification=True,
125
+ user=None, # Can extract from request if needed
126
+ )
127
+
128
+ try:
129
+ # Centrifugo API format: POST /api with method in body
130
+ payload = {
131
+ "method": "publish",
132
+ "params": {
133
+ "channel": channel,
134
+ "data": data,
135
+ },
136
+ }
137
+
138
+ response = await self.http_client.post("/api", json=payload)
139
+ response.raise_for_status()
140
+ result = response.json()
141
+
142
+ # Check for Centrifugo error
143
+ if "error" in result and result["error"]:
144
+ raise Exception(f"Centrifugo error: {result['error']}")
145
+
146
+ # Calculate duration
147
+ duration_ms = int((time.time() - start_time) * 1000)
148
+
149
+ # Mark as success
150
+ if log_entry:
151
+ await CentrifugoLogger.mark_success_async(
152
+ log_entry,
153
+ acks_received=0, # ACK tracking would be implemented separately
154
+ duration_ms=duration_ms,
155
+ )
156
+
157
+ # Return wrapper-compatible response
158
+ return {
159
+ "published": True,
160
+ "message_id": message_id,
161
+ "channel": channel,
162
+ "acks_received": 0,
163
+ "delivered": True, # Centrifugo confirms publish, not delivery
164
+ }
165
+
166
+ except Exception as e:
167
+ # Calculate duration
168
+ duration_ms = int((time.time() - start_time) * 1000)
169
+
170
+ # Mark as failed
171
+ if log_entry:
172
+ from asgiref.sync import sync_to_async
173
+ from ..models import CentrifugoLog
174
+
175
+ await sync_to_async(CentrifugoLog.objects.mark_failed)(
176
+ log_instance=log_entry,
177
+ error_code=type(e).__name__,
178
+ error_message=str(e),
179
+ duration_ms=duration_ms,
180
+ )
181
+
182
+ raise
183
+
184
+ def post(self, request):
185
+ """
186
+ Handle POST /api/publish request.
187
+
188
+ Request body:
189
+ {
190
+ "channel": "test#demo",
191
+ "data": {"key": "value"},
192
+ "wait_for_ack": false,
193
+ "ack_timeout": 10,
194
+ "message_id": "optional-uuid"
195
+ }
196
+
197
+ Response:
198
+ {
199
+ "published": true,
200
+ "message_id": "uuid",
201
+ "channel": "test#demo",
202
+ "delivered": true,
203
+ "acks_received": 0
204
+ }
205
+ """
206
+ try:
207
+ import json
208
+
209
+ # Parse request body
210
+ body = json.loads(request.body)
211
+ req_data = PublishRequest(**body)
212
+
213
+ # Generate message ID if not provided
214
+ message_id = req_data.message_id or str(uuid.uuid4())
215
+
216
+ # Publish to Centrifugo
217
+ result = asyncio.run(
218
+ self._publish_to_centrifugo(
219
+ channel=req_data.channel,
220
+ data=req_data.data,
221
+ wait_for_ack=req_data.wait_for_ack,
222
+ ack_timeout=req_data.ack_timeout,
223
+ message_id=message_id,
224
+ )
225
+ )
226
+
227
+ response = PublishResponse(**result)
228
+ return JsonResponse(response.model_dump(), status=200)
229
+
230
+ except Exception as e:
231
+ logger.error(f"Failed to publish via wrapper: {e}", exc_info=True)
232
+ return JsonResponse(
233
+ {
234
+ "published": False,
235
+ "message_id": "",
236
+ "channel": body.get("channel", "") if "body" in locals() else "",
237
+ "delivered": False,
238
+ "acks_received": 0,
239
+ "error": str(e),
240
+ },
241
+ status=500,
242
+ )
243
+
244
+ def __del__(self):
245
+ """Cleanup HTTP client on deletion."""
246
+ if self._http_client:
247
+ try:
248
+ loop = asyncio.get_event_loop()
249
+ if loop.is_running():
250
+ loop.create_task(self._http_client.aclose())
251
+ else:
252
+ loop.run_until_complete(self._http_client.aclose())
253
+ except Exception:
254
+ pass
255
+
256
+
257
+ __all__ = ["PublishWrapperView"]
@@ -0,0 +1,29 @@
1
+ """
2
+ gRPC → Centrifugo Integration.
3
+
4
+ Mixin and configuration for bridging gRPC streaming events to Centrifugo WebSocket.
5
+ """
6
+
7
+ from .bridge import CentrifugoBridgeMixin
8
+ from .config import ChannelConfig, CentrifugoChannels
9
+ from .demo import (
10
+ DemoChannels,
11
+ DemoBridgeService,
12
+ test_complete_integration,
13
+ test_demo_service,
14
+ send_demo_event,
15
+ )
16
+
17
+ __all__ = [
18
+ # Core components
19
+ "CentrifugoBridgeMixin",
20
+ "ChannelConfig",
21
+ "CentrifugoChannels",
22
+
23
+ # Demo/testing
24
+ "DemoChannels",
25
+ "DemoBridgeService",
26
+ "test_complete_integration",
27
+ "test_demo_service",
28
+ "send_demo_event",
29
+ ]
@@ -0,0 +1,277 @@
1
+ """
2
+ Centrifugo Bridge Mixin for gRPC Services.
3
+
4
+ Universal mixin that enables automatic publishing of gRPC stream events
5
+ to Centrifugo WebSocket channels using Pydantic configuration.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from datetime import datetime, timezone as tz
11
+ from typing import Dict, Optional, Any, TYPE_CHECKING
12
+
13
+ from .config import CentrifugoChannels, ChannelConfig
14
+ from .transformers import transform_protobuf_to_dict
15
+
16
+ if TYPE_CHECKING:
17
+ from django_cfg.apps.integrations.centrifugo import CentrifugoClient
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class CentrifugoBridgeMixin:
23
+ """
24
+ Universal mixin for publishing gRPC stream events to Centrifugo.
25
+
26
+ Uses Pydantic models for type-safe, validated configuration.
27
+
28
+ Features:
29
+ - Type-safe Pydantic configuration
30
+ - Automatic event publishing to WebSocket channels
31
+ - Built-in protobuf → JSON transformation
32
+ - Graceful degradation if Centrifugo unavailable
33
+ - Custom transform functions support
34
+ - Template-based channel naming
35
+ - Per-channel rate limiting
36
+ - Critical event bypassing
37
+
38
+ Usage:
39
+ ```python
40
+ from django_cfg.apps.integrations.grpc.mixins import (
41
+ CentrifugoBridgeMixin,
42
+ CentrifugoChannels,
43
+ ChannelConfig,
44
+ )
45
+
46
+ class BotChannels(CentrifugoChannels):
47
+ heartbeat: ChannelConfig = ChannelConfig(
48
+ template='bot#{bot_id}#heartbeat',
49
+ rate_limit=0.1
50
+ )
51
+ status: ChannelConfig = ChannelConfig(
52
+ template='bot#{bot_id}#status',
53
+ critical=True
54
+ )
55
+
56
+ class BotStreamingService(
57
+ bot_streaming_service_pb2_grpc.BotStreamingServiceServicer,
58
+ CentrifugoBridgeMixin
59
+ ):
60
+ centrifugo_channels = BotChannels()
61
+
62
+ async def ConnectBot(self, request_iterator, context):
63
+ async for message in request_iterator:
64
+ # Your business logic
65
+ await self._handle_message(bot_id, message)
66
+
67
+ # Auto-publish to Centrifugo (1 line!)
68
+ await self._notify_centrifugo(message, bot_id=bot_id)
69
+ ```
70
+ """
71
+
72
+ # Class-level Pydantic config (optional, can be set in __init__)
73
+ centrifugo_channels: Optional[CentrifugoChannels] = None
74
+
75
+ def __init__(self):
76
+ """Initialize Centrifugo bridge from Pydantic configuration."""
77
+ super().__init__()
78
+
79
+ # Instance attributes
80
+ self._centrifugo_enabled: bool = False
81
+ self._centrifugo_graceful: bool = True
82
+ self._centrifugo_client: Optional['CentrifugoClient'] = None
83
+ self._centrifugo_mappings: Dict[str, Dict[str, Any]] = {}
84
+ self._centrifugo_last_publish: Dict[str, float] = {}
85
+
86
+ # Auto-setup if config exists
87
+ if self.centrifugo_channels:
88
+ self._setup_from_pydantic_config(self.centrifugo_channels)
89
+
90
+ def _setup_from_pydantic_config(self, config: CentrifugoChannels):
91
+ """
92
+ Setup Centrifugo bridge from Pydantic configuration.
93
+
94
+ Args:
95
+ config: CentrifugoChannels instance with channel mappings
96
+ """
97
+ self._centrifugo_enabled = config.enabled
98
+ self._centrifugo_graceful = config.graceful_degradation
99
+
100
+ # Extract channel mappings
101
+ for field_name, channel_config in config.get_channel_mappings().items():
102
+ if channel_config.enabled:
103
+ self._centrifugo_mappings[field_name] = {
104
+ 'template': channel_config.template,
105
+ 'rate_limit': channel_config.rate_limit or config.default_rate_limit,
106
+ 'critical': channel_config.critical,
107
+ 'transform': channel_config.transform,
108
+ 'metadata': channel_config.metadata,
109
+ }
110
+
111
+ # Initialize client if enabled
112
+ if self._centrifugo_enabled and self._centrifugo_mappings:
113
+ self._initialize_centrifugo_client()
114
+
115
+ def _initialize_centrifugo_client(self):
116
+ """Lazy initialize Centrifugo client."""
117
+ try:
118
+ from django_cfg.apps.integrations.centrifugo import get_centrifugo_client
119
+ self._centrifugo_client = get_centrifugo_client()
120
+ logger.info(
121
+ f"✅ Centrifugo bridge enabled with {len(self._centrifugo_mappings)} channels"
122
+ )
123
+ except Exception as e:
124
+ logger.warning(f"⚠️ Centrifugo client not available: {e}")
125
+ if not self._centrifugo_graceful:
126
+ raise
127
+ self._centrifugo_enabled = False
128
+
129
+ async def _notify_centrifugo(
130
+ self,
131
+ message: Any, # Protobuf message
132
+ **context: Any # Template variables for channel rendering
133
+ ) -> bool:
134
+ """
135
+ Publish protobuf message to Centrifugo based on configured mappings.
136
+
137
+ Automatically detects which field is set in the message and publishes
138
+ to the corresponding channel.
139
+
140
+ Args:
141
+ message: Protobuf message (e.g., BotMessage with heartbeat/status/etc.)
142
+ **context: Template variables for channel name rendering
143
+ Example: bot_id='123', user_id='456'
144
+
145
+ Returns:
146
+ True if published successfully, False otherwise
147
+
148
+ Example:
149
+ ```python
150
+ # message = BotMessage with heartbeat field set
151
+ await self._notify_centrifugo(message, bot_id='bot-123')
152
+ # → Publishes to channel: bot#bot-123#heartbeat
153
+ ```
154
+ """
155
+ if not self._centrifugo_enabled or not self._centrifugo_client:
156
+ return False
157
+
158
+ # Check each mapped field
159
+ for field_name, mapping in self._centrifugo_mappings.items():
160
+ if message.HasField(field_name):
161
+ return await self._publish_field(
162
+ field_name,
163
+ message,
164
+ mapping,
165
+ context
166
+ )
167
+
168
+ return False
169
+
170
+ async def _publish_field(
171
+ self,
172
+ field_name: str,
173
+ message: Any,
174
+ mapping: Dict[str, Any],
175
+ context: dict
176
+ ) -> bool:
177
+ """
178
+ Publish specific message field to Centrifugo.
179
+
180
+ Args:
181
+ field_name: Name of the protobuf field
182
+ message: Full protobuf message
183
+ mapping: Channel mapping configuration
184
+ context: Template variables
185
+
186
+ Returns:
187
+ True if published successfully
188
+ """
189
+ try:
190
+ # Render channel from template
191
+ channel = mapping['template'].format(**context)
192
+
193
+ # Rate limiting check (unless critical)
194
+ if not mapping['critical'] and mapping['rate_limit']:
195
+ now = time.time()
196
+ last = self._centrifugo_last_publish.get(channel, 0)
197
+ if now - last < mapping['rate_limit']:
198
+ logger.debug(f"⏱️ Rate limit: skipping {field_name} for {channel}")
199
+ return False
200
+ self._centrifugo_last_publish[channel] = now
201
+
202
+ # Get field value
203
+ field_value = getattr(message, field_name)
204
+
205
+ # Transform to dict
206
+ data = self._transform_field(field_name, field_value, mapping, context)
207
+
208
+ # Publish to Centrifugo
209
+ await self._centrifugo_client.publish(
210
+ channel=channel,
211
+ data=data
212
+ )
213
+
214
+ logger.debug(f"✅ Published {field_name} to {channel}")
215
+ return True
216
+
217
+ except KeyError as e:
218
+ logger.error(
219
+ f"❌ Missing template variable in channel: {e}. "
220
+ f"Template: {mapping['template']}, Context: {context}"
221
+ )
222
+ return False
223
+
224
+ except Exception as e:
225
+ logger.error(
226
+ f"❌ Failed to publish {field_name} to Centrifugo: {e}",
227
+ exc_info=True
228
+ )
229
+ if not self._centrifugo_graceful:
230
+ raise
231
+ return False
232
+
233
+ def _transform_field(
234
+ self,
235
+ field_name: str,
236
+ field_value: Any,
237
+ mapping: Dict[str, Any],
238
+ context: dict
239
+ ) -> dict:
240
+ """
241
+ Transform protobuf field to JSON-serializable dict.
242
+
243
+ Args:
244
+ field_name: Field name
245
+ field_value: Protobuf message field value
246
+ mapping: Channel mapping with optional transform function
247
+ context: Template context variables
248
+
249
+ Returns:
250
+ JSON-serializable dictionary
251
+ """
252
+ # Use custom transform if provided
253
+ if mapping['transform']:
254
+ data = mapping['transform'](field_name, field_value)
255
+ else:
256
+ # Default protobuf → dict transform
257
+ data = transform_protobuf_to_dict(field_value)
258
+
259
+ # Add metadata
260
+ data['type'] = field_name
261
+ data['timestamp'] = datetime.now(tz.utc).isoformat()
262
+
263
+ # Merge channel metadata
264
+ if mapping['metadata']:
265
+ for key, value in mapping['metadata'].items():
266
+ if key not in data:
267
+ data[key] = value
268
+
269
+ # Add context variables (bot_id, user_id, etc.)
270
+ for key, value in context.items():
271
+ if key not in data:
272
+ data[key] = value
273
+
274
+ return data
275
+
276
+
277
+ __all__ = ["CentrifugoBridgeMixin"]