Compare commits

..
3 Commits
Author SHA1 Message Date
Abiba (pi) b1bef9024f fix(zulip): resolve bot user_id from /users/me for @mention detection
The /register endpoint doesn't always return user_id on all Zulip
server versions. Added _resolve_bot_user_id() that queries the
/users/me endpoint as a fallback. This ensures @mention detection
(is_direct_mention check) works even when register doesn't provide it.

Also bumped to v1.0.3 — cumulative fixes since v1.0.1:
- v1.0.2: BAD_EVENT_QUEUE_ID detection (silent queue expiry)
- v1.0.3: bot user_id resolution (@mention detection)
2026-06-27 05:51:12 +00:00
Abiba (pi) dcf2de0052 fix(zulip): detect BAD_EVENT_QUEUE_ID instead of silently swallowing it
Root cause of Tanko not responding to DMs after initial connection:
Zulip event queues expire after ~10min of dont_block polling. When the
queue expired, _api_call() returned None for all 400-level errors, which
_fetch_events() treated as 'no events' and returned []. The poll loop
never knew the queue died, so it never reconnected.

Fix:
1. _api_call() now returns (Optional[Dict], status_code) tuple
2. _fetch_events() explicitly checks for 400 status and raises
   RuntimeError('BAD_EVENT_QUEUE_ID') for the poll loop to catch
3. All callers updated to unpack the tuple

Now when a queue expires, the poll loop catches the error and
calls _reconnect() to register a fresh queue.
2026-06-27 05:50:13 +00:00
Abiba (pi) 715d54564f fix(zulip): pass Platform enum instead of string to BasePlatformAdapter.__init__
Root cause of 'str' object has no attribute 'value' error during connect():
the adapter passed a raw string 'zulip' to the base class's platform
parameter, which expects a Platform enum instance. When self.name was
accessed (calling self.platform.value.title()), the string had no .value
attribute.

Fix: import Platform from gateway.config and wrap with Platform('zulip').
2026-06-27 05:33:46 +00:00
+64 -18
View File
@@ -43,7 +43,7 @@ except ImportError:
HTTPX_AVAILABLE = False
httpx = None
from gateway.config import PlatformConfig
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@@ -118,8 +118,8 @@ class ZulipAdapter(BasePlatformAdapter):
"""
def __init__(self, config: PlatformConfig):
platform_name = "zulip"
super().__init__(config=config, platform=platform_name)
platform = Platform("zulip")
super().__init__(config=config, platform=platform)
extra = config.extra or {}
@@ -242,7 +242,7 @@ class ZulipAdapter(BasePlatformAdapter):
self._health_stats["started_at"] = datetime.now(timezone.utc).isoformat()
# Register event queue
queue_resp = await self._api_call(
queue_resp, _ = await self._api_call(
"POST", "/api/v1/register",
data={
"event_types": '["message"]',
@@ -259,6 +259,11 @@ class ZulipAdapter(BasePlatformAdapter):
self._last_event_id = data.get("last_event_id", -1)
self._bot_user_id = data.get("user_id")
# Resolve bot user_id if register endpoint didn't provide it
# (common on some Zulip versions)
if self._bot_user_id is None:
await self._resolve_bot_user_id()
# Gen 2: Try to resolve @all-bots user ID dynamically
await self._resolve_all_bots_user_id()
@@ -381,11 +386,15 @@ class ZulipAdapter(BasePlatformAdapter):
await asyncio.sleep(self._poll_interval)
async def _fetch_events(self) -> List[Dict[str, Any]]:
"""Fetch events from the Zulip event queue."""
"""Fetch events from the Zulip event queue.
Raises RuntimeError with BAD_EVENT_QUEUE_ID when the queue
expires, so _poll_forever can reconnect.
"""
if not self._queue_id:
return []
resp = await self._api_call(
resp, raw_status = await self._api_call(
"GET", "/api/v1/events",
params={
"queue_id": self._queue_id,
@@ -393,6 +402,9 @@ class ZulipAdapter(BasePlatformAdapter):
"dont_block": "true",
},
)
# Detect queue expiry from HTTP response
if raw_status == 400:
raise RuntimeError("BAD_EVENT_QUEUE_ID: queue expired")
if not resp:
return []
@@ -409,7 +421,7 @@ class ZulipAdapter(BasePlatformAdapter):
"""Re-register the event queue."""
self._queue_id = None
try:
resp = await self._api_call(
resp, _ = await self._api_call(
"POST", "/api/v1/register",
data={
"event_types": '["message"]',
@@ -496,6 +508,32 @@ class ZulipAdapter(BasePlatformAdapter):
"[%s] @all-bots refresh error (non-fatal)", self.name
)
# ------------------------------------------------------------------
# Gen 5: Bot user ID resolution
# ------------------------------------------------------------------
async def _resolve_bot_user_id(self) -> None:
"""Fetch the bot's own user ID from /api/v1/users/me.
The /register endpoint does not always return user_id on
all Zulip server versions. This ensures @mention detection
works by resolving it from the identity endpoint.
"""
try:
resp, _ = await self._api_call("GET", "/api/v1/users/me")
if resp:
uid = resp.get("user_id")
if uid is not None:
self._bot_user_id = int(uid)
logger.info(
"[%s] Resolved bot user_id=%s from /users/me",
self.name, uid,
)
except Exception as e:
logger.debug(
"[%s] Could not resolve bot user_id: %s", self.name, e
)
# ------------------------------------------------------------------
# Gen 2: Dynamic @all-bots resolution
# ------------------------------------------------------------------
@@ -506,7 +544,7 @@ class ZulipAdapter(BasePlatformAdapter):
Falls back to the configured or default value if resolution fails.
"""
try:
resp = await self._api_call("GET", "/api/v1/users")
resp, _ = await self._api_call("GET", "/api/v1/users")
if not resp:
return
members = resp.get("members", [])
@@ -766,7 +804,7 @@ class ZulipAdapter(BasePlatformAdapter):
"content": truncated,
}
try:
resp = await self._api_call("PATCH", "/api/v1/messages", data=payload)
resp, _ = await self._api_call("PATCH", "/api/v1/messages", data=payload)
return resp is not None
except Exception as e:
logger.warning("[%s] Edit message %s error: %s", self.name, message_id, e)
@@ -782,6 +820,7 @@ class ZulipAdapter(BasePlatformAdapter):
"POST", "/api/v1/typing",
data={"to": user_ids, "op": "start"},
)
# typing indicator failure is non-critical
except Exception:
pass # Non-critical
@@ -795,6 +834,7 @@ class ZulipAdapter(BasePlatformAdapter):
"POST", "/api/v1/typing",
data={"to": user_ids, "op": "stop"},
)
# typing indicator failure is non-critical
except Exception:
pass # Non-critical
@@ -1009,10 +1049,15 @@ class ZulipAdapter(BasePlatformAdapter):
path: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None,
) -> Optional[Dict]:
"""Make an API call to Zulip."""
) -> Tuple[Optional[Dict], int]:
"""Make an API call to Zulip.
Returns (response_json, status_code). status_code is useful for
callers that need to detect specific HTTP errors like 400
(BAD_EVENT_QUEUE_ID). Returns (None, 0) on transport errors.
"""
if not self._http_client:
return None
return None, 0
url = f"{self._site}{path}"
headers = {
@@ -1033,7 +1078,7 @@ class ZulipAdapter(BasePlatformAdapter):
url, data=data, headers=headers,
)
else:
return None
return None, 0
if response.status_code >= 400:
logger.warning(
@@ -1041,20 +1086,21 @@ class ZulipAdapter(BasePlatformAdapter):
self.name, method, path,
response.status_code, response.text[:200],
)
return None
return None, response.status_code
return response.json()
return response.json(), response.status_code
except httpx.TimeoutException:
logger.debug("[%s] Timeout on %s %s", self.name, method, path)
return None
return None, 0
except Exception as e:
logger.warning("[%s] API error %s %s: %s", self.name, method, path, e)
return None
return None, 0
async def _send_api_call(self, payload: Dict) -> Optional[Dict]:
"""Send a message to Zulip."""
return await self._api_call("POST", "/api/v1/messages", data=payload)
result, _ = await self._api_call("POST", "/api/v1/messages", data=payload)
return result
# ------------------------------------------------------------------
# Utilities