Skip to content

Add Zulip channel plugin#8365

Closed
rafaelreis-r wants to merge 13 commits intoopenclaw:mainfrom
rafaelreis-r:feat/zulip-channel-plugin
Closed

Add Zulip channel plugin#8365
rafaelreis-r wants to merge 13 commits intoopenclaw:mainfrom
rafaelreis-r:feat/zulip-channel-plugin

Conversation

@rafaelreis-r
Copy link
Copy Markdown
Contributor

@rafaelreis-r rafaelreis-r commented Feb 3, 2026

Adds an official Zulip channel plugin.

Discussion / Feature request: https://github.com/openclaw/openclaw/discussions/5163

Key features:

  • Streams + topics support (threads)
  • DM support, resolving recipient email -> user_id (including delivery_email)
  • 👀 reactions on inbound messages
  • Typing indicators in DMs
  • Optional Cloudflare Access Service Token support via env vars (CF-Access-Client-Id/Secret)

Docs:

  • New docs page: docs/channels/zulip.md

Tests:

  • Added vitest coverage for target parsing, user id resolution, Cloudflare Access HTML detection, outbound DM user_id behavior, reactions, and typing payloads.

Security defaults:

  • DM policy defaults to pairing.
  • Streams remain mention-gated by default.

Greptile Overview

Greptile Summary

This PR adds an official Zulip channel plugin under extensions/zulip/, including config schema + account resolution, target normalization/parsing, a Zulip HTTP client (with optional Cloudflare Access service-token headers), an event monitor loop using /register + /events, and outbound send support for streams/topics and DMs (resolving recipient emails to numeric user_id via /api/v1/users).

It also updates channel docs to include Zulip and adds unit tests covering target parsing, user-id resolution (including delivery_email), Cloudflare HTML detection, typing/reaction payload shapes, and DM send behavior. There’s a small core change in agent-runner-memory around compaction/flush counting plus added regression coverage.

Main issues found are around Zulip monitor gating: reactions are currently sent before access-control checks (so dropped messages still get acknowledged), and stream control-command authorization appears to use the DM allowlist decision rather than the group allowlist decision (mis-authorizing commands when groupAllowFrom differs).

Confidence Score: 3/5

  • Reasonably safe to merge after addressing a couple of Zulip monitor gating/authorization issues.
  • Most of the PR is new, well-scoped plugin code with tests, but the monitor currently reacts to messages before access-control gating and appears to use the DM allowlist decision when evaluating stream control-command authorization, which can cause observable acks and incorrect command authorization in common configurations (e.g., groupAllowFrom). Fixing those reduces behavioral/security surprises.
  • extensions/zulip/src/zulip/monitor.ts

Context used:

  • Context from dashboard - CLAUDE.md (source)
  • Context from dashboard - AGENTS.md (source)

@openclaw-barnacle openclaw-barnacle bot added the docs Improvements or additions to documentation label Feb 3, 2026
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +169 to +174
// Ack reaction (👀) on any inbound message we accept.
try {
await reactEyes(client, message.id);
} catch {
// Best-effort; ignore reaction failures.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reaction ack is applied before access-control gating

reactEyes() is executed for every inbound message right after basic validation (sender_email/self/text), before DM policy / allowlist / mention-gating checks. This means even messages that are later dropped (e.g. stream not mentioned, dmPolicy=disabled, sender not allowlisted) will still get an 👀 reaction, which is observable and can unintentionally “acknowledge” untrusted senders.

Consider moving the reaction call to after the message has passed the relevant policy gates (or gating it to the same conditions as reply/accept).

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/zulip/src/zulip/monitor.ts
Line: 169:174

Comment:
[P1] Reaction ack is applied before access-control gating

`reactEyes()` is executed for every inbound message right after basic validation (sender_email/self/text), before DM policy / allowlist / mention-gating checks. This means even messages that are later dropped (e.g. stream not mentioned, dmPolicy=disabled, sender not allowlisted) will still get an 👀 reaction, which is observable and can unintentionally “acknowledge” untrusted senders.

Consider moving the reaction call to after the message has passed the relevant policy gates (or gating it to the same conditions as reply/accept).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +226 to +234
const useAccessGroups = (cfg as any).commands?.useAccessGroups !== false;
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedDm }],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = isDm ? (dmPolicy === "open" || senderAllowedDm) : commandGate.commandAuthorized;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Stream control command gate uses DM allowlist decision

For stream messages, resolveControlCommandGate() is given allowed: senderAllowedDm (computed from effectiveAllowFrom). If groupAllowFrom is configured (or if stream allow rules differ from DM allow rules), a sender who is allowed in a stream can still be treated as not-authorized for control commands because the gate is using the DM allow decision.

This shows up when groupAllowFrom is non-empty (or group policy differs): the code passes senderAllowedGroup for stream gating earlier, but command authorization still keys off senderAllowedDm.

Also appears to affect: commandAuthorized for group messages (commandGate.commandAuthorized depends on that authorizer input).

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/zulip/src/zulip/monitor.ts
Line: 226:234

Comment:
[P0] Stream control command gate uses DM allowlist decision

For stream messages, `resolveControlCommandGate()` is given `allowed: senderAllowedDm` (computed from `effectiveAllowFrom`). If `groupAllowFrom` is configured (or if stream allow rules differ from DM allow rules), a sender who is allowed in a stream can still be treated as not-authorized for control commands because the gate is using the DM allow decision.

This shows up when `groupAllowFrom` is non-empty (or group policy differs): the code passes `senderAllowedGroup` for stream gating earlier, but command authorization still keys off `senderAllowedDm`.

Also appears to affect: `commandAuthorized` for group messages (`commandGate.commandAuthorized` depends on that authorizer input).

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +349 to +360

try {
await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
onModelSelected: prefixContext.onModelSelected,
},
});
} finally {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Indentation error in try/finally block

The try { await core.channel.reply.dispatchReplyFromConfig(...) } finally { ... } block is mis-indented (the await and closing braces are not indented under try). It still parses, but it’s easy to misread and looks like a formatting regression.

Suggestion: re-indent the block so the await is nested under try { and the closing braces align.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/zulip/src/zulip/monitor.ts
Line: 349:360

Comment:
[P2] Indentation error in try/finally block

The `try { await core.channel.reply.dispatchReplyFromConfig(...) } finally { ... }` block is mis-indented (the `await` and closing braces are not indented under `try`). It still parses, but it’s easy to misread and looks like a formatting regression.

Suggestion: re-indent the block so the `await` is nested under `try {` and the closing braces align.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 3, 2026

Additional Comments (1)

extensions/zulip/src/normalize.ts
[P1] stream: target parsing accepts #/: separators in addition to /

The docs and hint string specify stream:<stream>/<topic>, but parsing uses rest.split(/\s*[/#:]\s*/, 2), which will treat stream:Engineering#Alerts and stream:Engineering:Alerts as valid too. If : is part of a stream name (or if you want to reserve # for the shorthand form only), this can produce surprising routing.

If you intend to support only / for the explicit stream: form, consider tightening the split regex to just / (and keep # handling in the separate shorthand branch).

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/zulip/src/normalize.ts
Line: 664:668

Comment:
[P1] `stream:` target parsing accepts `#`/`:` separators in addition to `/`

The docs and hint string specify `stream:<stream>/<topic>`, but parsing uses `rest.split(/\s*[/#:]\s*/, 2)`, which will treat `stream:Engineering#Alerts` and `stream:Engineering:Alerts` as valid too. If `:` is part of a stream name (or if you want to reserve `#` for the shorthand form only), this can produce surprising routing.

If you intend to support only `/` for the explicit `stream:` form, consider tightening the split regex to just `/` (and keep `#` handling in the separate shorthand branch).

How can I resolve this? If you propose a fix, please make it concise.

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes:

  • Tighten stream: target parsing to only split on / (keep # shorthand separate).
  • Zulip monitor: move 👀 ack reaction after access-control gating (no acks for dropped/mention-gated messages).
  • Fix stream control-command authorization: use group allowlist decision when in streams (DM allowlist stays DM-only).
  • CI/install: add pnpm-lock.yaml update so frozen installs include the new extensions/zulip workspace.

Commits:

Thanks for the review.

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes (non-JSON diagnostics):

  • Improve error reporting: distinguish HTML-intercepted responses vs other non-JSON responses.
  • Include HTTP status, content-type, and a short response snippet to help diagnose proxies/load balancers/misconfigured base URLs (not just Cloudflare Access).
  • Updated unit test accordingly.

Commit: 23da029

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes (non-JSON diagnostics):

  • Improve parseJsonOrThrow error reporting: distinguish HTML-intercepted responses vs other non-JSON responses.
  • Include HTTP status, content-type, and a short response snippet to help diagnose proxies/load balancers/misconfigured base URLs (not just Cloudflare Access).
  • Updated unit test accordingly.

Commit: 23da029

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes (Windows/alt-deploy diagnostics):

  • Clarify HTML/non-JSON error messaging for HTTP 5xx: treat as upstream/proxy/LB/CDN failure page (not automatically Cloudflare Access / auth).
  • Add hint to compare DNS (IPv6 vs IPv4), egress network/proxies, and reverse-proxy timeouts for long-polling endpoints (/api/v1/events) when the same Zulip works from another environment.

Commit: ddb991d

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes (retry behavior aligned w/ other channel monitors):

  • Zulip events loop now retries transient poll failures without immediately re-registering the event queue.
  • Re-register is reserved for BAD_EVENT_QUEUE_ID (invalid queue) or repeated failures.
  • Adds simple capped delay between retries to reduce log spam and avoid hammering the API during intermittent 5xx.

Commit: adabeca

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed improvements (mirroring existing channel plugin patterns):

  • Add status probeAccount (authenticated /api/v1/users/me) with timeout to validate baseUrl + credentials.
  • Add security.collectWarnings for risky groupPolicy=open configuration (aligns with Slack/Mattermost warnings).
  • Use core markdown chunker for outbound text (consistent with other channel plugins).
  • Normalize DM policy config paths to account-scoped paths when using channels.zulip.accounts..
  • Use logInboundDrop for unauthorized stream control-command drops (consistent with other monitors).

Commit: 78c497d

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed fixes (Zulip uploads/media access):

  • Inbound Zulip messages can include /user_uploads links that require login (302 to /accounts/login). Tools like web_fetch/image fail on those unauthenticated URLs.
  • The Zulip monitor now extracts user_uploads URLs from message HTML and fetches them using the bot's Basic Auth (and optional CF Access service token headers).
  • Downloaded media is saved via core media store and attached to the inbound context as MediaPath/MediaPaths (+ MediaUrls/MediaTypes) so the agent can access it without needing browser cookies.
  • Added unit tests for upload URL extraction.

Commit: 47e9567

FtlC-ian added a commit to FtlC-ian/openclaw that referenced this pull request Feb 8, 2026
Adds a full Zulip channel plugin with:
- Event queue polling with exponential backoff
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Refs: #5163, openclaw#8365, openclaw#9643
simpliq-marvin pushed a commit to simpliq-marvin/openclaw that referenced this pull request Feb 10, 2026
Adds a full Zulip channel plugin with:
- Event queue polling with exponential backoff
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Refs: #5163, openclaw#8365, openclaw#9643
@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed updates to this PR branch with the Zulip finish-up work requested.

What was implemented

  1. Topic/session isolation for streams

    • Stream conversations are now isolated per (stream + topic).
    • Added a stable hashZulipTopicKey() (SHA-256 + base64url, truncated) so very long or odd topic names don’t break/session-key bloat.
    • Routing peer id and pending history key now use stream + hashed topic semantics.
  2. Outbound topic directive

    • Added support for: [[zulip_topic: <topic>]]
    • Directive is stripped from outbound text.
    • For stream replies, directive overrides reply topic target.
  3. Robust monitor backoff

    • Added ZulipApiError metadata (status, retryAfterMs, code) from API responses.
    • Poll loop now:
      • honors Retry-After when present,
      • applies stronger special backoff for HTTP 429,
      • applies defensive sleep on malformed/empty payloads (to avoid hot loops),
      • re-registers only on BAD_EVENT_QUEUE_ID (not on generic 5xx/transient failures).
  4. Wire textChunkLimit for markdown replies

    • textChunkLimit is now actively applied for monitor-generated replies.
    • Reply text passes through table conversion (convertMarkdownTables) then markdown chunking (chunkMarkdownText) before send.
  5. Failover behavior preserved

    • apiBaseUrls failover remains intact.
    • 5xx/network/HTML-proxy responses still fail over.
    • 429/other non-5xx API errors no longer fail over immediately, preserving Retry-After semantics for monitor handling.

Tests added/updated

  • directives.test.ts for topic directive extraction/strip behavior.
  • monitor.test.ts for topic hashing stability and monitor backoff behavior.
  • client.test.ts extended for Retry-After metadata and 429 non-failover behavior.

Validation run

  • pnpm vitest run extensions/zulip/src/**/*.test.ts
  • pnpm oxlint --type-aware --tsconfig tsconfig.oxlint.json extensions/zulip/src

Commit pushed:

  • 7284c7e0175cb5b02cd4749cf5304e48131ddae9

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Pushed additional fixes to the PR head branch (feat/zulip-channel-plugin).

Included changes (from PR #9643 best ideas + reliability improvements)

  • Stream session isolation per (stream + topic) using a stable hashed topic key to avoid context bleed.
  • Outbound topic directive support: [[zulip_topic: <topic>]] (stripped from text; overrides stream topic).
  • Monitor backoff improvements:
    • honors Retry-After metadata
    • stronger backoff for HTTP 429
    • defensive sleep to avoid tight loops on empty payloads
    • re-register only on BAD_EVENT_QUEUE_ID (not on generic 5xx)
  • textChunkLimit wired for markdown replies (convert tables + chunk markdown before send).
  • Multi-base URL failover preserved (apiBaseUrls + last-good TTL).

Validation

  • pnpm vitest run extensions/zulip/src/**/*.test.ts
  • pnpm oxlint --type-aware --tsconfig tsconfig.oxlint.json extensions/zulip/src

Head branch update:

  • Latest commit on PR head: 61ef6348f

@rafaelreis-r
Copy link
Copy Markdown
Contributor Author

Closing in favor of PR #12183, which supersedes this work.\n\nSuperseding PR: https://github.com/openclaw/openclaw/pull/12183\n\nThis PR implemented Zulip plugin improvements (multi-base-url failover, topic isolation, monitor backoff, etc.), but the upstreamed implementation in #12183 is now the recommended path.

FtlC-ian added a commit to FtlC-ian/openclaw that referenced this pull request Feb 11, 2026
Adds a full Zulip channel plugin with:
- Event queue polling with exponential backoff
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Refs: #5163, openclaw#8365, openclaw#9643
FtlC-ian added a commit to FtlC-ian/openclaw that referenced this pull request Feb 12, 2026
Adds a full-featured Zulip channel plugin with:

Core Features:
- Event queue polling with exponential backoff and auto-recovery
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Key Improvement Over Existing PRs:
- Implements CONCURRENT message processing with staggered start times
- Other PRs (openclaw#9643, openclaw#14182) process messages sequentially, causing
  reply delays and "message dumps" when multiple messages arrive
- This implementation starts processing each message immediately with
  a 200ms stagger for natural conversation flow
- Replies arrive as each finishes instead of all at once

Technical Details:
- Fire-and-forget message processing with per-message error handling
- Maintains event cursor and queue ID correctly during concurrent processing
- Tested live and confirmed significant UX improvement

Refs: openclaw#5163, openclaw#8365, openclaw#9643, openclaw#12183
simpliq-marvin pushed a commit to simpliq-marvin/openclaw that referenced this pull request Feb 26, 2026
Adds a full-featured Zulip channel plugin with:

Core Features:
- Event queue polling with exponential backoff and auto-recovery
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Key Improvement Over Existing PRs:
- Implements CONCURRENT message processing with staggered start times
- Other PRs (openclaw#9643, openclaw#14182) process messages sequentially, causing
  reply delays and "message dumps" when multiple messages arrive
- This implementation starts processing each message immediately with
  a 200ms stagger for natural conversation flow
- Replies arrive as each finishes instead of all at once

Technical Details:
- Fire-and-forget message processing with per-message error handling
- Maintains event cursor and queue ID correctly during concurrent processing
- Tested live and confirmed significant UX improvement

Refs: openclaw#5163, openclaw#8365, openclaw#9643, openclaw#12183
FtlC-ian added a commit to FtlC-ian/openclaw that referenced this pull request Feb 27, 2026
Adds a full-featured Zulip channel plugin with:

Core Features:
- Event queue polling with exponential backoff and auto-recovery
- Authenticated upload downloads from /user_uploads/
- Outbound file uploads via /api/v1/user_uploads
- Reaction indicators (eyes → check_mark/warning)
- Topic directive [[zulip_topic: <topic>]]
- HTTP retry with backoff (429/502/503/504)
- Full actions API (stream CRUD, user management, reactions)
- Channel docs and unit tests

Key Improvement Over Existing PRs:
- Implements CONCURRENT message processing with staggered start times
- Other PRs (openclaw#9643, openclaw#14182) process messages sequentially, causing
  reply delays and "message dumps" when multiple messages arrive
- This implementation starts processing each message immediately with
  a 200ms stagger for natural conversation flow
- Replies arrive as each finishes instead of all at once

Technical Details:
- Fire-and-forget message processing with per-message error handling
- Maintains event cursor and queue ID correctly during concurrent processing
- Tested live and confirmed significant UX improvement

Refs: openclaw#5163, openclaw#8365, openclaw#9643, openclaw#12183
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant