Skip to content

feat: Zulip channel plugin with concurrent message processing#15051

Closed
FtlC-ian wants to merge 13 commits intoopenclaw:mainfrom
FtlC-ian:feat/zulip-plugin-v2
Closed

feat: Zulip channel plugin with concurrent message processing#15051
FtlC-ian wants to merge 13 commits intoopenclaw:mainfrom
FtlC-ian:feat/zulip-plugin-v2

Conversation

@FtlC-ian
Copy link
Copy Markdown

@FtlC-ian FtlC-ian commented Feb 12, 2026

Summary

Adds a full-featured Zulip channel plugin with concurrent message processing that significantly improves conversation flow compared to existing Zulip PRs.

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 (16 passing)

Security & SDK Compliance

  • SSRF protection: All outbound HTTP uses fetchWithSsrFGuard() (no raw fetch())
  • Temp directory policy: Uses resolvePreferredOpenClawTmpDir() (no os.tmpdir())
  • Group auth composition: Uses resolveDmGroupAccessWithLists() shared resolver (no manual pairing-store group auth)
  • Pairing scope: Uses createScopedPairingAccess() for account-scoped pairing operations
  • ✅ All 6 CI lint gates pass (check-no-raw-channel-fetch, check-no-random-messaging-tmp, check-no-pairing-store-group-auth, check-pairing-account-scope, check-channel-agnostic-boundaries, check-no-raw-window-open)

Key Improvement Over Existing PRs

The Problem

Existing Zulip PRs (#9643, #14182) process messages sequentially, causing:

  • Long delays when multiple messages arrive during the polling window
  • "Message dumps" where all replies arrive at once after minutes of silence
  • Poor conversation UX

The Solution

This PR implements concurrent message processing with staggered start times:

// Old way (sequential - causes dumps):
for (const event of events) {
  if (event.type === "message" && event.message) {
    await processMessage(event.message);  // Blocks until complete
  }
}

// New way (concurrent - natural flow):
for (const event of events) {
  if (event.type === "message" && event.message) {
    processMessage(event.message).catch(handleError);  // Fire-and-forget
    await delay(200);  // Small stagger for natural pacing
  }
}

Verified Impact

  • ✅ Tested live on production Zulip instance
  • ✅ Messages start processing immediately (no blocking)
  • ✅ Replies arrive independently as each finishes
  • ✅ Natural conversation flow instead of reply dumps

Technical Details

  • Fire-and-forget message processing with per-message error handling
  • Maintains event cursor and queue ID correctly during concurrent processing
  • 200ms stagger between starting each message for natural pacing
  • Error handling does not block subsequent message processing
  • DM policy, group policy, and allowFrom enforcement via shared SDK resolvers
  • Mention detection, onchar prefix mode, and group chat history context
  • Message deduplication cache (5-minute TTL, 2000-entry max)
  • Media download with size limits and verbose error logging (no silent failures)

Comparison with PR #22308

PR #22308 provides a minimal Zulip skeleton (~1,670 lines). This PR is the full-featured implementation (~3,500+ lines) with:

  • Concurrent message processing (the key UX differentiator)
  • Proper DM/group policy enforcement in the monitor
  • Media download/upload handling
  • Message deduplication
  • Typing indicators
  • Group chat history context
  • Onchar prefix mode
  • Full SDK security compliance (SSRF guard, temp dirs, group auth resolvers)

References

Testing

  • 16 unit tests passing (channel config + uploads)
  • Deployed and tested live on self-hosted Zulip
  • All CI checks green

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.

23 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +333 to +336
mediaPaths.push(saved.path);
mediaTypes.push(saved.contentType);
mediaUrls.push(uploadUrl);
}
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.

Silent media download failures

The /user_uploads download loop swallows all exceptions (catch (err) {}), so attachment fetch failures become invisible and users will see missing media with no diagnostic trail. Please log (at least debug/verbose) when a download/save fails so the operator can understand why attachments aren’t being forwarded.

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

Comment:
**Silent media download failures**

The `/user_uploads` download loop swallows all exceptions (`catch (err) {}`), so attachment fetch failures become invisible and users will see missing media with no diagnostic trail. Please log (at least debug/verbose) when a download/save fails so the operator can understand why attachments aren’t being forwarded.

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

Comment on lines +176 to +184
} else {
tempFilePath = await writeTempFile(fetched.buffer, filename);
tempFileCleanup = true;
}
const upload = await uploadZulipFile(client, tempFilePath);
mediaUrl = upload.url;
if (tempFileCleanup && tempFilePath) {
await fsPromises.unlink(tempFilePath).catch(() => undefined);
}
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.

Temp directory leak

When mediaUrl is a remote URL, writeTempFile() creates a mkdtemp directory, but the cleanup only unlinks tempFilePath and never removes the containing temp directory. Over time this will leave many empty zulip-upload-* dirs in the system temp folder. Consider removing the temp dir after the upload (or reuse core.channel.media.saveMediaBuffer exclusively).

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/zulip/src/zulip/send.ts
Line: 176:184

Comment:
**Temp directory leak**

When `mediaUrl` is a remote URL, `writeTempFile()` creates a `mkdtemp` directory, but the cleanup only unlinks `tempFilePath` and never removes the containing temp directory. Over time this will leave many empty `zulip-upload-*` dirs in the system temp folder. Consider removing the temp dir after the upload (or reuse `core.channel.media.saveMediaBuffer` exclusively).

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

@maltokyo
Copy link
Copy Markdown

Great to see this coming, from my side, thank you!!

@wilenius
Copy link
Copy Markdown

Just tested this branch, thanks for your work on this! Based on a quick test, it works great, except there's some oddities with message processing. At one point the agent lost track of the thread context, and on another thread, when I asked the agent to fetch some data via Zotero MCP before answering, it posted the answer twice, and this in between the two answers:

I think you didn't see my reply — I responded here in our session but didn't send it to the Zulip stream. Let me post it there now.
Ah! I see — you asked a follow-up question about trust calibration that I missed. Let me search Zotero for relevant literature and elaborate.

Please let me know if you need more details or if I can help more with testing.

@baustin27
Copy link
Copy Markdown

Hi @FtlC-ian! 👋

I've been reviewing your Zulip plugin implementation and I'm impressed with the work you've done here. The typing notification implementation in particular is well-designed:

  • sendZulipTyping() in client.ts - Clean API wrapper for the /typing endpoint
  • createTypingCallbacks integration in monitor.ts - Proper start/stop handling with error logging

I noticed there's an opportunity to add unit tests for the typing functionality. The existing test coverage includes:

  • channel.test.ts - Plugin configuration and normalization
  • uploads.test.ts - Upload URL extraction and emoji normalization

Missing test coverage for:

  • client.ts - sendZulipTyping() function (stream and direct message variants)
  • monitor.ts - createTypingCallbacks integration

I'd like to contribute tests for the typing functionality to improve the plugin's reliability. Would you be open to collaborating on this? I can either:

  1. Submit a PR to your branch with the test additions
  2. Wait until your PR is merged and then submit a follow-up PR

Let me know what works best for you!


Quick PR assessment:

  • ✅ Typing implementation is complete and functional
  • ⚠️ Minor merge conflict with pnpm-lock.yaml (easy to resolve)
  • 📝 Test coverage could be expanded for typing functions

Thanks for the great work on this plugin!

@FtlC-ian
Copy link
Copy Markdown
Author

Hey @baustin27, thanks for the kind words and the thorough review! 🙏

Adding typing tests is a great idea — that's a real gap in the current coverage. I'd say option 2 (follow-up PR after merge) would work best. The branch has been pretty active with rebases and it'd be easier to avoid coordination headaches on a fork branch.

Once this lands, feel free to open a PR with the typing tests and I'll be happy to review it.

Re: the pnpm-lock conflict — yeah, that's just a rebase artifact, easy fix when we're ready to merge.

Thanks again!

FtlC-ian and others added 9 commits February 27, 2026 11:14
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
- Add logging for media download failures (silent exception swallowing)
- Fix temp directory leak by tracking and removing temp dirs after upload
- Update pnpm-lock.yaml with Zulip extension dependencies

Addresses CI failures and Greptile review comments.
…n file paths

OpenClaw core URL-encodes MessageThreadId for filesystem safety. The plugin
was passing raw topic names ('general chat') which became '-topic-general%20chat'
in session file paths.

Changes:
- Add sanitizeThreadId() to replace spaces and unsafe chars with hyphens
- Update resolveThreadSessionKeys() to return sanitizedThreadId
- Pass threadKeys.sanitizedThreadId instead of raw topic to MessageThreadId

Result: Session files now use clean names like 'general-chat' instead of 'general%20chat'
Zulip ANDs narrow terms together, so multiple ["stream", "x"] filters
in one narrow match nothing (stream=A AND stream=B is always empty).

For single-stream configs, keep the narrow filter for efficiency.
For multiple streams or "*", switch to all_public_streams=true and
filter client-side.
- Add zulip to MARKDOWN_CAPABLE_CHANNELS
- Add mode? to ChannelPlugin queue defaults type
- Add resolvePluginMode() to queue settings
- Ensure plugin registry initialized in message command
- Add resolveZulipSession() for default topic resolution
CI formatting check was failing after rebase.
Auto-formatted 4 files to match project style.
- Use createScopedPairingAccess() for readAllowFromStore/upsertPairingRequest
  (upstream now requires accountId in all pairing API calls)
- Use chatType ('direct'|'channel') not kind ('dm'|'channel') for peer.kind
  in resolveAgentRoute() to match ChatType constraint
@FtlC-ian FtlC-ian force-pushed the feat/zulip-plugin-v2 branch from 21e5b75 to b3b9f8c Compare February 27, 2026 23:42
@FtlC-ian FtlC-ian force-pushed the feat/zulip-plugin-v2 branch from ad93b68 to 0504c6e Compare February 27, 2026 23:52
@openclaw-barnacle
Copy link
Copy Markdown

Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community

@FtlC-ian
Copy link
Copy Markdown
Author

Published as a standalone third-party plugin: https://github.com/FtlC-ian/openclaw-channel-zulip — install with openclaw plugins install openclaw-channel-zulip

@maltokyo
Copy link
Copy Markdown

Thank you for this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants