feat(connectors): Connector Framework + Zalo OA connector#17
Conversation
Kicks off the Month-1 roadmap (magic-business technical-planning): a BaseConnector interface, ConnectorRegistry, AuthHandler abstractions (API key / OAuth2 / webhook signature), retryable error hierarchy, and Common Data Schema (Customer, Order, Case, Conversation, Message, KnowledgeDocument) in sdk/python/magic_ai_sdk/connectors — plus a first connector implementation (Zalo OA: send message, webhook receive with verified signature) under connectors/zalo, built as an external worker per the existing "core never contains platform-specific logic" principle. Also adds a 1-click docker-compose.yml (magic + postgres + redis) and scripts/quickstart.sh for local dev.
|
Warning Review limit reached
Next review available in: 39 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughIntroduces a reusable Python connector framework with shared schemas, authentication, retries, registry management, and tests. Adds a Zalo Official Account connector with outbound messaging and webhook handling, plus Docker-based local development and quickstart tooling. ChangesConnector platform
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ZaloOA as Zalo OA
participant WebhookServer as webhook.serve
participant ZaloConnector
participant Callback as on_event
ZaloOA->>WebhookServer: POST webhook payload and signature
WebhookServer->>ZaloConnector: Verify signature
ZaloConnector-->>WebhookServer: Validation result
WebhookServer->>ZaloConnector: Parse webhook event
ZaloConnector-->>WebhookServer: Common message records
WebhookServer->>Callback: Invoke on_event(records)
WebhookServer-->>ZaloOA: Return 200 response
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request introduces the MagiC Connector Framework and a basic Zalo Official Account connector, supporting message exchange, webhook handling, and tenant-isolated registration. The review feedback highlights several critical improvements: implementing an asyncio.Lock to serialize token refreshes and prevent concurrent invalidation of single-use refresh tokens, handling specific token expiration errors as transient to trigger retries, properly parsing Unix timestamps to avoid Pydantic validation errors, securing webhook signature verification against timing attacks using hmac.compare_digest with safe UTF-8 decoding, handling potential parsing errors on Content-Length in the webhook server, and enforcing type safety in the message schema with a direction Enum.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async def _ensure_token(self) -> str: | ||
| if self._expires_at is not None and time.monotonic() >= self._expires_at: | ||
| await self._refresh_access_token() | ||
| return self._access_token |
There was a problem hiding this comment.
Since Zalo's refresh tokens are single-use, concurrent requests calling _ensure_token when the token is expired will trigger multiple concurrent refresh requests. The first one will succeed and invalidate the refresh token, causing the second one to fail and permanently lock out the connector. We should use an asyncio.Lock to serialize token refresh attempts.
| async def _ensure_token(self) -> str: | |
| if self._expires_at is not None and time.monotonic() >= self._expires_at: | |
| await self._refresh_access_token() | |
| return self._access_token | |
| async def _ensure_token(self) -> str: | |
| if not hasattr(self, "_token_lock"): | |
| import asyncio | |
| self._token_lock = asyncio.Lock() | |
| async with self._token_lock: | |
| if self._expires_at is not None and time.monotonic() >= self._expires_at: | |
| await self._refresh_access_token() | |
| return self._access_token |
| error_code = data.get("error", 0) | ||
| if error_code in _RATE_LIMIT_ERROR_CODES: | ||
| raise RateLimitError(f"Zalo API rate limited: {data.get('message')}") | ||
| if error_code: | ||
| raise PermanentError(f"Zalo API error {error_code}: {data.get('message')}") |
There was a problem hiding this comment.
If Zalo returns an invalid or expired token error (e.g., error codes -203 or -216), the current implementation raises a PermanentError, which is not retried. We should clear the cached expiry and raise a TransientError so that the request is retried with a fresh token.
| error_code = data.get("error", 0) | |
| if error_code in _RATE_LIMIT_ERROR_CODES: | |
| raise RateLimitError(f"Zalo API rate limited: {data.get('message')}") | |
| if error_code: | |
| raise PermanentError(f"Zalo API error {error_code}: {data.get('message')}") | |
| error_code = data.get("error", 0) | |
| if error_code in _RATE_LIMIT_ERROR_CODES: | |
| raise RateLimitError(f"Zalo API rate limited: {data.get('message')}") | |
| if error_code in {-203, -216}: | |
| self._expires_at = 0.0 # Force refresh on next attempt | |
| raise TransientError(f"Zalo token expired/invalid (error {error_code}): {data.get('message')}") | |
| if error_code: | |
| raise PermanentError(f"Zalo API error {error_code}: {data.get('message')}") |
| return { | ||
| "id": message.get("msg_id", ""), | ||
| "conversation_id": sender_id, | ||
| "direction": "inbound", | ||
| "sender_id": sender_id, | ||
| "text": message.get("text"), | ||
| "attachments": message.get("attachments", []), | ||
| "sent_at": raw_data.get("timestamp"), | ||
| "metadata": {"oa_id": recipient_id, "event_name": raw_data.get("event_name")}, | ||
| } |
There was a problem hiding this comment.
Zalo's timestamp is a Unix timestamp (usually in milliseconds as a string or integer). Passing a raw string of digits directly to Pydantic's datetime field (sent_at) can cause validation errors in Pydantic v2. We should parse the timestamp to a timezone-aware UTC datetime object before returning.
timestamp_raw = raw_data.get("timestamp")
sent_at = None
if timestamp_raw is not None:
try:
ts = float(timestamp_raw)
if ts > 1e11: # Milliseconds
ts /= 1000.0
from datetime import datetime, timezone
sent_at = datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, TypeError):
pass
return {
"id": message.get("msg_id", ""),
"conversation_id": sender_id,
"direction": "inbound",
"sender_id": sender_id,
"text": message.get("text"),
"attachments": message.get("attachments", []),
"sent_at": sent_at,
"metadata": {"oa_id": recipient_id, "event_name": raw_data.get("event_name")},
}| try: | ||
| payload = json.loads(raw_body) | ||
| timestamp = str(payload.get("timestamp", "")) | ||
| except (json.JSONDecodeError, ValueError): | ||
| return False | ||
|
|
||
| mac_input = f"{self.app_id}{raw_body.decode()}{timestamp}{self.oa_secret_key}" | ||
| expected = hashlib.sha256(mac_input.encode()).hexdigest() | ||
| return expected == signature |
There was a problem hiding this comment.
The signature verification is vulnerable to timing attacks due to standard string comparison (==). Additionally, calling raw_body.decode() outside the try-except block can raise an uncaught UnicodeDecodeError if the payload contains invalid UTF-8 bytes. We should decode safely inside the try-except block and use hmac.compare_digest for timing-attack-safe comparison.
| try: | |
| payload = json.loads(raw_body) | |
| timestamp = str(payload.get("timestamp", "")) | |
| except (json.JSONDecodeError, ValueError): | |
| return False | |
| mac_input = f"{self.app_id}{raw_body.decode()}{timestamp}{self.oa_secret_key}" | |
| expected = hashlib.sha256(mac_input.encode()).hexdigest() | |
| return expected == signature | |
| try: | |
| body_str = raw_body.decode("utf-8") | |
| payload = json.loads(body_str) | |
| timestamp = str(payload.get("timestamp", "")) | |
| except (UnicodeDecodeError, json.JSONDecodeError, ValueError): | |
| return False | |
| mac_input = f"{self.app_id}{body_str}{timestamp}{self.oa_secret_key}" | |
| expected = hashlib.sha256(mac_input.encode()).hexdigest() | |
| import hmac | |
| return hmac.compare_digest(expected, signature) |
| length = int(content_length) | ||
| if length > 1 * 1024 * 1024: | ||
| self.send_error(413, "Request too large") | ||
| return |
There was a problem hiding this comment.
If the Content-Length header is present but not a valid integer, calling int(content_length) will raise a ValueError, crashing the request handler with an unhandled exception. We should wrap it in a try-except block and return a clean 400 Bad Request.
| length = int(content_length) | |
| if length > 1 * 1024 * 1024: | |
| self.send_error(413, "Request too large") | |
| return | |
| try: | |
| length = int(content_length) | |
| except ValueError: | |
| self.send_error(400, "Invalid Content-Length") | |
| return | |
| if length > 1 * 1024 * 1024: | |
| self.send_error(413, "Request too large") | |
| return |
| class Message(BaseModel): | ||
| id: str | ||
| conversation_id: str | ||
| direction: str # "inbound" | "outbound" | ||
| sender_id: str |
There was a problem hiding this comment.
The direction field in the Message model is defined as a plain str. To prevent typos and enforce type safety, we should define a MessageDirection enum (with inbound and outbound values) and use it as the type for direction.
| class Message(BaseModel): | |
| id: str | |
| conversation_id: str | |
| direction: str # "inbound" | "outbound" | |
| sender_id: str | |
| class MessageDirection(str, Enum): | |
| INBOUND = "inbound" | |
| OUTBOUND = "outbound" | |
| class Message(BaseModel): | |
| id: str | |
| conversation_id: str | |
| direction: MessageDirection | |
| sender_id: str |
- Remove unused import (fixes Python Tests CI lint failure) - Serialize Zalo access-token refresh with asyncio.Lock — refresh tokens are single-use, so concurrent refreshes could lock out the connector (Gemini review, critical) - Treat Zalo token-expired error codes (-203/-216) as TransientError that forces a refresh + retry, instead of failing permanently - Use hmac.compare_digest for webhook signature comparison instead of `==` (timing-attack safe — this is what SonarCloud's "B Security Rating on New Code" gate was flagging) and decode the body safely inside the try/except so invalid UTF-8 can't crash the handler - Guard webhook Content-Length parsing against non-numeric values (400 instead of an unhandled ValueError crashing the request thread) - Add MessageDirection enum instead of a free-form str field Verified pydantic v2 already parses numeric epoch-second/millisecond strings into datetime correctly, so the "timestamp parsing" review comment needed no code change. New/updated tests: token-expiry-triggers-retry, concurrent-refresh-is- serialized, and a new test_webhook.py exercising the real HTTP server (valid signature, invalid signature, malformed Content-Length).
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (4)
connectors/zalo/webhook.py (1)
39-39: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valuePass
self.headerswithout converting to dict.
self.headersis anHTTPMessagewhich natively supports case-insensitive lookups via.get(). Converting it to a standard dictionary (dict(self.headers)) strips this capability, which can cause subtle validation failures if upstream systems or proxies alter header casing.♻️ Proposed fix
- if not connector.verify_webhook_signature(dict(self.headers), raw_body): + if not connector.verify_webhook_signature(self.headers, raw_body):🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@connectors/zalo/webhook.py` at line 39, Update the verify_webhook_signature call in the webhook handler to pass self.headers directly, removing the dict conversion so its HTTPMessage case-insensitive lookup behavior is preserved.connectors/zalo/connector.py (1)
163-164: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove redundant
JSONDecodeErrorexception.json.JSONDecodeErrorinherits fromValueError, making it redundant to catch both. CatchingValueErroralone covers both cases cleanly.
connectors/zalo/connector.py#L163-L164: change toexcept ValueError:connectors/zalo/webhook.py#L46-L47: change toexcept ValueError:🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@connectors/zalo/connector.py` around lines 163 - 164, Replace the redundant (json.JSONDecodeError, ValueError) exception tuples with ValueError-only handlers in the relevant parsing logic: connectors/zalo/connector.py lines 163-164 and connectors/zalo/webhook.py lines 46-47. Preserve the existing return behavior in both locations.Source: Linters/SAST tools
sdk/python/magic_ai_sdk/connectors/auth.py (1)
17-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
OAuth2Auth.apply()violates theAuthHandlercontract it implements.
AuthHandler.apply()is the abstract contract every handler must fulfill, butOAuth2Auth.apply()unconditionally raisesNotImplementedError. Generic code that treats handlers polymorphically (handler.apply(kwargs)) will always fail for this subclass, unlikeApiKeyAuth. Consider makingapplyasync in the base class soOAuth2Authcan implement it properly (callingtoken()internally), keeping the interface uniform across handlers.♻️ Possible direction
class AuthHandler(ABC): """Base class for connector authentication strategies.""" `@abstractmethod` - def apply(self, request_kwargs: dict[str, Any]) -> dict[str, Any]: + async def apply(self, request_kwargs: dict[str, Any]) -> dict[str, Any]: """Mutate/return httpx request kwargs (headers, params...) with credentials applied."""- def apply(self, request_kwargs: dict[str, Any]) -> dict[str, Any]: - raise NotImplementedError("OAuth2Auth requires async token() — call it explicitly before apply()") + async def apply(self, request_kwargs: dict[str, Any]) -> dict[str, Any]: + headers = dict(request_kwargs.get("headers") or {}) + headers["Authorization"] = f"Bearer {await self.token()}" + request_kwargs["headers"] = headers + return request_kwargsAlso applies to: 76-77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@sdk/python/magic_ai_sdk/connectors/auth.py` around lines 17 - 22, Update AuthHandler.apply and OAuth2Auth.apply to share a uniform asynchronous contract: make the abstract base method async and implement OAuth2Auth.apply by obtaining credentials through token() and applying them to the request kwargs instead of raising NotImplementedError. Update callers and other handler implementations as needed to await apply while preserving ApiKeyAuth behavior.sdk/python/magic_ai_sdk/connectors/schema.py (1)
45-50: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winTighten field constraints for
directionandquantity.
Message.directionis a barestrdespite only"inbound"/"outbound"being valid (per the comment);OrderItem.quantityhas no lower bound. Both would silently accept invalid values today.♻️ Proposed tightening
+from typing import Literal + class OrderItem(BaseModel): sku: Optional[str] = None name: str - quantity: int = 1 + quantity: int = Field(default=1, ge=1) unit_price: float = 0.0class Message(BaseModel): id: str conversation_id: str - direction: str # "inbound" | "outbound" + direction: Literal["inbound", "outbound"]Also applies to: 65-74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@sdk/python/magic_ai_sdk/connectors/schema.py` around lines 45 - 50, Tighten validation on Message.direction to accept only the documented inbound and outbound values, using the project’s existing typed-validation conventions. Update OrderItem.quantity to enforce a positive lower bound while preserving its default value and integer type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@connectors/zalo/connector.py`:
- Around line 85-88: Protect token refresh in _ensure_token with an asyncio.Lock
initialized during connect. Add the asyncio import, create and store the lock
after establishing the client, and double-check expiration inside the lock
before calling _refresh_access_token so concurrent callers reuse the refreshed
token.
- Around line 81-82: Persist the rotated tokens immediately after the successful
refresh in the connector flow that assigns self._access_token and
self._refresh_token. Invoke the framework’s existing configuration-update
callback, such as on_config_updated, with the updated token configuration so
both values survive process restarts; preserve the in-memory assignments and
avoid persisting before refresh succeeds.
- Line 156: Update the signature extraction in the connector’s header-processing
flow to iterate over the provided headers case-insensitively, matching
“X-Zevent-Signature” regardless of key casing and preserving the empty fallback
when absent.
- Around line 160-164: Update the payload handling in the webhook verification
flow to validate that json.loads returns a dictionary before calling
payload.get. Treat valid non-object JSON payloads the same as malformed JSON by
returning False, while preserving timestamp extraction for dictionary payloads.
- Line 94: Update the method containing the _client.post call to catch
httpx.RequestError around the request and wrap or re-raise it as the framework’s
retryable TransientError, preserving the original exception as context so
with_retry can retry network failures such as timeouts and connection drops.
- Line 168: Update the signature validation comparison in the surrounding
webhook verification function to use hmac.compare_digest instead of ==, and add
the hmac import at the module level. Preserve the existing expected and
signature values and boolean result.
In `@connectors/zalo/webhook.py`:
- Around line 32-35: Update the Content-Length handling around the integer
conversion in the request handler to catch ValueError for malformed non-numeric
values and respond gracefully with HTTP 400, while preserving the existing 413
response for valid values exceeding the size limit.
In `@sdk/python/magic_ai_sdk/connectors/registry.py`:
- Around line 33-40: Update the instance-cache key in ConnectorRegistry.get to
use the unambiguous (tenant_id, name) tuple instead of the colon-delimited
composite string, preserving separate cached connector instances for every
tenant and connector-name pair.
---
Nitpick comments:
In `@connectors/zalo/connector.py`:
- Around line 163-164: Replace the redundant (json.JSONDecodeError, ValueError)
exception tuples with ValueError-only handlers in the relevant parsing logic:
connectors/zalo/connector.py lines 163-164 and connectors/zalo/webhook.py lines
46-47. Preserve the existing return behavior in both locations.
In `@connectors/zalo/webhook.py`:
- Line 39: Update the verify_webhook_signature call in the webhook handler to
pass self.headers directly, removing the dict conversion so its HTTPMessage
case-insensitive lookup behavior is preserved.
In `@sdk/python/magic_ai_sdk/connectors/auth.py`:
- Around line 17-22: Update AuthHandler.apply and OAuth2Auth.apply to share a
uniform asynchronous contract: make the abstract base method async and implement
OAuth2Auth.apply by obtaining credentials through token() and applying them to
the request kwargs instead of raising NotImplementedError. Update callers and
other handler implementations as needed to await apply while preserving
ApiKeyAuth behavior.
In `@sdk/python/magic_ai_sdk/connectors/schema.py`:
- Around line 45-50: Tighten validation on Message.direction to accept only the
documented inbound and outbound values, using the project’s existing
typed-validation conventions. Update OrderItem.quantity to enforce a positive
lower bound while preserving its default value and integer type.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6c0c75ec-d49a-47e8-a7f3-b90cb9fd199e
📒 Files selected for processing (21)
connectors/README.mdconnectors/conftest.pyconnectors/zalo/README.mdconnectors/zalo/__init__.pyconnectors/zalo/config.example.yamlconnectors/zalo/connector.pyconnectors/zalo/pytest.iniconnectors/zalo/requirements.txtconnectors/zalo/tests/__init__.pyconnectors/zalo/tests/test_connector.pyconnectors/zalo/webhook.pydocker-compose.ymlscripts/quickstart.shsdk/python/magic_ai_sdk/connectors/__init__.pysdk/python/magic_ai_sdk/connectors/auth.pysdk/python/magic_ai_sdk/connectors/base.pysdk/python/magic_ai_sdk/connectors/errors.pysdk/python/magic_ai_sdk/connectors/registry.pysdk/python/magic_ai_sdk/connectors/schema.pysdk/python/pyproject.tomlsdk/python/tests/test_connectors.py
… install - CI: install sdk/python[dev,connectors] not just [dev] — pydantic lives in the connectors extra, so Python Tests failed importing schema.py - webhook.py: pass self.headers (HTTPMessage) through unconverted so case-insensitive lookup still works; NOSONAR python:S5332 on the plain-HTTP serve_forever() (TLS terminates at the reverse proxy, documented in README — same convention as benchmarks/scripts/worker.py) - connector.py: case-insensitive webhook signature header lookup (works for both HTTPMessage and plain dicts), reject non-dict JSON payloads before calling .get() on them, merge redundant JSONDecodeError/ ValueError catch, wrap httpx.RequestError as TransientError so network blips go through with_retry instead of crashing the caller - connector.py: on_token_refreshed callback — Zalo refresh tokens are single-use/rotated, so without a way to persist the new pair a process restart reloads the stale refresh_token and permanently breaks auth until manual re-auth - auth.py: AuthHandler.apply is now async so OAuth2Auth can implement it properly (fetch token via token()) instead of always raising NotImplementedError, matching ApiKeyAuth's interface - registry.py: cache instances by (tenant_id, name) tuple instead of a colon-joined string — avoids a theoretical cross-tenant collision if either value contains ':' - schema.py: OrderItem.quantity >= 1 New/updated tests: token-refresh persistence callback, network-error wrapping, case-insensitive signature header, non-dict webhook payload, OAuth2Auth.apply(). sdk/python 36 tests, connectors/zalo 22 tests, all passing; Go core build unaffected.
…install SonarCloud's PR gate re-flags any line it considers "new/changed code" — editing the existing `pip install -e ".[dev]"` line to add the connectors extra made Sonar treat it as new and apply two supply-chain hotspot rules (S4830: package scripts during install, S5445: deps not locked to verified versions) that the identical, untouched `pip install ruff` line right below it also matches but isn't flagged for, purely because it wasn't touched by this PR's diff. This dropped the Quality Gate's Security Rating from B to C. NOSONAR per the repo's existing convention (benchmarks/scripts/worker.py) — this installs our own local package + PyPI dev/test extras in an ephemeral CI runner, versions come from pyproject.toml, and supply-chain risk is already covered by Dependabot + Socket Security on this repo.
|
Root cause of the lingering "C Reliability Rating on New Code" gate on PR #18, which persisted even after the Google Sheet connector's own redundant excepts were fixed in 89d2db5. UnicodeDecodeError and json.JSONDecodeError are both subclasses of ValueError, so listing them alongside ValueError in the same except tuple is dead weight — SonarCloud rule python:S5713, which it classifies as a Bug (Reliability), not a code smell. It's the same rule SonarCloud flagged on PR #17 (connector.py:163) before that PR merged. Because the Zalo connector landed on main today (PR #17), it still falls inside SonarCloud's New Code window, so its issues counted against PR #18's quality gate even though PR #18 doesn't touch those files. Behavior is unchanged: the same exceptions are still caught, just via their common base class. Left sdk/python/magic_ai_sdk/worker.py:131 (same pattern) untouched on purpose — it's long-standing code outside this PR's scope, and editing it would pull the whole file into Sonar's new-code window. Tests: zalo 22, google_sheet 22, sdk/python 36 — all passing.
* feat(connectors): add Google Sheet connector (Week 2 roadmap)
Second connector per MAGIC_MONTH1_TASK_LIST.md (magic-business): reads
and writes Order/Customer data in a Google Sheet, for shops without a
dedicated order-management system yet.
- Auth via Google service account (JWT bearer through google-auth);
the actual Sheets API v4 calls go through httpx like the Zalo
connector, so credential refresh uses a small httpx-based adapter
for google.auth.transport.Request instead of pulling in the separate
`requests` library just for that one call.
- Operations: read_range, update_range, append_row, batch_update
- Common Schema mapping is header-name-based (Customer/Order), with
Order.items round-tripped as a JSON string in an items_json column
since sheets have no nested types
- Error mapping follows Google's standard {error: {code, status,
message}} shape: RESOURCE_EXHAUSTED -> RateLimitError, 5xx/UNAVAILABLE/
INTERNAL -> TransientError (retried), everything else -> PermanentError
- 13 tests (auth mocked, no real RSA key needed; HTTP mocked via respx)
connectors/README.md updated to reflect google_sheet is implemented.
* fix(connectors): address review feedback on Google Sheet connector
Fixes the SonarCloud "C Reliability Rating" gate failure and Gemini/
CodeRabbit review comments on PR #18:
- Replace `assert self._client is not None` with an explicit check +
PermanentError — assertions are stripped under `python -O`, so this
was flagged as a real reliability bug, not just style
- connect(): close and clear the httpx client if token init fails
during connect(), instead of leaking it
- _request(): catch GoogleAuthError (covers RefreshError and friends)
and raise AuthError — a revoked/invalid service account key isn't
fixed by retrying, so it shouldn't go through with_retry; genuine
network errors during token refresh still flow through the existing
httpx.RequestError -> TransientError path since token fetch now runs
inside the same try block as the request
- _request(): treat HTTP 401 as TransientError and clear the cached
token so the retry is forced to refresh, instead of failing
permanently on a token that expired mid-flight
- map_to_common_schema: reject non-dict raw_data, only accept a JSON
list (not object/other) for items_json, and parse total_amount/
shipping_fee defensively instead of letting a malformed cell crash
the whole mapping
- map_from_common_schema: accept Pydantic models (via model_dump/dict)
in addition to plain dicts, default items to [] instead of "null"
- Simplify redundant `except (json.JSONDecodeError, ValueError)` to
just ValueError (JSONDecodeError is already a ValueError subclass);
use resp.is_success instead of `== 200`
- README: warn that config.yaml/service-account key are secrets and
shouldn't be committed, use repo-root-relative paths consistently,
and document that map_from_common_schema writes in a fixed column
order (reordering sheet columns will misalign writes)
9 new/updated tests covering the above; all passing (22 total in this
connector, 36 in sdk/python, 22 in zalo — none affected).
* fix(connectors): remove redundant exception classes in Zalo connector
Root cause of the lingering "C Reliability Rating on New Code" gate on
PR #18, which persisted even after the Google Sheet connector's own
redundant excepts were fixed in 89d2db5.
UnicodeDecodeError and json.JSONDecodeError are both subclasses of
ValueError, so listing them alongside ValueError in the same except
tuple is dead weight — SonarCloud rule python:S5713, which it classifies
as a Bug (Reliability), not a code smell. It's the same rule SonarCloud
flagged on PR #17 (connector.py:163) before that PR merged.
Because the Zalo connector landed on main today (PR #17), it still falls
inside SonarCloud's New Code window, so its issues counted against
PR #18's quality gate even though PR #18 doesn't touch those files.
Behavior is unchanged: the same exceptions are still caught, just via
their common base class.
Left sdk/python/magic_ai_sdk/worker.py:131 (same pattern) untouched on
purpose — it's long-standing code outside this PR's scope, and editing
it would pull the whole file into Sonar's new-code window.
Tests: zalo 22, google_sheet 22, sdk/python 36 — all passing.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Tuần 2 of the Month-1 roadmap: the customer-service pipeline for Vietnamese
online shops.
Nhận tin -> Classify Intent -> Extract Order Info -> Fetch Order Status
-> Draft Response -> Guardrail Check -> Send hoặc Handoff
packs/ecomops/ (new):
- text.py — Vietnamese normalization. Customers routinely type without
diacritics, so both the message and the keyword lists are folded to a
diacritics-free form; "đơn hàng" and "don hang" match the same rule. đ/Đ
are handled explicitly since they don't decompose under NFD.
- intents.py — 7 EcomOps intents. RuleBasedIntentClassifier matches keyword
tokens in order allowing one filler word between them, because Vietnamese
slots pronouns inside fixed phrases ("khi nào EM nhận được hàng" must still
match "khi nao nhan"). A greeting only wins when it's the whole message, so
"chào shop, đơn em đâu" classifies as order_status. LLMIntentClassifier goes
through MagiC's own /api/v1/llm/chat gateway (cheapest routing) rather than a
provider SDK, so spend lands in the Cost Controller. HybridIntentClassifier
only pays for the LLM when the rules aren't confident, and keeps the rule
guess if the gateway is down.
- extraction.py — order code + phone. Phones are masked out before the bare
numeric order-code pattern runs, otherwise "sdt 0901234567" returns the phone
as the order id and every lookup silently misses.
- guardrails.py — an LLM draft is not automatically safe to send from a shop's
official account. Blocks leaked third-party contacts, hard delivery
guarantees, unapproved refunds/vouchers, and system-prompt/credential leaks.
Runs on normalized text so it can't be evaded by dropping diacritics.
- prompts.py — Vietnamese prompt library, kept as data so shops can retune tone
without touching pipeline code.
- workflow.py — the pipeline. Every dependency is injected, so it makes no
network calls and knows nothing about Zalo or Sheets. A blocked draft is
never returned to the caller: reply falls back to a safe message and action
becomes "handoff". Complaints skip drafting entirely.
- example_worker.py — end-to-end demo (Zalo webhook -> Sheet lookup -> guarded
reply), which is the stated Tuần 2 deliverable.
CI (this is the part that was quietly broken): the python job only ever ran
sdk/python. The 44 connector tests added in #17/#18 have never executed on CI,
and neither would these 76. Running `pytest connectors packs` from the root
failed outright because asyncio_mode lived only in per-directory pytest.ini
files — hence the new root pytest.ini. ruff was also weaker outside sdk/python
(E501/I aren't in ruff's default rule set), so a root ruff.toml now mirrors
sdk/python's settings; the 4 import-order issues that surfaced are fixed.
Tests: 76 new (packs) + 44 (connectors) + 36 (sdk) = 156, all passing. Go core
untouched, builds clean.
* feat(packs): EcomOps workflow + run connector/pack tests in CI
Tuần 2 of the Month-1 roadmap: the customer-service pipeline for Vietnamese
online shops.
Nhận tin -> Classify Intent -> Extract Order Info -> Fetch Order Status
-> Draft Response -> Guardrail Check -> Send hoặc Handoff
packs/ecomops/ (new):
- text.py — Vietnamese normalization. Customers routinely type without
diacritics, so both the message and the keyword lists are folded to a
diacritics-free form; "đơn hàng" and "don hang" match the same rule. đ/Đ
are handled explicitly since they don't decompose under NFD.
- intents.py — 7 EcomOps intents. RuleBasedIntentClassifier matches keyword
tokens in order allowing one filler word between them, because Vietnamese
slots pronouns inside fixed phrases ("khi nào EM nhận được hàng" must still
match "khi nao nhan"). A greeting only wins when it's the whole message, so
"chào shop, đơn em đâu" classifies as order_status. LLMIntentClassifier goes
through MagiC's own /api/v1/llm/chat gateway (cheapest routing) rather than a
provider SDK, so spend lands in the Cost Controller. HybridIntentClassifier
only pays for the LLM when the rules aren't confident, and keeps the rule
guess if the gateway is down.
- extraction.py — order code + phone. Phones are masked out before the bare
numeric order-code pattern runs, otherwise "sdt 0901234567" returns the phone
as the order id and every lookup silently misses.
- guardrails.py — an LLM draft is not automatically safe to send from a shop's
official account. Blocks leaked third-party contacts, hard delivery
guarantees, unapproved refunds/vouchers, and system-prompt/credential leaks.
Runs on normalized text so it can't be evaded by dropping diacritics.
- prompts.py — Vietnamese prompt library, kept as data so shops can retune tone
without touching pipeline code.
- workflow.py — the pipeline. Every dependency is injected, so it makes no
network calls and knows nothing about Zalo or Sheets. A blocked draft is
never returned to the caller: reply falls back to a safe message and action
becomes "handoff". Complaints skip drafting entirely.
- example_worker.py — end-to-end demo (Zalo webhook -> Sheet lookup -> guarded
reply), which is the stated Tuần 2 deliverable.
CI (this is the part that was quietly broken): the python job only ever ran
sdk/python. The 44 connector tests added in #17/#18 have never executed on CI,
and neither would these 76. Running `pytest connectors packs` from the root
failed outright because asyncio_mode lived only in per-directory pytest.ini
files — hence the new root pytest.ini. ruff was also weaker outside sdk/python
(E501/I aren't in ruff's default rule set), so a root ruff.toml now mirrors
sdk/python's settings; the 4 import-order issues that surfaced are fixed.
Tests: 76 new (packs) + 44 (connectors) + 36 (sdk) = 156, all passing. Go core
untouched, builds clean.
* fix(ecomops): canonicalize VN phone forms in the leaked-contact guardrail
Found while PR #19's CI was running: a shop that configures its hotline as
"+84987654321" (or "84987654321") had its OWN number flagged as a leaked
third-party contact, so any reply quoting the hotline was blocked and pushed to
a human. The rule compared raw digit strings, so the +84 and 0-prefixed
spellings of the same number never matched.
Adds `canonical_phone()` — folds "+84 987-654-321", "84987654321",
"987654321" and "0987-654-321" all to "0987654321" — and compares canonical
forms on both sides. Detection is unaffected: a genuinely foreign number is
still blocked (covered by a new test that allowlists the hotline and checks a
different number is still caught).
Also NOSONAR the `pip install ruff` lint line. Editing that line to widen the
lint scope made SonarCloud treat it as new code and re-apply the same two
supply-chain hotspots (S4830/S5445) already suppressed on the install lines
above it — that's what dropped this PR's Security Rating to C.
Tests: 14 new (canonical_phone table + hotline spellings + allowlist scoping),
170 total, all passing.
---------
Co-authored-by: Claude <noreply@anthropic.com>


What does this PR do?
Kicks off the Month-1 roadmap from magic-business's
MAGIC_MONTH1_TASK_LIST.md/MAGIC_CONNECTOR_FRAMEWORK_DESIGN.md: a Connector Framework plus a first working connector.sdk/python/magic_ai_sdk/connectors/—BaseConnector(async ABC: connect/disconnect/execute/map_to_common_schema/map_from_common_schema/health_check/webhook hooks),ConnectorRegistry(per-tenant instance caching),AuthHandlerabstractions (ApiKeyAuth,OAuth2Auth,WebhookSignatureAuth), a retryable error hierarchy (TransientError/RateLimitError/PermanentError/AuthError+with_retryexponential backoff decorator), and the Common Data Schema as Pydantic models (Customer,Order,Case,Conversation,Message,KnowledgeDocument). New optional extra:pip install magic-ai-sdk[connectors].connectors/zalo/— Zalo Official Account connector implementingBaseConnector: send text/template messages, receive + verify webhook signatures (SHA256 overapp_id + raw_body + timestamp + oa_secret_key, per Zalo's official scheme — not HMAC), access-token refresh flow, Common Schema mapping for inbound messages. Runs as an external Python worker/service, consistent with the existing "core never contains platform-specific logic" principle (workers are external HTTP servers; MagiC core just routes).docker-compose.yml(magic + postgres + redis) +scripts/quickstart.shfor 1-click local dev, as specified in the Month-1 task list.This is architecture built on top of the already-merged #5 (enterprise hardening) — no changes to Go core.
Related issue
Tracks Month 1 (Tuần 1) of the technical roadmap in magic-business/technical-planning.
Checklist
cd core && go build ./...(Go core untouched, builds clean)sdk/python/tests/test_connectors.py,connectors/zalo/tests/test_connector.py), all passingconnectors/README.md(Vietnamese framework guide) +connectors/zalo/README.md(Vietnamese Zalo setup guide)Generated by Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests